248 lines
7.8 KiB
Rust
248 lines
7.8 KiB
Rust
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 common_telemetry::AppError;
|
||
use rand::RngCore;
|
||
use sqlx::PgPool;
|
||
use tracing::instrument;
|
||
use uuid::Uuid;
|
||
|
||
#[derive(Clone)]
|
||
pub struct AuthService {
|
||
pool: PgPool,
|
||
// jwt_secret removed, using RS256 keys
|
||
}
|
||
|
||
impl AuthService {
|
||
/// 创建认证服务实例。
|
||
///
|
||
/// 说明:
|
||
/// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。
|
||
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
|
||
Self { pool }
|
||
}
|
||
|
||
// 注册业务
|
||
#[instrument(skip(self, req))]
|
||
/// 在指定租户下注册新用户,并在首次注册时自动引导初始化租户管理员权限。
|
||
///
|
||
/// 业务规则:
|
||
/// - 用户必须绑定到 `tenant_id`,禁止跨租户注册写入。
|
||
/// - 密码以安全哈希形式存储,不回传明文。
|
||
/// - 若该租户用户数为 1(首个用户),自动创建/获取 `Admin` 系统角色并授予全量权限,同时绑定到该用户。
|
||
///
|
||
/// 输入:
|
||
/// - `tenant_id`:目标租户
|
||
/// - `req.email` / `req.password`:注册信息
|
||
///
|
||
/// 输出:
|
||
/// - 返回创建后的 `User` 记录(包含 `id/tenant_id/email` 等字段)
|
||
///
|
||
/// 异常:
|
||
/// - 数据库写入失败(如唯一约束冲突、连接错误等)
|
||
/// - 密码哈希失败
|
||
pub async fn register(
|
||
&self,
|
||
tenant_id: Uuid,
|
||
req: CreateUserRequest,
|
||
) -> Result<User, AppError> {
|
||
let mut tx = self.pool.begin().await?;
|
||
|
||
// 1. 哈希密码
|
||
let hashed =
|
||
hash_password(&req.password).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
|
||
|
||
// 2. 存入数据库 (带上 tenant_id)
|
||
let query = r#"
|
||
INSERT INTO users (tenant_id, email, password_hash)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING id, tenant_id, email, password_hash
|
||
"#;
|
||
let user = sqlx::query_as::<_, User>(query)
|
||
.bind(tenant_id)
|
||
.bind(&req.email)
|
||
.bind(&hashed)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
|
||
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1")
|
||
.bind(tenant_id)
|
||
.fetch_one(&mut *tx)
|
||
.await?;
|
||
|
||
if user_count == 1 {
|
||
self.bootstrap_tenant_admin(&mut tx, tenant_id, user.id)
|
||
.await?;
|
||
}
|
||
|
||
tx.commit().await?;
|
||
Ok(user)
|
||
}
|
||
|
||
// 登录业务
|
||
#[instrument(skip(self, req))]
|
||
/// 在指定租户内完成用户认证并签发访问令牌与刷新令牌。
|
||
///
|
||
/// 业务规则:
|
||
/// - 仅在当前租户内按 `email` 查找用户,防止跨租户登录。
|
||
/// - 密码校验失败返回 `InvalidCredentials`。
|
||
/// - 登录成功后生成:
|
||
/// - `access_token`:JWT(包含租户、用户、角色与权限)
|
||
/// - `refresh_token`:随机生成并哈希后入库(默认 30 天过期)
|
||
///
|
||
/// 输出:
|
||
/// - `LoginResponse`(token_type 固定为 `Bearer`,`expires_in` 当前为 15 分钟)
|
||
///
|
||
/// 异常:
|
||
/// - 用户不存在(404)
|
||
/// - 密码错误(401)
|
||
/// - Token 签发失败或数据库写入失败
|
||
pub async fn login(
|
||
&self,
|
||
tenant_id: Uuid,
|
||
req: LoginRequest,
|
||
) -> Result<LoginResponse, AppError> {
|
||
// 1. 查找用户 (带 tenant_id 防止跨租户登录)
|
||
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)
|
||
.fetch_optional(&self.pool)
|
||
.await?
|
||
.ok_or(AppError::NotFound("User not found".into()))?;
|
||
|
||
// 2. 验证密码
|
||
if !verify_password(&req.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?;
|
||
|
||
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)))?;
|
||
|
||
// 5. 存储 Refresh Token (30天过期)
|
||
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
|
||
|
||
sqlx::query(
|
||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||
)
|
||
.bind(user.id)
|
||
.bind(refresh_token_hash)
|
||
.bind(expires_at)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
|
||
Ok(LoginResponse {
|
||
access_token,
|
||
refresh_token,
|
||
token_type: "Bearer".to_string(),
|
||
expires_in: 15 * 60, // 15 mins
|
||
})
|
||
}
|
||
|
||
async fn bootstrap_tenant_admin(
|
||
&self,
|
||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||
tenant_id: Uuid,
|
||
user_id: Uuid,
|
||
) -> Result<(), AppError> {
|
||
let role_id: Uuid = sqlx::query_scalar(
|
||
r#"
|
||
INSERT INTO roles (tenant_id, name, description, is_system)
|
||
VALUES ($1, 'Admin', 'Tenant administrator', TRUE)
|
||
ON CONFLICT (tenant_id, name)
|
||
DO UPDATE SET name = EXCLUDED.name
|
||
RETURNING id
|
||
"#,
|
||
)
|
||
.bind(tenant_id)
|
||
.fetch_one(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO role_permissions (role_id, permission_id)
|
||
SELECT $1, p.id
|
||
FROM permissions p
|
||
WHERE ($2::uuid = '00000000-0000-0000-0000-000000000001' OR p.code NOT LIKE 'iam:%')
|
||
ON CONFLICT DO NOTHING
|
||
"#,
|
||
)
|
||
.bind(role_id)
|
||
.bind(tenant_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
sqlx::query(
|
||
r#"
|
||
INSERT INTO user_roles (user_id, role_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT DO NOTHING
|
||
"#,
|
||
)
|
||
.bind(user_id)
|
||
.bind(role_id)
|
||
.execute(&mut **tx)
|
||
.await?;
|
||
|
||
Ok(())
|
||
}
|
||
}
|