1use anyhow::{Context, Result};
14use argon2::{
15 Argon2,
16 password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
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 Argon2::default()
40 .hash_password(password.as_bytes())
41 .map(|hash| hash.to_string())
42 .map_err(|error| anyhow::anyhow!("failed to hash the password: {error}"))
43}
44
45pub fn verify_password(password: &str, stored: &str) -> bool {
51 let Ok(parsed) = PasswordHash::new(stored) else {
52 tracing::error!("a stored password hash could not be parsed; the account cannot be logged into");
53 return false;
54 };
55
56 Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
57}
58
59pub struct IssuedSession {
61 pub token: String,
63 pub expires_at: DateTime<Utc>,
64}
65
66pub async fn issue(pool: &PgPool, user_id: Uuid) -> Result<IssuedSession> {
68 use rand::RngExt;
69
70 let bytes: [u8; 32] = rand::rng().random();
73 let token = bytes.iter().fold(String::with_capacity(64), |mut acc, byte| {
74 use std::fmt::Write;
75 let _ = write!(acc, "{byte:02x}");
76 acc
77 });
78 let expires_at = Utc::now() + Duration::days(SESSION_LIFETIME_DAYS);
79
80 sqlx::query("INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
81 .bind(user_id)
82 .bind(hash_token(&token))
83 .bind(expires_at)
84 .execute(pool)
85 .await
86 .context("failed to store the session")?;
87
88 Ok(IssuedSession { token, expires_at })
89}
90
91#[derive(Debug, Clone)]
93pub struct SessionUser {
94 pub session_id: Uuid,
95 pub user_id: Uuid,
96 pub role: crate::model::UserRole,
97 pub email: String,
100}
101
102pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
108 let row: Option<(Uuid, Uuid, crate::model::UserRole, String)> = sqlx::query_as(
109 "SELECT s.id, s.user_id, u.role, u.email FROM sessions s
110 JOIN users u ON u.id = s.user_id
111 WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
112 )
113 .bind(hash_token(token))
114 .fetch_optional(pool)
115 .await?;
116
117 let Some((session_id, user_id, role, email)) = row else { return Ok(None) };
118
119 if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
123 .bind(session_id)
124 .bind(SESSION_LIFETIME_DAYS.to_string())
125 .execute(pool)
126 .await
127 {
128 tracing::warn!(%error, %session_id, "failed to extend the session");
129 }
130
131 Ok(Some(SessionUser {
132 session_id,
133 user_id,
134 role,
135 email,
136 }))
137}
138
139pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
141 sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
142 Ok(())
143}
144
145pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
148 let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
149 .bind(user_id)
150 .execute(pool)
151 .await?
152 .rows_affected();
153 Ok(deleted)
154}
155
156pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
161 let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
162 .execute(pool)
163 .await?
164 .rows_affected();
165 Ok(deleted)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn a_password_verifies_against_its_own_hash_and_nothing_else() {
174 let hash = hash_password("correct horse battery staple").unwrap();
175 assert!(verify_password("correct horse battery staple", &hash));
176 assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
177 assert!(!verify_password("", &hash));
178 }
179
180 #[test]
181 fn a_hash_from_an_earlier_release_still_verifies() {
182 const RELEASED_0_12_0: &str = "$argon2id$v=19$m=19456,t=2,p=1$IuuVYrGRFHiMJVcyee67FQ$hKIMtAWUiR0BAJvDvau0apBSl9+rv/5T9kWC3aKerjg";
191
192 assert!(
193 verify_password("a fixture password", RELEASED_0_12_0),
194 "an upgrade must not invalidate stored passwords"
195 );
196 assert!(!verify_password("a different password", RELEASED_0_12_0));
197 }
198
199 #[test]
200 fn the_stored_form_reveals_nothing() {
201 let hash = hash_password("hunter2").unwrap();
202 assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
203 assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
204 }
205
206 #[test]
207 fn the_same_password_hashes_differently_every_time() {
208 let first = hash_password("same").unwrap();
211 let second = hash_password("same").unwrap();
212 assert_ne!(first, second);
213 assert!(verify_password("same", &first) && verify_password("same", &second));
214 }
215
216 #[test]
217 fn a_damaged_hash_refuses_rather_than_admits() {
218 assert!(!verify_password("anything", ""));
221 assert!(!verify_password("anything", "not-a-hash"));
222 assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
223 }
224}