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.
51pub fn verify_password(hash: &str, password: &str) -> Result<bool> {
52 let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?;
53 Ok(Argon2::default()
54 .verify_password(password.as_bytes(), &parsed)
55 .is_ok())
56}
57
58/// Create a new user, hashing `password` before storage.
59///
60/// Returns [`Error::AlreadyExists`] if the username is taken.
61pub async fn create(
62 db: &toasty::Db,
63 username: &str,
64 email: &str,
65 password: &str,
66 is_admin: bool,
67) -> Result<User> {
68 if username.trim().is_empty() {
69 return Err(Error::Invalid("username must not be empty".into()));
70 }
71 // Usernames live in the URL root namespace (e.g. `/<username>`) and on disk
72 // under `repositories/<username>/`. Reject path-unsafe characters and names
73 // reserved for system routes (the `/-/…` prefix is reserved structurally,
74 // but we keep a denylist as defense-in-depth).
75 if username.contains('/') || username.contains('\\') || username.contains("..") {
76 return Err(Error::Invalid(format!("invalid username: {username:?}")));
77 }
78 if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) {
79 return Err(Error::Invalid(format!("username '{username}' is reserved")));
80 }
81 if find_by_username(db, username).await?.is_some() {
82 return Err(Error::AlreadyExists(format!("user {username}")));
83 }
84
85 let mut db = db.clone();
86 let user = toasty::create!(User {
87 username: username,
88 email: email,
89 password_hash: hash_password(password)?,
90 is_admin: is_admin,
91 created_at: crate::now(),
92 })
93 .exec(&mut db)
94 .await?;
95 Ok(user)
96}
97
98/// Look up a user by id.
99pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> {
100 let mut db = db.clone();
101 let user = User::filter(User::fields().id().eq(id))
102 .first()
103 .exec(&mut db)
104 .await?;
105 Ok(user)
106}
107
108/// Look up a user by exact username.
109pub async fn find_by_username(db: &toasty::Db, username: &str) -> Result<Option<User>> {
110 let mut db = db.clone();
111 let user = User::filter(User::fields().username().eq(username))
112 .first()
113 .exec(&mut db)
114 .await?;
115 Ok(user)
116}