feat(project): init

This commit is contained in:
2026-01-29 18:14:47 +08:00
commit bb82c75834
15 changed files with 3715 additions and 0 deletions

5
.cargo/config.toml Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
/target
/logs
.env
.env.*
!.env.example
/log
*.log

3058
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

45
Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,162 @@
# iam-service多租户 IAM 服务)
一个基于 Rust 的多租户身份识别与访问管理IAM服务雏形当前提供“租户隔离 + 用户注册”能力并为后续扩展登录、JWT 认证、授权RBAC/ABAC、租户管理等功能预留了模块边界。
## 技术栈
- 语言与运行时Rustedition 2024、Tokio
- WebAxum
- 数据库PostgreSQL + SQLx
- 密码Argon2
- TokenJWT签发已实现验签/认证中间件待补齐)
- 可观测性tracing + `common-telemetry`(私有 registrykellnr
- 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())
}