Files
iam-service/src/services/auth.rs
2026-01-31 11:11:55 +08:00

248 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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