1use anyhow::{Context, Result};
14use argon2::{
15 Argon2,
16 password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
17};
18use chrono::{DateTime, Duration, Utc};
19use sqlx::PgPool;
20use uuid::Uuid;
21
22use crate::auth::hash_token;
23
24pub const SESSION_LIFETIME_DAYS: i64 = 14;
29
30pub const SESSION_COOKIE: &str = "kasl_session";
33
34pub fn hash_password(password: &str) -> Result<String> {
36 let salt = SaltString::generate(&mut OsRng);
37 Argon2::default()
38 .hash_password(password.as_bytes(), &salt)
39 .map(|hash| hash.to_string())
40 .map_err(|error| anyhow::anyhow!("failed to hash the password: {error}"))
41}
42
43pub fn verify_password(password: &str, stored: &str) -> bool {
49 let Ok(parsed) = PasswordHash::new(stored) else {
50 tracing::error!("a stored password hash could not be parsed; the account cannot be logged into");
51 return false;
52 };
53
54 Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
55}
56
57pub struct IssuedSession {
59 pub token: String,
61 pub expires_at: DateTime<Utc>,
62}
63
64pub async fn issue(pool: &PgPool, user_id: Uuid) -> Result<IssuedSession> {
66 use rand::RngExt;
67
68 let bytes: [u8; 32] = rand::rng().random();
71 let token = bytes.iter().fold(String::with_capacity(64), |mut acc, byte| {
72 use std::fmt::Write;
73 let _ = write!(acc, "{byte:02x}");
74 acc
75 });
76 let expires_at = Utc::now() + Duration::days(SESSION_LIFETIME_DAYS);
77
78 sqlx::query("INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
79 .bind(user_id)
80 .bind(hash_token(&token))
81 .bind(expires_at)
82 .execute(pool)
83 .await
84 .context("failed to store the session")?;
85
86 Ok(IssuedSession { token, expires_at })
87}
88
89#[derive(Debug, Clone)]
91pub struct SessionUser {
92 pub session_id: Uuid,
93 pub user_id: Uuid,
94 pub role: crate::model::UserRole,
95 pub email: String,
98}
99
100pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
106 let row: Option<(Uuid, Uuid, crate::model::UserRole, String)> = sqlx::query_as(
107 "SELECT s.id, s.user_id, u.role, u.email FROM sessions s
108 JOIN users u ON u.id = s.user_id
109 WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
110 )
111 .bind(hash_token(token))
112 .fetch_optional(pool)
113 .await?;
114
115 let Some((session_id, user_id, role, email)) = row else { return Ok(None) };
116
117 if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
121 .bind(session_id)
122 .bind(SESSION_LIFETIME_DAYS.to_string())
123 .execute(pool)
124 .await
125 {
126 tracing::warn!(%error, %session_id, "failed to extend the session");
127 }
128
129 Ok(Some(SessionUser {
130 session_id,
131 user_id,
132 role,
133 email,
134 }))
135}
136
137pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
139 sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
140 Ok(())
141}
142
143pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
146 let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
147 .bind(user_id)
148 .execute(pool)
149 .await?
150 .rows_affected();
151 Ok(deleted)
152}
153
154pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
159 let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
160 .execute(pool)
161 .await?
162 .rows_affected();
163 Ok(deleted)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn a_password_verifies_against_its_own_hash_and_nothing_else() {
172 let hash = hash_password("correct horse battery staple").unwrap();
173 assert!(verify_password("correct horse battery staple", &hash));
174 assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
175 assert!(!verify_password("", &hash));
176 }
177
178 #[test]
179 fn the_stored_form_reveals_nothing() {
180 let hash = hash_password("hunter2").unwrap();
181 assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
182 assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
183 }
184
185 #[test]
186 fn the_same_password_hashes_differently_every_time() {
187 let first = hash_password("same").unwrap();
190 let second = hash_password("same").unwrap();
191 assert_ne!(first, second);
192 assert!(verify_password("same", &first) && verify_password("same", &second));
193 }
194
195 #[test]
196 fn a_damaged_hash_refuses_rather_than_admits() {
197 assert!(!verify_password("anything", ""));
200 assert!(!verify_password("anything", "not-a-hash"));
201 assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
202 }
203}