119 lines
3.4 KiB
Rust
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,
|
|
})
|
|
}
|
|
}
|