feat(callback): add callback

This commit is contained in:
2026-02-03 10:17:11 +08:00
parent 27a6791591
commit 202b5eaad5
27 changed files with 1806 additions and 124 deletions

View File

@@ -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()

View File

@@ -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 = "角色:创建/列表(需权限)"),

View File

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

View File

@@ -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
View 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一次性 code5 分钟有效,单次使用)。
#[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,
}))
}

View File

@@ -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. 启动服务器

View File

@@ -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
View 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
}

View File

@@ -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 指纹HMACpepper因此保留 `_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
View 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())
}
}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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};