feat: add auth me endpoint
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
|
||||
9. 接入 `POST /api/auth/entry` 首版密码登录链路
|
||||
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
|
||||
11. 接入 `GET /api/auth/me` 当前用户查询链路
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -40,6 +41,7 @@
|
||||
5. [x] 接入 `/healthz`
|
||||
6. [x] 接入 `/api/auth/entry`
|
||||
7. [x] 接入 `/api/assets/direct-upload-tickets`
|
||||
8. [x] 接入 `/api/auth/me`
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -99,3 +101,4 @@
|
||||
3. 外部副作用通过 `platform-auth`、`platform-oss`、`platform-llm` 与各模块 crate 的应用层完成。
|
||||
4. 不把领域规则直接堆在 handler 中。
|
||||
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
|
||||
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
require_bearer_auth,
|
||||
},
|
||||
auth_me::auth_me,
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
password_entry::password_entry,
|
||||
@@ -46,6 +47,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/direct-upload-tickets",
|
||||
post(create_direct_upload_ticket),
|
||||
@@ -506,4 +514,106 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_me_returns_current_user_and_available_login_methods() {
|
||||
let config = AppConfig {
|
||||
sms_auth_enabled: true,
|
||||
wechat_auth_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state
|
||||
.password_entry_service()
|
||||
.execute(module_auth::PasswordEntryInput {
|
||||
username: "guest_001".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("seed login should succeed");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_00000001".to_string(),
|
||||
session_id: "sess_me_query".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("guest_001".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
||||
|
||||
assert_eq!(
|
||||
payload["user"]["id"],
|
||||
Value::String("user_00000001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["availableLoginMethods"],
|
||||
serde_json::json!(["phone", "wechat"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_me_returns_unauthorized_when_user_missing() {
|
||||
let config = AppConfig::default();
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "user_missing".to_string(),
|
||||
session_id: "sess_missing".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: 1,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some("ghost".to_string()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/me")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
75
server-rs/crates/api-server/src/auth_me.rs
Normal file
75
server-rs/crates/api-server/src/auth_me.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeResponse {
|
||||
pub user: AuthMeUserPayload,
|
||||
pub available_login_methods: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthMeUserPayload {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_method: &'static str,
|
||||
pub binding_status: &'static str,
|
||||
pub wechat_bound: bool,
|
||||
}
|
||||
|
||||
pub async fn auth_me(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let user = state
|
||||
.password_entry_service()
|
||||
.get_user_by_id(&user_id)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("当前登录态已失效,请重新登录")
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AuthMeResponse {
|
||||
user: AuthMeUserPayload {
|
||||
id: user.user.id,
|
||||
username: user.user.username,
|
||||
display_name: user.user.display_name,
|
||||
phone_number_masked: user.user.phone_number_masked,
|
||||
login_method: user.user.login_method.as_str(),
|
||||
binding_status: user.user.binding_status.as_str(),
|
||||
wechat_bound: user.user.wechat_bound,
|
||||
},
|
||||
available_login_methods: build_available_login_methods(&state),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn build_available_login_methods(state: &AppState) -> Vec<&'static str> {
|
||||
let mut methods = Vec::new();
|
||||
if state.config.sms_auth_enabled {
|
||||
methods.push("phone");
|
||||
}
|
||||
if state.config.wechat_auth_enabled {
|
||||
methods.push("wechat");
|
||||
}
|
||||
methods
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct AppConfig {
|
||||
pub refresh_cookie_secure: bool,
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub wechat_auth_enabled: bool,
|
||||
pub oss_bucket: Option<String>,
|
||||
pub oss_endpoint: Option<String>,
|
||||
pub oss_access_key_id: Option<String>,
|
||||
@@ -38,6 +40,8 @@ impl Default for AppConfig {
|
||||
refresh_cookie_secure: false,
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
sms_auth_enabled: false,
|
||||
wechat_auth_enabled: false,
|
||||
oss_bucket: None,
|
||||
oss_endpoint: None,
|
||||
oss_access_key_id: None,
|
||||
@@ -115,6 +119,14 @@ impl AppConfig {
|
||||
config.refresh_session_ttl_days = refresh_session_ttl_days;
|
||||
}
|
||||
|
||||
if let Some(sms_auth_enabled) = read_first_bool_env(&["SMS_AUTH_ENABLED"]) {
|
||||
config.sms_auth_enabled = sms_auth_enabled;
|
||||
}
|
||||
|
||||
if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) {
|
||||
config.wechat_auth_enabled = wechat_auth_enabled;
|
||||
}
|
||||
|
||||
config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]);
|
||||
config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]);
|
||||
config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]);
|
||||
|
||||
@@ -2,6 +2,7 @@ mod api_response;
|
||||
mod app;
|
||||
mod assets;
|
||||
mod auth;
|
||||
mod auth_me;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
|
||||
@@ -49,3 +49,4 @@
|
||||
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。
|
||||
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
|
||||
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
|
||||
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
|
||||
|
||||
@@ -49,6 +49,11 @@ pub struct PasswordEntryResult {
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthMeResult {
|
||||
pub user: AuthUser,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PasswordEntryError {
|
||||
InvalidUsername,
|
||||
@@ -138,6 +143,17 @@ impl PasswordEntryService {
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordEntryService {
|
||||
pub fn get_user_by_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<AuthMeResult>, PasswordEntryError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryPasswordUserStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -198,6 +214,22 @@ impl InMemoryPasswordUserStore {
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn find_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
|
||||
Ok(state
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
||||
Reference in New Issue
Block a user