1use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
31use rusqlite::Connection;
32use serde::{Deserialize, Serialize};
33use url::{Url, form_urlencoded};
34
35use crate::auth::{self, Role};
36use crate::db::queries::api_keys;
37use crate::db::queries::auth as users;
38
39pub const JOIN_PAGE: &str = "https://koan.rocks/join/";
40pub const APP_STORE: &str = "https://apps.apple.com/app/id6817137172";
41pub const MAC_DOWNLOAD: &str = "https://github.com/radiosilence/koan/releases/latest";
42
43pub const TOKEN_TTL_SECS: u64 = 7 * 24 * 60 * 60;
45const TOKEN_TYP: &str = "koan-invite";
46
47const MAX_USERNAME: usize = 64;
48const MIN_PASSWORD: usize = 8;
49const MAX_DEVICE_NAME: usize = 100;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Invite {
53 pub server: String,
54 pub username: String,
55 pub token: Option<String>,
58 pub password: Option<String>,
61}
62
63impl Invite {
64 pub fn with_token(server: &str, username: &str, token: &str, password: Option<&str>) -> Self {
67 Self {
68 server: server.trim().trim_end_matches('/').to_owned(),
69 username: username.to_owned(),
70 token: Some(token.to_owned()),
71 password: password.map(str::to_owned),
72 }
73 }
74
75 pub fn with_password(server: &str, username: &str, password: &str) -> Self {
78 Self {
79 server: server.trim().trim_end_matches('/').to_owned(),
80 username: username.to_owned(),
81 token: None,
82 password: Some(password.to_owned()),
83 }
84 }
85
86 fn params(&self) -> String {
87 let mut p = form_urlencoded::Serializer::new(String::new());
88 p.append_pair("server", &self.server)
89 .append_pair("username", &self.username);
90 if let Some(token) = &self.token {
91 p.append_pair("invite", token);
92 }
93 p.finish()
94 }
95
96 pub fn link(&self) -> String {
98 format!("{JOIN_PAGE}#{}", self.params())
99 }
100
101 pub fn app_link(&self) -> String {
103 format!("koan://join?{}", self.params())
104 }
105
106 pub fn parse(link: &str) -> Option<Self> {
109 let url = Url::parse(link.trim()).ok()?;
110 if matches!(url.scheme(), "http" | "https") && !url.username().is_empty() {
111 let decode = |s: &str| percent_decode(s);
112 let (username, password) = (decode(url.username()), decode(url.password()?));
113 if password.is_empty() {
114 return None;
115 }
116 let mut server = url;
117 server.set_username("").ok()?;
118 server.set_password(None).ok()?;
119 return Some(Self::with_password(server.as_str(), &username, &password));
120 }
121 let params = match (url.scheme(), url.host_str()) {
122 ("koan", Some("join")) => url.query().or(url.fragment()),
123 ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/join" => {
124 url.fragment()
125 }
126 _ => None,
127 }?;
128 let (mut server, mut username, mut token) = (None, None, None);
129 for (k, v) in form_urlencoded::parse(params.as_bytes()) {
130 let v = Some(v.into_owned()).filter(|v| !v.is_empty());
131 match &*k {
132 "server" => server = v,
133 "username" => username = v,
134 "invite" => token = v,
135 _ => {}
136 }
137 }
138 let (server, username) = (server?, username?);
139 let scheme = Url::parse(&server).ok()?.scheme().to_owned();
140 if !matches!(scheme.as_str(), "http" | "https") {
141 return None;
142 }
143 Some(Self::with_token(&server, &username, &token?, None))
144 }
145
146 pub fn email_subject(&self) -> String {
147 "Your koan account".to_owned()
148 }
149
150 fn other_apps_text(&self) -> String {
153 match &self.password {
154 Some(password) => format!(
155 "Using a different Subsonic app, or the web player at {server}? Sign in with:\n\
156 \n\
157 Server URL: {server}\n\
158 Username: {username}\n\
159 Password: {password}\n",
160 server = self.server,
161 username = self.username,
162 ),
163 None => format!(
164 "Using a different Subsonic app? Sign in at {server} with your password and \
165 make an API key under API keys.\n",
166 server = self.server,
167 ),
168 }
169 }
170
171 pub fn email_text(&self) -> String {
172 format!(
173 "I've made you an account on my music server.\n\
174 \n\
175 1. Install koan: from the App Store on an iPhone or iPad ({APP_STORE}), \
176 or for a Mac from {MAC_DOWNLOAD}\n\
177 2. On that device, open this link:\n\
178 \n\
179 {link}\n\
180 \n\
181 koan signs in and loads the library by itself. The link works on each of \
182 your devices for a week.\n\
183 \n\
184 {other}",
185 link = self.link(),
186 other = self.other_apps_text(),
187 )
188 }
189
190 pub fn email_html(&self) -> String {
193 let e = html_escape;
194 let other = match &self.password {
195 Some(password) => format!(
196 "<p>Using a different Subsonic app, or the web player at {server}? Sign in \
197 with:</p><p>Server URL: {server}<br>Username: {username}<br>Password: \
198 {password}</p>",
199 server = e(&self.server),
200 username = e(&self.username),
201 password = e(password),
202 ),
203 None => format!(
204 "<p>Using a different Subsonic app? Sign in at {server} with your password \
205 and make an API key under API keys.</p>",
206 server = e(&self.server),
207 ),
208 };
209 format!(
210 "<p>I've made you an account on my music server.</p>\
211 <ol><li>Install koan: from the <a href=\"{APP_STORE}\">App Store</a> on an iPhone \
212 or iPad, or <a href=\"{MAC_DOWNLOAD}\">for a Mac</a>.</li>\
213 <li>On that device, open this link:</li></ol>\
214 <p><a href=\"{link}\" style=\"display:inline-block;padding:10px 18px;\
215 border-radius:8px;background:#111;color:#fff;text-decoration:none;\
216 font-weight:600\">Open in koan</a></p>\
217 <p>koan signs in and loads the library by itself. The link works on each of \
218 your devices for a week.</p>{other}",
219 link = e(&self.link()),
220 )
221 }
222
223 pub fn mailto(&self) -> String {
225 let enc = |s: &str| {
226 form_urlencoded::byte_serialize(s.as_bytes())
227 .collect::<String>()
228 .replace('+', "%20")
229 };
230 format!(
231 "mailto:?subject={}&body={}",
232 enc(&self.email_subject()),
233 enc(&self.email_text())
234 )
235 }
236}
237
238fn percent_decode(s: &str) -> String {
239 form_urlencoded::parse(format!("x={}", s.replace('+', "%2B")).as_bytes())
240 .next()
241 .map(|(_, v)| v.into_owned())
242 .unwrap_or_default()
243}
244
245fn html_escape(s: &str) -> String {
246 s.replace('&', "&")
247 .replace('<', "<")
248 .replace('>', ">")
249 .replace('"', """)
250}
251
252#[derive(Serialize, Deserialize)]
257struct Claims {
258 typ: String,
259 sub: i64,
262 username: String,
263 pwd: String,
266 iat: u64,
267 exp: u64,
268}
269
270fn password_mark(password_hash: &str) -> String {
273 auth::sha256_hex(password_hash)[..16].to_owned()
274}
275
276pub fn mint_token(
278 conn: &Connection,
279 private_pem: &[u8],
280 user_id: i64,
281) -> Result<String, AccountError> {
282 let user = users::get_user_by_id(conn, user_id)
283 .map_err(other)?
284 .ok_or_else(|| AccountError::NoSuchUser(user_id.to_string()))?;
285 let now = auth::now_unix();
286 let claims = Claims {
287 typ: TOKEN_TYP.into(),
288 sub: user.id,
289 username: user.username,
290 pwd: password_mark(&user.password_hash),
291 iat: now,
292 exp: now + TOKEN_TTL_SECS,
293 };
294 let key = EncodingKey::from_ed_pem(private_pem).map_err(other)?;
295 jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &key).map_err(other)
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct Redeemed {
302 pub username: String,
303 pub api_key: String,
304}
305
306pub fn redeem(
309 conn: &Connection,
310 public_pem: &[u8],
311 token: &str,
312 device: &str,
313) -> Result<Redeemed, AccountError> {
314 let key = DecodingKey::from_ed_pem(public_pem).map_err(other)?;
315 let mut validation = Validation::new(Algorithm::EdDSA);
316 validation.set_required_spec_claims(&["exp"]);
317 let claims = jsonwebtoken::decode::<Claims>(token, &key, &validation)
318 .map_err(|_| AccountError::BadInvite)?
319 .claims;
320 if claims.typ != TOKEN_TYP {
321 return Err(AccountError::BadInvite);
322 }
323 let user = users::get_user_by_id(conn, claims.sub)
324 .map_err(other)?
325 .filter(|u| u.username == claims.username)
326 .filter(|u| password_mark(&u.password_hash) == claims.pwd)
327 .ok_or(AccountError::BadInvite)?;
328 let name: String = device
329 .trim()
330 .chars()
331 .filter(|c| !c.is_control())
332 .take(MAX_DEVICE_NAME)
333 .collect();
334 let name = if name.is_empty() { "koan" } else { &name };
335 let (_, api_key) = api_keys::create_api_key(conn, user.id, name).map_err(other)?;
336 Ok(Redeemed {
337 username: user.username,
338 api_key,
339 })
340}
341
342pub fn generate_password() -> Result<String, auth::AuthError> {
349 use ring::rand::SecureRandom;
350
351 const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
352 let rng = ring::rand::SystemRandom::new();
353 let mut out = String::with_capacity(20);
354 let mut byte = [0u8; 1];
355 while out.len() < 20 {
356 rng.fill(&mut byte)
357 .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
358 let limit = 256 - 256 % ALPHABET.len();
360 if (byte[0] as usize) < limit {
361 out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
362 }
363 }
364 Ok(out)
365}
366
367#[derive(Debug, thiserror::Error)]
368pub enum AccountError {
369 #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
370 BadUsername,
371 #[error("passwords are at least {MIN_PASSWORD} characters")]
372 ShortPassword,
373 #[error("there is already an account called {0}")]
374 Taken(String),
375 #[error("{0} is reserved")]
376 Reserved(String),
377 #[error("there is no account called {0}")]
378 NoSuchUser(String),
379 #[error("this invite is not valid here, or has expired")]
380 BadInvite,
381 #[error("the last admin cannot be removed or demoted")]
382 LastAdmin,
383 #[error(transparent)]
384 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
385}
386
387fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
388 AccountError::Other(Box::new(e))
389}
390
391#[derive(Debug)]
394pub struct NewAccount {
395 pub id: i64,
396 pub password: String,
397}
398
399pub fn create_account(
401 conn: &Connection,
402 username: &str,
403 role: Role,
404) -> Result<NewAccount, AccountError> {
405 let username = username.trim();
406 if username.is_empty()
407 || username.chars().count() > MAX_USERNAME
408 || username.chars().any(char::is_whitespace)
409 {
410 return Err(AccountError::BadUsername);
411 }
412 if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
413 return Err(AccountError::Reserved(username.to_owned()));
414 }
415 if users::get_user_by_username(conn, username)
416 .map_err(other)?
417 .is_some()
418 {
419 return Err(AccountError::Taken(username.to_owned()));
420 }
421 let password = generate_password().map_err(other)?;
422 let id = users::create_user(conn, username, &password, role).map_err(other)?;
423 Ok(NewAccount { id, password })
424}
425
426pub fn account(conn: &Connection, username: &str) -> Result<users::UserRow, AccountError> {
428 users::get_user_by_username(conn, username)
429 .map_err(other)?
430 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))
431}
432
433pub fn set_password(
437 conn: &Connection,
438 username: &str,
439 password: Option<&str>,
440) -> Result<String, AccountError> {
441 account(conn, username)?;
442 let password = match password {
443 Some(p) if p.chars().count() < MIN_PASSWORD => return Err(AccountError::ShortPassword),
444 Some(p) => p.to_owned(),
445 None => generate_password().map_err(other)?,
446 };
447 users::update_password(conn, username, &password)
448 .map_err(|e| AccountError::Other(e.to_string().into()))?;
449 Ok(password)
450}
451
452pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
454 let user = account(conn, username)?;
455 if user.role == Role::Admin
456 && role != Role::Admin
457 && users::admin_count(conn).map_err(other)? <= 1
458 {
459 return Err(AccountError::LastAdmin);
460 }
461 users::update_role(conn, username, role).map_err(other)?;
462 Ok(())
463}
464
465pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
467 let user = account(conn, username)?;
468 if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
469 return Err(AccountError::LastAdmin);
470 }
471 users::delete_user(conn, user.id).map_err(other)?;
472 Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn invite() -> Invite {
480 Invite::with_token(
481 "https://music.example.com/",
482 "sarita",
483 "a.b-c_d",
484 Some("p&ss word=#?"),
485 )
486 }
487
488 #[test]
489 fn both_links_round_trip_without_the_password() {
490 let i = invite();
491 assert_eq!(i.server, "https://music.example.com");
492 assert!(i.link().starts_with("https://koan.rocks/join/#server="));
493 assert!(!i.link().contains("password"));
494 let read = Invite {
495 password: None,
496 ..i.clone()
497 };
498 assert_eq!(Invite::parse(&i.link()), Some(read.clone()));
499 assert_eq!(Invite::parse(&i.app_link()), Some(read));
500 }
501
502 #[test]
503 fn links_carrying_a_password_are_not_invites() {
504 let old =
505 "https://koan.rocks/join/#server=https%3A%2F%2Fa.example&username=u&password=p%26q";
506 assert_eq!(Invite::parse(old), None);
507 }
508
509 #[test]
510 fn the_join_page_without_its_slash_still_parses() {
511 let link = invite().link().replacen("/join/#", "/join#", 1);
512 assert_eq!(
513 Invite::parse(&link).unwrap().token.as_deref(),
514 Some("a.b-c_d")
515 );
516 }
517
518 #[test]
519 fn an_address_with_the_account_in_it_is_split() {
520 assert_eq!(
521 Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
522 Some(Invite::with_password(
523 "https://koan.example.com",
524 "sarita",
525 "p@ss+w"
526 ))
527 );
528 assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
529 }
530
531 #[test]
532 fn other_links_and_missing_fields_are_refused() {
533 assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
534 assert_eq!(
535 Invite::parse("https://koan.rocks/#server=https://a&username=u&invite=t"),
536 None
537 );
538 assert_eq!(
539 Invite::parse("koan://join?server=https://a&username=u"),
540 None
541 );
542 assert_eq!(
543 Invite::parse("koan://join?server=https://a&username=u&invite="),
544 None
545 );
546 assert_eq!(
547 Invite::parse("koan://join?server=ftp://a&username=u&invite=t"),
548 None
549 );
550 assert_eq!(Invite::parse("not a url"), None);
551 }
552
553 #[test]
554 fn credentials_stay_in_the_fragment() {
555 let url = Url::parse(&invite().link()).unwrap();
556 assert_eq!(url.query(), None);
557 assert!(!url.path().contains("sarita"));
558 }
559
560 #[test]
561 fn the_email_carries_the_link_and_a_new_accounts_password() {
562 let i = invite();
563 let text = i.email_text();
564 assert!(text.contains(&i.link()));
565 assert!(text.contains("Server URL: https://music.example.com"));
566 assert!(text.contains("Password: p&ss word=#?"));
567 assert!(i.email_html().contains("p&ss word=#?"));
568 assert!(!i.mailto().contains(' '));
569
570 let again = Invite {
571 password: None,
572 ..invite()
573 };
574 assert!(!again.email_text().contains("Password:"));
575 assert!(again.email_text().contains("API key"));
576 }
577
578 #[test]
579 fn generated_passwords_are_typeable() {
580 let p = generate_password().unwrap();
581 assert_eq!(p.len(), 20);
582 assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
583 assert_ne!(p, generate_password().unwrap());
584 }
585
586 fn db() -> (tempfile::TempDir, crate::db::connection::Database) {
587 let dir = tempfile::tempdir().unwrap();
588 let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
589 (dir, db)
590 }
591
592 #[test]
593 fn a_token_is_redeemed_for_a_key_per_device() {
594 let (_dir, db) = db();
595 let conn = &db.conn;
596 let (private, public) = auth::generate_keypair_pem().unwrap();
597 let made = create_account(conn, "sarita", Role::User).unwrap();
598 let token = mint_token(conn, private.as_bytes(), made.id).unwrap();
599
600 let phone = redeem(conn, public.as_bytes(), &token, "Sarita's iPhone").unwrap();
601 let mac = redeem(conn, public.as_bytes(), &token, "").unwrap();
602 assert_eq!(phone.username, "sarita");
603 assert_ne!(phone.api_key, mac.api_key);
604 let keys = api_keys::list_api_keys(conn, Some(made.id)).unwrap();
605 let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
606 assert!(names.contains(&"Sarita's iPhone") && names.contains(&"koan"));
607 assert!(
608 api_keys::authenticate_api_key(conn, &phone.api_key)
609 .unwrap()
610 .is_some()
611 );
612
613 let (_, elsewhere) = auth::generate_keypair_pem().unwrap();
616 assert!(matches!(
617 redeem(conn, elsewhere.as_bytes(), &token, "x"),
618 Err(AccountError::BadInvite)
619 ));
620 assert!(matches!(
621 redeem(conn, public.as_bytes(), "nonsense", "x"),
622 Err(AccountError::BadInvite)
623 ));
624 let session =
625 auth::mint_access_token(private.as_bytes(), made.id, "sarita", Role::User, 60).unwrap();
626 assert!(matches!(
627 redeem(conn, public.as_bytes(), &session, "x"),
628 Err(AccountError::BadInvite)
629 ));
630 delete_account(conn, "sarita").unwrap();
631 assert!(matches!(
632 redeem(conn, public.as_bytes(), &token, "x"),
633 Err(AccountError::BadInvite)
634 ));
635 }
636
637 #[test]
638 fn a_new_password_withdraws_links_already_sent() {
639 let (_dir, db) = db();
640 let conn = &db.conn;
641 let (private, public) = auth::generate_keypair_pem().unwrap();
642 let made = create_account(conn, "sarita", Role::User).unwrap();
643 let sent = mint_token(conn, private.as_bytes(), made.id).unwrap();
644 set_password(conn, "sarita", None).unwrap();
645 assert!(matches!(
646 redeem(conn, public.as_bytes(), &sent, "x"),
647 Err(AccountError::BadInvite)
648 ));
649 let again = mint_token(conn, private.as_bytes(), made.id).unwrap();
650 redeem(conn, public.as_bytes(), &again, "x").unwrap();
651 }
652
653 #[test]
654 fn accounts_are_created_and_guarded() {
655 let (_dir, db) = db();
656 let conn = &db.conn;
657 create_account(conn, "owner", Role::Admin).unwrap();
658 assert!(matches!(
659 create_account(conn, "owner", Role::User),
660 Err(AccountError::Taken(_))
661 ));
662 assert!(matches!(
663 create_account(conn, "two words", Role::User),
664 Err(AccountError::BadUsername)
665 ));
666 assert!(matches!(
667 create_account(conn, "anonymous", Role::User),
668 Err(AccountError::Reserved(_))
669 ));
670 assert!(matches!(
671 set_role(conn, "owner", Role::User),
672 Err(AccountError::LastAdmin)
673 ));
674 assert!(matches!(
675 delete_account(conn, "owner"),
676 Err(AccountError::LastAdmin)
677 ));
678 assert!(matches!(
679 set_password(conn, "nobody", None),
680 Err(AccountError::NoSuchUser(_))
681 ));
682 }
683
684 #[test]
685 fn a_new_password_signs_every_device_out() {
686 let (_dir, db) = db();
687 let conn = &db.conn;
688 let made = create_account(conn, "sarita", Role::Readonly).unwrap();
689 let hash = |conn| account(conn, "sarita").unwrap().password_hash;
690 auth::verify_password(&made.password, &hash(conn)).unwrap();
691 let (_, key) = api_keys::create_api_key(conn, made.id, "phone").unwrap();
692
693 assert!(matches!(
694 set_password(conn, "sarita", Some("short")),
695 Err(AccountError::ShortPassword)
696 ));
697 set_password(conn, "sarita", Some("correct horse")).unwrap();
698 auth::verify_password("correct horse", &hash(conn)).unwrap();
699 assert!(
700 api_keys::authenticate_api_key(conn, &key)
701 .unwrap()
702 .is_none()
703 );
704
705 let generated = set_password(conn, "sarita", None).unwrap();
706 assert_ne!(generated, made.password);
707 auth::verify_password(&generated, &hash(conn)).unwrap();
708 }
709}