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