feat(callback): add callback

This commit is contained in:
2026-02-03 10:17:52 +08:00
parent ed3219deb4
commit 2ddc11eb7b
8 changed files with 252 additions and 7 deletions

View File

@@ -10,6 +10,8 @@ DATABASE_URL=postgres://cms_service_user:cms_service_password@127.0.0.1:5432/cms
DB_MAX_CONNECTIONS=20 DB_MAX_CONNECTIONS=20
DB_MIN_CONNECTIONS=5 DB_MIN_CONNECTIONS=5
RUN_MIGRATIONS=0
IAM_BASE_URL=http://127.0.0.1:3000 IAM_BASE_URL=http://127.0.0.1:3000
IAM_JWKS_URL= IAM_JWKS_URL=
JWT_PUBLIC_KEY_PEM= JWT_PUBLIC_KEY_PEM=
@@ -17,3 +19,10 @@ IAM_TIMEOUT_MS=2000
IAM_CACHE_TTL_SECONDS=10 IAM_CACHE_TTL_SECONDS=10
IAM_STALE_IF_ERROR_SECONDS=60 IAM_STALE_IF_ERROR_SECONDS=60
IAM_CACHE_MAX_ENTRIES=50000 IAM_CACHE_MAX_ENTRIES=50000
# SSO业务服务端回调/auth/callback用到的 IAM client 凭证
IAM_CLIENT_ID=cms
IAM_CLIENT_SECRET=please_replace_with_client_secret
# SSO回调处理完成后跳回 CMS 前端的基准地址(用于限制 next 的 open redirect
CMS_FRONT_BASE_URL=https://cms.example.com

7
Cargo.lock generated
View File

@@ -280,6 +280,7 @@ dependencies = [
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding",
"utoipa", "utoipa",
"utoipa-scalar", "utoipa-scalar",
"uuid", "uuid",
@@ -2832,6 +2833,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View File

@@ -50,3 +50,4 @@ reqwest = { version = "0.12", default-features = false, features = [
"json", "json",
"rustls-tls", "rustls-tls",
] } ] }
urlencoding = "2.1.3"

View File

