fix: address Phase 1-2 audit findings

- CORS: replace permissive() with configurable whitelist (default.toml)
- Auth store: synchronously restore state at creation to eliminate
  flash-of-login-page on refresh
- MainLayout: menu highlight now tracks current route via useLocation
- Add extractErrorMessage() utility to reduce repeated error parsing
- Fix all clippy warnings across 4 crates (erp-auth, erp-config,
  erp-workflow, erp-message): remove unnecessary casts, use div_ceil,
  collapse nested ifs, reduce function arguments with DTOs
This commit is contained in:
iven
2026-04-11 12:36:34 +08:00
parent 5c899e6f4a
commit 3a05523d23
35 changed files with 283 additions and 187 deletions

View File

@@ -8,7 +8,7 @@ use erp_core::types::{ApiResponse, TenantContext};
use crate::auth_state::AuthState;
use crate::dto::{LoginReq, LoginResp, RefreshReq};
use crate::service::auth_service::AuthService;
use crate::service::auth_service::{AuthService, JwtConfig};
/// POST /api/v1/auth/login
///
@@ -29,14 +29,18 @@ where
let tenant_id = state.default_tenant_id;
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::login(
tenant_id,
&req.username,
&req.password,
&state.db,
&state.jwt_secret,
state.access_ttl_secs,
state.refresh_ttl_secs,
&jwt_config,
&state.event_bus,
)
.await?;
@@ -56,12 +60,16 @@ where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::refresh(
&req.refresh_token,
&state.db,
&state.jwt_secret,
state.access_ttl_secs,
state.refresh_ttl_secs,
&jwt_config,
)
.await?;

View File

@@ -184,10 +184,7 @@ where
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.manager_id,
&req.sort_order,
&req,
&state.db,
)
.await?;
@@ -291,10 +288,7 @@ where
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.level,
&req.sort_order,
&req,
&state.db,
)
.await?;

View File

@@ -32,7 +32,7 @@ where
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = (total + page_size - 1) / page_size;
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: roles,

View File

@@ -31,7 +31,7 @@ where
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = (total + page_size - 1) / page_size;
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: users,

View File

@@ -12,6 +12,13 @@ use crate::error::AuthResult;
use super::password;
use super::token_service::TokenService;
/// JWT configuration needed for token signing.
pub struct JwtConfig<'a> {
pub secret: &'a str,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
}
/// Authentication service handling login, token refresh, and logout.
pub struct AuthService;
@@ -32,9 +39,7 @@ impl AuthService {
username: &str,
password_plain: &str,
db: &sea_orm::DatabaseConnection,
jwt_secret: &str,
access_ttl_secs: i64,
refresh_ttl_secs: i64,
jwt: &JwtConfig<'_>,
event_bus: &EventBus,
) -> AuthResult<LoginResp> {
// 1. Find user by tenant_id + username
@@ -85,15 +90,15 @@ impl AuthService {
tenant_id,
roles.clone(),
permissions,
jwt_secret,
access_ttl_secs,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (refresh_token, _) = TokenService::sign_refresh_token(
user_model.id,
tenant_id,
db,
jwt_secret,
refresh_ttl_secs,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
@@ -129,7 +134,7 @@ impl AuthService {
Ok(LoginResp {
access_token,
refresh_token,
expires_in: access_ttl_secs as u64,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}
@@ -138,13 +143,11 @@ impl AuthService {
pub async fn refresh(
refresh_token_str: &str,
db: &sea_orm::DatabaseConnection,
jwt_secret: &str,
access_ttl_secs: i64,
refresh_ttl_secs: i64,
jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> {
// Validate existing refresh token
let (old_token_id, claims) =
TokenService::validate_refresh_token(refresh_token_str, db, jwt_secret).await?;
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, db).await?;
@@ -161,15 +164,15 @@ impl AuthService {
claims.tid,
roles.clone(),
permissions,
jwt_secret,
access_ttl_secs,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (new_refresh_token, _) = TokenService::sign_refresh_token(
claims.sub,
claims.tid,
db,
jwt_secret,
refresh_ttl_secs,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
@@ -195,7 +198,7 @@ impl AuthService {
Ok(LoginResp {
access_token,
refresh_token: new_refresh_token,
expires_in: access_ttl_secs as u64,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}

View File

@@ -4,7 +4,7 @@ use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateDepartmentReq, DepartmentResp};
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
@@ -141,10 +141,7 @@ impl DeptService {
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
code: &Option<String>,
manager_id: &Option<Uuid>,
sort_order: &Option<i32>,
req: &UpdateDepartmentReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<DepartmentResp> {
let model = department::Entity::find_by_id(id)
@@ -155,33 +152,33 @@ impl DeptService {
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = code {
if Some(new_code) != model.code.as_ref() {
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(new_code.as_str()))
.filter(department::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()));
}
if let Some(new_code) = &req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(new_code.as_str()))
.filter(department::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 mut active: department::ActiveModel = model.into();
if let Some(n) = name {
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(c) = code {
if let Some(c) = &req.code {
active.code = Set(Some(c.clone()));
}
if let Some(mgr_id) = manager_id {
if let Some(mgr_id) = &req.manager_id {
active.manager_id = Set(Some(*mgr_id));
}
if let Some(so) = sort_order {
if let Some(so) = &req.sort_order {
active.sort_order = Set(*so);
}

View File

@@ -137,18 +137,18 @@ impl OrgService {
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(ref new_code) = req.code {
if Some(new_code) != model.code.as_ref() {
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(new_code.as_str()))
.filter(organization::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()));
}
if let Some(ref new_code) = req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(new_code.as_str()))
.filter(organization::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()));
}
}

View File

@@ -2,7 +2,7 @@ use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreatePositionReq, PositionResp};
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
use crate::entity::department;
use crate::entity::position;
use crate::error::{AuthError, AuthResult};
@@ -122,10 +122,7 @@ impl PositionService {
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
code: &Option<String>,
level: &Option<i32>,
sort_order: &Option<i32>,
req: &UpdatePositionReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<PositionResp> {
let model = position::Entity::find_by_id(id)
@@ -136,33 +133,33 @@ impl PositionService {
.ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = code {
if Some(new_code) != model.code.as_ref() {
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(new_code.as_str()))
.filter(position::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()));
}
if let Some(new_code) = &req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(new_code.as_str()))
.filter(position::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 mut active: position::ActiveModel = model.into();
if let Some(n) = name {
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(c) = code {
if let Some(c) = &req.code {
active.code = Set(Some(c.clone()));
}
if let Some(l) = level {
if let Some(l) = &req.level {
active.level = Set(*l);
}
if let Some(so) = sort_order {
if let Some(so) = &req.sort_order {
active.sort_order = Set(*so);
}

View File

@@ -34,7 +34,7 @@ impl RoleService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await

View File

@@ -140,7 +140,7 @@ impl UserService {
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await