Skip to main content

koan_core/
invite.rs

1//! Invite links: an account's server, username and password as one link.
2//!
3//! The link is the whole invitation. Nothing is redeemed on the server, so it
4//! works against any Subsonic server and a mail scanner fetching it changes
5//! nothing. The credentials travel in the fragment of a koan.rocks address,
6//! which browsers do not send, so the site serving the page never sees them.
7//! With the app installed the address is a universal link and opens it
8//! directly; without, the page offers the downloads, a `koan://join` button
9//! and the details in plain text for other clients.
10//!
11//! The server never sends mail. It produces the email for the admin to send
12//! from their own client.
13
14use rusqlite::Connection;
15use url::{Url, form_urlencoded};
16
17use crate::auth::{self, Role};
18use crate::db::queries::auth as users;
19
20pub const JOIN_PAGE: &str = "https://koan.rocks/join/";
21pub const APP_STORE: &str = "https://apps.apple.com/app/id6817137172";
22pub const MAC_DOWNLOAD: &str = "https://github.com/radiosilence/koan/releases/latest";
23
24const MAX_USERNAME: usize = 64;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Invite {
28    pub server: String,
29    pub username: String,
30    pub password: String,
31}
32
33impl Invite {
34    pub fn new(server: &str, username: &str, password: &str) -> Self {
35        Self {
36            server: server.trim().trim_end_matches('/').to_owned(),
37            username: username.to_owned(),
38            password: password.to_owned(),
39        }
40    }
41
42    fn params(&self) -> String {
43        form_urlencoded::Serializer::new(String::new())
44            .append_pair("server", &self.server)
45            .append_pair("username", &self.username)
46            .append_pair("password", &self.password)
47            .finish()
48    }
49
50    /// The link to send: a universal link into the app, or the join page.
51    pub fn link(&self) -> String {
52        format!("{JOIN_PAGE}#{}", self.params())
53    }
54
55    /// The app's own scheme, for where universal links do not reach.
56    pub fn app_link(&self) -> String {
57        format!("koan://join?{}", self.params())
58    }
59
60    /// Reads either form of the link, or a server address with the account
61    /// in it (`https://user:password@host`), which is what someone pasting
62    /// into the server field may have. Anything else, or a link missing a
63    /// field, is `None`.
64    pub fn parse(link: &str) -> Option<Self> {
65        let url = Url::parse(link.trim()).ok()?;
66        if matches!(url.scheme(), "http" | "https") && !url.username().is_empty() {
67            let decode = |s: &str| percent_decode(s);
68            let (username, password) = (decode(url.username()), decode(url.password()?));
69            if password.is_empty() {
70                return None;
71            }
72            let mut server = url;
73            server.set_username("").ok()?;
74            server.set_password(None).ok()?;
75            return Some(Self::new(server.as_str(), &username, &password));
76        }
77        let params = match (url.scheme(), url.host_str()) {
78            ("koan", Some("join")) => url.query().or(url.fragment()),
79            ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/join" => {
80                url.fragment()
81            }
82            _ => None,
83        }?;
84        let (mut server, mut username, mut password) = (None, None, None);
85        for (k, v) in form_urlencoded::parse(params.as_bytes()) {
86            match &*k {
87                "server" => server = Some(v.into_owned()),
88                "username" => username = Some(v.into_owned()),
89                "password" => password = Some(v.into_owned()),
90                _ => {}
91            }
92        }
93        let (server, username, password) = (server?, username?, password?);
94        let scheme = Url::parse(&server).ok()?.scheme().to_owned();
95        if !matches!(scheme.as_str(), "http" | "https")
96            || username.is_empty()
97            || password.is_empty()
98        {
99            return None;
100        }
101        Some(Self::new(&server, &username, &password))
102    }
103
104    pub fn email_subject(&self) -> String {
105        "Your koan account".to_owned()
106    }
107
108    pub fn email_text(&self) -> String {
109        format!(
110            "I've made you an account on my music server.\n\
111             \n\
112             1. Install koan: from the App Store on an iPhone or iPad ({APP_STORE}), \
113             or for a Mac from {MAC_DOWNLOAD}\n\
114             2. On that device, open this link:\n\
115             \n\
116             {link}\n\
117             \n\
118             koan signs in and loads the library by itself.\n\
119             \n\
120             Using a different Subsonic app? Sign in with:\n\
121             \n\
122             Server URL: {server}\n\
123             Username: {username}\n\
124             Password: {password}\n",
125            link = self.link(),
126            server = self.server,
127            username = self.username,
128            password = self.password,
129        )
130    }
131
132    /// The same email with the link as a button, for pasting into a mail
133    /// client as rich text.
134    pub fn email_html(&self) -> String {
135        let e = html_escape;
136        format!(
137            "<p>I've made you an account on my music server.</p>\
138             <ol><li>Install koan: from the <a href=\"{APP_STORE}\">App Store</a> on an iPhone \
139             or iPad, or <a href=\"{MAC_DOWNLOAD}\">for a Mac</a>.</li>\
140             <li>On that device, open this link:</li></ol>\
141             <p><a href=\"{link}\" style=\"display:inline-block;padding:10px 18px;\
142             border-radius:8px;background:#111;color:#fff;text-decoration:none;\
143             font-weight:600\">Open in koan</a></p>\
144             <p>koan signs in and loads the library by itself.</p>\
145             <p>Using a different Subsonic app? Sign in with:</p>\
146             <p>Server URL: {server}<br>Username: {username}<br>Password: {password}</p>",
147            link = e(&self.link()),
148            server = e(&self.server),
149            username = e(&self.username),
150            password = e(&self.password),
151        )
152    }
153
154    /// A `mailto:` with the subject and plain body filled in.
155    pub fn mailto(&self) -> String {
156        let enc = |s: &str| {
157            form_urlencoded::byte_serialize(s.as_bytes())
158                .collect::<String>()
159                .replace('+', "%20")
160        };
161        format!(
162            "mailto:?subject={}&body={}",
163            enc(&self.email_subject()),
164            enc(&self.email_text())
165        )
166    }
167}
168
169fn percent_decode(s: &str) -> String {
170    form_urlencoded::parse(format!("x={}", s.replace('+', "%2B")).as_bytes())
171        .next()
172        .map(|(_, v)| v.into_owned())
173        .unwrap_or_default()
174}
175
176fn html_escape(s: &str) -> String {
177    s.replace('&', "&amp;")
178        .replace('<', "&lt;")
179        .replace('>', "&gt;")
180        .replace('"', "&quot;")
181}
182
183/// A password someone can type from an email: 20 characters with no
184/// lookalikes (0/O, 1/l/I), about 116 bits.
185pub fn generate_password() -> Result<String, auth::AuthError> {
186    use ring::rand::SecureRandom;
187
188    const ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
189    let rng = ring::rand::SystemRandom::new();
190    let mut out = String::with_capacity(20);
191    let mut byte = [0u8; 1];
192    while out.len() < 20 {
193        rng.fill(&mut byte)
194            .map_err(|_| auth::AuthError::Hash("rng failure".into()))?;
195        // Reject the top of the range so every character is equally likely.
196        let limit = 256 - 256 % ALPHABET.len();
197        if (byte[0] as usize) < limit {
198            out.push(ALPHABET[byte[0] as usize % ALPHABET.len()] as char);
199        }
200    }
201    Ok(out)
202}
203
204#[derive(Debug, thiserror::Error)]
205pub enum AccountError {
206    #[error("usernames are 1 to {MAX_USERNAME} characters, without spaces")]
207    BadUsername,
208    #[error("there is already an account called {0}")]
209    Taken(String),
210    #[error("{0} is reserved")]
211    Reserved(String),
212    #[error("there is no account called {0}")]
213    NoSuchUser(String),
214    #[error("{0}'s password is not recoverable; invite with a new password instead")]
215    NotRecoverable(String),
216    #[error("the last admin cannot be removed or demoted")]
217    LastAdmin,
218    #[error(transparent)]
219    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
220}
221
222fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
223    AccountError::Other(Box::new(e))
224}
225
226fn seal(
227    conn: &Connection,
228    key: &[u8; 32],
229    username: &str,
230    password: &str,
231) -> Result<(), AccountError> {
232    let sealed = auth::seal_password(key, username, password).map_err(other)?;
233    users::set_sealed_password(conn, username, &sealed).map_err(other)
234}
235
236/// Create an account with a generated password, sealed under `key` (the
237/// server's `auth::subsonic_key`) so it can use token auth and be recovered
238/// for a later invite. Returns the password.
239pub fn create_account(
240    conn: &Connection,
241    key: &[u8; 32],
242    username: &str,
243    role: Role,
244) -> Result<String, AccountError> {
245    let username = username.trim();
246    if username.is_empty()
247        || username.chars().count() > MAX_USERNAME
248        || username.chars().any(char::is_whitespace)
249    {
250        return Err(AccountError::BadUsername);
251    }
252    if username.eq_ignore_ascii_case(auth::ANONYMOUS) {
253        return Err(AccountError::Reserved(username.to_owned()));
254    }
255    if users::get_user_by_username(conn, username)
256        .map_err(other)?
257        .is_some()
258    {
259        return Err(AccountError::Taken(username.to_owned()));
260    }
261    let password = generate_password().map_err(other)?;
262    users::create_user(conn, username, &password, role).map_err(other)?;
263    seal(conn, key, username, &password)?;
264    Ok(password)
265}
266
267/// The password to put in an invite for an existing account.
268///
269/// Recovered from the sealed copy, so the account's other devices keep
270/// working. With `reset`, a new password replaces it instead, which signs
271/// every existing device out (see `update_password`); open links are the
272/// server's to drop.
273pub fn account_password(
274    conn: &Connection,
275    key: &[u8; 32],
276    username: &str,
277    reset: bool,
278) -> Result<String, AccountError> {
279    if users::get_user_by_username(conn, username)
280        .map_err(other)?
281        .is_none()
282    {
283        return Err(AccountError::NoSuchUser(username.to_owned()));
284    }
285    if !reset {
286        return users::sealed_password(conn, username)
287            .map_err(other)?
288            .and_then(|sealed| auth::open_password(key, username, &sealed))
289            .ok_or_else(|| AccountError::NotRecoverable(username.to_owned()));
290    }
291    let password = generate_password().map_err(other)?;
292    users::update_password(conn, username, &password)
293        .map_err(|e| AccountError::Other(e.to_string().into()))?;
294    seal(conn, key, username, &password)?;
295    Ok(password)
296}
297
298/// Change an account's role, refusing to demote the last admin.
299pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
300    let user = users::get_user_by_username(conn, username)
301        .map_err(other)?
302        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
303    if user.role == Role::Admin
304        && role != Role::Admin
305        && users::admin_count(conn).map_err(other)? <= 1
306    {
307        return Err(AccountError::LastAdmin);
308    }
309    users::update_role(conn, username, role).map_err(other)?;
310    Ok(())
311}
312
313/// Delete an account, refusing to delete the last admin.
314pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
315    let user = users::get_user_by_username(conn, username)
316        .map_err(other)?
317        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
318    if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
319        return Err(AccountError::LastAdmin);
320    }
321    users::delete_user(conn, user.id).map_err(other)?;
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn invite() -> Invite {
330        Invite::new("https://music.example.com/", "sarita", "p&ss word=#?")
331    }
332
333    #[test]
334    fn both_links_round_trip() {
335        let i = invite();
336        assert_eq!(i.server, "https://music.example.com");
337        assert!(i.link().starts_with("https://koan.rocks/join/#server="));
338        assert_eq!(Invite::parse(&i.link()), Some(i.clone()));
339        assert_eq!(Invite::parse(&i.app_link()), Some(i));
340    }
341
342    #[test]
343    fn the_join_page_without_its_slash_still_parses() {
344        let link = invite().link().replacen("/join/#", "/join#", 1);
345        assert_eq!(Invite::parse(&link), Some(invite()));
346    }
347
348    #[test]
349    fn an_address_with_the_account_in_it_is_split() {
350        assert_eq!(
351            Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
352            Some(Invite::new("https://koan.example.com", "sarita", "p@ss+w"))
353        );
354        assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
355    }
356
357    #[test]
358    fn other_links_and_missing_fields_are_refused() {
359        assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
360        assert_eq!(
361            Invite::parse("https://koan.rocks/#server=https://a&username=u&password=p"),
362            None
363        );
364        assert_eq!(
365            Invite::parse("koan://join?server=https://a&username=u"),
366            None
367        );
368        assert_eq!(
369            Invite::parse("koan://join?server=ftp://a&username=u&password=p"),
370            None
371        );
372        assert_eq!(Invite::parse("not a url"), None);
373    }
374
375    #[test]
376    fn credentials_stay_in_the_fragment() {
377        let url = Url::parse(&invite().link()).unwrap();
378        assert_eq!(url.query(), None);
379        assert!(!url.path().contains("sarita"));
380    }
381
382    #[test]
383    fn the_email_carries_the_link_and_the_details() {
384        let i = invite();
385        let text = i.email_text();
386        assert!(text.contains(&i.link()));
387        assert!(text.contains("Server URL: https://music.example.com"));
388        assert!(text.contains("Password: p&ss word=#?"));
389        assert!(i.email_html().contains("p&amp;ss word=#?"));
390        assert!(!i.mailto().contains(' '));
391    }
392
393    #[test]
394    fn generated_passwords_are_typeable() {
395        let p = generate_password().unwrap();
396        assert_eq!(p.len(), 20);
397        assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
398        assert_ne!(p, generate_password().unwrap());
399    }
400
401    #[test]
402    fn accounts_are_created_recovered_and_guarded() {
403        let dir = tempfile::tempdir().unwrap();
404        let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
405        let conn = &db.conn;
406        let key = &[7; 32];
407        let admin = create_account(conn, key, "owner", Role::Admin).unwrap();
408        assert_eq!(account_password(conn, key, "owner", false).unwrap(), admin);
409        assert!(matches!(
410            create_account(conn, key, "owner", Role::User),
411            Err(AccountError::Taken(_))
412        ));
413        assert!(matches!(
414            create_account(conn, key, "two words", Role::User),
415            Err(AccountError::BadUsername)
416        ));
417        assert!(matches!(
418            create_account(conn, key, "anonymous", Role::User),
419            Err(AccountError::Reserved(_))
420        ));
421        assert!(matches!(
422            set_role(conn, "owner", Role::User),
423            Err(AccountError::LastAdmin)
424        ));
425        assert!(matches!(
426            delete_account(conn, "owner"),
427            Err(AccountError::LastAdmin)
428        ));
429
430        let first = create_account(conn, key, "sarita", Role::Readonly).unwrap();
431        let sarita = users::get_user_by_username(conn, "sarita")
432            .unwrap()
433            .unwrap()
434            .id;
435        let (_, api_key) =
436            crate::db::queries::api_keys::create_api_key(conn, sarita, "phone").unwrap();
437        // Recovering the password for an invite leaves the keys alone.
438        account_password(conn, key, "sarita", false).unwrap();
439        assert!(
440            crate::db::queries::api_keys::authenticate_api_key(conn, &api_key)
441                .unwrap()
442                .is_some()
443        );
444        let reset = account_password(conn, key, "sarita", true).unwrap();
445        assert_ne!(first, reset);
446        assert_eq!(account_password(conn, key, "sarita", false).unwrap(), reset);
447        // A reset takes the keys with the old password.
448        assert!(
449            crate::db::queries::api_keys::authenticate_api_key(conn, &api_key)
450                .unwrap()
451                .is_none()
452        );
453        delete_account(conn, "sarita").unwrap();
454        assert!(matches!(
455            account_password(conn, key, "sarita", false),
456            Err(AccountError::NoSuchUser(_))
457        ));
458    }
459}