feat(project): init
This commit is contained in:
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[registries.kellnr]
|
||||||
|
index = "sparse+https://kellnr.shay7sev.site/api/v1/crates/"
|
||||||
|
|
||||||
|
[net]
|
||||||
|
git-fetch-with-cli = true
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
SERVICE_NAME=iam-service
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_TO_FILE=false
|
||||||
|
LOG_DIR=./log
|
||||||
|
LOG_FILE_NAME=iam.log
|
||||||
|
|
||||||
|
DATABASE_URL=postgres://iam_service_user:iam_service_password@localhost:5432/iam_service_db
|
||||||
|
JWT_SECRET=please_replace_with_a_secure_random_string
|
||||||
|
PORT=3000
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
/logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/log
|
||||||
|
*.log
|
||||||
3058
Cargo.lock
generated
Normal file
3058
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
Cargo.toml
Normal file
45
Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[package]
|
||||||
|
name = "iam-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 统一日志处理与错误处理
|
||||||
|
common-telemetry = { version = "0.1.3", registry = "kellnr", default-features = false, features = [
|
||||||
|
"error",
|
||||||
|
"telemetry",
|
||||||
|
"with-sqlx",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Web 框架
|
||||||
|
axum = "0.8.8"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# 序列化
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# 数据库 (PostgreSQL)
|
||||||
|
sqlx = { version = "0.8", features = [
|
||||||
|
"runtime-tokio-native-tls",
|
||||||
|
"postgres",
|
||||||
|
"uuid",
|
||||||
|
"chrono",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# 工具
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
dotenvy = "0.15" # 加载 .env
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.9.2"
|
||||||
|
config = "0.15.19" # 方便读取配置
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
|
||||||
|
# API 文档 (关键部分)
|
||||||
|
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||||
|
utoipa-scalar = { version = "0.3.0", features = ["axum"] } # Scalar 集成
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
http = "1.4.0"
|
||||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# iam-service(多租户 IAM 服务)
|
||||||
|
|
||||||
|
一个基于 Rust 的多租户身份识别与访问管理(IAM)服务雏形,当前提供“租户隔离 + 用户注册”能力,并为后续扩展登录、JWT 认证、授权(RBAC/ABAC)、租户管理等功能预留了模块边界。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 语言与运行时:Rust(edition 2024)、Tokio
|
||||||
|
- Web:Axum
|
||||||
|
- 数据库:PostgreSQL + SQLx
|
||||||
|
- 密码:Argon2
|
||||||
|
- Token:JWT(签发已实现;验签/认证中间件待补齐)
|
||||||
|
- 可观测性:tracing + `common-telemetry`(私有 registry:kellnr)
|
||||||
|
- API 文档:utoipa + Scalar(`/scalar`)
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
### 请求链路(当前)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
C[Client] -->|HTTP + JSON| R[Axum Router]
|
||||||
|
R --> M[租户中间件 resolve_tenant]
|
||||||
|
M --> H[Handlers]
|
||||||
|
H --> S[Services]
|
||||||
|
S --> DB[(PostgreSQL)]
|
||||||
|
|
||||||
|
H --> O[统一错误 AppError]
|
||||||
|
R --> D[OpenAPI/Scalar /scalar]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多租户实现方式
|
||||||
|
|
||||||
|
当前采用“共享数据库 + 共享表,通过 `tenant_id` 区分”的模式:
|
||||||
|
|
||||||
|
- `users.tenant_id` 外键引用 `tenants.id`(强制用户必须归属某个租户)
|
||||||
|
- `users(tenant_id, email)` 联合唯一索引(同租户邮箱唯一,不同租户可重复)
|
||||||
|
- HTTP 层通过请求头 `X-Tenant-ID: <uuid>` 传入租户上下文,并在 Service/SQL 查询中使用 `tenant_id` 过滤
|
||||||
|
|
||||||
|
## 项目结构说明
|
||||||
|
|
||||||
|
- [src/main.rs](file:///home/shay/project/backend/iam-service/src/main.rs):服务入口、路由组装、Telemetry 初始化、DB 连接池初始化
|
||||||
|
- [src/config/mod.rs](file:///home/shay/project/backend/iam-service/src/config/mod.rs):环境变量配置加载
|
||||||
|
- [src/db/mod.rs](file:///home/shay/project/backend/iam-service/src/db/mod.rs):PostgreSQL 连接池初始化(迁移功能目前未启用)
|
||||||
|
- [src/middleware/mod.rs](file:///home/shay/project/backend/iam-service/src/middleware/mod.rs):多租户中间件与 `TenantId` 提取器
|
||||||
|
- [src/handlers/mod.rs](file:///home/shay/project/backend/iam-service/src/handlers/mod.rs):HTTP Handler(控制器层),负责参数解析、调用 Service、返回统一响应
|
||||||
|
- [src/services/mod.rs](file:///home/shay/project/backend/iam-service/src/services/mod.rs):业务逻辑(注册/登录)与数据库交互
|
||||||
|
- [src/models.rs](file:///home/shay/project/backend/iam-service/src/models.rs):DB Model 与请求/响应 DTO(同时用于 OpenAPI Schema)
|
||||||
|
- [src/utils/mod.rs](file:///home/shay/project/backend/iam-service/src/utils/mod.rs):密码哈希与 JWT 签发工具
|
||||||
|
- [init.sql](file:///home/shay/project/backend/iam-service/init.sql):初始化数据库/表结构的 SQL(包含 tenants/users)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Rust:支持 edition 2024 的版本(建议使用最新 stable)
|
||||||
|
- PostgreSQL:建议 14+
|
||||||
|
|
||||||
|
### 安装与运行
|
||||||
|
|
||||||
|
1. 克隆仓库并进入目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd iam-service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 初始化数据库(示例)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h <pg_host> -U <admin_user> -f init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
按需修改 `.env`:
|
||||||
|
|
||||||
|
- `DATABASE_URL`:PostgreSQL 连接串
|
||||||
|
- `JWT_SECRET`:JWT 签名密钥(务必替换为强随机字符串)
|
||||||
|
- `PORT`:监听端口
|
||||||
|
|
||||||
|
4. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后:
|
||||||
|
|
||||||
|
- 服务地址:`http://127.0.0.1:<PORT>`
|
||||||
|
- API 文档:`http://127.0.0.1:<PORT>/scalar`
|
||||||
|
|
||||||
|
## 核心功能与 API
|
||||||
|
|
||||||
|
### 用户注册
|
||||||
|
|
||||||
|
`POST /auth/register`(需要请求头 `X-Tenant-ID`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:3000/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Tenant-ID: 11111111-1111-1111-1111-111111111111" \
|
||||||
|
-d '{"email":"user@example.com","password":"securePassword123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回(示例):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 登录与鉴权(待完善)
|
||||||
|
|
||||||
|
代码中已存在 `AuthService::login()` 与 JWT 签发工具,但目前没有对外暴露的 HTTP 路由,也未实现 JWT 验签/认证中间件与权限控制。
|
||||||
|
|
||||||
|
## 多租户使用指南
|
||||||
|
|
||||||
|
### 创建租户
|
||||||
|
|
||||||
|
当前无“租户管理 API”,建议通过 SQL 初始化租户:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO tenants (id, name) VALUES ('22222222-2222-2222-2222-222222222222', 'Tenant A');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问租户资源
|
||||||
|
|
||||||
|
所有请求都需要携带租户头:
|
||||||
|
|
||||||
|
- `X-Tenant-ID: <tenant_uuid>`
|
||||||
|
|
||||||
|
建议在后续迭代中:
|
||||||
|
|
||||||
|
- 将 `tenant_id` 与用户身份绑定(JWT claims / session)
|
||||||
|
- 校验请求头租户与 token 内租户一致,避免伪造跨租户访问
|
||||||
|
|
||||||
|
## 部署指南
|
||||||
|
|
||||||
|
### 本地/测试环境
|
||||||
|
|
||||||
|
- 使用 `.env` 配置连接串、端口、日志等
|
||||||
|
- 直接运行:`cargo run`
|
||||||
|
|
||||||
|
### 生产环境(建议)
|
||||||
|
|
||||||
|
- 构建:`cargo build --release`
|
||||||
|
- 以环境变量方式注入 `DATABASE_URL/JWT_SECRET/...`,避免把 `.env` 放入镜像或仓库
|
||||||
|
- 使用反向代理(Nginx/Envoy)处理 TLS、限流与审计日志(视需求)
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
当前仓库尚未包含单元测试/集成测试用例;建议在新增登录、鉴权与授权功能时补充覆盖(尤其是租户隔离与权限边界)。
|
||||||
36
init.sql
Normal file
36
init.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- 1. 创建 iam_service 专用用户
|
||||||
|
CREATE USER iam_service_user WITH PASSWORD 'iam_service_password';
|
||||||
|
|
||||||
|
-- 2. 创建 iam_service 专用数据库
|
||||||
|
CREATE DATABASE iam_service_db OWNER iam_service_user;
|
||||||
|
|
||||||
|
-- 3. 赋予权限(确保它能在 iam_service_db 库里创建 Schema)
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE iam_service_db TO iam_service_user;
|
||||||
|
|
||||||
|
-- 进入 iam_service_db
|
||||||
|
|
||||||
|
-- 1. 启用 UUID 扩展
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 2. 租户表
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 用户表 (多租户核心)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. 关键:创建联合唯一索引
|
||||||
|
-- 允许不同租户拥有相同的 email,但同一租户内 email 必须唯一
|
||||||
|
CREATE UNIQUE INDEX idx_users_tenant_email ON users(tenant_id, email);
|
||||||
|
|
||||||
|
-- 5. 初始化一个测试租户 (方便后续测试)
|
||||||
|
INSERT INTO tenants (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Default Corp');
|
||||||
34
src/config/mod.rs
Normal file
34
src/config/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub service_name: String,
|
||||||
|
pub log_level: String,
|
||||||
|
pub log_to_file: bool,
|
||||||
|
pub log_dir: String,
|
||||||
|
pub log_file_name: String,
|
||||||
|
pub database_url: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
Self {
|
||||||
|
service_name: env::var("SERVICE_NAME").unwrap_or_else(|_| "iam-service".into()),
|
||||||
|
log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()),
|
||||||
|
log_to_file: env::var("LOG_TO_FILE")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false),
|
||||||
|
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").expect("DATABASE_URL required"),
|
||||||
|
jwt_secret: env::var("JWT_SECRET")
|
||||||
|
.expect("JWT_SECRET required, generate by run 'openssl rand -base64 32'"),
|
||||||
|
port: env::var("PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/db/mod.rs
Normal file
18
src/db/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 初始化数据库连接池
|
||||||
|
pub async fn init_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(20) // 根据服务器规格调整,IAM服务通常并发高
|
||||||
|
.min_connections(5)
|
||||||
|
.acquire_timeout(Duration::from_secs(3)) // 获取连接超时时间
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// (可选) 可以在应用启动时自动运行迁移
|
||||||
|
// pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
|
// // 这要求你在项目根目录有 `migrations/` 文件夹
|
||||||
|
// sqlx::migrate!("./migrations").run(pool).await
|
||||||
|
// }
|
||||||
48
src/handlers/mod.rs
Normal file
48
src/handlers/mod.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use crate::middleware::TenantId;
|
||||||
|
use crate::models::{CreateUserRequest, UserResponse};
|
||||||
|
use crate::services::AuthService;
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use common_telemetry::AppError; // 引入刚刚写的中间件类型
|
||||||
|
|
||||||
|
// 状态对象,包含 Service
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub auth_service: AuthService,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册接口
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/auth/register",
|
||||||
|
request_body = CreateUserRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "User created", body = UserResponse),
|
||||||
|
(status = 400, description = "Bad request")
|
||||||
|
),
|
||||||
|
params(
|
||||||
|
("X-Tenant-ID" = String, Header, description = "Tenant UUID")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn register_handler(
|
||||||
|
// 1. 自动注入 TenantId (由中间件解析)
|
||||||
|
TenantId(tenant_id): TenantId,
|
||||||
|
// 2. 获取全局状态中的 Service
|
||||||
|
State(state): State<AppState>,
|
||||||
|
// 3. 获取 Body
|
||||||
|
Json(payload): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<UserResponse>, AppError> {
|
||||||
|
let user = state
|
||||||
|
.auth_service
|
||||||
|
.register(tenant_id, payload)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::BadRequest)?;
|
||||||
|
|
||||||
|
// 转换为 Response DTO (隐藏密码等敏感信息)
|
||||||
|
let response = UserResponse {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email.clone(),
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
78
src/main.rs
Normal file
78
src/main.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
mod config;
|
||||||
|
mod db; // 声明 db 模块
|
||||||
|
mod handlers;
|
||||||
|
mod middleware;
|
||||||
|
mod models;
|
||||||
|
mod services;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use axum::{Router, middleware::from_fn, routing::post};
|
||||||
|
use config::AppConfig;
|
||||||
|
use handlers::{AppState, register_handler};
|
||||||
|
use services::AuthService;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
|
// 引入 models 下的所有结构体以生成文档
|
||||||
|
use common_telemetry::telemetry::{self, TelemetryConfig};
|
||||||
|
use models::*;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
paths(handlers::register_handler),
|
||||||
|
components(schemas(CreateUserRequest, UserResponse)),
|
||||||
|
tags((name = "auth", description = "Authentication API"))
|
||||||
|
)]
|
||||||
|
struct ApiDoc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 1. 加载配置
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
let telemetry_config = TelemetryConfig {
|
||||||
|
service_name: config.service_name,
|
||||||
|
log_level: config.log_level,
|
||||||
|
log_to_file: config.log_to_file,
|
||||||
|
log_dir: Some(config.log_dir),
|
||||||
|
log_file: Some(config.log_file_name),
|
||||||
|
};
|
||||||
|
// 2. 初始化 Tracing
|
||||||
|
let _guard = telemetry::init(telemetry_config);
|
||||||
|
|
||||||
|
// 3. 初始化数据库 (使用 db 模块)
|
||||||
|
let pool = match db::init_pool(&config.database_url).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
// 记录到日志文件和控制台
|
||||||
|
tracing::error!(%e, "Fatal error: Failed to connect to database!");
|
||||||
|
// 退出程序 (或者 panic)
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// (可选) 运行迁移
|
||||||
|
// tracing::info!("🔄 Running migrations...");
|
||||||
|
// db::run_migrations(&pool).await.expect("Failed to run migrations");
|
||||||
|
|
||||||
|
// 4. 初始化 Service 和 AppState
|
||||||
|
let auth_service = AuthService::new(pool.clone(), config.jwt_secret.clone());
|
||||||
|
let state = AppState { auth_service };
|
||||||
|
|
||||||
|
// 5. 构建路由
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/auth/register", post(register_handler))
|
||||||
|
// 挂载多租户中间件
|
||||||
|
.layer(from_fn(middleware::resolve_tenant))
|
||||||
|
// 挂载 Scalar 文档
|
||||||
|
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
// 6. 启动服务器
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||||
|
tracing::info!("🚀 Server started at http://{}", addr);
|
||||||
|
tracing::info!("📄 Docs available at http://{}/scalar", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
56
src/middleware/mod.rs
Normal file
56
src/middleware/mod.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use axum::extract::FromRequestParts;
|
||||||
|
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||||
|
use http::request::Parts;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// --- 1. 租户 ID 提取器 ---
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)] // 这是一个类型安全的 Wrapper,用于在 Handler 中注入
|
||||||
|
pub struct TenantId(pub Uuid);
|
||||||
|
|
||||||
|
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, StatusCode> {
|
||||||
|
// 尝试从 Header 获取 X-Tenant-ID
|
||||||
|
let tenant_id_str = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Tenant-ID")
|
||||||
|
.and_then(|val| val.to_str().ok());
|
||||||
|
|
||||||
|
match tenant_id_str {
|
||||||
|
Some(id_str) => {
|
||||||
|
if let Ok(uuid) = Uuid::parse_str(id_str) {
|
||||||
|
// 验证成功,注入到 Extension 中
|
||||||
|
req.extensions_mut().insert(TenantId(uuid));
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::BAD_REQUEST) // ID 格式错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 如果是公开接口(如登录注册),可能不需要 TenantID,视业务而定
|
||||||
|
// 这里假设严格模式,必须带 TenantID
|
||||||
|
Err(StatusCode::BAD_REQUEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现 FromRequestParts 让 Handler 可以直接写 `tid: TenantId`
|
||||||
|
impl<S> FromRequestParts<S> for TenantId
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = StatusCode;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let tenant_id_str = parts
|
||||||
|
.headers
|
||||||
|
.get("X-Tenant-ID")
|
||||||
|
.and_then(|val| val.to_str().ok());
|
||||||
|
|
||||||
|
match tenant_id_str {
|
||||||
|
Some(id_str) => uuid::Uuid::parse_str(id_str)
|
||||||
|
.map(TenantId)
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST),
|
||||||
|
None => Err(StatusCode::BAD_REQUEST),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/models.rs
Normal file
30
src/models.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid; // 关键引入
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, FromRow, ToSchema)]
|
||||||
|
pub struct User {
|
||||||
|
#[schema(example = "550e8400-e29b-41d4-a716-446655440000")]
|
||||||
|
pub id: Uuid,
|
||||||
|
#[schema(example = "11111111-1111-1111-1111-111111111111")]
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[schema(example = "user@example.com")]
|
||||||
|
pub email: String,
|
||||||
|
#[schema(ignore)] // 不在文档中显示密码哈希
|
||||||
|
pub password_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
#[schema(example = "user@example.com")]
|
||||||
|
pub email: String,
|
||||||
|
#[schema(example = "securePassword123")]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
69
src/services/mod.rs
Normal file
69
src/services/mod.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use crate::models::{CreateUserRequest, User}; // 假设你在 models 定义了这些
|
||||||
|
use crate::utils::{create_jwt, hash_password, verify_password};
|
||||||
|
use axum::Json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthService {
|
||||||
|
pool: PgPool,
|
||||||
|
jwt_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new(pool: PgPool, jwt_secret: String) -> Self {
|
||||||
|
Self { pool, jwt_secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册业务
|
||||||
|
pub async fn register(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
req: CreateUserRequest,
|
||||||
|
) -> Result<Json<User>, String> {
|
||||||
|
// 1. 哈希密码
|
||||||
|
let hashed = hash_password(&req.password)?;
|
||||||
|
|
||||||
|
// 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, created_at
|
||||||
|
"#;
|
||||||
|
let user = sqlx::query_as::<_, User>(query)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(&hashed)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(Json(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录业务
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 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(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or("User not found")?;
|
||||||
|
|
||||||
|
// 2. 验证密码
|
||||||
|
if !verify_password(password, &user.password_hash) {
|
||||||
|
return Err("Invalid password".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 签发 Token
|
||||||
|
create_jwt(user.id, user.tenant_id, &self.jwt_secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/utils/mod.rs
Normal file
60
src/utils/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// --- 密码部分 ---
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> Result<String, String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.to_string();
|
||||||
|
Ok(password_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password(password: &str, password_hash: &str) -> bool {
|
||||||
|
let parsed_hash = match PasswordHash::new(password_hash) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JWT 部分 ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String, // 用户ID
|
||||||
|
pub tenant_id: String, // 租户ID (关键!)
|
||||||
|
pub exp: usize, // 过期时间
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_jwt(user_id: Uuid, tenant_id: Uuid, secret: &str) -> Result<String, String> {
|
||||||
|
let expiration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as usize
|
||||||
|
+ 24 * 3600; // 24小时过期
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
tenant_id: tenant_id.to_string(),
|
||||||
|
exp: expiration,
|
||||||
|
};
|
||||||
|
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_ref()),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user