feat: unify recommend anonymous runtime guest auth
- Route recommended runtime launches through shared runtime guest token handling - Extend recommend-page anonymous play beyond jump-hop - Add regression coverage for runtime guest launch clients - Update docs to reflect the full anonymous-play matrix
This commit is contained in:
@@ -9,9 +9,13 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
|
||||
AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims,
|
||||
RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token,
|
||||
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::auth::RuntimeGuestTokenResponse;
|
||||
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -34,6 +38,18 @@ pub struct RefreshSessionToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RuntimePrincipal {
|
||||
User(AuthenticatedAccessToken),
|
||||
Guest(RuntimeGuestTokenClaims),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimePrincipalKind {
|
||||
User,
|
||||
Guest,
|
||||
}
|
||||
|
||||
impl AuthenticatedAccessToken {
|
||||
pub fn new(claims: AccessTokenClaims) -> Self {
|
||||
Self { claims }
|
||||
@@ -54,6 +70,66 @@ impl RefreshSessionToken {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimePrincipal {
|
||||
pub fn subject(&self) -> &str {
|
||||
match self {
|
||||
Self::User(authenticated) => authenticated.claims().user_id(),
|
||||
Self::Guest(claims) => claims.subject(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> RuntimePrincipalKind {
|
||||
match self {
|
||||
Self::User(_) => RuntimePrincipalKind::User,
|
||||
Self::Guest(_) => RuntimePrincipalKind::Guest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimePrincipalKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::Guest => "guest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn issue_runtime_guest_token(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let issued_at = OffsetDateTime::now_utc();
|
||||
let claims = RuntimeGuestTokenClaims::from_input(
|
||||
RuntimeGuestTokenClaimsInput {
|
||||
subject: format!("guest-runtime-{}", new_uuid_simple_string()),
|
||||
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
issued_at,
|
||||
)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let token = sign_runtime_guest_token(&claims, state.auth_jwt_config()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
let expires_at = OffsetDateTime::from_unix_timestamp(claims.expires_at_unix() as i64)
|
||||
.ok()
|
||||
.and_then(|value| format_rfc3339(value).ok())
|
||||
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
RuntimeGuestTokenResponse {
|
||||
token,
|
||||
expires_at,
|
||||
subject: claims.subject().to_string(),
|
||||
scope: claims.scope().to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn require_bearer_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
@@ -70,29 +146,70 @@ pub async fn require_bearer_auth(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn attach_optional_bearer_auth(
|
||||
pub async fn require_runtime_principal_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Some(authenticated) = authenticate_request(&state, &request)? {
|
||||
request.extensions_mut().insert(authenticated.clone());
|
||||
let mut response = next.run(request).await;
|
||||
response.extensions_mut().insert(authenticated);
|
||||
return Ok(response);
|
||||
let Some(principal) = authenticate_runtime_principal(&state, &request)? else {
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
};
|
||||
request.extensions_mut().insert(principal.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
response.extensions_mut().insert(principal);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn authenticate_runtime_principal(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
) -> Result<Option<RuntimePrincipal>, AppError> {
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
match authenticate_request(state, request) {
|
||||
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(_) => {
|
||||
let bearer_token = extract_bearer_token(request.headers())?;
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"runtime guest JWT 校验失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
})?;
|
||||
if claims.scope() != RUNTIME_GUEST_SCOPE_PUBLIC_PLAY {
|
||||
warn!(
|
||||
%request_id,
|
||||
scope = %claims.scope(),
|
||||
"runtime guest JWT scope 非法"
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
|
||||
}
|
||||
Ok(Some(RuntimePrincipal::Guest(claims)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_request(
|
||||
state: &AppState,
|
||||
request: &Request,
|
||||
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
|
||||
if allows_internal_forwarded_auth(request.uri().path())
|
||||
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
|
||||
{
|
||||
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||
if allows_internal_forwarded_auth(request.uri().path()) {
|
||||
if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) {
|
||||
return Ok(Some(AuthenticatedAccessToken::new(claims)));
|
||||
}
|
||||
}
|
||||
|
||||
if !request.headers().contains_key(AUTHORIZATION) {
|
||||
|
||||
@@ -24,7 +24,7 @@ use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||
@@ -51,7 +51,6 @@ const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||||
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||||
const JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID: &str = "anonymous-runtime";
|
||||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||||
|
||||
pub async fn create_jump_hop_session(
|
||||
@@ -231,15 +230,13 @@ pub async fn get_jump_hop_runtime_work(
|
||||
pub async fn start_jump_hop_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||||
let authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated);
|
||||
let owner_user_id = authenticated
|
||||
.map(|authenticated| authenticated.claims().user_id().to_string())
|
||||
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let principal_kind = principal.kind().as_str();
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.start_jump_hop_run(payload, owner_user_id.clone())
|
||||
@@ -256,7 +253,7 @@ pub async fn start_jump_hop_run(
|
||||
&state,
|
||||
&request_context,
|
||||
build_jump_hop_work_play_tracking_draft(
|
||||
authenticated,
|
||||
&principal,
|
||||
run.profile_id.clone(),
|
||||
JUMP_HOP_RUNTIME_RUNS_ROUTE,
|
||||
)
|
||||
@@ -265,7 +262,7 @@ pub async fn start_jump_hop_run(
|
||||
.profile_id(run.profile_id.clone())
|
||||
.extra(json!({
|
||||
"runStatus": run.status,
|
||||
"isAnonymous": maybe_authenticated.is_none(),
|
||||
"principalKind": principal_kind,
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -280,15 +277,12 @@ pub async fn jump_hop_run_jump(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||||
let owner_user_id = maybe_authenticated
|
||||
.as_ref()
|
||||
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
|
||||
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
||||
@@ -311,15 +305,12 @@ pub async fn restart_jump_hop_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||||
let owner_user_id = maybe_authenticated
|
||||
.as_ref()
|
||||
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
|
||||
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
|
||||
let owner_user_id = principal.subject().to_string();
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
||||
@@ -711,16 +702,11 @@ async fn persist_jump_hop_generated_image_asset(
|
||||
}
|
||||
|
||||
fn build_jump_hop_work_play_tracking_draft(
|
||||
authenticated: Option<&AuthenticatedAccessToken>,
|
||||
principal: &RuntimePrincipal,
|
||||
work_id: impl Into<String>,
|
||||
source_route: &'static str,
|
||||
) -> WorkPlayTrackingDraft {
|
||||
match authenticated {
|
||||
Some(authenticated) => {
|
||||
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
|
||||
}
|
||||
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
|
||||
}
|
||||
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{attach_refresh_session_token, require_bearer_auth},
|
||||
auth::{attach_refresh_session_token, issue_runtime_guest_token, require_bearer_auth},
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::{auth_sessions, revoke_auth_session},
|
||||
@@ -65,6 +65,7 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
attach_refresh_session_token,
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token))
|
||||
.route("/api/auth/phone/send-code", post(send_phone_code))
|
||||
.route("/api/auth/phone/login", post(phone_login))
|
||||
.route("/api/auth/wechat/start", get(start_wechat_login))
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{attach_optional_bearer_auth, require_bearer_auth},
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
jump_hop::{
|
||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
@@ -58,21 +58,21 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/jump-hop/runs",
|
||||
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs/{run_id}/jump",
|
||||
post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/jump-hop/runs/{run_id}/restart",
|
||||
post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
attach_optional_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||
state::AppState,
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
@@ -52,21 +52,21 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/runtime/wooden-fish/runs",
|
||||
post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/checkpoint",
|
||||
post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/finish",
|
||||
post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
require_runtime_principal_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::generated_image_assets::{
|
||||
};
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
|
||||
@@ -229,14 +229,14 @@ pub async fn get_wooden_fish_runtime_work(
|
||||
pub async fn start_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishStartRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
|
||||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.start_wooden_fish_run(payload, authenticated.claims().user_id().to_string())
|
||||
.start_wooden_fish_run(payload, principal.subject().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
@@ -256,7 +256,7 @@ pub async fn checkpoint_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishCheckpointRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
@@ -265,7 +265,7 @@ pub async fn checkpoint_wooden_fish_run(
|
||||
.spacetime_client()
|
||||
.checkpoint_wooden_fish_run(
|
||||
run_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
@@ -287,7 +287,7 @@ pub async fn finish_wooden_fish_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Extension(principal): Extension<RuntimePrincipal>,
|
||||
payload: Result<Json<WoodenFishFinishRunRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
@@ -296,7 +296,7 @@ pub async fn finish_wooden_fish_run(
|
||||
.spacetime_client()
|
||||
.finish_wooden_fish_run(
|
||||
run_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
principal.subject().to_string(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -2,7 +2,7 @@ use module_runtime::RuntimeTrackingScopeKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
request_context::RequestContext,
|
||||
state::{AppState, PuzzleApiState},
|
||||
tracking::{TrackingEventDraft, record_tracking_event_after_success},
|
||||
@@ -36,12 +36,28 @@ impl WorkPlayTrackingDraft {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn anonymous(
|
||||
pub(crate) fn runtime_principal(
|
||||
play_type: &'static str,
|
||||
work_id: impl Into<String>,
|
||||
principal: &RuntimePrincipal,
|
||||
source_route: &'static str,
|
||||
) -> Self {
|
||||
Self::with_user_id(play_type, work_id, None, source_route)
|
||||
match principal {
|
||||
RuntimePrincipal::User(authenticated) => {
|
||||
Self::new(play_type, work_id, authenticated, source_route)
|
||||
}
|
||||
RuntimePrincipal::Guest(claims) => Self::with_user_id(
|
||||
play_type,
|
||||
work_id,
|
||||
Some(claims.subject().to_string()),
|
||||
source_route,
|
||||
)
|
||||
.extra(json!({
|
||||
"principalKind": "guest",
|
||||
"guestSubject": claims.subject(),
|
||||
"guestScope": claims.scope(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_user_id(
|
||||
|
||||
@@ -21,6 +21,9 @@ use url::Url;
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
pub const DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS: u64 = 15 * 60;
|
||||
pub const RUNTIME_GUEST_TOKEN_TYPE: &str = "runtime_guest";
|
||||
pub const RUNTIME_GUEST_SCOPE_PUBLIC_PLAY: &str = "runtime:public-play";
|
||||
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
|
||||
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
|
||||
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
|
||||
@@ -107,6 +110,21 @@ pub struct AccessTokenClaims {
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
pub struct RuntimeGuestTokenClaimsInput {
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeGuestTokenClaims {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub typ: String,
|
||||
pub scope: String,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JwtConfig {
|
||||
@@ -417,6 +435,10 @@ impl JwtConfig {
|
||||
pub fn access_token_ttl_seconds(&self) -> u64 {
|
||||
self.access_token_ttl_seconds
|
||||
}
|
||||
|
||||
pub fn runtime_guest_token_ttl_seconds(&self) -> u64 {
|
||||
DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshCookieSameSite {
|
||||
@@ -1474,6 +1496,74 @@ impl AccessTokenClaims {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeGuestTokenClaims {
|
||||
pub fn from_input(
|
||||
input: RuntimeGuestTokenClaimsInput,
|
||||
config: &JwtConfig,
|
||||
issued_at: OffsetDateTime,
|
||||
) -> Result<Self, JwtError> {
|
||||
let subject = normalize_required_field(input.subject, "runtime guest JWT sub 不能为空")?;
|
||||
let scope = normalize_required_field(input.scope, "runtime guest JWT scope 不能为空")?;
|
||||
|
||||
let issued_at_unix = issued_at.unix_timestamp();
|
||||
if issued_at_unix < 0 {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch"));
|
||||
}
|
||||
|
||||
let expires_at = issued_at
|
||||
.checked_add(Duration::seconds(
|
||||
i64::try_from(config.runtime_guest_token_ttl_seconds()).map_err(|_| {
|
||||
JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限")
|
||||
})?,
|
||||
))
|
||||
.ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?;
|
||||
let expires_at_unix = expires_at.unix_timestamp();
|
||||
if expires_at_unix <= issued_at_unix {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
|
||||
let claims = Self {
|
||||
iss: config.issuer().to_string(),
|
||||
sub: subject,
|
||||
typ: RUNTIME_GUEST_TOKEN_TYPE.to_string(),
|
||||
scope,
|
||||
iat: issued_at_unix as u64,
|
||||
exp: expires_at_unix as u64,
|
||||
};
|
||||
claims.validate_for_config(config)?;
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> &str {
|
||||
&self.sub
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> &str {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
pub fn expires_at_unix(&self) -> u64 {
|
||||
self.exp
|
||||
}
|
||||
|
||||
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
|
||||
if self.iss.trim() != config.issuer() {
|
||||
return Err(JwtError::InvalidClaims(
|
||||
"runtime guest JWT iss 与当前配置不一致",
|
||||
));
|
||||
}
|
||||
normalize_required_field(self.sub.clone(), "runtime guest JWT sub 不能为空")?;
|
||||
normalize_required_field(self.scope.clone(), "runtime guest JWT scope 不能为空")?;
|
||||
if self.typ.trim() != RUNTIME_GUEST_TOKEN_TYPE {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法"));
|
||||
}
|
||||
if self.exp <= self.iat {
|
||||
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessTokenDeviceInfo {
|
||||
pub fn normalize(self) -> Result<Self, JwtError> {
|
||||
Ok(Self {
|
||||
@@ -1526,6 +1616,26 @@ pub fn sign_access_token(
|
||||
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn sign_runtime_guest_token(
|
||||
claims: &RuntimeGuestTokenClaims,
|
||||
config: &JwtConfig,
|
||||
) -> Result<String, JwtError> {
|
||||
claims.validate_for_config(config)?;
|
||||
|
||||
let header = Header {
|
||||
alg: ACCESS_TOKEN_ALGORITHM,
|
||||
typ: Some("JWT".to_string()),
|
||||
..Header::default()
|
||||
};
|
||||
|
||||
encode(
|
||||
&header,
|
||||
claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|error| JwtError::SignFailed(format!("runtime guest JWT 签发失败:{error}")))
|
||||
}
|
||||
|
||||
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
@@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn verify_runtime_guest_token(
|
||||
token: &str,
|
||||
config: &JwtConfig,
|
||||
) -> Result<RuntimeGuestTokenClaims, JwtError> {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
|
||||
validation.required_spec_claims = HashSet::from([
|
||||
"exp".to_string(),
|
||||
"iat".to_string(),
|
||||
"iss".to_string(),
|
||||
"sub".to_string(),
|
||||
]);
|
||||
validation.set_issuer(&[config.issuer()]);
|
||||
|
||||
let decoded = decode::<RuntimeGuestTokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(map_verify_error)?;
|
||||
|
||||
decoded.claims.validate_for_config(config)?;
|
||||
Ok(decoded.claims)
|
||||
}
|
||||
|
||||
pub fn read_refresh_session_token(
|
||||
cookie_header: &str,
|
||||
config: &RefreshCookieConfig,
|
||||
@@ -2218,6 +2357,30 @@ mod tests {
|
||||
.expect("real aliyun sms config should be valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_runtime_guest_token() {
|
||||
let config = build_jwt_config();
|
||||
let issued_at = OffsetDateTime::now_utc();
|
||||
let claims = RuntimeGuestTokenClaims::from_input(
|
||||
RuntimeGuestTokenClaimsInput {
|
||||
subject: "guest-runtime-123".to_string(),
|
||||
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
|
||||
},
|
||||
&config,
|
||||
issued_at,
|
||||
)
|
||||
.expect("runtime guest claims should build");
|
||||
|
||||
let token = sign_runtime_guest_token(&claims, &config).expect("token should sign");
|
||||
let verified = verify_runtime_guest_token(&token, &config).expect("token should verify");
|
||||
|
||||
assert_eq!(verified, claims);
|
||||
assert_eq!(verified.subject(), "guest-runtime-123");
|
||||
assert_eq!(verified.scope(), RUNTIME_GUEST_SCOPE_PUBLIC_PLAY);
|
||||
assert_eq!(verified.typ, RUNTIME_GUEST_TOKEN_TYPE);
|
||||
assert_eq!(verified.expires_at_unix() - verified.iat, DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_sign_and_verify_access_token() {
|
||||
let config = build_jwt_config();
|
||||
|
||||
@@ -42,6 +42,15 @@ pub struct PublicUserSearchResponse {
|
||||
pub user: PublicUserSummaryPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeGuestTokenResponse {
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub subject: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordEntryRequest {
|
||||
|
||||
Reference in New Issue
Block a user