feat(callback): add callback
This commit is contained in:
@@ -8,9 +8,12 @@ pub struct AppConfig {
|
||||
pub log_dir: String,
|
||||
pub log_file_name: String,
|
||||
pub database_url: String,
|
||||
pub redis_url: String,
|
||||
pub db_max_connections: u32,
|
||||
pub db_min_connections: u32,
|
||||
pub jwt_secret: String,
|
||||
pub auth_code_jwt_secret: String,
|
||||
pub client_secret_prev_ttl_days: u32,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
@@ -25,9 +28,16 @@ impl AppConfig {
|
||||
log_dir: env::var("LOG_DIR").unwrap_or_else(|_| "./log".into()),
|
||||
log_file_name: env::var("LOG_FILE_NAME").unwrap_or_else(|_| "iam.log".into()),
|
||||
database_url: env::var("DATABASE_URL").map_err(|_| "DATABASE_URL environment variable is required")?,
|
||||
redis_url: env::var("REDIS_URL").map_err(|_| "REDIS_URL environment variable is required")?,
|
||||
db_max_connections: env::var("DB_MAX_CONNECTIONS").unwrap_or("20".into()).parse().map_err(|_| "DB_MAX_CONNECTIONS must be a number")?,
|
||||
db_min_connections: env::var("DB_MIN_CONNECTIONS").unwrap_or("5".into()).parse().map_err(|_| "DB_MIN_CONNECTIONS must be a number")?,
|
||||
jwt_secret: env::var("JWT_SECRET").map_err(|_| "JWT_SECRET environment variable is required")?,
|
||||
auth_code_jwt_secret: env::var("AUTH_CODE_JWT_SECRET")
|
||||
.map_err(|_| "AUTH_CODE_JWT_SECRET environment variable is required")?,
|
||||
client_secret_prev_ttl_days: env::var("CLIENT_SECRET_PREV_TTL_DAYS")
|
||||
.unwrap_or_else(|_| "7".to_string())
|
||||
.parse()
|
||||
.map_err(|_| "CLIENT_SECRET_PREV_TTL_DAYS must be a number")?,
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
|
||||
26
src/docs.rs
26
src/docs.rs
@@ -2,12 +2,15 @@ use crate::handlers;
|
||||
use crate::models::{
|
||||
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
|
||||
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
|
||||
CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse,
|
||||
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
|
||||
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
|
||||
ClientSummary, Code2TokenRequest, Code2TokenResponse, CreateClientRequest, CreateClientResponse,
|
||||
CreateUserRequest, ListAppsQuery, ListPermissionsQuery, LoginRequest, LoginResponse, Permission,
|
||||
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse,
|
||||
RotateClientSecretResponse, Tenant, TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest,
|
||||
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
|
||||
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
|
||||
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse, AuthorizationCheckRequest, AuthorizationCheckResponse,
|
||||
UpdateClientRedirectUrisRequest,
|
||||
LoginCodeRequest, LoginCodeResponse,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
@@ -138,7 +141,14 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
|
||||
paths(
|
||||
handlers::auth::register_handler,
|
||||
handlers::auth::login_handler,
|
||||
handlers::sso::login_code_handler,
|
||||
handlers::auth::refresh_handler,
|
||||
handlers::auth::logout_handler,
|
||||
handlers::sso::code2token_handler,
|
||||
handlers::client::create_client_handler,
|
||||
handlers::client::rotate_client_secret_handler,
|
||||
handlers::client::list_clients_handler,
|
||||
handlers::client::update_client_redirect_uris_handler,
|
||||
handlers::authorization::my_permissions_handler,
|
||||
handlers::authorization::authorization_check_handler,
|
||||
handlers::permission::list_permissions_handler,
|
||||
@@ -185,7 +195,16 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
|
||||
UpdateUserRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LoginCodeRequest,
|
||||
LoginCodeResponse,
|
||||
RefreshTokenRequest,
|
||||
Code2TokenRequest,
|
||||
Code2TokenResponse,
|
||||
CreateClientRequest,
|
||||
CreateClientResponse,
|
||||
RotateClientSecretResponse,
|
||||
ClientSummary,
|
||||
UpdateClientRedirectUrisRequest,
|
||||
Permission,
|
||||
ListPermissionsQuery,
|
||||
Role,
|
||||
@@ -218,6 +237,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
|
||||
),
|
||||
tags(
|
||||
(name = "Auth", description = "认证:注册/登录/令牌"),
|
||||
(name = "Client", description = "客户端:clientId/clientSecret 管理(平台级)"),
|
||||
(name = "Tenant", description = "租户:创建/查询/更新/状态/删除"),
|
||||
(name = "User", description = "用户:查询/列表/更新/删除(需权限)"),
|
||||
(name = "Role", description = "角色:创建/列表(需权限)"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse,
|
||||
};
|
||||
@@ -93,3 +94,29 @@ pub async fn refresh_handler(
|
||||
.await?;
|
||||
Ok(AppResponse::ok(response))
|
||||
}
|
||||
|
||||
/// Logout (revoke all refresh tokens for current user).
|
||||
/// 退出登录(吊销当前用户所有 refresh token)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/logout",
|
||||
tag = "Auth",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Logged out"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn logout_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
) -> Result<AppResponse<serde_json::Value>, AppError> {
|
||||
state.auth_service.logout(user_id).await?;
|
||||
Ok(AppResponse::ok(serde_json::json!({})))
|
||||
}
|
||||
|
||||
185
src/handlers/client.rs
Normal file
185
src/handlers/client.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::models::{
|
||||
ClientSummary, CreateClientRequest, CreateClientResponse, RotateClientSecretResponse,
|
||||
UpdateClientRedirectUrisRequest,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use tracing::instrument;
|
||||
|
||||
/// Create a new client and return its secret (shown once).
|
||||
/// 创建 client 并返回 clientSecret(仅展示一次)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/clients",
|
||||
tag = "Client",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
request_body = CreateClientRequest,
|
||||
responses(
|
||||
(status = 201, description = "Created", body = CreateClientResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn create_client_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
Json(payload): Json<CreateClientRequest>,
|
||||
) -> Result<AppResponse<CreateClientResponse>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:client:write")
|
||||
.await?;
|
||||
|
||||
let secret = state
|
||||
.client_service
|
||||
.create_client(
|
||||
payload.client_id.clone(),
|
||||
payload.name.clone(),
|
||||
payload.redirect_uris.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(AppResponse::created(CreateClientResponse {
|
||||
client_id: payload.client_id,
|
||||
client_secret: secret,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update allowed redirect URIs for a client.
|
||||
/// 更新 client 的允许回调地址(redirectUris)。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/platform/clients/{client_id}/redirect-uris",
|
||||
tag = "Client",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
request_body = UpdateClientRedirectUrisRequest,
|
||||
responses(
|
||||
(status = 200, description = "Updated"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Not found")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("client_id" = String, Path, description = "clientId")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn update_client_redirect_uris_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
Path(client_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientRedirectUrisRequest>,
|
||||
) -> Result<AppResponse<serde_json::Value>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:client:write")
|
||||
.await?;
|
||||
|
||||
state
|
||||
.client_service
|
||||
.set_redirect_uris(client_id, payload.redirect_uris)
|
||||
.await?;
|
||||
|
||||
Ok(AppResponse::ok(serde_json::json!({})))
|
||||
}
|
||||
|
||||
/// Rotate client secret (previous secret stays valid for grace period).
|
||||
/// 轮换 clientSecret(旧密钥在宽限期内仍可用)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/platform/clients/{client_id}/rotate-secret",
|
||||
tag = "Client",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Rotated", body = RotateClientSecretResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Not found")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
|
||||
("client_id" = String, Path, description = "clientId")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn rotate_client_secret_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
Path(client_id): Path<String>,
|
||||
) -> Result<AppResponse<RotateClientSecretResponse>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:client:write")
|
||||
.await?;
|
||||
|
||||
let secret = state
|
||||
.client_service
|
||||
.rotate_secret(client_id.clone())
|
||||
.await?;
|
||||
Ok(AppResponse::ok(RotateClientSecretResponse {
|
||||
client_id,
|
||||
client_secret: secret,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List clients (secrets are never returned).
|
||||
/// 查询 client 列表(不返回 secret)。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/platform/clients",
|
||||
tag = "Client",
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "OK", body = [ClientSummary]),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden")
|
||||
),
|
||||
params(
|
||||
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn list_clients_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthContext { user_id, .. }: AuthContext,
|
||||
) -> Result<AppResponse<Vec<ClientSummary>>, AppError> {
|
||||
state
|
||||
.authorization_service
|
||||
.require_platform_permission(user_id, "iam:client:read")
|
||||
.await?;
|
||||
|
||||
let rows = state.client_service.list_clients().await?;
|
||||
let clients = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(client_id, name, redirect_uris, created_at, updated_at)| ClientSummary {
|
||||
client_id,
|
||||
name,
|
||||
redirect_uris,
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(AppResponse::ok(clients))
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod client;
|
||||
pub mod jwks;
|
||||
pub mod permission;
|
||||
pub mod platform;
|
||||
pub mod role;
|
||||
pub mod sso;
|
||||
pub mod tenant;
|
||||
pub mod user;
|
||||
|
||||
use crate::services::{
|
||||
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
|
||||
UserService,
|
||||
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
|
||||
TenantService, UserService,
|
||||
};
|
||||
use redis::aio::ConnectionManager;
|
||||
|
||||
pub use app::{
|
||||
approve_app_status_change_handler, create_app_handler, delete_app_handler, get_app_handler,
|
||||
list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler,
|
||||
request_app_status_change_handler, update_app_handler,
|
||||
};
|
||||
pub use auth::{login_handler, refresh_handler, register_handler};
|
||||
pub use auth::{login_handler, logout_handler, refresh_handler, register_handler};
|
||||
pub use authorization::{authorization_check_handler, my_permissions_handler};
|
||||
pub use client::{
|
||||
create_client_handler, list_clients_handler, rotate_client_secret_handler,
|
||||
update_client_redirect_uris_handler,
|
||||
};
|
||||
pub use jwks::jwks_handler;
|
||||
pub use permission::list_permissions_handler;
|
||||
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
|
||||
@@ -28,6 +35,7 @@ pub use role::{
|
||||
grant_role_users_handler, list_roles_handler, revoke_role_permissions_handler,
|
||||
revoke_role_users_handler, update_role_handler,
|
||||
};
|
||||
pub use sso::{code2token_handler, login_code_handler};
|
||||
pub use tenant::{
|
||||
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
|
||||
update_tenant_status_handler,
|
||||
@@ -42,10 +50,13 @@ pub use user::{
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub client_service: ClientService,
|
||||
pub user_service: UserService,
|
||||
pub role_service: RoleService,
|
||||
pub tenant_service: TenantService,
|
||||
pub authorization_service: AuthorizationService,
|
||||
pub app_service: AppService,
|
||||
pub permission_service: PermissionService,
|
||||
pub redis: ConnectionManager,
|
||||
pub auth_code_jwt_secret: String,
|
||||
}
|
||||
|
||||
219
src/handlers/sso.rs
Normal file
219
src/handlers/sso.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use crate::handlers::AppState;
|
||||
use crate::middleware::TenantId;
|
||||
use crate::models::{Code2TokenRequest, Code2TokenResponse, LoginCodeRequest, LoginCodeResponse};
|
||||
use anyhow::anyhow;
|
||||
use axum::{Json, extract::State};
|
||||
use common_telemetry::{AppError, AppResponse};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use redis::AsyncCommands;
|
||||
use redis::Script;
|
||||
use serde::Deserialize;
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize, serde::Serialize)]
|
||||
struct AuthCodeClaims {
|
||||
sub: String,
|
||||
tenant_id: String,
|
||||
client_id: Option<String>,
|
||||
redirect_uri: Option<String>,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
iss: String,
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthCodeRedisValue {
|
||||
user_id: String,
|
||||
tenant_id: String,
|
||||
client_id: Option<String>,
|
||||
redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
fn redis_key(jti: &str) -> String {
|
||||
format!("iam:auth_code:{}", jti)
|
||||
}
|
||||
|
||||
/// Exchange one-time authorization code to access/refresh token.
|
||||
/// 授权码换取 token(一次性 code,5 分钟有效,单次使用)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/code2token",
|
||||
tag = "Auth",
|
||||
request_body = Code2TokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token issued", body = Code2TokenResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn code2token_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<Code2TokenRequest>,
|
||||
) -> Result<AppResponse<Code2TokenResponse>, AppError> {
|
||||
state
|
||||
.client_service
|
||||
.verify_client_secret(&payload.client_id, &payload.client_secret)
|
||||
.await?;
|
||||
|
||||
if payload.code.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("code is required".into()));
|
||||
}
|
||||
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.set_issuer(&["iam-front", "iam-service"]);
|
||||
|
||||
let token_data = decode::<AuthCodeClaims>(
|
||||
payload.code.trim(),
|
||||
&DecodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|e| AppError::AuthError(e.to_string()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
if let Some(cid) = &claims.client_id {
|
||||
if cid != payload.client_id.trim() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
}
|
||||
let jti = claims.jti.trim();
|
||||
if jti.is_empty() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
|
||||
let script = Script::new(
|
||||
r#"
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return v
|
||||
"#,
|
||||
);
|
||||
|
||||
let key = redis_key(jti);
|
||||
let mut conn = state.redis.clone();
|
||||
let val: Option<String> = script
|
||||
.key(key)
|
||||
.invoke_async(&mut conn)
|
||||
.await
|
||||
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
|
||||
let Some(val) = val else {
|
||||
return Err(AppError::AuthError("Invalid or used code".into()));
|
||||
};
|
||||
|
||||
let stored: AuthCodeRedisValue =
|
||||
serde_json::from_str(&val).map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
|
||||
if let Some(cid) = stored.client_id.as_deref() {
|
||||
if cid != payload.client_id.trim() {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if stored.user_id != claims.sub || stored.tenant_id != claims.tenant_id {
|
||||
return Err(AppError::AuthError("Invalid code".into()));
|
||||
}
|
||||
|
||||
let user_id =
|
||||
Uuid::parse_str(&stored.user_id).map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
let tenant_id = Uuid::parse_str(&stored.tenant_id)
|
||||
.map_err(|_| AppError::AuthError("Invalid code".into()))?;
|
||||
|
||||
let tokens = state
|
||||
.auth_service
|
||||
.issue_tokens_for_user(tenant_id, user_id, 7200)
|
||||
.await?;
|
||||
|
||||
Ok(AppResponse::ok(Code2TokenResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_type: tokens.token_type,
|
||||
expires_in: tokens.expires_in,
|
||||
tenant_id: tenant_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Login with username/password and issue one-time authorization code.
|
||||
/// 用户账户密码登录并签发一次性授权码(用于 SSO 授权码模式)。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/login-code",
|
||||
tag = "Auth",
|
||||
request_body = LoginCodeRequest,
|
||||
responses(
|
||||
(status = 200, description = "Code issued", body = LoginCodeResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 429, description = "Too many requests")
|
||||
),
|
||||
params(
|
||||
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
|
||||
)
|
||||
)]
|
||||
#[instrument(skip(state, payload))]
|
||||
pub async fn login_code_handler(
|
||||
TenantId(tenant_id): TenantId,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginCodeRequest>,
|
||||
) -> Result<AppResponse<LoginCodeResponse>, AppError> {
|
||||
let redirect_uri = state
|
||||
.client_service
|
||||
.assert_redirect_uri_allowed(&payload.client_id, &payload.redirect_uri)
|
||||
.await?;
|
||||
|
||||
let user = state
|
||||
.auth_service
|
||||
.verify_user_credentials(tenant_id, payload.email, payload.password)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp() as usize;
|
||||
let exp = now + 5 * 60;
|
||||
let jti = Uuid::new_v4().to_string();
|
||||
|
||||
let claims = AuthCodeClaims {
|
||||
sub: user.id.to_string(),
|
||||
tenant_id: user.tenant_id.to_string(),
|
||||
client_id: Some(payload.client_id),
|
||||
redirect_uri: Some(redirect_uri.clone()),
|
||||
exp,
|
||||
iat: now,
|
||||
iss: "iam-service".to_string(),
|
||||
jti: jti.clone(),
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
let code = encode(
|
||||
&header,
|
||||
&claims,
|
||||
&EncodingKey::from_secret(state.auth_code_jwt_secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
|
||||
let value = serde_json::json!({
|
||||
"user_id": user.id.to_string(),
|
||||
"tenant_id": user.tenant_id.to_string(),
|
||||
"client_id": claims.client_id,
|
||||
"redirect_uri": claims.redirect_uri
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut conn = state.redis.clone();
|
||||
let _: () = conn
|
||||
.set_ex(redis_key(&jti), value, 5 * 60)
|
||||
.await
|
||||
.map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
|
||||
let mut u = url::Url::parse(&redirect_uri)
|
||||
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?;
|
||||
u.query_pairs_mut().append_pair("code", &code);
|
||||
|
||||
Ok(AppResponse::ok(LoginCodeResponse {
|
||||
redirect_to: u.to_string(),
|
||||
expires_at: exp,
|
||||
}))
|
||||
}
|
||||
82
src/main.rs
82
src/main.rs
@@ -4,6 +4,7 @@ mod docs;
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod redis;
|
||||
mod services;
|
||||
mod utils;
|
||||
|
||||
@@ -12,26 +13,27 @@ use axum::{
|
||||
http::StatusCode,
|
||||
middleware::from_fn,
|
||||
middleware::from_fn_with_state,
|
||||
routing::{get, post},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use config::AppConfig;
|
||||
use handlers::{
|
||||
AppState, approve_app_status_change_handler, authorization_check_handler, create_app_handler,
|
||||
create_role_handler, create_tenant_handler, delete_app_handler, delete_role_handler,
|
||||
delete_tenant_handler, delete_user_handler, get_app_handler, get_role_handler,
|
||||
get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
|
||||
grant_role_permissions_handler, grant_role_users_handler, jwks_handler,
|
||||
list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler,
|
||||
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
|
||||
my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler,
|
||||
request_app_status_change_handler, reset_my_password_handler, reset_user_password_handler,
|
||||
revoke_role_permissions_handler, revoke_role_users_handler, set_tenant_enabled_apps_handler,
|
||||
set_user_roles_handler, update_app_handler, update_role_handler, update_tenant_handler,
|
||||
update_tenant_status_handler, update_user_handler,
|
||||
AppState, approve_app_status_change_handler, authorization_check_handler, code2token_handler,
|
||||
create_app_handler, create_client_handler, create_role_handler, create_tenant_handler,
|
||||
delete_app_handler, delete_role_handler, delete_tenant_handler, delete_user_handler,
|
||||
get_app_handler, get_role_handler, get_tenant_enabled_apps_handler, get_tenant_handler,
|
||||
get_user_handler, grant_role_permissions_handler, grant_role_users_handler, jwks_handler,
|
||||
list_app_status_change_requests_handler, list_apps_handler, list_clients_handler,
|
||||
list_permissions_handler, list_roles_handler, list_user_roles_handler, list_users_handler,
|
||||
login_code_handler, login_handler, logout_handler, my_permissions_handler, refresh_handler,
|
||||
register_handler, reject_app_status_change_handler, request_app_status_change_handler,
|
||||
reset_my_password_handler, reset_user_password_handler, revoke_role_permissions_handler,
|
||||
revoke_role_users_handler, rotate_client_secret_handler, set_tenant_enabled_apps_handler,
|
||||
set_user_roles_handler, update_app_handler, update_client_redirect_uris_handler,
|
||||
update_role_handler, update_tenant_handler, update_tenant_status_handler, update_user_handler,
|
||||
};
|
||||
use services::{
|
||||
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
|
||||
UserService,
|
||||
AppService, AuthService, AuthorizationService, ClientService, PermissionService, RoleService,
|
||||
TenantService, UserService,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use utoipa::OpenApi;
|
||||
@@ -80,21 +82,32 @@ async fn main() {
|
||||
|
||||
// 4. 初始化 Service 和 AppState
|
||||
let auth_service = AuthService::new(pool.clone(), config.jwt_secret.clone());
|
||||
let client_service = ClientService::new(pool.clone(), config.client_secret_prev_ttl_days);
|
||||
let user_service = UserService::new(pool.clone());
|
||||
let role_service = RoleService::new(pool.clone());
|
||||
let tenant_service = TenantService::new(pool.clone());
|
||||
let authorization_service = AuthorizationService::new(pool.clone());
|
||||
let app_service = AppService::new(pool.clone());
|
||||
let permission_service = PermissionService::new(pool.clone());
|
||||
let redis = match redis::init_manager(&config).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!(%e, "Fatal error: Failed to connect to redis!");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
auth_service,
|
||||
client_service,
|
||||
user_service,
|
||||
role_service,
|
||||
tenant_service,
|
||||
authorization_service,
|
||||
app_service,
|
||||
permission_service,
|
||||
redis,
|
||||
auth_code_jwt_secret: config.auth_code_jwt_secret.clone(),
|
||||
};
|
||||
|
||||
let auth_cfg = middleware::auth::AuthMiddlewareConfig {
|
||||
@@ -103,7 +116,16 @@ async fn main() {
|
||||
"/tenants/register".to_string(),
|
||||
"/auth/register".to_string(),
|
||||
"/auth/login".to_string(),
|
||||
"/auth/login-code".to_string(),
|
||||
"/auth/refresh".to_string(),
|
||||
"/auth/code2token".to_string(),
|
||||
"/iam/api/v1/.well-known/jwks.json".to_string(),
|
||||
"/iam/api/v1/tenants/register".to_string(),
|
||||
"/iam/api/v1/auth/register".to_string(),
|
||||
"/iam/api/v1/auth/login".to_string(),
|
||||
"/iam/api/v1/auth/login-code".to_string(),
|
||||
"/iam/api/v1/auth/refresh".to_string(),
|
||||
"/iam/api/v1/auth/code2token".to_string(),
|
||||
],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
jwt: JwtVerifyConfig::rs256_from_pem(
|
||||
@@ -117,6 +139,11 @@ async fn main() {
|
||||
"/.well-known/jwks.json".to_string(),
|
||||
"/tenants/register".to_string(),
|
||||
"/auth/refresh".to_string(),
|
||||
"/auth/code2token".to_string(),
|
||||
"/iam/api/v1/.well-known/jwks.json".to_string(),
|
||||
"/iam/api/v1/tenants/register".to_string(),
|
||||
"/iam/api/v1/auth/refresh".to_string(),
|
||||
"/iam/api/v1/auth/code2token".to_string(),
|
||||
],
|
||||
skip_path_prefixes: vec!["/scalar".to_string()],
|
||||
};
|
||||
@@ -144,12 +171,20 @@ async fn main() {
|
||||
.layer(middleware::rate_limit::login_rate_limiter())
|
||||
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
|
||||
)
|
||||
.route(
|
||||
"/auth/login-code",
|
||||
post(login_code_handler)
|
||||
.layer(middleware::rate_limit::login_rate_limiter())
|
||||
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
|
||||
)
|
||||
.route(
|
||||
"/auth/refresh",
|
||||
post(refresh_handler)
|
||||
.layer(middleware::rate_limit::login_rate_limiter())
|
||||
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
|
||||
)
|
||||
.route("/auth/logout", post(logout_handler))
|
||||
.route("/auth/code2token", post(code2token_handler))
|
||||
.route("/me/permissions", get(my_permissions_handler))
|
||||
.route("/authorize/check", post(authorization_check_handler))
|
||||
.route("/users", get(list_users_handler))
|
||||
@@ -203,6 +238,18 @@ async fn main() {
|
||||
"/platform/tenants/{tenant_id}/enabled-apps",
|
||||
get(get_tenant_enabled_apps_handler).put(set_tenant_enabled_apps_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients",
|
||||
get(list_clients_handler).post(create_client_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/rotate-secret",
|
||||
post(rotate_client_secret_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/clients/{client_id}/redirect-uris",
|
||||
put(update_client_redirect_uris_handler),
|
||||
)
|
||||
.route(
|
||||
"/platform/apps",
|
||||
get(list_apps_handler).post(create_app_handler),
|
||||
@@ -237,11 +284,12 @@ async fn main() {
|
||||
common_telemetry::axum_middleware::trace_http_request,
|
||||
));
|
||||
|
||||
let v1 = Router::new().merge(platform_api).merge(api);
|
||||
let app = Router::new()
|
||||
.route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.merge(platform_api)
|
||||
.merge(api)
|
||||
.merge(v1.clone())
|
||||
.nest("/iam/api/v1", v1)
|
||||
.with_state(state);
|
||||
|
||||
// 6. 启动服务器
|
||||
|
||||
133
src/models.rs
133
src/models.rs
@@ -360,6 +360,139 @@ pub struct RefreshTokenRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginCodeRequest {
|
||||
#[schema(default = "", example = "user@example.com")]
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
#[schema(default = "", example = "password")]
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[schema(
|
||||
default = "",
|
||||
example = "https://cms-api.example.com/auth/callback?next=https%3A%2F%2Fcms.example.com%2F"
|
||||
)]
|
||||
#[serde(default)]
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginCodeResponse {
|
||||
#[schema(
|
||||
default = "",
|
||||
example = "https://cms-api.example.com/auth/callback?next=...&code=..."
|
||||
)]
|
||||
#[serde(default)]
|
||||
pub redirect_to: String,
|
||||
#[schema(default = 1700000000, example = 1700000000)]
|
||||
#[serde(default)]
|
||||
pub expires_at: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Code2TokenRequest {
|
||||
#[schema(default = "", example = "one_time_code_jwt")]
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[schema(default = "", example = "client_secret")]
|
||||
#[serde(default)]
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Code2TokenResponse {
|
||||
#[schema(default = "", example = "access_token")]
|
||||
#[serde(default)]
|
||||
pub access_token: String,
|
||||
#[schema(default = "", example = "refresh_token")]
|
||||
#[serde(default)]
|
||||
pub refresh_token: String,
|
||||
#[schema(default = "Bearer", example = "Bearer")]
|
||||
#[serde(default = "default_token_type")]
|
||||
pub token_type: String,
|
||||
#[schema(default = 7200, example = 7200)]
|
||||
#[serde(default)]
|
||||
pub expires_in: usize,
|
||||
#[schema(default = "00000000-0000-0000-0000-000000000000")]
|
||||
#[serde(default)]
|
||||
pub tenant_id: String,
|
||||
#[schema(default = "00000000-0000-0000-0000-000000000000")]
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateClientRequest {
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
|
||||
#[serde(default)]
|
||||
pub redirect_uris: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateClientResponse {
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[schema(default = "", example = "client_secret")]
|
||||
#[serde(default)]
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RotateClientSecretResponse {
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[schema(default = "", example = "new_client_secret")]
|
||||
#[serde(default)]
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientSummary {
|
||||
#[schema(default = "", example = "cms")]
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
|
||||
#[serde(default)]
|
||||
pub redirect_uris: Vec<String>,
|
||||
#[schema(default = "", example = "2026-02-02T12:00:00Z")]
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[schema(default = "", example = "2026-02-02T12:00:00Z")]
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientRedirectUrisRequest {
|
||||
#[schema(example = "[\"https://cms.example.com/auth/callback\"]")]
|
||||
#[serde(default)]
|
||||
pub redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
|
||||
pub struct Permission {
|
||||
#[serde(default = "default_uuid")]
|
||||
|
||||
8
src/redis.rs
Normal file
8
src/redis.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::config::AppConfig;
|
||||
use redis::aio::ConnectionManager;
|
||||
|
||||
pub async fn init_manager(config: &AppConfig) -> Result<ConnectionManager, redis::RedisError> {
|
||||
let client = redis::Client::open(config.redis_url.clone())?;
|
||||
ConnectionManager::new(client).await
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
|
||||
use crate::utils::authz::filter_permissions_by_enabled_apps;
|
||||
use crate::utils::{hash_password, sign, verify_password};
|
||||
use crate::utils::{hash_password, sign, sign_with_ttl, verify_password};
|
||||
use common_telemetry::AppError;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
@@ -12,7 +12,6 @@ use uuid::Uuid;
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
pool: PgPool,
|
||||
// jwt_secret removed, using RS256 keys
|
||||
refresh_token_pepper: String,
|
||||
}
|
||||
|
||||
@@ -20,7 +19,8 @@ impl AuthService {
|
||||
/// 创建认证服务实例。
|
||||
///
|
||||
/// 说明:
|
||||
/// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。
|
||||
/// - Access Token 使用 RS256 密钥对进行签发与校验,不使用对称密钥(HS256)。
|
||||
/// - 但仍需要一个服务端 Secret 作为 Refresh Token 指纹(HMAC)pepper,因此保留 `_jwt_secret` 入参(对齐环境变量名 `JWT_SECRET`)。
|
||||
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
@@ -35,6 +35,92 @@ impl AuthService {
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn issue_tokens_for_user(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
access_ttl_secs: usize,
|
||||
) -> Result<LoginResponse, AppError> {
|
||||
let roles = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT r.name
|
||||
FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let permissions = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT DISTINCT p.code
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||
r#"
|
||||
SELECT enabled_apps, version
|
||||
FROM tenant_entitlements
|
||||
WHERE tenant_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.unwrap_or_else(|| (vec![], 0));
|
||||
|
||||
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
|
||||
|
||||
let access_token = sign_with_ttl(
|
||||
user_id,
|
||||
tenant_id,
|
||||
roles,
|
||||
permissions,
|
||||
enabled_apps,
|
||||
apps_version,
|
||||
access_ttl_secs,
|
||||
)?;
|
||||
|
||||
let mut refresh_bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut refresh_bytes);
|
||||
let refresh_token = hex::encode(refresh_bytes);
|
||||
|
||||
let refresh_token_hash =
|
||||
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
|
||||
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(refresh_token_hash)
|
||||
.bind(refresh_token_fingerprint)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: access_ttl_secs,
|
||||
})
|
||||
}
|
||||
|
||||
// 注册业务
|
||||
#[instrument(skip(self, req))]
|
||||
/// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。
|
||||
@@ -115,101 +201,47 @@ impl AuthService {
|
||||
tenant_id: Uuid,
|
||||
req: LoginRequest,
|
||||
) -> Result<LoginResponse, AppError> {
|
||||
// 1. 查找用户 (带 tenant_id 防止跨租户登录)
|
||||
let user = self
|
||||
.verify_user_credentials(tenant_id, req.email, req.password)
|
||||
.await?;
|
||||
|
||||
self.issue_tokens_for_user(user.tenant_id, user.id, 15 * 60).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, password))]
|
||||
pub async fn verify_user_credentials(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<User, AppError> {
|
||||
let email = email.trim().to_string();
|
||||
if email.is_empty() || password.is_empty() {
|
||||
return Err(AppError::BadRequest("email and password are required".into()));
|
||||
}
|
||||
|
||||
let query = "SELECT * FROM users WHERE tenant_id = $1 AND email = $2";
|
||||
let user = sqlx::query_as::<_, User>(query)
|
||||
.bind(tenant_id)
|
||||
.bind(&req.email)
|
||||
.bind(&email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("User not found".into()))?;
|
||||
|
||||
// 2. 验证密码
|
||||
if !verify_password(&req.password, &user.password_hash) {
|
||||
if !verify_password(&password, &user.password_hash) {
|
||||
return Err(AppError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let roles = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT r.name
|
||||
FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user.tenant_id)
|
||||
.bind(user.id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
let permissions = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT DISTINCT p.code
|
||||
FROM permissions p
|
||||
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = $1 AND ur.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user.tenant_id)
|
||||
.bind(user.id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
|
||||
r#"
|
||||
SELECT enabled_apps, version
|
||||
FROM tenant_entitlements
|
||||
WHERE tenant_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user.tenant_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.unwrap_or_else(|| (vec![], 0));
|
||||
|
||||
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
|
||||
|
||||
// 3. 签发 Access Token
|
||||
let access_token = sign(
|
||||
user.id,
|
||||
user.tenant_id,
|
||||
roles,
|
||||
permissions,
|
||||
enabled_apps,
|
||||
apps_version,
|
||||
)?;
|
||||
|
||||
// 4. 生成 Refresh Token
|
||||
let mut refresh_bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut refresh_bytes);
|
||||
let refresh_token = hex::encode(refresh_bytes);
|
||||
|
||||
// Hash refresh token for storage
|
||||
let refresh_token_hash =
|
||||
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
|
||||
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
|
||||
|
||||
// 5. 存储 Refresh Token (30天过期)
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(refresh_token_hash)
|
||||
.bind(refresh_token_fingerprint)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(LoginResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: 15 * 60, // 15 mins
|
||||
})
|
||||
#[instrument(skip(self))]
|
||||
pub async fn logout(&self, user_id: Uuid) -> Result<(), AppError> {
|
||||
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bootstrap_tenant_admin(
|
||||
|
||||
365
src/services/client.rs
Normal file
365
src/services/client.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use crate::utils::{hash_password, verify_password};
|
||||
use anyhow::anyhow;
|
||||
use common_telemetry::AppError;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use rand::RngCore;
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientService {
|
||||
pool: PgPool,
|
||||
prev_ttl_days: u32,
|
||||
}
|
||||
|
||||
impl ClientService {
|
||||
pub fn new(pool: PgPool, prev_ttl_days: u32) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
prev_ttl_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_secret(&self) -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn normalize_redirect_uri(&self, raw: &str) -> Result<String, AppError> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(AppError::BadRequest("redirectUri is required".into()));
|
||||
}
|
||||
|
||||
if raw.contains('\r') || raw.contains('\n') {
|
||||
return Err(AppError::BadRequest("redirectUri is invalid".into()));
|
||||
}
|
||||
|
||||
let mut url = match url::Url::parse(raw) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
if raw.contains('%') {
|
||||
let decoded = percent_decode_str(raw).decode_utf8_lossy().to_string();
|
||||
if decoded.contains('\r') || decoded.contains('\n') {
|
||||
return Err(AppError::BadRequest("redirectUri is invalid".into()));
|
||||
}
|
||||
url::Url::parse(&decoded)
|
||||
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?
|
||||
} else {
|
||||
return Err(AppError::BadRequest("redirectUri is invalid".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
url.set_fragment(None);
|
||||
|
||||
let host = url.host_str().unwrap_or_default();
|
||||
let is_localhost = host == "localhost" || host == "127.0.0.1";
|
||||
let is_allowed_scheme = url.scheme() == "https" || (is_localhost && url.scheme() == "http");
|
||||
if !is_allowed_scheme {
|
||||
return Err(AppError::BadRequest(
|
||||
"redirectUri must be https (or http://localhost in dev)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn normalize_redirect_uris(&self, raw: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
let mut out = Vec::new();
|
||||
for u in raw {
|
||||
out.push(self.normalize_redirect_uri(&u)?);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn create_client(
|
||||
&self,
|
||||
client_id: String,
|
||||
name: Option<String>,
|
||||
redirect_uris: Option<Vec<String>>,
|
||||
) -> Result<String, AppError> {
|
||||
let client_id = client_id.trim().to_string();
|
||||
if client_id.is_empty() {
|
||||
return Err(AppError::BadRequest("clientId is required".into()));
|
||||
}
|
||||
|
||||
let secret = self.generate_secret();
|
||||
let secret_hash = hash_password(&secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
let redirect_uris = redirect_uris
|
||||
.map(|v| self.normalize_redirect_uris(v))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let redirect_uris_json =
|
||||
Value::Array(redirect_uris.into_iter().map(Value::String).collect());
|
||||
|
||||
let inserted = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO oauth_clients (client_id, name, secret_hash, redirect_uris)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (client_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&client_id)
|
||||
.bind(name)
|
||||
.bind(secret_hash)
|
||||
.bind(redirect_uris_json)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if inserted == 0 {
|
||||
return Err(AppError::BadRequest("clientId already exists".into()));
|
||||
}
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn rotate_secret(&self, client_id: String) -> Result<String, AppError> {
|
||||
let client_id = client_id.trim().to_string();
|
||||
if client_id.is_empty() {
|
||||
return Err(AppError::BadRequest("clientId is required".into()));
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT secret_hash FROM oauth_clients WHERE client_id = $1",
|
||||
)
|
||||
.bind(&client_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some((current_hash,)) = row else {
|
||||
return Err(AppError::NotFound("client not found".into()));
|
||||
};
|
||||
|
||||
let new_secret = self.generate_secret();
|
||||
let new_secret_hash =
|
||||
hash_password(&new_secret).map_err(|e| AppError::AnyhowError(anyhow!(e)))?;
|
||||
|
||||
let prev_expires_at =
|
||||
chrono::Utc::now() + chrono::Duration::days(self.prev_ttl_days as i64);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE oauth_clients
|
||||
SET prev_secret_hash = $1,
|
||||
prev_expires_at = $2,
|
||||
secret_hash = $3,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $4
|
||||
"#,
|
||||
)
|
||||
.bind(current_hash)
|
||||
.bind(prev_expires_at)
|
||||
.bind(new_secret_hash)
|
||||
.bind(&client_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(new_secret)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn set_redirect_uris(
|
||||
&self,
|
||||
client_id: String,
|
||||
redirect_uris: Vec<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let client_id = client_id.trim().to_string();
|
||||
if client_id.is_empty() {
|
||||
return Err(AppError::BadRequest("clientId is required".into()));
|
||||
}
|
||||
|
||||
let redirect_uris = self.normalize_redirect_uris(redirect_uris)?;
|
||||
let redirect_uris_json =
|
||||
Value::Array(redirect_uris.into_iter().map(Value::String).collect());
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
UPDATE oauth_clients
|
||||
SET redirect_uris = $1,
|
||||
updated_at = NOW()
|
||||
WHERE client_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(redirect_uris_json)
|
||||
.bind(&client_id)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound("client not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn assert_redirect_uri_allowed(
|
||||
&self,
|
||||
client_id: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let client_id = client_id.trim();
|
||||
if client_id.is_empty() {
|
||||
return Err(AppError::BadRequest("clientId is required".into()));
|
||||
}
|
||||
|
||||
let normalized = self.normalize_redirect_uri(redirect_uri)?;
|
||||
let row = sqlx::query_as::<_, (Value,)>(
|
||||
"SELECT redirect_uris FROM oauth_clients WHERE client_id = $1",
|
||||
)
|
||||
.bind(client_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some((v,)) = row else {
|
||||
return Err(AppError::AuthError("Invalid client credentials".into()));
|
||||
};
|
||||
|
||||
let Some(arr) = v.as_array() else {
|
||||
return Err(AppError::ConfigError(
|
||||
"Invalid oauth_clients.redirect_uris".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let requested = url::Url::parse(&normalized)
|
||||
.map_err(|_| AppError::BadRequest("redirectUri is invalid".into()))?;
|
||||
let requested_host = requested.host_str().unwrap_or_default().to_string();
|
||||
let requested_port = requested.port_or_known_default().unwrap_or(0);
|
||||
let requested_path = requested.path().to_string();
|
||||
let requested_scheme = requested.scheme().to_string();
|
||||
|
||||
let allowed = arr.iter().filter_map(|x| x.as_str()).any(|raw_allowed| {
|
||||
let allowed_norm = match self.normalize_redirect_uri(raw_allowed) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let allowed_url = match url::Url::parse(&allowed_norm) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let host = allowed_url.host_str().unwrap_or_default();
|
||||
let port = allowed_url.port_or_known_default().unwrap_or(0);
|
||||
|
||||
if allowed_url.scheme() != requested_scheme
|
||||
|| host != requested_host
|
||||
|| port != requested_port
|
||||
|| allowed_url.path() != requested_path
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let q = allowed_url.query().unwrap_or_default();
|
||||
if q.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
allowed_norm == normalized
|
||||
});
|
||||
if !allowed {
|
||||
return Err(AppError::AuthError("redirectUri is not allowed".into()));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, client_secret))]
|
||||
pub async fn verify_client_secret(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if client_id.trim().is_empty() || client_secret.trim().is_empty() {
|
||||
return Err(AppError::AuthError("Invalid client credentials".into()));
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Option<String>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT secret_hash, prev_secret_hash, prev_expires_at
|
||||
FROM oauth_clients
|
||||
WHERE client_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(client_id.trim())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
let Some((secret_hash, prev_hash, prev_expires_at)) = row else {
|
||||
return Err(AppError::AuthError("Invalid client credentials".into()));
|
||||
};
|
||||
|
||||
if verify_password(client_secret.trim(), &secret_hash) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let (Some(prev_hash), Some(prev_expires_at)) = (prev_hash, prev_expires_at) {
|
||||
if chrono::Utc::now() < prev_expires_at
|
||||
&& verify_password(client_secret.trim(), &prev_hash)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::AuthError("Invalid client credentials".into()))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_clients(
|
||||
&self,
|
||||
) -> Result<Vec<(String, Option<String>, Vec<String>, String, String)>, AppError> {
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Option<String>,
|
||||
Value,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT client_id, name, redirect_uris, created_at, updated_at
|
||||
FROM oauth_clients
|
||||
ORDER BY client_id ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, redirect_uris, created_at, updated_at)| {
|
||||
let uris = redirect_uris
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(
|
||||
id,
|
||||
name,
|
||||
uris,
|
||||
created_at.to_rfc3339(),
|
||||
updated_at.to_rfc3339(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod authorization;
|
||||
pub mod client;
|
||||
pub mod permission;
|
||||
pub mod role;
|
||||
pub mod tenant;
|
||||
@@ -9,6 +10,7 @@ pub mod user;
|
||||
pub use app::AppService;
|
||||
pub use auth::AuthService;
|
||||
pub use authorization::AuthorizationService;
|
||||
pub use client::ClientService;
|
||||
pub use permission::PermissionService;
|
||||
pub use role::RoleService;
|
||||
pub use tenant::TenantService;
|
||||
|
||||
@@ -29,13 +29,33 @@ pub fn sign(
|
||||
permissions: Vec<String>,
|
||||
apps: Vec<String>,
|
||||
apps_version: i32,
|
||||
) -> Result<String, AppError> {
|
||||
sign_with_ttl(
|
||||
user_id,
|
||||
tenant_id,
|
||||
roles,
|
||||
permissions,
|
||||
apps,
|
||||
apps_version,
|
||||
15 * 60,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sign_with_ttl(
|
||||
user_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
roles: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
apps: Vec<String>,
|
||||
apps_version: i32,
|
||||
ttl_secs: usize,
|
||||
) -> Result<String, AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as usize;
|
||||
|
||||
let expiration = now + 15 * 60; // 15 minutes access token
|
||||
let expiration = now + ttl_secs;
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod keys;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod authz;
|
||||
pub mod jwt;
|
||||
pub mod keys;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::{sign, sign_with_ttl, verify};
|
||||
pub use password::{hash_password, verify_password};
|
||||
pub use jwt::{sign, verify};
|
||||
|
||||
Reference in New Issue
Block a user