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 (_, api_key) =
329 api_keys::create_api_key(conn, user.id, &device_name(device)).map_err(other)?;
330 Ok(Redeemed {
331 username: user.username,
332 api_key,
333 })
334}
335
336pub fn device_name(device: &str) -> String {
339 let name: String = device
340 .trim()
341 .chars()
342 .filter(|c| !c.is_control())
343 .take(MAX_DEVICE_NAME)
344 .collect();
345 if name.is_empty() { "koan".into() } else { name }
346}
347
348pub fn generate_password() -> Result<String, auth::AuthError> {
355 use ring::rand::SecureRandom;
356
357 const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
358 let rng = ring::rand::SystemRandom::new();
359 let mut out = String::with_capacity(20);
360 let mut byte = [0u8; 1];
361 while out.len() < 20 {
362 rng.fill(&mut byte)
363 .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
364 let limit = 256 - 256 % ALPHABET.len();
366 if (byte[0] as usize) < limit {
367 out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
368 }
369 }
370 Ok(out)
371}
372
373#[derive(Debug, thiserror::Error)]
374pub enum AccountError {
375 #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
376 BadUsername,
377 #[error("passwords are at least {MIN_PASSWORD} characters")]
378 ShortPassword,
379 #[error("there is already an account called {0}")]
380 Taken(String),
381 #[error("{0} is reserved")]
382 Reserved(String),
383 #[error("there is no account called {0}")]
384 NoSuchUser(String),
385 #[error("this invite is not valid here, or has expired")]
386 BadInvite,
387 #[error("the last admin cannot be removed or demoted")]
388 LastAdmin,
389 #[error("this server already has accounts")]
390 AlreadySetUp,
391 #[error(transparent)]
392 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
393}
394
395fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
396 AccountError::Other(Box::new(e))
397}
398
399#[derive(Debug)]
402pub struct NewAccount {
403 pub id: i64,
404 pub password: String,
405}
406
407pub fn create_account(
409 conn: &Connection,
410 username: &str,
411 role: Role,
412) -> Result<NewAccount, AccountError> {
413 let username = valid_username(username)?;
414 if users::get_user_by_username(conn, username)
415 .map_err(other)?
416 .is_some()
417 {
418 return Err(AccountError::Taken(username.to_owned()));
419 }
420 let password = generate_password().map_err(other)?;
421 let id = users::create_user(conn, username, &password, role).map_err(other)?;
422 Ok(NewAccount { id, password })
423}
424
425fn valid_username(username: &str) -> Result<&str, AccountError> {
427 let username = username.trim();
428 if username.is_empty()
429 || username.chars().count() > MAX_USERNAME
430 || username.chars().any(char::is_whitespace)
431 {
432 return Err(AccountError::BadUsername);
433 }
434 if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
435 return Err(AccountError::Reserved(username.to_owned()));
436 }
437 Ok(username)
438}
439
440pub fn create_first_admin(
445 conn: &Connection,
446 username: &str,
447 password: &str,
448) -> Result<i64, AccountError> {
449 let username = valid_username(username)?;
450 if password.chars().count() < MIN_PASSWORD {
451 return Err(AccountError::ShortPassword);
452 }
453 let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
454 .map_err(other)?;
455 if users::has_users(&tx).map_err(other)? {
456 return Err(AccountError::AlreadySetUp);
457 }
458 let id = users::create_user(&tx, username, password, Role::Admin).map_err(other)?;
459 tx.commit().map_err(other)?;
460 Ok(id)
461}
462
463pub fn account(conn: &Connection, username: &str) -> Result<users::UserRow, AccountError> {
465 users::get_user_by_username(conn, username)
466 .map_err(other)?
467 .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))
468}
469
470pub fn set_password(
474 conn: &Connection,
475 username: &str,
476 password: Option<&str>,
477) -> Result<String, AccountError> {
478 account(conn, username)?;
479 let password = match password {
480 Some(p) if p.chars().count() < MIN_PASSWORD => return Err(AccountError::ShortPassword),
481 Some(p) => p.to_owned(),
482 None => generate_password().map_err(other)?,
483 };
484 users::update_password(conn, username, &password)
485 .map_err(|e| AccountError::Other(e.to_string().into()))?;
486 Ok(password)
487}
488
489pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
491 let user = account(conn, username)?;
492 if user.role == Role::Admin
493 && role != Role::Admin
494 && users::admin_count(conn).map_err(other)? <= 1
495 {
496 return Err(AccountError::LastAdmin);
497 }
498 users::update_role(conn, username, role).map_err(other)?;
499 Ok(())
500}
501
502pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
504 let user = account(conn, username)?;
505 if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
506 return Err(AccountError::LastAdmin);
507 }
508 users::delete_user(conn, user.id).map_err(other)?;
509 Ok(())
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn the_first_admin_is_made_once() {
518 let dir = tempfile::tempdir().unwrap();
519 let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
520 assert!(matches!(
521 create_first_admin(&db.conn, "ada", "short"),
522 Err(AccountError::ShortPassword)
523 ));
524 assert!(matches!(
525 create_first_admin(&db.conn, "a da", "long enough"),
526 Err(AccountError::BadUsername)
527 ));
528 let id = create_first_admin(&db.conn, " ada ", "long enough").unwrap();
529 let ada = users::get_user_by_id(&db.conn, id).unwrap().unwrap();
530 assert_eq!((ada.username.as_str(), ada.role), ("ada", Role::Admin));
531 assert!(matches!(
532 create_first_admin(&db.conn, "mallory", "long enough"),
533 Err(AccountError::AlreadySetUp)
534 ));
535 assert!(
536 users::get_user_by_username(&db.conn, "mallory")
537 .unwrap()
538 .is_none()
539 );
540 }
541
542 fn invite() -> Invite {
543 Invite::with_token(
544 "https://music.example.com/",
545 "sarita",
546 "a.b-c_d",
547 Some("p&ss word=#?"),
548 )
549 }
550
551 #[test]
552 fn both_links_round_trip_without_the_password() {
553 let i = invite();
554 assert_eq!(i.server, "https://music.example.com");
555 assert!(i.link().starts_with("https://koan.rocks/join/#server="));
556 assert!(!i.link().contains("password"));
557 let read = Invite {
558 password: None,
559 ..i.clone()
560 };
561 assert_eq!(Invite::parse(&i.link()), Some(read.clone()));
562 assert_eq!(Invite::parse(&i.app_link()), Some(read));
563 }
564
565 #[test]
566 fn links_carrying_a_password_are_not_invites() {
567 let old =
568 "https://koan.rocks/join/#server=https%3A%2F%2Fa.example&username=u&password=p%26q";
569 assert_eq!(Invite::parse(old), None);
570 }
571
572 #[test]
573 fn the_join_page_without_its_slash_still_parses() {
574 let link = invite().link().replacen("/join/#", "/join#", 1);
575 assert_eq!(
576 Invite::parse(&link).unwrap().token.as_deref(),
577 Some("a.b-c_d")
578 );
579 }
580
581 #[test]
582 fn an_address_with_the_account_in_it_is_split() {
583 assert_eq!(
584 Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
585 Some(Invite::with_password(
586 "https://koan.example.com",
587 "sarita",
588 "p@ss+w"
589 ))
590 );
591 assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
592 }
593
594 #[test]
595 fn other_links_and_missing_fields_are_refused() {
596 assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
597 assert_eq!(
598 Invite::parse("https://koan.rocks/#server=https://a&username=u&invite=t"),
599 None
600 );
601 assert_eq!(
602 Invite::parse("koan://join?server=https://a&username=u"),
603 None
604 );
605 assert_eq!(
606 Invite::parse("koan://join?server=https://a&username=u&invite="),
607 None
608 );
609 assert_eq!(
610 Invite::parse("koan://join?server=ftp://a&username=u&invite=t"),
611 None
612 );
613 assert_eq!(Invite::parse("not a url"), None);
614 }
615
616 #[test]
617 fn credentials_stay_in_the_fragment() {
618 let url = Url::parse(&invite().link()).unwrap();
619 assert_eq!(url.query(), None);
620 assert!(!url.path().contains("sarita"));
621 }
622
623 #[test]
624 fn the_email_carries_the_link_and_a_new_accounts_password() {
625 let i = invite();
626 let text = i.email_text();
627 assert!(text.contains(&i.link()));
628 assert!(text.contains("Server URL: https://music.example.com"));
629 assert!(text.contains("Password: p&ss word=#?"));
630 assert!(i.email_html().contains("p&ss word=#?"));
631 assert!(!i.mailto().contains(' '));
632
633 let again = Invite {
634 password: None,
635 ..invite()
636 };
637 assert!(!again.email_text().contains("Password:"));
638 assert!(again.email_text().contains("API key"));
639 }
640
641 #[test]
642 fn generated_passwords_are_typeable() {
643 let p = generate_password().unwrap();
644 assert_eq!(p.len(), 20);
645 assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
646 assert_ne!(p, generate_password().unwrap());
647 }
648
649 fn db() -> (tempfile::TempDir, crate::db::connection::Database) {
650 let dir = tempfile::tempdir().unwrap();
651 let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
652 (dir, db)
653 }
654
655 #[test]
656 fn a_token_is_redeemed_for_a_key_per_device() {
657 let (_dir, db) = db();
658 let conn = &db.conn;
659 let (private, public) = auth::generate_keypair_pem().unwrap();
660 let made = create_account(conn, "sarita", Role::User).unwrap();
661 let token = mint_token(conn, private.as_bytes(), made.id).unwrap();
662
663 let phone = redeem(conn, public.as_bytes(), &token, "Sarita's iPhone").unwrap();
664 let mac = redeem(conn, public.as_bytes(), &token, "").unwrap();
665 assert_eq!(phone.username, "sarita");
666 assert_ne!(phone.api_key, mac.api_key);
667 let keys = api_keys::list_api_keys(conn, Some(made.id)).unwrap();
668 let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
669 assert!(names.contains(&"Sarita's iPhone") && names.contains(&"koan"));
670 assert!(
671 api_keys::authenticate_api_key(conn, &phone.api_key)
672 .unwrap()
673 .is_some()
674 );
675
676 let (_, elsewhere) = auth::generate_keypair_pem().unwrap();
679 assert!(matches!(
680 redeem(conn, elsewhere.as_bytes(), &token, "x"),
681 Err(AccountError::BadInvite)
682 ));
683 assert!(matches!(
684 redeem(conn, public.as_bytes(), "nonsense", "x"),
685 Err(AccountError::BadInvite)
686 ));
687 let session =
688 auth::mint_access_token(private.as_bytes(), made.id, "sarita", Role::User, 60).unwrap();
689 assert!(matches!(
690 redeem(conn, public.as_bytes(), &session, "x"),
691 Err(AccountError::BadInvite)
692 ));
693 delete_account(conn, "sarita").unwrap();
694 assert!(matches!(
695 redeem(conn, public.as_bytes(), &token, "x"),
696 Err(AccountError::BadInvite)
697 ));
698 }
699
700 #[test]
701 fn a_new_password_withdraws_links_already_sent() {
702 let (_dir, db) = db();
703 let conn = &db.conn;
704 let (private, public) = auth::generate_keypair_pem().unwrap();
705 let made = create_account(conn, "sarita", Role::User).unwrap();
706 let sent = mint_token(conn, private.as_bytes(), made.id).unwrap();
707 set_password(conn, "sarita", None).unwrap();
708 assert!(matches!(
709 redeem(conn, public.as_bytes(), &sent, "x"),
710 Err(AccountError::BadInvite)
711 ));
712 let again = mint_token(conn, private.as_bytes(), made.id).unwrap();
713 redeem(conn, public.as_bytes(), &again, "x").unwrap();
714 }
715
716 #[test]
717 fn accounts_are_created_and_guarded() {
718 let (_dir, db) = db();
719 let conn = &db.conn;
720 create_account(conn, "owner", Role::Admin).unwrap();
721 assert!(matches!(
722 create_account(conn, "owner", Role::User),
723 Err(AccountError::Taken(_))
724 ));
725 assert!(matches!(
726 create_account(conn, "two words", Role::User),
727 Err(AccountError::BadUsername)
728 ));
729 assert!(matches!(
730 create_account(conn, "anonymous", Role::User),
731 Err(AccountError::Reserved(_))
732 ));
733 assert!(matches!(
734 set_role(conn, "owner", Role::User),
735 Err(AccountError::LastAdmin)
736 ));
737 assert!(matches!(
738 delete_account(conn, "owner"),
739 Err(AccountError::LastAdmin)
740 ));
741 assert!(matches!(
742 set_password(conn, "nobody", None),
743 Err(AccountError::NoSuchUser(_))
744 ));
745 }
746
747 #[test]
748 fn a_new_password_signs_every_device_out() {
749 let (_dir, db) = db();
750 let conn = &db.conn;
751 let made = create_account(conn, "sarita", Role::Readonly).unwrap();
752 let hash = |conn| account(conn, "sarita").unwrap().password_hash;
753 auth::verify_password(&made.password, &hash(conn)).unwrap();
754 let (_, key) = api_keys::create_api_key(conn, made.id, "phone").unwrap();
755
756 assert!(matches!(
757 set_password(conn, "sarita", Some("short")),
758 Err(AccountError::ShortPassword)
759 ));
760 set_password(conn, "sarita", Some("correct horse")).unwrap();
761 auth::verify_password("correct horse", &hash(conn)).unwrap();
762 assert!(
763 api_keys::authenticate_api_key(conn, &key)
764 .unwrap()
765 .is_none()
766 );
767
768 let generated = set_password(conn, "sarita", None).unwrap();
769 assert_ne!(generated, made.password);
770 auth::verify_password(&generated, &hash(conn)).unwrap();
771 }
772}