feat(auth): implement core service layer (password, JWT, auth, user CRUD)
- error.rs: AuthError with proper HTTP status mapping - service/password.rs: Argon2 hash/verify with tests - service/token_service.rs: JWT sign/validate, token DB storage with SHA-256 hash - service/auth_service.rs: login/refresh/logout flows with event publishing - service/user_service.rs: user CRUD with soft delete and tenant isolation - Added sha2 dependency to workspace for token hashing
This commit is contained in:
@@ -59,6 +59,9 @@ jsonwebtoken = "9"
|
|||||||
# Password hashing
|
# Password hashing
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
|
||||||
|
# Cryptographic hashing (token storage)
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
# API docs
|
# API docs
|
||||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||||
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
|
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ anyhow.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
jsonwebtoken.workspace = true
|
jsonwebtoken.workspace = true
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
validator.workspace = true
|
validator.workspace = true
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
|
|||||||
42
crates/erp-auth/src/error.rs
Normal file
42
crates/erp-auth/src/error.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
/// Auth module error types
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthError {
|
||||||
|
#[error("用户名或密码错误")]
|
||||||
|
InvalidCredentials,
|
||||||
|
|
||||||
|
#[error("Token 已过期")]
|
||||||
|
TokenExpired,
|
||||||
|
|
||||||
|
#[error("Token 已被吊销")]
|
||||||
|
TokenRevoked,
|
||||||
|
|
||||||
|
#[error("用户已被{0}")]
|
||||||
|
UserDisabled(String),
|
||||||
|
|
||||||
|
#[error("密码哈希错误")]
|
||||||
|
HashError(String),
|
||||||
|
|
||||||
|
#[error("JWT 错误: {0}")]
|
||||||
|
JwtError(#[from] jsonwebtoken::errors::Error),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthError> for AppError {
|
||||||
|
fn from(err: AuthError) -> Self {
|
||||||
|
match err {
|
||||||
|
AuthError::InvalidCredentials => AppError::Unauthorized,
|
||||||
|
AuthError::TokenExpired => AppError::Unauthorized,
|
||||||
|
AuthError::TokenRevoked => AppError::Unauthorized,
|
||||||
|
AuthError::UserDisabled(s) => AppError::Forbidden(s),
|
||||||
|
AuthError::Validation(s) => AppError::Validation(s),
|
||||||
|
AuthError::HashError(_) => AppError::Internal(err.to_string()),
|
||||||
|
AuthError::JwtError(_) => AppError::Unauthorized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AuthResult<T> = Result<T, AuthError>;
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
pub mod dto;
|
pub mod dto;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
|
pub mod error;
|
||||||
|
pub mod service;
|
||||||
|
|||||||
248
crates/erp-auth/src/service/auth_service.rs
Normal file
248
crates/erp-auth/src/service/auth_service.rs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{LoginResp, RoleResp, UserResp};
|
||||||
|
use crate::entity::{role, user, user_credential, user_role};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
use super::password;
|
||||||
|
use super::token_service::TokenService;
|
||||||
|
|
||||||
|
/// Authentication service handling login, token refresh, and logout.
|
||||||
|
pub struct AuthService;
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
/// Authenticate a user and issue access + refresh tokens.
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Look up user by tenant + username (soft-delete aware)
|
||||||
|
/// 2. Verify user status is "active"
|
||||||
|
/// 3. Fetch the stored password credential
|
||||||
|
/// 4. Verify password hash
|
||||||
|
/// 5. Collect roles and permissions
|
||||||
|
/// 6. Sign JWT tokens
|
||||||
|
/// 7. Update last_login_at
|
||||||
|
/// 8. Publish login event
|
||||||
|
pub async fn login(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
password_plain: &str,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
jwt_secret: &str,
|
||||||
|
access_ttl_secs: i64,
|
||||||
|
refresh_ttl_secs: i64,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
// 1. Find user by tenant_id + username
|
||||||
|
let user_model = user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::Username.eq(username))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
// 2. Check user status
|
||||||
|
if user_model.status != "active" {
|
||||||
|
return Err(AuthError::UserDisabled(user_model.status.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find password credential
|
||||||
|
let cred = user_credential::Entity::find()
|
||||||
|
.filter(user_credential::Column::UserId.eq(user_model.id))
|
||||||
|
.filter(user_credential::Column::CredentialType.eq("password"))
|
||||||
|
.filter(user_credential::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
// 4. Verify password
|
||||||
|
let stored_hash = cred
|
||||||
|
.credential_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
|
||||||
|
.ok_or(AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
if !password::verify_password(password_plain, stored_hash)? {
|
||||||
|
return Err(AuthError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Get roles and permissions
|
||||||
|
let roles: Vec<String> =
|
||||||
|
TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
|
||||||
|
let permissions =
|
||||||
|
TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
// 6. Sign tokens
|
||||||
|
let access_token = TokenService::sign_access_token(
|
||||||
|
user_model.id,
|
||||||
|
tenant_id,
|
||||||
|
roles.clone(),
|
||||||
|
permissions,
|
||||||
|
jwt_secret,
|
||||||
|
access_ttl_secs,
|
||||||
|
)?;
|
||||||
|
let (refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
|
user_model.id,
|
||||||
|
tenant_id,
|
||||||
|
db,
|
||||||
|
jwt_secret,
|
||||||
|
refresh_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 7. Update last_login_at
|
||||||
|
let mut user_active: user::ActiveModel = user_model.clone().into();
|
||||||
|
user_active.last_login_at = Set(Some(Utc::now()));
|
||||||
|
user_active.updated_at = Set(Utc::now());
|
||||||
|
user_active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 8. Build response
|
||||||
|
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
|
||||||
|
let user_resp = UserResp {
|
||||||
|
id: user_model.id,
|
||||||
|
username: user_model.username.clone(),
|
||||||
|
email: user_model.email,
|
||||||
|
phone: user_model.phone,
|
||||||
|
display_name: user_model.display_name,
|
||||||
|
avatar_url: user_model.avatar_url,
|
||||||
|
status: user_model.status,
|
||||||
|
roles: role_resps,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 9. Publish event
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"user.login",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "user_id": user_model.id, "username": user_model.username }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(LoginResp {
|
||||||
|
access_token,
|
||||||
|
refresh_token,
|
||||||
|
expires_in: access_ttl_secs as u64,
|
||||||
|
user: user_resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
|
||||||
|
pub async fn refresh(
|
||||||
|
refresh_token_str: &str,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
jwt_secret: &str,
|
||||||
|
access_ttl_secs: i64,
|
||||||
|
refresh_ttl_secs: i64,
|
||||||
|
) -> AuthResult<LoginResp> {
|
||||||
|
// Validate existing refresh token
|
||||||
|
let (old_token_id, claims) =
|
||||||
|
TokenService::validate_refresh_token(refresh_token_str, db, jwt_secret).await?;
|
||||||
|
|
||||||
|
// Revoke the old token (rotation)
|
||||||
|
TokenService::revoke_token(old_token_id, db).await?;
|
||||||
|
|
||||||
|
// Fetch fresh roles and permissions
|
||||||
|
let roles: Vec<String> =
|
||||||
|
TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
|
||||||
|
let permissions =
|
||||||
|
TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
|
||||||
|
|
||||||
|
// Sign new token pair
|
||||||
|
let access_token = TokenService::sign_access_token(
|
||||||
|
claims.sub,
|
||||||
|
claims.tid,
|
||||||
|
roles.clone(),
|
||||||
|
permissions,
|
||||||
|
jwt_secret,
|
||||||
|
access_ttl_secs,
|
||||||
|
)?;
|
||||||
|
let (new_refresh_token, _) = TokenService::sign_refresh_token(
|
||||||
|
claims.sub,
|
||||||
|
claims.tid,
|
||||||
|
db,
|
||||||
|
jwt_secret,
|
||||||
|
refresh_ttl_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Fetch user for the response
|
||||||
|
let user_model = user::Entity::find_by_id(claims.sub)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
|
||||||
|
let user_resp = UserResp {
|
||||||
|
id: user_model.id,
|
||||||
|
username: user_model.username,
|
||||||
|
email: user_model.email,
|
||||||
|
phone: user_model.phone,
|
||||||
|
display_name: user_model.display_name,
|
||||||
|
avatar_url: user_model.avatar_url,
|
||||||
|
status: user_model.status,
|
||||||
|
roles: role_resps,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LoginResp {
|
||||||
|
access_token,
|
||||||
|
refresh_token: new_refresh_token,
|
||||||
|
expires_in: access_ttl_secs as u64,
|
||||||
|
user: user_resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
|
||||||
|
pub async fn logout(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch role details for a user, returning RoleResp DTOs.
|
||||||
|
async fn get_user_role_resps(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<RoleResp>> {
|
||||||
|
let user_roles = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles
|
||||||
|
.iter()
|
||||||
|
.map(|r| RoleResp {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name.clone(),
|
||||||
|
code: r.code.clone(),
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_system: r.is_system,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/erp-auth/src/service/mod.rs
Normal file
4
crates/erp-auth/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod auth_service;
|
||||||
|
pub mod password;
|
||||||
|
pub mod token_service;
|
||||||
|
pub mod user_service;
|
||||||
56
crates/erp-auth/src/service/password.rs
Normal file
56
crates/erp-auth/src/service/password.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::error::{AuthError, AuthResult};
|
||||||
|
|
||||||
|
/// Hash a plaintext password using Argon2 with a random salt.
|
||||||
|
///
|
||||||
|
/// Returns a PHC-format string suitable for database storage.
|
||||||
|
pub fn hash_password(plain: &str) -> AuthResult<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(plain.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a stored PHC-format hash.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if the password matches, `Ok(false)` if not.
|
||||||
|
pub fn verify_password(plain: &str, hash: &str) -> AuthResult<bool> {
|
||||||
|
let parsed = PasswordHash::new(hash).map_err(|e| AuthError::HashError(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_and_verify() {
|
||||||
|
let hash = hash_password("test123").unwrap();
|
||||||
|
assert!(
|
||||||
|
verify_password("test123", &hash).unwrap(),
|
||||||
|
"Correct password should verify"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!verify_password("wrong", &hash).unwrap(),
|
||||||
|
"Wrong password should not verify"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_is_unique() {
|
||||||
|
let hash1 = hash_password("same_password").unwrap();
|
||||||
|
let hash2 = hash_password("same_password").unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
hash1, hash2,
|
||||||
|
"Two hashes of the same password should differ (different salts)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
crates/erp-auth/src/service/token_service.rs
Normal file
271
crates/erp-auth/src/service/token_service.rs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::entity::{permission, role, role_permission, user_role, user_token};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
/// JWT claims embedded in access and refresh tokens.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
/// Subject — the user ID
|
||||||
|
pub sub: Uuid,
|
||||||
|
/// Tenant ID
|
||||||
|
pub tid: Uuid,
|
||||||
|
/// Role codes assigned to this user
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
/// Permission codes granted to this user
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
/// Expiry (unix timestamp)
|
||||||
|
pub exp: i64,
|
||||||
|
/// Issued at (unix timestamp)
|
||||||
|
pub iat: i64,
|
||||||
|
/// Token type: "access" or "refresh"
|
||||||
|
pub token_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateless service for JWT token signing, validation, and revocation.
|
||||||
|
pub struct TokenService;
|
||||||
|
|
||||||
|
impl TokenService {
|
||||||
|
/// Sign a short-lived access token containing roles and permissions.
|
||||||
|
pub fn sign_access_token(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
roles: Vec<String>,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
secret: &str,
|
||||||
|
ttl_secs: i64,
|
||||||
|
) -> AuthResult<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
tid: tenant_id,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
exp: now.timestamp() + ttl_secs,
|
||||||
|
iat: now.timestamp(),
|
||||||
|
token_type: "access".to_string(),
|
||||||
|
};
|
||||||
|
let header = jsonwebtoken::Header::default();
|
||||||
|
let encoded = jsonwebtoken::encode(
|
||||||
|
&header,
|
||||||
|
&claims,
|
||||||
|
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a long-lived refresh token and persist its SHA-256 hash in the database.
|
||||||
|
///
|
||||||
|
/// Returns the raw token string (sent to client) and the database row ID.
|
||||||
|
pub async fn sign_refresh_token(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
secret: &str,
|
||||||
|
ttl_secs: i64,
|
||||||
|
) -> AuthResult<(String, Uuid)> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let token_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id,
|
||||||
|
tid: tenant_id,
|
||||||
|
roles: vec![],
|
||||||
|
permissions: vec![],
|
||||||
|
exp: now.timestamp() + ttl_secs,
|
||||||
|
iat: now.timestamp(),
|
||||||
|
token_type: "refresh".to_string(),
|
||||||
|
};
|
||||||
|
let raw_token = jsonwebtoken::encode(
|
||||||
|
&jsonwebtoken::Header::default(),
|
||||||
|
&claims,
|
||||||
|
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Store the SHA-256 hash — the raw token is never persisted.
|
||||||
|
let hash = sha256_hex(&raw_token);
|
||||||
|
|
||||||
|
let token_model = user_token::ActiveModel {
|
||||||
|
id: Set(token_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
token_hash: Set(hash),
|
||||||
|
token_type: Set("refresh".to_string()),
|
||||||
|
expires_at: Set(now + chrono::Duration::seconds(ttl_secs)),
|
||||||
|
revoked_at: Set(None),
|
||||||
|
device_info: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(user_id),
|
||||||
|
updated_by: Set(user_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
token_model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((raw_token, token_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a refresh token against the database.
|
||||||
|
///
|
||||||
|
/// Returns the database row ID and decoded claims.
|
||||||
|
pub async fn validate_refresh_token(
|
||||||
|
token: &str,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
secret: &str,
|
||||||
|
) -> AuthResult<(Uuid, Claims)> {
|
||||||
|
let claims = Self::decode_token(token, secret)?;
|
||||||
|
if claims.token_type != "refresh" {
|
||||||
|
return Err(AuthError::Validation("不是 refresh token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = sha256_hex(token);
|
||||||
|
let token_row = user_token::Entity::find()
|
||||||
|
.filter(user_token::Column::TokenHash.eq(hash))
|
||||||
|
.filter(user_token::Column::RevokedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
Ok((token_row.id, claims))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and validate any JWT token, returning the claims.
|
||||||
|
pub fn decode_token(token: &str, secret: &str) -> AuthResult<Claims> {
|
||||||
|
let data = jsonwebtoken::decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&jsonwebtoken::Validation::default(),
|
||||||
|
)?;
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a specific refresh token by database ID.
|
||||||
|
pub async fn revoke_token(token_id: Uuid, db: &DatabaseConnection) -> AuthResult<()> {
|
||||||
|
let token_row = user_token::Entity::find_by_id(token_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.ok_or(AuthError::TokenRevoked)?;
|
||||||
|
|
||||||
|
let mut active: user_token::ActiveModel = token_row.into();
|
||||||
|
active.revoked_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke all non-revoked refresh tokens for a given user within a tenant.
|
||||||
|
pub async fn revoke_all_user_tokens(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let tokens = user_token::Entity::find()
|
||||||
|
.filter(user_token::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_token::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user_token::Column::RevokedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
for token in tokens {
|
||||||
|
let mut active: user_token::ActiveModel = token.into();
|
||||||
|
active.revoked_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a user's permission codes through user_roles -> role_permissions -> permissions.
|
||||||
|
pub async fn get_user_permissions(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<String>> {
|
||||||
|
let user_role_rows = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_perm_rows = role_permission::Entity::find()
|
||||||
|
.filter(role_permission::Column::RoleId.is_in(role_ids))
|
||||||
|
.filter(role_permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let perm_ids: Vec<Uuid> = role_perm_rows.iter().map(|rp| rp.permission_id).collect();
|
||||||
|
if perm_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let perms = permission::Entity::find()
|
||||||
|
.filter(permission::Column::Id.is_in(perm_ids))
|
||||||
|
.filter(permission::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(perms.iter().map(|p| p.code.clone()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a user's role codes through user_roles -> roles.
|
||||||
|
pub async fn get_user_roles(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<String>> {
|
||||||
|
let user_role_rows = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles.iter().map(|r| r.code.clone()).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a SHA-256 hex digest of the input string.
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
let hash = Sha256::digest(input.as_bytes());
|
||||||
|
format!("{:x}", hash)
|
||||||
|
}
|
||||||
289
crates/erp-auth/src/service/user_service.rs
Normal file
289
crates/erp-auth/src/service/user_service.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
|
||||||
|
use crate::entity::{role, user, user_credential, user_role};
|
||||||
|
use crate::error::AuthError;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
use crate::error::AuthResult;
|
||||||
|
|
||||||
|
use super::password;
|
||||||
|
|
||||||
|
/// User CRUD service — create, read, update, soft-delete users within a tenant.
|
||||||
|
pub struct UserService;
|
||||||
|
|
||||||
|
impl UserService {
|
||||||
|
/// Create a new user with a password credential.
|
||||||
|
///
|
||||||
|
/// Validates username uniqueness within the tenant, hashes the password,
|
||||||
|
/// and publishes a `user.created` event.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateUserReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
// Check username uniqueness within tenant
|
||||||
|
let existing = user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::Username.eq(&req.username))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(AuthError::Validation("用户名已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let user_id = Uuid::now_v7();
|
||||||
|
|
||||||
|
// Insert user record
|
||||||
|
let user_model = user::ActiveModel {
|
||||||
|
id: Set(user_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
username: Set(req.username.clone()),
|
||||||
|
email: Set(req.email.clone()),
|
||||||
|
phone: Set(req.phone.clone()),
|
||||||
|
display_name: Set(req.display_name.clone()),
|
||||||
|
avatar_url: Set(None),
|
||||||
|
status: Set("active".to_string()),
|
||||||
|
last_login_at: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
user_model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Insert password credential
|
||||||
|
let hash = password::hash_password(&req.password)?;
|
||||||
|
let cred = user_credential::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
credential_type: Set("password".to_string()),
|
||||||
|
credential_data: Set(Some(serde_json::json!({ "hash": hash }))),
|
||||||
|
verified: Set(true),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
cred
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// Publish domain event
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"user.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "user_id": user_id, "username": req.username }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(UserResp {
|
||||||
|
id: user_id,
|
||||||
|
username: req.username.clone(),
|
||||||
|
email: req.email.clone(),
|
||||||
|
phone: req.phone.clone(),
|
||||||
|
display_name: req.display_name.clone(),
|
||||||
|
avatar_url: None,
|
||||||
|
status: "active".to_string(),
|
||||||
|
roles: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single user by ID, scoped to the given tenant.
|
||||||
|
pub async fn get_by_id(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
let user_model = user::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||||
|
Ok(model_to_resp(&user_model, roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List users within a tenant with pagination.
|
||||||
|
///
|
||||||
|
/// Returns `(users, total_count)`.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<(Vec<UserResp>, u64)> {
|
||||||
|
let paginator = user::Entity::find()
|
||||||
|
.filter(user::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(user::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut resps = Vec::with_capacity(models.len());
|
||||||
|
for m in models {
|
||||||
|
let roles: Vec<RoleResp> = Self::fetch_user_role_resps(m.id, tenant_id, db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
resps.push(model_to_resp(&m, roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable user fields.
|
||||||
|
///
|
||||||
|
/// Supports updating email, phone, display_name, and status.
|
||||||
|
/// Status must be one of: "active", "disabled", "locked".
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &UpdateUserReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<UserResp> {
|
||||||
|
let user_model = user::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
|
|
||||||
|
if let Some(email) = &req.email {
|
||||||
|
active.email = Set(Some(email.clone()));
|
||||||
|
}
|
||||||
|
if let Some(phone) = &req.phone {
|
||||||
|
active.phone = Set(Some(phone.clone()));
|
||||||
|
}
|
||||||
|
if let Some(display_name) = &req.display_name {
|
||||||
|
active.display_name = Set(Some(display_name.clone()));
|
||||||
|
}
|
||||||
|
if let Some(status) = &req.status {
|
||||||
|
if !["active", "disabled", "locked"].contains(&status.as_str()) {
|
||||||
|
return Err(AuthError::Validation("无效的状态值".to_string()));
|
||||||
|
}
|
||||||
|
active.status = Set(status.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?;
|
||||||
|
Ok(model_to_resp(&updated, roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a user by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> AuthResult<()> {
|
||||||
|
let user_model = user::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
||||||
|
.filter(|u| u.tenant_id == tenant_id && u.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: user::ActiveModel = user_model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"user.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "user_id": id }),
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch RoleResp DTOs for a given user within a tenant.
|
||||||
|
async fn fetch_user_role_resps(
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> AuthResult<Vec<RoleResp>> {
|
||||||
|
let user_roles = user_role::Entity::find()
|
||||||
|
.filter(user_role::Column::UserId.eq(user_id))
|
||||||
|
.filter(user_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
|
||||||
|
if role_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let roles = role::Entity::find()
|
||||||
|
.filter(role::Column::Id.is_in(role_ids))
|
||||||
|
.filter(role::Column::TenantId.eq(tenant_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(roles
|
||||||
|
.iter()
|
||||||
|
.map(|r| RoleResp {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name.clone(),
|
||||||
|
code: r.code.clone(),
|
||||||
|
description: r.description.clone(),
|
||||||
|
is_system: r.is_system,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a SeaORM user Model and its role DTOs into a UserResp.
|
||||||
|
fn model_to_resp(m: &user::Model, roles: Vec<RoleResp>) -> UserResp {
|
||||||
|
UserResp {
|
||||||
|
id: m.id,
|
||||||
|
username: m.username.clone(),
|
||||||
|
email: m.email.clone(),
|
||||||
|
phone: m.phone.clone(),
|
||||||
|
display_name: m.display_name.clone(),
|
||||||
|
avatar_url: m.avatar_url.clone(),
|
||||||
|
status: m.status.clone(),
|
||||||
|
roles,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user