| 1 | //! User accounts: creation, lookup, and password hashing. |
| 2 | |
| 3 | use argon2::{ |
| 4 | Argon2, |
| 5 | PasswordHash, |
| 6 | password_hash::{ |
| 7 | PasswordHasher, |
| 8 | PasswordVerifier, |
| 9 | }, |
| 10 | }; |
| 11 | |
| 12 | use crate::{ |
| 13 | error::{ |
| 14 | Error, |
| 15 | Result, |
| 16 | }, |
| 17 | models::User, |
| 18 | }; |
| 19 | |
| 20 | /// Usernames reserved for system use — they collide with (or could be confused |
| 21 | /// for) top-level routes such as the `/-/…` namespace. |
| 22 | const RESERVED_USERNAMES: &[&str] = &[ |
| 23 | "-", |
| 24 | "new", |
| 25 | "settings", |
| 26 | "login", |
| 27 | "logout", |
| 28 | "static", |
| 29 | "healthz", |
| 30 | "admin", |
| 31 | "api", |
| 32 | "about", |
| 33 | "help", |
| 34 | "assets", |
| 35 | "favicon.ico", |
| 36 | "robots.txt", |
| 37 | ]; |
| 38 | |
| 39 | /// Hash a plaintext password into a PHC-format Argon2 string. |
| 40 | /// |
| 41 | /// argon2 0.6 generates the salt itself rather than taking one, so there is no |
| 42 | /// `SaltString` here any more; it still draws from the OS and still lands in |
| 43 | /// the PHC string, so stored hashes are the same shape as before. |
| 44 | pub fn hash_password(password: &str) -> Result<String> { |
| 45 | Argon2::default() |
| 46 | .hash_password(password.as_bytes()) |
| 47 | .map(|h| h.to_string()) |
| 48 | .map_err(|e| Error::Password(e.to_string())) |
| 49 | } |
| 50 | |
| 51 | /// Verify a plaintext password against a stored PHC hash. |
| 52 | /// |
| 53 | /// An empty hash is not a parse failure to report but an account with no |
| 54 | /// password at all (one provisioned through single sign-on): every guess is |
| 55 | /// simply wrong. |
| 56 | pub fn verify_password(hash: &str, password: &str) -> Result<bool> { |
| 57 | if hash.is_empty() { |
| 58 | return Ok(false); |
| 59 | } |
| 60 | let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?; |
| 61 | Ok(Argon2::default() |
| 62 | .verify_password(password.as_bytes(), &parsed) |
| 63 | .is_ok()) |
| 64 | } |
| 65 | |
| 66 | /// Create a new user, hashing `password` before storage. |
| 67 | /// |
| 68 | /// Returns [`Error::AlreadyExists`] if the username is taken. |
| 69 | pub async fn create( |
| 70 | db: &toasty::Db, |
| 71 | username: &str, |
| 72 | email: &str, |
| 73 | password: &str, |
| 74 | is_admin: bool, |
| 75 | ) -> Result<User> { |
| 76 | insert(db, username, email, hash_password(password)?, is_admin, "").await |
| 77 | } |
| 78 | |
| 79 | /// Reject a username that cannot safely be one. |
| 80 | /// |
| 81 | /// Usernames live in the URL root namespace (e.g. `/<username>`) and on disk |
| 82 | /// under `repositories/<username>/`, so path-unsafe characters are out, as are |
| 83 | /// names reserved for system routes (the `/-/…` prefix is reserved |
| 84 | /// structurally, but we keep a denylist as defense-in-depth). |
| 85 | fn validate_username(username: &str) -> Result<()> { |
| 86 | if username.trim().is_empty() { |
| 87 | return Err(Error::Invalid("username must not be empty".into())); |
| 88 | } |
| 89 | if username.contains('/') || username.contains('\\') || username.contains("..") { |
| 90 | return Err(Error::Invalid(format!("invalid username: {username:?}"))); |
| 91 | } |
| 92 | if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) { |
| 93 | return Err(Error::Invalid(format!("username '{username}' is reserved"))); |
| 94 | } |
| 95 | Ok(()) |
| 96 | } |
| 97 | |
| 98 | /// Insert a user row from an already-hashed password. The one place a `User` |
| 99 | /// is created, so every path shares the name checks. |
| 100 | async fn insert( |
| 101 | db: &toasty::Db, |
| 102 | username: &str, |
| 103 | email: &str, |
| 104 | password_hash: String, |
| 105 | is_admin: bool, |
| 106 | sso_sub: &str, |
| 107 | ) -> Result<User> { |
| 108 | validate_username(username)?; |
| 109 | if find_by_username(db, username).await?.is_some() { |
| 110 | return Err(Error::AlreadyExists(format!("user {username}"))); |
| 111 | } |
| 112 | |
| 113 | let mut db = db.clone(); |
| 114 | let user = toasty::create!(User { |
| 115 | username: username, |
| 116 | email: email, |
| 117 | password_hash: password_hash, |
| 118 | is_admin: is_admin, |
| 119 | created_at: crate::now(), |
| 120 | sso_sub: sso_sub, |
| 121 | }) |
| 122 | .exec(&mut db) |
| 123 | .await?; |
| 124 | Ok(user) |
| 125 | } |
| 126 | |
| 127 | /// Replace a user's password hash. |
| 128 | /// |
| 129 | /// Returns [`Error::NotFound`] if no user has that id. Existing sessions are |
| 130 | /// left alone — resetting a password does not sign anyone out. |
| 131 | pub async fn set_password(db: &toasty::Db, user_id: i64, password: &str) -> Result<()> { |
| 132 | if password.is_empty() { |
| 133 | return Err(Error::Invalid("password must not be empty".into())); |
| 134 | } |
| 135 | let mut conn = db.clone(); |
| 136 | let Some(mut user) = User::filter(User::fields().id().eq(user_id)) |
| 137 | .first() |
| 138 | .exec(&mut conn) |
| 139 | .await? |
| 140 | else { |
| 141 | return Err(Error::NotFound(format!("user id {user_id}"))); |
| 142 | }; |
| 143 | let hash = hash_password(password)?; |
| 144 | let mut conn = db.clone(); |
| 145 | user.update().password_hash(hash).exec(&mut conn).await?; |
| 146 | Ok(()) |
| 147 | } |
| 148 | |
| 149 | /// Look up a user by id. |
| 150 | pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> { |
| 151 | let mut db = db.clone(); |
| 152 | let user = User::filter(User::fields().id().eq(id)) |
| 153 | .first() |
| 154 | .exec(&mut db) |
| 155 | .await?; |
| 156 | Ok(user) |
| 157 | } |
| 158 | |
| 159 | /// Look up a user by exact username. |
| 160 | pub async fn find_by_username(db: &toasty::Db, username: &str) -> Result<Option<User>> { |
| 161 | let mut db = db.clone(); |
| 162 | let user = User::filter(User::fields().username().eq(username)) |
| 163 | .first() |
| 164 | .exec(&mut db) |
| 165 | .await?; |
| 166 | Ok(user) |
| 167 | } |
| 168 | |
| 169 | /// Look up a user by exact email. Empty matches nothing: plenty of accounts |
| 170 | /// have no address, and they are not all the same person. |
| 171 | pub async fn find_by_email(db: &toasty::Db, email: &str) -> Result<Option<User>> { |
| 172 | if email.is_empty() { |
| 173 | return Ok(None); |
| 174 | } |
| 175 | let mut db = db.clone(); |
| 176 | let user = User::filter(User::fields().email().eq(email)) |
| 177 | .first() |
| 178 | .exec(&mut db) |
| 179 | .await?; |
| 180 | Ok(user) |
| 181 | } |
| 182 | |
| 183 | /// Look up the account linked to an OIDC `sub`. |
| 184 | pub async fn find_by_sso_sub(db: &toasty::Db, sub: &str) -> Result<Option<User>> { |
| 185 | if sub.is_empty() { |
| 186 | return Ok(None); |
| 187 | } |
| 188 | let mut db = db.clone(); |
| 189 | let user = User::filter(User::fields().sso_sub().eq(sub)) |
| 190 | .first() |
| 191 | .exec(&mut db) |
| 192 | .await?; |
| 193 | Ok(user) |
| 194 | } |
| 195 | |
| 196 | /// Link an existing account to an OIDC `sub`, so later sign-ins find it by |
| 197 | /// subject rather than by address. |
| 198 | /// |
| 199 | /// Refuses an account already linked to a *different* subject: that is either |
| 200 | /// a provider reissuing subjects or two identities converging on one row, and |
| 201 | /// silently repointing it would hand one person another's account. |
| 202 | pub async fn link_sso_sub(db: &toasty::Db, user_id: i64, sub: &str) -> Result<User> { |
| 203 | let Some(mut user) = find_by_id(db, user_id).await? else { |
| 204 | return Err(Error::NotFound(format!("user id {user_id}"))); |
| 205 | }; |
| 206 | if user.sso_sub == sub { |
| 207 | return Ok(user); |
| 208 | } |
| 209 | if !user.sso_sub.is_empty() { |
| 210 | return Err(Error::Invalid(format!( |
| 211 | "{} is already linked to a different sign-in identity", |
| 212 | user.username |
| 213 | ))); |
| 214 | } |
| 215 | let mut conn = db.clone(); |
| 216 | user.update().sso_sub(sub).exec(&mut conn).await?; |
| 217 | Ok(user) |
| 218 | } |
| 219 | |
| 220 | /// Copy the claims the provider owns onto a linked account: the address it |
| 221 | /// vouches for, and whether this app considers them an admin. |
| 222 | /// |
| 223 | /// The email is skipped when another account already holds it — the provider |
| 224 | /// is authoritative about identity, not about which local row gets the string. |
| 225 | /// `is_admin` is `None` when the provider asserted no role, which leaves the |
| 226 | /// local flag alone rather than quietly demoting an admin. |
| 227 | pub async fn sync_from_sso( |
| 228 | db: &toasty::Db, |
| 229 | user_id: i64, |
| 230 | email: &str, |
| 231 | is_admin: Option<bool>, |
| 232 | ) -> Result<User> { |
| 233 | let Some(mut user) = find_by_id(db, user_id).await? else { |
| 234 | return Err(Error::NotFound(format!("user id {user_id}"))); |
| 235 | }; |
| 236 | let taken = match find_by_email(db, email).await? { |
| 237 | Some(other) => other.id != user.id, |
| 238 | None => false, |
| 239 | }; |
| 240 | let email = if email.is_empty() || taken { |
| 241 | user.email.clone() |
| 242 | } else { |
| 243 | email.to_string() |
| 244 | }; |
| 245 | let is_admin = is_admin.unwrap_or(user.is_admin); |
| 246 | if user.email == email && user.is_admin == is_admin { |
| 247 | return Ok(user); |
| 248 | } |
| 249 | let mut conn = db.clone(); |
| 250 | user.update() |
| 251 | .email(email) |
| 252 | .is_admin(is_admin) |
| 253 | .exec(&mut conn) |
| 254 | .await?; |
| 255 | Ok(user) |
| 256 | } |
| 257 | |
| 258 | /// Create an account for an identity the provider vouches for. It has no |
| 259 | /// password: `password_hash` is empty, which |
| 260 | /// [`verify_password`] refuses unconditionally, so the only way in is the |
| 261 | /// provider (or an admin setting a password later). |
| 262 | pub async fn create_from_sso( |
| 263 | db: &toasty::Db, |
| 264 | preferred_username: &str, |
| 265 | email: &str, |
| 266 | is_admin: bool, |
| 267 | sub: &str, |
| 268 | ) -> Result<User> { |
| 269 | let username = allocate_username(db, preferred_username, email).await?; |
| 270 | insert(db, &username, email, String::new(), is_admin, sub).await |
| 271 | } |
| 272 | |
| 273 | /// Pick a free, path-safe username from what the provider suggested. |
| 274 | /// |
| 275 | /// The provider's `preferred_username` is a display preference, not a |
| 276 | /// namespace reservation: it can collide, be reserved, or contain characters a |
| 277 | /// URL path cannot. Sanitize it, fall back to the email's local part, then |
| 278 | /// append `-2`, `-3`, … until one is free. |
| 279 | async fn allocate_username(db: &toasty::Db, preferred: &str, email: &str) -> Result<String> { |
| 280 | let sanitize = |raw: &str| -> String { |
| 281 | raw.trim() |
| 282 | .to_ascii_lowercase() |
| 283 | .chars() |
| 284 | .map(|c| match c { |
| 285 | 'a'..='z' | '0'..='9' | '-' | '_' => c, |
| 286 | _ => '-', |
| 287 | }) |
| 288 | .collect::<String>() |
| 289 | .trim_matches('-') |
| 290 | .to_string() |
| 291 | }; |
| 292 | |
| 293 | let base = [preferred, email.split('@').next().unwrap_or_default()] |
| 294 | .into_iter() |
| 295 | .map(sanitize) |
| 296 | .find(|s| !s.is_empty() && validate_username(s).is_ok()) |
| 297 | .unwrap_or_else(|| "user".to_string()); |
| 298 | |
| 299 | for suffix in 1..1000 { |
| 300 | let candidate = if suffix == 1 { |
| 301 | base.clone() |
| 302 | } else { |
| 303 | format!("{base}-{suffix}") |
| 304 | }; |
| 305 | if validate_username(&candidate).is_ok() |
| 306 | && find_by_username(db, &candidate).await?.is_none() |
| 307 | { |
| 308 | return Ok(candidate); |
| 309 | } |
| 310 | } |
| 311 | Err(Error::AlreadyExists(format!( |
| 312 | "no free username near {base}" |
| 313 | ))) |
| 314 | } |
| 315 | |
| 316 | #[cfg(test)] |
| 317 | mod tests { |
| 318 | use super::*; |
| 319 | |
| 320 | /// Every account provisioned before an argon2 bump has its password stored |
| 321 | /// as a PHC string written by the *old* library, and the only thing that |
| 322 | /// keeps those logins working is that the new one still reads them. This |
| 323 | /// hash was produced by argon2 0.5 for the password below; if a future |
| 324 | /// bump breaks it, every existing account is locked out and no other test |
| 325 | /// here would notice, because they all hash and verify within one version. |
| 326 | const PHC_FROM_ARGON2_0_5: &str = "$argon2id$v=19$m=19456,t=2,p=1$\ |
| 327 | ERcTTFqHk+Nvke8V0i1j6Q$jQrVdlL3mxHw5/0culSLYUXyHX5++eQGOUI/W3txoR4"; |
| 328 | |
| 329 | #[test] |
| 330 | fn verifies_a_hash_written_by_an_older_argon2() { |
| 331 | assert!( |
| 332 | verify_password(PHC_FROM_ARGON2_0_5, "correct horse battery staple").unwrap(), |
| 333 | "a password hash written by argon2 0.5 no longer verifies" |
| 334 | ); |
| 335 | assert!(!verify_password(PHC_FROM_ARGON2_0_5, "wrong password").unwrap()); |
| 336 | } |
| 337 | |
| 338 | /// A reset must persist a hash the login path accepts, and retire the old |
| 339 | /// password. |
| 340 | #[tokio::test] |
| 341 | async fn set_password_replaces_the_stored_hash() { |
| 342 | let dir = tempfile::tempdir().unwrap(); |
| 343 | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); |
| 344 | |
| 345 | let user = create(&db, "alice", "", "old-pw", false).await.unwrap(); |
| 346 | set_password(&db, user.id, "new-pw").await.unwrap(); |
| 347 | |
| 348 | let reloaded = find_by_id(&db, user.id).await.unwrap().unwrap(); |
| 349 | assert!(verify_password(&reloaded.password_hash, "new-pw").unwrap()); |
| 350 | assert!(!verify_password(&reloaded.password_hash, "old-pw").unwrap()); |
| 351 | } |
| 352 | |
| 353 | #[tokio::test] |
| 354 | async fn set_password_rejects_empty_and_unknown_users() { |
| 355 | let dir = tempfile::tempdir().unwrap(); |
| 356 | let db = crate::db::connect(dir.path().join("t.db")).await.unwrap(); |
| 357 | |
| 358 | let user = create(&db, "bob", "", "pw", false).await.unwrap(); |
| 359 | assert!(matches!( |
| 360 | set_password(&db, user.id, "").await, |
| 361 | Err(Error::Invalid(_)) |
| 362 | )); |
| 363 | assert!(matches!( |
| 364 | set_password(&db, user.id + 999, "pw").await, |
| 365 | Err(Error::NotFound(_)) |
| 366 | )); |
| 367 | } |
| 368 | } |