1use std::future::Future;
2use std::sync::LazyLock;
3
4use argon2::Argon2;
5use argon2::password_hash::phc::PasswordHash;
6use argon2::password_hash::{PasswordHasher, PasswordVerifier};
7use serde::{Deserialize, Serialize};
8
9use super::Policy;
10use crate::db::{DateTime, Db, DbValue, Executor, FromRow, Model, Row, ToDbValue, sql};
11use crate::{Error, Result};
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct User {
24 pub id: i64,
26 pub name: String,
28 pub email: String,
30 #[serde(skip_serializing, default)]
32 pub password: String,
33 pub email_verified_at: Option<DateTime>,
35 pub created_at: Option<DateTime>,
37 pub updated_at: Option<DateTime>,
39 #[serde(flatten, default)]
41 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
42}
43
44const NOT_EXTRA: &[&str] = &[
46 "id",
47 "name",
48 "email",
49 "password",
50 "email_verified_at",
51 "created_at",
52 "updated_at",
53 "sessions_revoked_at",
54 "remember_token",
55 "session_revoked",
56];
57
58impl FromRow for User {
59 fn from_row(row: &Row) -> std::result::Result<Self, crate::db::DbError> {
60 let extra = row
61 .columns()
62 .into_iter()
63 .filter(|column| !NOT_EXTRA.contains(column))
64 .map(|column| (column.to_owned(), row.json(column)))
65 .collect();
66 Ok(Self {
67 id: row.try_get("id")?,
68 name: row.try_get("name")?,
69 email: row.try_get("email")?,
70 password: row.try_get("password")?,
71 email_verified_at: row.try_get("email_verified_at")?,
72 created_at: row.try_get("created_at")?,
73 updated_at: row.try_get("updated_at")?,
74 extra,
75 })
76 }
77}
78
79impl Model for User {
80 const TABLE: &'static str = "users";
81 const SELECT_ALL: bool = true;
82 const COLUMNS: &'static [&'static str] = &[
83 "id",
84 "name",
85 "email",
86 "password",
87 "email_verified_at",
88 "created_at",
89 "updated_at",
90 ];
91
92 type Key = i64;
93
94 fn id(&self) -> i64 {
95 self.id
96 }
97
98 fn set_id(&mut self, id: i64) {
99 self.id = id;
100 }
101
102 fn values(&self) -> Vec<DbValue> {
103 vec![
104 self.name.to_db_value(),
105 self.email.to_db_value(),
106 self.password.to_db_value(),
107 self.email_verified_at.to_db_value(),
108 self.created_at.to_db_value(),
109 self.updated_at.to_db_value(),
110 ]
111 }
112
113 fn touch(&mut self, now: DateTime, creating: bool) {
114 if creating && self.created_at.is_none() {
115 self.created_at = Some(now);
116 }
117 self.updated_at = Some(now);
118 }
119}
120
121impl User {
122 pub fn find_by_email<'c, E: Executor<'c>>(
124 db: E,
125 email: &str,
126 ) -> impl Future<Output = Result<Option<Self>>> + Send {
127 Self::query()
128 .where_eq("email", normalize_email(email))
129 .first(db)
130 }
131
132 pub async fn register(db: &Db, name: &str, email: &str, password: &str) -> Result<Self> {
134 let user = Self {
135 name: name.trim().to_owned(),
136 email: normalize_email(email),
137 password: hash_password(password).await?,
138 ..Self::default()
139 };
140 let id = Self::create(db, user).await?.id;
141 Self::find_or_404(db, id).await
143 }
144
145 pub async fn set_password(&mut self, db: &Db, password: &str) -> Result {
149 self.password = hash_password(password).await?;
150 self.save(db).await
151 }
152
153 pub fn get<T: serde::de::DeserializeOwned>(&self, column: &str) -> Option<T> {
158 let value = self.extra.get(column)?;
159 serde_json::from_value(value.clone()).ok().or_else(|| {
160 let flag = value.as_i64().filter(|n| *n == 0 || *n == 1)?;
161 serde_json::from_value(serde_json::Value::Bool(flag == 1)).ok()
162 })
163 }
164
165 pub async fn set(&mut self, db: &Db, column: &str, value: impl ToDbValue) -> Result {
168 let plain = !column.is_empty()
169 && column
170 .chars()
171 .all(|c| c.is_ascii_alphanumeric() || c == '_');
172 if !plain || NOT_EXTRA.contains(&column) {
173 return Err(anyhow::anyhow!("User::set can't change `{column}`").into());
174 }
175 let value = value.to_db_value();
176 sql(format!(
177 "UPDATE users SET {} = ? WHERE id = ?",
178 crate::db::quote(column)
179 ))
180 .bind(value.clone())
181 .bind(self.id)
182 .execute(db)
183 .await?;
184 self.extra.insert(column.to_owned(), value.to_json());
185 Ok(())
186 }
187
188 pub async fn revoke_sessions(&self, db: &Db) -> Result {
191 revoke_sessions(db, self.id).await.map(|_| ())
192 }
193
194 pub(crate) async fn find_with_revocation(
197 db: &Db,
198 id: i64,
199 session_id: &str,
200 ) -> Result<Option<(Self, i64, bool)>> {
201 let row = sql(
202 "SELECT users.*, (SELECT COUNT(*) FROM revoked_sessions WHERE id = ?) AS session_revoked \
203 FROM users WHERE id = ?",
204 )
205 .bind(session_id)
206 .bind(id)
207 .fetch_optional(db)
208 .await?;
209 Ok(match row {
210 Some(row) => {
211 let revoked: i64 = row.try_get("session_revoked")?;
212 Some((
213 Self::from_row(&row)?,
214 row.try_get("sessions_revoked_at")?,
215 revoked > 0,
216 ))
217 }
218 None => None,
219 })
220 }
221
222 pub async fn attempt(db: &Db, email: &str, password: &str) -> Result<Option<Self>> {
226 let user = Self::find_by_email(db, email).await?;
227 let hash = user
228 .as_ref()
229 .map_or_else(dummy_hash, |u| u.password.clone());
230 let valid = verify_password(password, &hash).await;
231 let Some(mut user) = user.filter(|_| valid) else {
232 return Ok(None);
233 };
234 user.rehash_if_needed(db, password).await?;
235 Ok(Some(user))
236 }
237
238 pub(crate) async fn rehash_if_needed(&mut self, db: &Db, password: &str) -> Result {
241 if needs_rehash(&self.password) {
242 self.set_password(db, password).await?;
243 }
244 Ok(())
245 }
246
247 pub fn has_password(&self) -> bool {
252 !self.password.is_empty()
253 }
254
255 pub async fn check_password(&self, password: &str) -> bool {
257 verify_password(password, &self.password).await
258 }
259
260 pub fn can(&self, ability: &str, target: &impl Policy) -> bool {
262 target.allows(self, ability)
263 }
264
265 pub fn authorize(&self, ability: &str, target: &impl Policy) -> Result {
267 if self.can(ability, target) {
268 Ok(())
269 } else {
270 Err(Error::Forbidden)
271 }
272 }
273}
274
275pub(crate) async fn revoke_sessions(db: &Db, id: i64) -> Result<i64> {
277 let now = super::unix_millis();
278 sql("UPDATE users SET sessions_revoked_at = ? WHERE id = ?")
279 .bind(now)
280 .bind(id)
281 .execute(db)
282 .await?;
283 Ok(now)
284}
285
286pub(crate) fn normalize_email(email: &str) -> String {
290 email.trim().to_lowercase()
291}
292
293pub(crate) fn dummy_hash() -> String {
296 static HASH: LazyLock<String> = LazyLock::new(|| {
297 Argon2::default()
298 .hash_password(b"renox-timing-equaliser")
299 .map(|hash| hash.to_string())
300 .unwrap_or_default()
301 });
302 HASH.clone()
303}
304
305pub async fn hash_password(password: &str) -> Result<String> {
307 let password = password.to_owned();
308 tokio::task::spawn_blocking(move || {
309 Argon2::default()
310 .hash_password(password.as_bytes())
311 .map(|hash| hash.to_string())
312 .map_err(|err| anyhow::anyhow!("could not hash the password: {err}"))
313 })
314 .await
315 .map_err(anyhow::Error::from)?
316 .map_err(Error::from)
317}
318
319pub async fn verify_password(password: &str, hash: &str) -> bool {
322 let (password, hash) = (password.to_owned(), hash.to_owned());
323 tokio::task::spawn_blocking(move || {
324 if is_bcrypt(&hash) {
325 let hash = hash.replacen("$2y$", "$2b$", 1);
327 return bcrypt::verify(password.as_bytes(), &hash).unwrap_or(false);
328 }
329 PasswordHash::new(&hash).is_ok_and(|parsed| {
330 Argon2::default()
331 .verify_password(password.as_bytes(), &parsed)
332 .is_ok()
333 })
334 })
335 .await
336 .unwrap_or(false)
337}
338
339fn is_bcrypt(hash: &str) -> bool {
340 ["$2y$", "$2b$", "$2a$"].iter().any(|p| hash.starts_with(p))
341}
342
343pub fn needs_rehash(hash: &str) -> bool {
346 !hash.starts_with("$argon2id$")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[tokio::test]
354 async fn hashes_verify() {
355 let hash = hash_password("rahasia123").await.unwrap();
356 assert!(hash.starts_with("$argon2id$"));
357 assert!(verify_password("rahasia123", &hash).await);
358 assert!(!verify_password("salah", &hash).await);
359 assert!(!verify_password("rahasia123", "not a hash").await);
360 assert!(!needs_rehash(&hash));
361 }
362
363 #[tokio::test]
364 async fn laravel_bcrypt_hashes_verify() {
365 let laravel = bcrypt::hash("password", 4)
367 .unwrap()
368 .replacen("$2b$", "$2y$", 1);
369 assert!(laravel.starts_with("$2y$04$"));
370 assert!(verify_password("password", &laravel).await);
371 assert!(!verify_password("wrong", &laravel).await);
372 assert!(needs_rehash(&laravel));
373 }
374}