@@ -36,13 +36,17 @@ DDD 分层目录:
1. 复制并修改环境变量: 1. 复制并修改环境变量:
- `cp .env.example .env` - `cp .env.example .env`
2. 准备 PostgreSQL 并配置 `DATABASE_URL` 2. 准备 PostgreSQL 并配置 `DATABASE_URL`
3. 启动服务(会自动运行 migrations 3. 执行数据库迁移(推荐使用脚本体系
- `./scripts/db/migrate.sh`
- `./scripts/db/verify.sh`
4. 启动服务:
- `cargo run` - `cargo run`
## 文档 ## 文档
- Scalar`GET /scalar` - Scalar`GET /scalar`
- 健康检查:`GET /healthz` - 健康检查:`GET /healthz`
- SSO 回调code → token → Set-Cookie`GET /auth/callback?code=...&next=...`
## APIv1 ## APIv1
@@ -95,7 +99,8 @@ CMS 运行时依赖 IAM 提供以下能力:
## 数据库迁移 ## 数据库迁移
- 迁移文件目录: [migrations](file:///home/shay/project/backend/cms-service/migrations) - 迁移文件目录: [migrations](file:///home/shay/project/backend/cms-service/migrations)
- 启动时自动执行:见 [db::run_migrations](file:///home/shay/project/backend/cms-service/src/infrastructure/db/mod.rs#L14-L16) - 启动时默认不执行迁移;如需在本地启动时自动执行,设置 `RUN_MIGRATIONS=1`
- 服务内迁移入口:见 [db::run_migrations](file:///home/shay/project/backend/cms-service/src/infrastructure/db/mod.rs#L14-L16)
- 运维脚本migrate/verify/rollback见 [scripts/db/README.md](file:///home/shay/project/backend/cms-service/scripts/db/README.md) - 运维脚本migrate/verify/rollback见 [scripts/db/README.md](file:///home/shay/project/backend/cms-service/scripts/db/README.md)
## 测试 ## 测试

213
src/api/handlers/auth.rs Normal file
View File

@@ -0,0 +1,213 @@
use axum::{
Router,
extract::Query,
http::{HeaderValue, header},
response::{IntoResponse, Redirect},
routing::get,
};
use common_telemetry::AppError;
use serde::Deserialize;
use crate::api::AppState;
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub code: String,
pub next: Option<String>,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Code2TokenRequest {
code: String,
client_id: String,
client_secret: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Code2TokenData {
access_token: String,
refresh_token: String,
expires_in: usize,
token_type: String,
tenant_id: String,
user_id: String,
}
#[derive(Debug, Deserialize)]
struct AppResponse<T> {
code: i32,
message: String,
data: Option<T>,
}
pub fn router() -> Router<AppState> {
Router::new().route("/callback", get(sso_callback_handler))
}
fn is_https(headers: &axum::http::HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
fn cookie_header(
name: &str,
value: &str,
secure: bool,
http_only: bool,
max_age: Option<u64>,
) -> String {
let mut s = format!("{}={}; Path=/; SameSite=Strict", name, value);
if secure {
s.push_str("; Secure");
}
if http_only {
s.push_str("; HttpOnly");
}
if let Some(v) = max_age {
s.push_str(&format!("; Max-Age={}", v));
}
s
}
fn resolve_front_redirect(next: Option<String>) -> String {
let base = std::env::var("CMS_FRONT_BASE_URL").ok();
let Some(raw) = next else {
return base.unwrap_or_else(|| "/".to_string());
};
if raw.starts_with('/') {
return raw;
}
if raw.starts_with("https://") {
if let Some(base) = base {
if raw.starts_with(&base) {
return raw;
}
return base;
}
return raw;
}
base.unwrap_or_else(|| "/".to_string())
}
fn resolve_front_error_redirect(message: &str) -> String {
let base = std::env::var("CMS_FRONT_BASE_URL").ok();
let encoded = urlencoding::encode(message);
if let Some(base) = base {
format!(
"{}/auth-error?message={}",
base.trim_end_matches('/'),
encoded
)
} else {
format!("/auth-error?message={}", encoded)
}
}
async fn sso_callback_handler(
headers: axum::http::HeaderMap,
Query(q): Query<CallbackQuery>,
) -> Result<axum::response::Response, AppError> {
if q.code.trim().is_empty() {
let target = resolve_front_error_redirect("missing code");
return Ok(Redirect::temporary(&target).into_response());
}
let iam_base = std::env::var("IAM_BASE_URL")
.or_else(|_| std::env::var("IAM_SERVICE_BASE_URL"))
.map_err(|_| AppError::ConfigError("IAM_BASE_URL is required".into()))?;
let client_id = std::env::var("IAM_CLIENT_ID")
.map_err(|_| AppError::ConfigError("IAM_CLIENT_ID is required".into()))?;
let client_secret = std::env::var("IAM_CLIENT_SECRET")
.map_err(|_| AppError::ConfigError("IAM_CLIENT_SECRET is required".into()))?;
let http = reqwest::Client::new();
let resp = http
.post(format!(
"{}/iam/api/v1/auth/code2token",
iam_base.trim_end_matches('/')
))
.json(&Code2TokenRequest {
code: q.code,
client_id,
client_secret,
})
.send()
.await
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let status = resp.status();
let body = resp
.json::<AppResponse<Code2TokenData>>()
.await
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
if !status.is_success() || body.code != 0 {
let target = resolve_front_error_redirect(&body.message);
return Ok(Redirect::temporary(&target).into_response());
}
let Some(data) = body.data else {
let target = resolve_front_error_redirect("invalid code2token response");
return Ok(Redirect::temporary(&target).into_response());
};
let target = resolve_front_redirect(q.next);
let secure = is_https(&headers);
let mut res = Redirect::temporary(&target).into_response();
let refresh_max_age = 30_u64 * 24 * 60 * 60;
res.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_header(
"accessToken",
&data.access_token,
secure,
true,
Some(data.expires_in as u64),
))
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?,
);
res.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_header(
"refreshToken",
&data.refresh_token,
secure,
true,
Some(refresh_max_age),
))
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?,
);
res.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_header(
"tenantId",
&data.tenant_id,
secure,
true,
Some(refresh_max_age),
))
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?,
);
res.headers_mut().append(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_header(
"userId",
&data.user_id,
secure,
true,
Some(refresh_max_age),
))
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?,
);
Ok(res)
}

View File

@@ -1,4 +1,5 @@
pub mod article; pub mod article;
pub mod auth;
pub mod column; pub mod column;
pub mod common; pub mod common;
pub mod media; pub mod media;

View File

@@ -21,6 +21,8 @@ pub struct AppState {
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let health = Router::new().route("/healthz", get(|| async { axum::http::StatusCode::OK })); let health = Router::new().route("/healthz", get(|| async { axum::http::StatusCode::OK }));
let auth = Router::new().nest("/auth", handlers::auth::router());
let v1 = Router::new() let v1 = Router::new()
.nest("/columns", handlers::column::router()) .nest("/columns", handlers::column::router())
.nest("/tags", handlers::tag::router()) .nest("/tags", handlers::tag::router())
@@ -31,6 +33,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/favicon.ico", get(|| async { axum::http::StatusCode::NO_CONTENT })) .route("/favicon.ico", get(|| async { axum::http::StatusCode::NO_CONTENT }))
.merge(Scalar::with_url("/scalar", ApiDoc::openapi())) .merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.merge(health) .merge(health)
.merge(auth)
.nest("/v1", v1) .nest("/v1", v1)
.layer(axum::middleware::from_fn(catch_panic)) .layer(axum::middleware::from_fn(catch_panic))
.layer(axum::middleware::from_fn(request_logger)) .layer(axum::middleware::from_fn(request_logger))

View File

@@ -24,9 +24,15 @@ async fn main() {
}); });
let pool = db::init_pool(&config).await.expect("failed to init db pool"); let pool = db::init_pool(&config).await.expect("failed to init db pool");
let run_migrations = std::env::var("RUN_MIGRATIONS")
.ok()
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE"))
.unwrap_or(false);
if run_migrations {
db::run_migrations(&pool) db::run_migrations(&pool)
.await .await
.expect("failed to run migrations"); .expect("failed to run migrations");
}
let state = AppState { let state = AppState {
services: CmsServices::new(pool), services: CmsServices::new(pool),
@@ -40,7 +46,7 @@ async fn main() {
}; };
let auth_cfg = AuthMiddlewareConfig { let auth_cfg = AuthMiddlewareConfig {
skip_exact_paths: vec!["/healthz".to_string()], skip_exact_paths: vec!["/healthz".to_string(), "/auth/callback".to_string()],
skip_path_prefixes: vec!["/scalar".to_string()], skip_path_prefixes: vec!["/scalar".to_string()],
jwt: match &config.jwt_public_key_pem { jwt: match &config.jwt_public_key_pem {
Some(pem) => auth_kit::jwt::JwtVerifyConfig::rs256_from_pem("iam-service", pem) Some(pem) => auth_kit::jwt::JwtVerifyConfig::rs256_from_pem("iam-service", pem)
@@ -58,7 +64,7 @@ async fn main() {
}, },
}; };
let tenant_cfg = TenantMiddlewareConfig { let tenant_cfg = TenantMiddlewareConfig {
skip_exact_paths: vec!["/healthz".to_string()], skip_exact_paths: vec!["/healthz".to_string(), "/auth/callback".to_string()],
skip_path_prefixes: vec!["/scalar".to_string()], skip_path_prefixes: vec!["/scalar".to_string()],
}; };