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/// Replace a user's password hash.
99///
100/// Returns [`Error::NotFound`] if no user has that id. Existing sessions are
101/// left alone — resetting a password does not sign anyone out.
102pub async fn set_password(db: &toasty::Db, user_id: i64, password: &str) -> Result<()> {
103 if password.is_empty() {
104 return Err(Error::Invalid("password must not be empty".into()));
105 }
106 let mut conn = db.clone();
107 let Some(mut user) = User::filter(User::fields().id().eq(user_id))
108 .first()
109 .exec(&mut conn)
110 .await?
111 else {
112 return Err(Error::NotFound(format!("user id {user_id}")));
113 };
114 let hash = hash_password(password)?;
115 let mut conn = db.clone();
116 user.update().password_hash(hash).exec(&mut conn).await?;
117 Ok(())
118}
119
120/// Look up a user by id.
121pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> {
122 let mut db = db.clone();
123 let user = User::filter(User::fields().id().eq(id))
124 .first()
125 .exec(&mut db)
126 .await?;
127 Ok(user)
128}
129
130/// Look up a user by exact username.
131pub async fn find_by_username(db: &toasty::Db, username: &str) -> Result<Option<User>> {
132 let mut db = db.clone();
133 let user = User::filter(User::fields().username().eq(username))
134 .first()
135 .exec(&mut db)
136 .await?;
137 Ok(user)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 /// A reset must persist a hash the login path accepts, and retire the old
145 /// password.
146 #[tokio::test]
147 async fn set_password_replaces_the_stored_hash() {
148 let dir = tempfile::tempdir().unwrap();
149 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
150
151 let user = create(&db, "alice", "", "old-pw", false).await.unwrap();
152 set_password(&db, user.id, "new-pw").await.unwrap();
153
154 let reloaded = find_by_id(&db, user.id).await.unwrap().unwrap();
155 assert!(verify_password(&reloaded.password_hash, "new-pw").unwrap());
156 assert!(!verify_password(&reloaded.password_hash, "old-pw").unwrap());
157 }
158
159 #[tokio::test]
160 async fn set_password_rejects_empty_and_unknown_users() {
161 let dir = tempfile::tempdir().unwrap();
162 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
163
164 let user = create(&db, "bob", "", "pw", false).await.unwrap();
165 assert!(matches!(
166 set_password(&db, user.id, "").await,
167 Err(Error::Invalid(_))
168 ));
169 assert!(matches!(
170 set_password(&db, user.id + 999, "pw").await,
171 Err(Error::NotFound(_))
172 ));
173 }
174}