anvilsign in

collin/anvil

main / crates / anvil-core / src / users.rs
1//! User accounts: creation, lookup, and password hashing.
2
3use argon2::{
4 Argon2,
5 PasswordHash,
6 password_hash::{
7 PasswordHasher,
8 PasswordVerifier,
9 },
10};
11
12use 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.
22const 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.
44pub 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.
56pub 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.
69pub 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).
85fn 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.
100async 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.
131pub 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.
150pub 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.
160pub 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.
171pub 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`.
184pub 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.
202pub 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.
227pub 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).
262pub 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.
279async 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)]
317mod 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}