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:
iven
2026-04-11 03:05:17 +08:00
parent 411a07caa1
commit edc41a1500
9 changed files with 916 additions and 0 deletions

View 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())
}
}