use anyhow::{Context, Result};
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::hash_token;
pub const SESSION_LIFETIME_DAYS: i64 = 14;
pub const SESSION_COOKIE: &str = "kasl_session";
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| anyhow::anyhow!("failed to hash the password: {error}"))
}
pub fn verify_password(password: &str, stored: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored) else {
tracing::error!("a stored password hash could not be parsed; the account cannot be logged into");
return false;
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
pub struct IssuedSession {
pub token: String,
pub expires_at: DateTime<Utc>,
}
pub async fn issue(pool: &PgPool, user_id: Uuid) -> Result<IssuedSession> {
use rand::RngExt;
let bytes: [u8; 32] = rand::rng().random();
let token = bytes.iter().fold(String::with_capacity(64), |mut acc, byte| {
use std::fmt::Write;
let _ = write!(acc, "{byte:02x}");
acc
});
let expires_at = Utc::now() + Duration::days(SESSION_LIFETIME_DAYS);
sqlx::query("INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
.bind(user_id)
.bind(hash_token(&token))
.bind(expires_at)
.execute(pool)
.await
.context("failed to store the session")?;
Ok(IssuedSession { token, expires_at })
}
#[derive(Debug, Clone, Copy)]
pub struct SessionUser {
pub session_id: Uuid,
pub user_id: Uuid,
pub role: crate::model::UserRole,
}
pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<SessionUser>> {
let row: Option<(Uuid, Uuid, crate::model::UserRole)> = sqlx::query_as(
"SELECT s.id, s.user_id, u.role FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now() AND u.active",
)
.bind(hash_token(token))
.fetch_optional(pool)
.await?;
let Some((session_id, user_id, role)) = row else { return Ok(None) };
if let Err(error) = sqlx::query("UPDATE sessions SET last_used_at = now(), expires_at = now() + ($2 || ' days')::interval WHERE id = $1")
.bind(session_id)
.bind(SESSION_LIFETIME_DAYS.to_string())
.execute(pool)
.await
{
tracing::warn!(%error, %session_id, "failed to extend the session");
}
Ok(Some(SessionUser { session_id, user_id, role }))
}
pub async fn revoke(pool: &PgPool, session_id: Uuid) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE id = $1").bind(session_id).execute(pool).await?;
Ok(())
}
pub async fn revoke_all(pool: &PgPool, user_id: Uuid) -> Result<u64> {
let deleted = sqlx::query("DELETE FROM sessions WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?
.rows_affected();
Ok(deleted)
}
pub async fn sweep_expired(pool: &PgPool) -> Result<u64> {
let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= now()")
.execute(pool)
.await?
.rows_affected();
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_verifies_against_its_own_hash_and_nothing_else() {
let hash = hash_password("correct horse battery staple").unwrap();
assert!(verify_password("correct horse battery staple", &hash));
assert!(!verify_password("Correct horse battery staple", &hash), "verification is exact");
assert!(!verify_password("", &hash));
}
#[test]
fn the_stored_form_reveals_nothing() {
let hash = hash_password("hunter2").unwrap();
assert!(!hash.contains("hunter2"), "the password must not survive in the hash");
assert!(hash.starts_with("$argon2id$"), "a memory-hard hash, not a bare digest: {hash}");
}
#[test]
fn the_same_password_hashes_differently_every_time() {
let first = hash_password("same").unwrap();
let second = hash_password("same").unwrap();
assert_ne!(first, second);
assert!(verify_password("same", &first) && verify_password("same", &second));
}
#[test]
fn a_damaged_hash_refuses_rather_than_admits() {
assert!(!verify_password("anything", ""));
assert!(!verify_password("anything", "not-a-hash"));
assert!(!verify_password("anything", "$argon2id$v=19$m=19456,t=2,p=1$truncated"));
}
}