use std::time::{SystemTime, UNIX_EPOCH}; use axum::extract::{Json, State, FromRequestParts}; use axum::response::IntoResponse; use axum::http::{request::Parts}; use axum_extra::extract::CookieJar; use axum_extra::extract::cookie::{Cookie, SameSite}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use time::Duration; use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::{ Error, PasswordHasher, SaltString, rand_core::OsRng }}; use crate::AppState; use crate::routes::errors::RouteError; #[derive(Serialize, Deserialize)] pub struct Claims { pub sub: String, pub exp: usize, pub iat: usize, pub username: String, pub is_admin: bool, } pub struct AuthUser(pub Claims); impl FromRequestParts for AuthUser { type Rejection = RouteError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let jar = CookieJar::from_request_parts(parts, state).await.unwrap(); let token = jar.get("token") .map(|c| c.value().to_string()) .ok_or(RouteError::AuthorizationFailure())?; let key = DecodingKey::from_secret(state.secret.as_ref()); let data = decode::(token, &key, &Validation::default()) .map_err(|_| RouteError::AuthorizationFailure())?; Ok(AuthUser(data.claims)) } } pub fn hash_password(password: &str) -> Result { let argon2 = Argon2::default(); let salt = SaltString::generate(&mut OsRng); Ok(argon2.hash_password(password.as_bytes(), &salt)?.to_string()) } pub fn check_password(password: &str, password_hash: &str) -> Result { let argon2 = Argon2::default(); let hash = PasswordHash::new(password_hash)?; Ok(argon2.verify_password(password.as_bytes(), &hash).is_ok()) } #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String, } pub async fn login( jar: CookieJar, State(state): State, Json(request): Json ) -> Result { let user = match state.database.fetch_user_by_email(&request.email) { Err(_) => return Err(RouteError::Internal("database action failed".into())), Ok(None) => return Err(RouteError::UnregisteredEmail(request.email)), Ok(Some(user)) => user }; match check_password(&request.password, user.password_hash()) { Err(_) => return Err(RouteError::Internal("failed to check password".into())), Ok(false) => return Err(RouteError::AuthorizationFailure()), Ok(true) => {}, } let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| RouteError::Internal("failed to access system clock".into()))? .as_secs() as usize; let claims = Claims { sub: user.email().to_string(), iat: now, exp: now + 60 * 60 * 24, username: user.username().to_string(), is_admin: false }; let key = EncodingKey::from_secret(state.secret.as_ref()); let token = encode(&Header::default(), &claims, &key) .map_err(|e| RouteError::Internal(format!("failed to encode jwt: {e}")))?; let cookie = Cookie::build(("token", token)) .path("/") .max_age(Duration::hours(24)) .same_site(SameSite::Strict) .http_only(true) .build(); Ok(jar.add(cookie)) } pub async fn logout( jar: CookieJar, ) -> Result { Ok(jar.remove(Cookie::from("token"))) } #[cfg(test)] mod tests { use super::*; #[test] fn test_password_hashing() { let passwords = vec!["password", "test1", "random"]; for password in &passwords { let hash = hash_password(password).unwrap(); assert!(check_password(password, &hash).unwrap()); } } }