Files
vmall/apps/api/src/auth.rs
T

119 lines
3.4 KiB
Rust

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<String, ApiError> {
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<Uuid>,
pub exp: i64,
}
pub fn issue_token(
secret: &str,
ttl_secs: i64,
user_id: Uuid,
role: UserRole,
shop_id: Option<Uuid>,
) -> Result<String, ApiError> {
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<Uuid>,
}
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<Uuid, ApiError> {
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<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
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::<Claims>(
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,
})
}
}