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.clone();
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("there is no account called {0}")]
211    NoSuchUser(String),
212    #[error("{0}'s password is not recoverable; invite with a new password instead")]
213    NotRecoverable(String),
214    #[error("the last admin cannot be removed or demoted")]
215    LastAdmin,
216    #[error(transparent)]
217    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
218}
219
220fn other(e: impl std::error::Error + Send + Sync + 'static) -> AccountError {
221    AccountError::Other(Box::new(e))
222}
223
224fn seal(
225    conn: &Connection,
226    key: &[u8; 32],
227    username: &str,
228    password: &str,
229) -> Result<(), AccountError> {
230    let sealed = auth::seal_password(key, username, password).map_err(other)?;
231    users::set_sealed_password(conn, username, &sealed).map_err(other)
232}
233
234/// Create an account with a generated password, sealed under `key` (the
235/// server's `auth::subsonic_key`) so it can use token auth and be recovered
236/// for a later invite. Returns the password.
237pub fn create_account(
238    conn: &Connection,
239    key: &[u8; 32],
240    username: &str,
241    role: Role,
242) -> Result<String, AccountError> {
243    let username = username.trim();
244    if username.is_empty()
245        || username.chars().count() > MAX_USERNAME
246        || username.chars().any(char::is_whitespace)
247    {
248        return Err(AccountError::BadUsername);
249    }
250    if users::get_user_by_username(conn, username)
251        .map_err(other)?
252        .is_some()
253    {
254        return Err(AccountError::Taken(username.to_owned()));
255    }
256    let password = generate_password().map_err(other)?;
257    users::create_user(conn, username, &password, role).map_err(other)?;
258    seal(conn, key, username, &password)?;
259    Ok(password)
260}
261
262/// The password to put in an invite for an existing account.
263///
264/// Recovered from the sealed copy, so the account's other devices keep
265/// working. With `reset`, a new password replaces it instead, which signs
266/// every existing device out.
267pub fn account_password(
268    conn: &Connection,
269    key: &[u8; 32],
270    username: &str,
271    reset: bool,
272) -> Result<String, AccountError> {
273    if users::get_user_by_username(conn, username)
274        .map_err(other)?
275        .is_none()
276    {
277        return Err(AccountError::NoSuchUser(username.to_owned()));
278    }
279    if !reset {
280        return users::sealed_password(conn, username)
281            .map_err(other)?
282            .and_then(|sealed| auth::open_password(key, username, &sealed))
283            .ok_or_else(|| AccountError::NotRecoverable(username.to_owned()));
284    }
285    let password = generate_password().map_err(other)?;
286    users::update_password(conn, username, &password)
287        .map_err(|e| AccountError::Other(e.to_string().into()))?;
288    seal(conn, key, username, &password)?;
289    Ok(password)
290}
291
292/// Change an account's role, refusing to demote the last admin.
293pub fn set_role(conn: &Connection, username: &str, role: Role) -> Result<(), AccountError> {
294    let user = users::get_user_by_username(conn, username)
295        .map_err(other)?
296        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
297    if user.role == Role::Admin
298        && role != Role::Admin
299        && users::admin_count(conn).map_err(other)? <= 1
300    {
301        return Err(AccountError::LastAdmin);
302    }
303    users::update_role(conn, username, role).map_err(other)?;
304    Ok(())
305}
306
307/// Delete an account, refusing to delete the last admin.
308pub fn delete_account(conn: &Connection, username: &str) -> Result<(), AccountError> {
309    let user = users::get_user_by_username(conn, username)
310        .map_err(other)?
311        .ok_or_else(|| AccountError::NoSuchUser(username.to_owned()))?;
312    if user.role == Role::Admin && users::admin_count(conn).map_err(other)? <= 1 {
313        return Err(AccountError::LastAdmin);
314    }
315    users::delete_user(conn, user.id).map_err(other)?;
316    Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn invite() -> Invite {
324        Invite::new("https://music.example.com/", "sarita", "p&ss word=#?")
325    }
326
327    #[test]
328    fn both_links_round_trip() {
329        let i = invite();
330        assert_eq!(i.server, "https://music.example.com");
331        assert!(i.link().starts_with("https://koan.rocks/join/#server="));
332        assert_eq!(Invite::parse(&i.link()), Some(i.clone()));
333        assert_eq!(Invite::parse(&i.app_link()), Some(i));
334    }
335
336    #[test]
337    fn the_join_page_without_its_slash_still_parses() {
338        let link = invite().link().replacen("/join/#", "/join#", 1);
339        assert_eq!(Invite::parse(&link), Some(invite()));
340    }
341
342    #[test]
343    fn an_address_with_the_account_in_it_is_split() {
344        assert_eq!(
345            Invite::parse("https://sarita:p%40ss+w@koan.example.com/"),
346            Some(Invite::new("https://koan.example.com", "sarita", "p@ss+w"))
347        );
348        assert_eq!(Invite::parse("https://sarita@koan.example.com"), None);
349    }
350
351    #[test]
352    fn other_links_and_missing_fields_are_refused() {
353        assert_eq!(Invite::parse("https://example.com/join/#server=x"), None);
354        assert_eq!(
355            Invite::parse("https://koan.rocks/#server=https://a&username=u&password=p"),
356            None
357        );
358        assert_eq!(
359            Invite::parse("koan://join?server=https://a&username=u"),
360            None
361        );
362        assert_eq!(
363            Invite::parse("koan://join?server=ftp://a&username=u&password=p"),
364            None
365        );
366        assert_eq!(Invite::parse("not a url"), None);
367    }
368
369    #[test]
370    fn credentials_stay_in_the_fragment() {
371        let url = Url::parse(&invite().link()).unwrap();
372        assert_eq!(url.query(), None);
373        assert!(!url.path().contains("sarita"));
374    }
375
376    #[test]
377    fn the_email_carries_the_link_and_the_details() {
378        let i = invite();
379        let text = i.email_text();
380        assert!(text.contains(&i.link()));
381        assert!(text.contains("Server URL: https://music.example.com"));
382        assert!(text.contains("Password: p&ss word=#?"));
383        assert!(i.email_html().contains("p&amp;ss word=#?"));
384        assert!(!i.mailto().contains(' '));
385    }
386
387    #[test]
388    fn generated_passwords_are_typeable() {
389        let p = generate_password().unwrap();
390        assert_eq!(p.len(), 20);
391        assert!(!p.contains(['0', 'O', '1', 'l', 'I']));
392        assert_ne!(p, generate_password().unwrap());
393    }
394
395    #[test]
396    fn accounts_are_created_recovered_and_guarded() {
397        let dir = tempfile::tempdir().unwrap();
398        let db = crate::db::connection::Database::open(&dir.path().join("t.db")).unwrap();
399        let conn = &db.conn;
400        let key = &[7; 32];
401        let admin = create_account(conn, key, "owner", Role::Admin).unwrap();
402        assert_eq!(account_password(conn, key, "owner", false).unwrap(), admin);
403        assert!(matches!(
404            create_account(conn, key, "owner", Role::User),
405            Err(AccountError::Taken(_))
406        ));
407        assert!(matches!(
408            create_account(conn, key, "two words", Role::User),
409            Err(AccountError::BadUsername)
410        ));
411        assert!(matches!(
412            set_role(conn, "owner", Role::User),
413            Err(AccountError::LastAdmin)
414        ));
415        assert!(matches!(
416            delete_account(conn, "owner"),
417            Err(AccountError::LastAdmin)
418        ));
419
420        let first = create_account(conn, key, "sarita", Role::Readonly).unwrap();
421        let reset = account_password(conn, key, "sarita", true).unwrap();
422        assert_ne!(first, reset);
423        assert_eq!(account_password(conn, key, "sarita", false).unwrap(), reset);
424        delete_account(conn, "sarita").unwrap();
425        assert!(matches!(
426            account_password(conn, key, "sarita", false),
427            Err(AccountError::NoSuchUser(_))
428        ));
429    }
430}