feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user