feat: add auth me endpoint
This commit is contained in:
@@ -170,7 +170,8 @@
|
||||
- [ ] 实现 refresh token 轮换
|
||||
- [ ] 实现会话吊销
|
||||
- [ ] 实现全端登出
|
||||
- [ ] 实现 `me` 查询
|
||||
- [x] 实现 `me` 查询
|
||||
交付物:[../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md](../docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
|
||||
### 手机验证码登录
|
||||
|
||||
@@ -214,7 +215,8 @@
|
||||
- [ ] 兼容 `/api/auth/login-options`
|
||||
- [x] 兼容 `/api/auth/entry`
|
||||
交付物:[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/me`
|
||||
- [x] 兼容 `/api/auth/me`
|
||||
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
|
||||
- [ ] 兼容 `/api/auth/logout`
|
||||
- [ ] 兼容 `/api/auth/logout-all`
|
||||
- [ ] 兼容 `/api/auth/refresh`
|
||||
|
||||
121
docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md
Normal file
121
docs/technical/AUTH_ME_QUERY_DESIGN_2026-04-21.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `/api/auth/me` 查询落地设计
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于指导 `M2` 中 `实现 me 查询` 任务的首版落地,冻结:
|
||||
|
||||
1. `GET /api/auth/me` 的请求与响应 contract。
|
||||
2. 当前阶段 Bearer JWT 与用户快照读取的衔接方式。
|
||||
3. `availableLoginMethods` 的返回口径。
|
||||
4. JWT 有效但本地用户不存在时的错误处理语义。
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前 Node `/api/auth/me` 具备以下最小语义:
|
||||
|
||||
1. 必须先通过 Bearer JWT 校验。
|
||||
2. 用 `sub = user_id` 读取当前用户。
|
||||
3. 返回 `user + availableLoginMethods`。
|
||||
4. `availableLoginMethods` 只返回当前对外开启的补充登录方式,不包含 `password`。
|
||||
|
||||
Rust 首版需要保留这条最小 contract,但当前阶段允许继续使用进程内仓储承接用户真相。
|
||||
|
||||
## 3. 当前阶段范围
|
||||
|
||||
本阶段只落以下内容:
|
||||
|
||||
1. `module-auth` 增加按 `user_id` 查询当前用户能力。
|
||||
2. `api-server` 暴露 `GET /api/auth/me`。
|
||||
3. 返回与当前前端兼容的 `user + availableLoginMethods`。
|
||||
|
||||
本阶段不包含:
|
||||
|
||||
1. `refresh token` 轮换。
|
||||
2. 会话列表、审计、风控等扩展信息。
|
||||
3. `SpacetimeDB` 真正的身份表读取。
|
||||
|
||||
## 4. contract
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
1. 方法:`GET`
|
||||
2. 路径:`/api/auth/me`
|
||||
3. 鉴权:`Authorization: Bearer <token>`
|
||||
|
||||
### 4.2 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_00000001",
|
||||
"username": "guest_001",
|
||||
"displayName": "guest_001",
|
||||
"phoneNumberMasked": null,
|
||||
"loginMethod": "password",
|
||||
"bindingStatus": "active",
|
||||
"wechatBound": false
|
||||
},
|
||||
"availableLoginMethods": []
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前阶段 `user` 字段固定返回当前登录用户快照,不返回 `null`。
|
||||
2. `availableLoginMethods` 只按当前对外配置返回:
|
||||
- `SMS_AUTH_ENABLED=true` 时包含 `phone`
|
||||
- `WECHAT_AUTH_ENABLED=true` 时包含 `wechat`
|
||||
3. `password` 不进入 `availableLoginMethods`,保持和 Node 现状一致。
|
||||
|
||||
## 5. 错误语义
|
||||
|
||||
### 5.1 缺少或非法 Bearer token
|
||||
|
||||
1. 返回 `401 UNAUTHORIZED`
|
||||
|
||||
### 5.2 JWT 有效但用户不存在
|
||||
|
||||
1. 返回 `401 UNAUTHORIZED`
|
||||
2. 语义视为“当前登录态已失效,需要重新登录”
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前阶段不把这种情况返回为 `404`。
|
||||
2. 这样可以与后续 `token_version`、会话吊销和用户禁用策略保持同一类恢复路径。
|
||||
|
||||
## 6. crate 边界
|
||||
|
||||
### 6.1 `module-auth`
|
||||
|
||||
负责:
|
||||
|
||||
1. 提供按 `user_id` 查询当前用户快照的能力。
|
||||
2. 继续复用密码登录阶段已经建立的同一份进程内用户真相。
|
||||
|
||||
### 6.2 `api-server`
|
||||
|
||||
负责:
|
||||
|
||||
1. 复用现有 Bearer JWT 中间件拿到 `sub`。
|
||||
2. 调用 `module-auth` 查询用户。
|
||||
3. 组装 `AuthMeResponse`。
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. 已登录用户可通过 `/api/auth/me` 取回当前用户。
|
||||
2. 当短信/微信开关开启时,`availableLoginMethods` 返回对应值。
|
||||
3. JWT 有效但用户不存在时返回 `401`。
|
||||
|
||||
## 8. 后续衔接
|
||||
|
||||
这条任务完成后,下一步顺序固定为:
|
||||
|
||||
1. refresh token 轮换
|
||||
2. 会话吊销
|
||||
3. 手机验证码登录
|
||||
|
||||
微信登录继续按“暂缓执行”处理。
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md):`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract,以及用户不存在时的 `401` 语义。
|
||||
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
|
||||
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
|
||||
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
|
||||
|
||||
@@ -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