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::{
11 ColumnKind, DateTime, Db, DbValue, Executor, FromRow, Model, ModelColumn, Row, ToDbValue, sql,
12};
13use crate::{Error, Result};
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct User {
26 pub id: i64,
28 pub name: String,
30 pub email: String,
32 #[serde(skip_serializing, default)]
34 pub password: String,
35 pub email_verified_at: Option<DateTime>,
37 pub created_at: Option<DateTime>,
39 pub updated_at: Option<DateTime>,
41 #[serde(flatten, default)]
43 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
44}
45
46const NOT_EXTRA: &[&str] = &[
48 "id",
49 "name",
50 "email",
51 "password",
52 "email_verified_at",
53 "created_at",
54 "updated_at",
55 "sessions_revoked_at",
56 "remember_token",
57 "session_revoked",
58];
59
60impl FromRow for User {
61 fn from_row(row: &Row) -> std::result::Result<Self, crate::db::DbError> {
62 let extra = row
63 .columns()
64 .into_iter()
65 .filter(|column| !NOT_EXTRA.contains(column))
66 .map(|column| (column.to_owned(), row.json(column)))
67 .collect();
68 Ok(Self {
69 id: row.try_get("id")?,
70 name: row.try_get("name")?,
71 email: row.try_get("email")?,
72 password: row.try_get("password")?,
73 email_verified_at: row.try_get("email_verified_at")?,
74 created_at: row.try_get("created_at")?,
75 updated_at: row.try_get("updated_at")?,
76 extra,
77 })
78 }
79}
80
81impl Model for User {
82 const TABLE: &'static str = "users";
83 const SELECT_ALL: bool = true;
84 const COLUMNS: &'static [&'static str] = &[
85 "id",
86 "name",
87 "email",
88 "password",
89 "email_verified_at",
90 "created_at",
91 "updated_at",
92 ];
93
94 type Key = i64;
95
96 fn id(&self) -> i64 {
97 self.id
98 }
99
100 fn set_id(&mut self, id: i64) {
101 self.id = id;
102 }
103
104 fn values(&self) -> Vec<DbValue> {
105 vec![
106 self.name.to_db_value(),
107 self.email.to_db_value(),
108 self.password.to_db_value(),
109 self.email_verified_at.to_db_value(),
110 self.created_at.to_db_value(),
111 self.updated_at.to_db_value(),
112 ]
113 }
114
115 fn touch(&mut self, now: DateTime, creating: bool) {
116 if creating && self.created_at.is_none() {
117 self.created_at = Some(now);
118 }
119 self.updated_at = Some(now);
120 }
121
122 fn column_info() -> Vec<ModelColumn> {
123 let col = ModelColumn::new;
124 vec![
125 col("id", "i64", ColumnKind::BigInt, false),
126 col("name", "String", ColumnKind::Text, false),
127 col("email", "String", ColumnKind::Text, false),
128 col("password", "String", ColumnKind::Text, false),
129 col(
130 "email_verified_at",
131 "Option<DateTime>",
132 ColumnKind::DateTime,
133 true,
134 ),
135 col("created_at", "Option<DateTime>", ColumnKind::DateTime, true),
136 col("updated_at", "Option<DateTime>", ColumnKind::DateTime, true),
137 ]
138 }
139}
140
141impl User {
142 pub fn find_by_email<'c, E: Executor<'c>>(
144 db: E,
145 email: &str,
146 ) -> impl Future<Output = Result<Option<Self>>> + Send {
147 Self::query()
148 .where_eq("email", normalize_email(email))
149 .first(db)
150 }
151
152 pub async fn register(db: &Db, name: &str, email: &str, password: &str) -> Result<Self> {
154 let user = Self {
155 name: name.trim().to_owned(),
156 email: normalize_email(email),
157 password: hash_password(password).await?,
158 ..Self::default()
159 };
160 let id = Self::create(db, user).await?.id;
161 Self::find_or_404(db, id).await
163 }
164
165 pub async fn set_password(&mut self, db: &Db, password: &str) -> Result {
169 self.password = hash_password(password).await?;
170 self.save(db).await
171 }
172
173 pub fn get<T: serde::de::DeserializeOwned>(&self, column: &str) -> Option<T> {
178 let value = self.extra.get(column)?;
179 serde_json::from_value(value.clone()).ok().or_else(|| {
180 let flag = value.as_i64().filter(|n| *n == 0 || *n == 1)?;
181 serde_json::from_value(serde_json::Value::Bool(flag == 1)).ok()
182 })
183 }
184
185 pub async fn set(&mut self, db: &Db, column: &str, value: impl ToDbValue) -> Result {
188 let plain = !column.is_empty()
189 && column
190 .chars()
191 .all(|c| c.is_ascii_alphanumeric() || c == '_');
192 if !plain || NOT_EXTRA.contains(&column) {
193 return Err(anyhow::anyhow!("User::set can't change `{column}`").into());
194 }
195 let value = value.to_db_value();
196 sql(format!(
197 "UPDATE users SET {} = ? WHERE id = ?",
198 crate::db::quote(column)
199 ))
200 .bind(value.clone())
201 .bind(self.id)
202 .execute(db)
203 .await?;
204 self.extra.insert(column.to_owned(), value.to_json());
205 Ok(())
206 }
207
208 pub async fn revoke_sessions(&self, db: &Db) -> Result {
211 revoke_sessions(db, self.id).await.map(|_| ())
212 }
213
214 pub(crate) async fn find_with_revocation(
217 db: &Db,
218 id: i64,
219 session_id: &str,
220 ) -> Result<Option<(Self, i64, bool)>> {
221 let row = sql(
222 "SELECT users.*, (SELECT COUNT(*) FROM revoked_sessions WHERE id = ?) AS session_revoked \
223 FROM users WHERE id = ?",
224 )
225 .bind(session_id)
226 .bind(id)
227 .fetch_optional(db)
228 .await?;
229 Ok(match row {
230 Some(row) => {
231 let revoked: i64 = row.try_get("session_revoked")?;
232 Some((
233 Self::from_row(&row)?,
234 row.try_get("sessions_revoked_at")?,
235 revoked > 0,
236 ))
237 }
238 None => None,
239 })
240 }
241
242 pub async fn attempt(db: &Db, email: &str, password: &str) -> Result<Option<Self>> {
246 let user = Self::find_by_email(db, email).await?;
247 let hash = user
248 .as_ref()
249 .map_or_else(dummy_hash, |u| u.password.clone());
250 let valid = verify_password(password, &hash).await;
251 let Some(mut user) = user.filter(|_| valid) else {
252 return Ok(None);
253 };
254 user.rehash_if_needed(db, password).await?;
255 Ok(Some(user))
256 }
257
258 pub(crate) async fn rehash_if_needed(&mut self, db: &Db, password: &str) -> Result {
261 if needs_rehash(&self.password) {
262 self.set_password(db, password).await?;
263 }
264 Ok(())
265 }
266
267 pub fn has_password(&self) -> bool {
272 !self.password.is_empty()
273 }
274
275 pub async fn check_password(&self, password: &str) -> bool {
277 verify_password(password, &self.password).await
278 }
279
280 pub fn can(&self, ability: &str, target: &impl Policy) -> bool {
282 target.allows(self, ability)
283 }
284
285 pub fn authorize(&self, ability: &str, target: &impl Policy) -> Result {
287 if self.can(ability, target) {
288 Ok(())
289 } else {
290 Err(Error::Forbidden)
291 }
292 }
293}
294
295pub(crate) async fn revoke_sessions(db: &Db, id: i64) -> Result<i64> {
297 let now = super::unix_millis();
298 sql("UPDATE users SET sessions_revoked_at = ? WHERE id = ?")
299 .bind(now)
300 .bind(id)
301 .execute(db)
302 .await?;
303 Ok(now)
304}
305
306pub fn normalize_email(email: &str) -> String {
317 email.trim().to_lowercase()
318}
319
320pub(crate) fn dummy_hash() -> String {
323 static HASH: LazyLock<String> = LazyLock::new(|| {
324 Argon2::default()
325 .hash_password(b"renox-timing-equaliser")
326 .map(|hash| hash.to_string())
327 .unwrap_or_default()
328 });
329 HASH.clone()
330}
331
332pub async fn hash_password(password: &str) -> Result<String> {
334 let password = password.to_owned();
335 tokio::task::spawn_blocking(move || {
336 Argon2::default()
337 .hash_password(password.as_bytes())
338 .map(|hash| hash.to_string())
339 .map_err(|err| anyhow::anyhow!("could not hash the password: {err}"))
340 })
341 .await
342 .map_err(anyhow::Error::from)?
343 .map_err(Error::from)
344}
345
346pub async fn verify_password(password: &str, hash: &str) -> bool {
349 let (password, hash) = (password.to_owned(), hash.to_owned());
350 tokio::task::spawn_blocking(move || {
351 if is_bcrypt(&hash) {
352 let hash = hash.replacen("$2y$", "$2b$", 1);
354 return bcrypt::verify(password.as_bytes(), &hash).unwrap_or(false);
355 }
356 PasswordHash::new(&hash).is_ok_and(|parsed| {
357 Argon2::default()
358 .verify_password(password.as_bytes(), &parsed)
359 .is_ok()
360 })
361 })
362 .await
363 .unwrap_or(false)
364}
365
366fn is_bcrypt(hash: &str) -> bool {
367 ["$2y$", "$2b$", "$2a$"].iter().any(|p| hash.starts_with(p))
368}
369
370pub fn needs_rehash(hash: &str) -> bool {
373 !hash.starts_with("$argon2id$")
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[tokio::test]
381 async fn hashes_verify() {
382 let hash = hash_password("rahasia123").await.unwrap();
383 assert!(hash.starts_with("$argon2id$"));
384 assert!(verify_password("rahasia123", &hash).await);
385 assert!(!verify_password("salah", &hash).await);
386 assert!(!verify_password("rahasia123", "not a hash").await);
387 assert!(!needs_rehash(&hash));
388 }
389
390 #[tokio::test]
391 async fn laravel_bcrypt_hashes_verify() {
392 let laravel = bcrypt::hash("password", 4)
394 .unwrap()
395 .replacen("$2b$", "$2y$", 1);
396 assert!(laravel.starts_with("$2y$04$"));
397 assert!(verify_password("password", &laravel).await);
398 assert!(!verify_password("wrong", &laravel).await);
399 assert!(needs_rehash(&laravel));
400 }
401}