anvilsign in

collin/anvil

1//! User accounts: creation, lookup, and password hashing.
2
3use argon2::Argon2;
4use argon2::password_hash::rand_core::OsRng;
5use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
6
7use crate::error::{Error, Result};
8use crate::models::User;
9
10/// Usernames reserved for system use — they collide with (or could be confused
11/// for) top-level routes such as the `/-/…` namespace.
12const RESERVED_USERNAMES: &[&str] = &[
13 "-",
14 "new",
15 "settings",
16 "login",
17 "logout",
18 "static",
19 "healthz",
20 "admin",
21 "api",
22 "about",
23 "help",
24 "assets",
25 "favicon.ico",
26 "robots.txt",
27];
28
29/// Hash a plaintext password into a PHC-format Argon2 string.
30pub fn hash_password(password: &str) -> Result<String> {
31 let salt = SaltString::generate(&mut OsRng);
32 Argon2::default()
33 .hash_password(password.as_bytes(), &salt)
34 .map(|h| h.to_string())
35 .map_err(|e| Error::Password(e.to_string()))
36}
37
38/// Verify a plaintext password against a stored PHC hash.
39pub fn verify_password(hash: &str, password: &str) -> Result<bool> {
40 let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?;
41 Ok(Argon2::default()
42 .verify_password(password.as_bytes(), &parsed)
43 .is_ok())
44}
45
46/// Create a new user, hashing `password` before storage.
47///
48/// Returns [`Error::AlreadyExists`] if the username is taken.
49pub async fn create(
50 db: &toasty::Db,
51 username: &str,
52 email: &str,
53 password: &str,
54 is_admin: bool,
55) -> Result<User> {
56 if username.trim().is_empty() {
57 return Err(Error::Invalid("username must not be empty".into()));
58 }
59 // Usernames live in the URL root namespace (e.g. `/<username>`) and on disk
60 // under `repositories/<username>/`. Reject path-unsafe characters and names
61 // reserved for system routes (the `/-/…` prefix is reserved structurally,
62 // but we keep a denylist as defense-in-depth).
63 if username.contains('/') || username.contains('\\') || username.contains("..") {
64 return Err(Error::Invalid(format!("invalid username: {username:?}")));
65 }
66 if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) {
67 return Err(Error::Invalid(format!("username '{username}' is reserved")));
68 }
69 if find_by_username(db, username).await?.is_some() {
70 return Err(Error::AlreadyExists(format!("user {username}")));
71 }
72
73 let mut db = db.clone();
74 let user = toasty::create!(User {
75 username: username,
76 email: email,
77 password_hash: hash_password(password)?,
78 is_admin: is_admin,
79 created_at: crate::now(),
80 })
81 .exec(&mut db)
82 .await?;
83 Ok(user)
84}
85
86/// Look up a user by id.
87pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> {
88 let mut db = db.clone();
89 let user = User::filter(User::fields().id().eq(id))
90 .first()
91 .exec(&mut db)
92 .await?;
93 Ok(user)
94}
95
96/// Look up a user by exact username.
97pub async fn find_by_username(db: &toasty::Db, username: &str) -> Result<Option<User>> {
98 let mut db = db.clone();
99 let user = User::filter(User::fields().username().eq(username))
100 .first()
101 .exec(&mut db)
102 .await?;
103 Ok(user)
104}