use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; use axum::{ extract::FromRequestParts, http::{header::AUTHORIZATION, request::Parts}, }; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::ApiError; use crate::models::UserRole; use crate::state::AppState; pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(password.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| ApiError::BadRequest(format!("password hashing failed: {e}"))) } pub fn verify_password(password: &str, hash: &str) -> bool { PasswordHash::new(hash) .map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()) .unwrap_or(false) } #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: Uuid, pub role: UserRole, pub shop_id: Option, pub exp: i64, } pub fn issue_token( secret: &str, ttl_secs: i64, user_id: Uuid, role: UserRole, shop_id: Option, ) -> Result { let claims = Claims { sub: user_id, role, shop_id, exp: (Utc::now() + Duration::seconds(ttl_secs)).timestamp(), }; encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()), ) .map_err(ApiError::internal) } /// Authenticated principal extracted from the Bearer token. #[derive(Debug, Clone)] pub struct AuthUser { pub id: Uuid, pub role: UserRole, pub shop_id: Option, } impl AuthUser { /// 403 unless the principal holds one of `roles`. Shop roles are further /// scoped to their own shop via [`AuthUser::own_shop`]. pub fn require(&self, roles: &[UserRole]) -> Result<(), ApiError> { if roles.contains(&self.role) { Ok(()) } else { Err(ApiError::Forbidden("insufficient role".into())) } } /// The shop this principal may operate on; 403 for non-shop roles. pub fn own_shop(&self) -> Result { if self.role.is_shop_role() { self.shop_id .ok_or_else(|| ApiError::Forbidden("account has no shop".into())) } else { Err(ApiError::Forbidden("shop role required".into())) } } } impl FromRequestParts for AuthUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let header = parts .headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or_else(|| ApiError::Unauthorized("missing bearer token".into()))?; let token = header .strip_prefix("Bearer ") .ok_or_else(|| ApiError::Unauthorized("malformed authorization header".into()))?; let data = decode::( token, &DecodingKey::from_secret(state.config.jwt_secret.as_bytes()), &Validation::default(), ) .map_err(|_| ApiError::Unauthorized("invalid or expired token".into()))?; Ok(AuthUser { id: data.claims.sub, role: data.claims.role, shop_id: data.claims.shop_id, }) } }