anvilsign in

collin/anvil

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