Files
Genarrative/server-node/src/routes/authRoutes.ts
kdletters cf8da3f50f Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
2026-04-21 20:16:01 +08:00

516 lines
14 KiB
TypeScript

import { type Request, type Response, Router } from 'express';
import { z } from 'zod';
import type {
AuthEntryRequest,
AuthPhoneChangeRequest,
AuthPhoneLoginRequest,
AuthPhoneSendCodeRequest,
AuthWechatBindPhoneRequest,
} from '../../../packages/shared/src/contracts/auth.js';
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
import {
bindWechatPhone,
buildAuthLoginOptionsResponse,
buildAuthMeResponse,
changeUserPhone,
createRefreshSession,
entryWithPassword,
entryWithPhoneCode,
liftRiskBlock,
listActiveRiskBlocks,
listAuthAuditLogs,
listUserSessions,
logoutAllUserSessions,
logoutUser,
refreshAuthSession,
resolveWechatCallback,
revokeRefreshSession,
revokeUserSession,
sendPhoneLoginCode,
startWechatLogin,
} from '../auth/authService.js';
import {
clearRefreshSessionCookie,
readRefreshSessionToken,
setRefreshSessionCookie,
} from '../auth/refreshSessionCookie.js';
import type { AppContext } from '../context.js';
import { asyncHandler, sendApiResponse } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const authEntrySchema = z.object({
username: z.string(),
password: z.string(),
});
const authPhoneSendCodeSchema = z.object({
phone: z.string(),
scene: z.enum(['login', 'bind_phone', 'change_phone']).optional(),
captchaChallengeId: z.string().optional(),
captchaAnswer: z.string().optional(),
});
const authPhoneLoginSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authPhoneChangeSchema = z.object({
phone: z.string(),
code: z.string(),
});
const authWechatBindPhoneSchema = z.object({
phone: z.string(),
code: z.string(),
});
function resolveRequestOrigin(request: Request) {
const forwardedProto = request.header('x-forwarded-proto')?.split(',')[0]?.trim();
const forwardedHost = request.header('x-forwarded-host')?.split(',')[0]?.trim();
const protocol = forwardedProto || request.protocol || 'http';
const host = forwardedHost || request.header('host') || '127.0.0.1:8081';
return `${protocol}://${host}`;
}
function normalizeRedirectPath(rawValue: unknown, fallback: string) {
if (typeof rawValue !== 'string' || !rawValue.trim()) {
return fallback;
}
const value = rawValue.trim();
if (value.startsWith('/')) {
return value;
}
try {
const url = new URL(value);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return fallback;
}
}
function buildAuthResultRedirectUrl(
redirectPath: string,
params: Record<string, string>,
) {
const hash = new URLSearchParams(params).toString();
const [pathWithoutHash] = redirectPath.split('#');
return `${pathWithoutHash || '/'}#${hash}`;
}
function buildRefreshCookieLifetimeSeconds(
context: AppContext,
expiresAt: string,
) {
return Math.max(
0,
Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000),
);
}
export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.get(
'/login-options',
routeMeta({ operation: 'auth.login_options' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, buildAuthLoginOptionsResponse(context));
}),
);
router.post(
'/entry',
routeMeta({ operation: 'auth.entry' }),
asyncHandler(async (request, response) => {
const payload = authEntrySchema.parse(request.body) as AuthEntryRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPassword(
context,
payload.username,
payload.password,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after password entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/phone/send-code',
routeMeta({ operation: 'auth.phone.send_code' }),
asyncHandler(async (request, response) => {
const payload = authPhoneSendCodeSchema.parse(
request.body,
) as AuthPhoneSendCodeRequest;
sendApiResponse(
response,
await sendPhoneLoginCode(
context,
payload.phone,
payload.scene,
buildAuthRequestContext(request),
{
captchaChallengeId: payload.captchaChallengeId,
captchaAnswer: payload.captchaAnswer,
},
),
);
}),
);
router.post(
'/phone/change',
routeMeta({ operation: 'auth.phone.change' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authPhoneChangeSchema.parse(
request.body,
) as AuthPhoneChangeRequest;
const requestContext = buildAuthRequestContext(request);
sendApiResponse(
response,
await changeUserPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
),
);
}),
);
router.post(
'/phone/login',
routeMeta({ operation: 'auth.phone.login' }),
asyncHandler(async (request, response) => {
const payload = authPhoneLoginSchema.parse(
request.body,
) as AuthPhoneLoginRequest;
const requestContext = buildAuthRequestContext(request);
const result = await entryWithPhoneCode(
context,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after phone entry');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.get(
'/wechat/start',
routeMeta({ operation: 'auth.wechat.start' }),
asyncHandler(async (request, response) => {
const redirectPath = normalizeRedirectPath(
request.query.redirectPath,
context.config.wechatAuth.defaultRedirectPath,
);
const requestContext = buildAuthRequestContext(request);
const callbackUrl = new URL(
context.config.wechatAuth.callbackPath,
resolveRequestOrigin(request),
).toString();
sendApiResponse(
response,
await startWechatLogin(
context,
callbackUrl,
redirectPath,
requestContext,
),
);
}),
);
router.get(
'/wechat/callback',
routeMeta({ operation: 'auth.wechat.callback' }),
asyncHandler(async (request, response) => {
const state =
typeof request.query.state === 'string' ? request.query.state.trim() : '';
const stateRecord = context.wechatAuthStates.consume(state);
const redirectPath =
stateRecord?.redirectPath ?? context.config.wechatAuth.defaultRedirectPath;
if (!stateRecord) {
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: '微信登录状态已失效,请重新发起登录。',
}),
);
return;
}
try {
const requestContext = buildAuthRequestContext(request);
const result = await resolveWechatCallback(context, {
code: typeof request.query.code === 'string' ? request.query.code : null,
mockCode:
typeof request.query.mock_code === 'string'
? request.query.mock_code
: null,
}, requestContext);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat callback');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_token: result.token,
auth_binding_status: result.user.bindingStatus,
}),
);
} catch (error) {
const message =
error instanceof Error ? error.message : '微信登录失败,请稍后再试。';
response.redirect(
302,
buildAuthResultRedirectUrl(redirectPath, {
auth_provider: 'wechat',
auth_error: message,
}),
);
}
}),
);
router.post(
'/wechat/bind-phone',
routeMeta({ operation: 'auth.wechat.bind_phone' }),
requireAuth,
asyncHandler(async (request, response) => {
const payload = authWechatBindPhoneSchema.parse(
request.body,
) as AuthWechatBindPhoneRequest;
const requestContext = buildAuthRequestContext(request);
const result = await bindWechatPhone(
context,
request.userId!,
payload.phone,
payload.code,
requestContext,
);
const user = await context.userRepository.findById(result.user.id);
if (!user) {
throw new Error('failed to resolve auth user after wechat bind');
}
const refreshSession = await createRefreshSession(
context,
user,
requestContext,
);
setRefreshSessionCookie(
response,
context.config,
refreshSession.refreshToken,
buildRefreshCookieLifetimeSeconds(context, refreshSession.expiresAt),
);
sendApiResponse(response, result);
}),
);
router.post(
'/refresh',
routeMeta({ operation: 'auth.refresh' }),
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
try {
const result = await refreshAuthSession(context, refreshToken);
setRefreshSessionCookie(
response,
context.config,
result.refreshToken,
buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt),
);
sendApiResponse(response, {
ok: true,
token: result.token,
});
} catch (error) {
clearRefreshSessionCookie(response, context.config);
throw error;
}
}),
);
router.get(
'/risk-blocks',
routeMeta({ operation: 'auth.risk_blocks' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await listActiveRiskBlocks(
context,
user!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/risk-blocks/:scopeType/lift',
routeMeta({ operation: 'auth.risk_blocks.lift' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(
response,
await liftRiskBlock(
context,
user!,
buildAuthRequestContext(request),
request.params.scopeType === 'phone' ? 'phone' : 'ip',
),
);
}),
);
router.get(
'/sessions',
routeMeta({ operation: 'auth.sessions' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await listUserSessions(context, request.userId!, refreshToken),
);
}),
);
router.post(
'/sessions/:sessionId/revoke',
routeMeta({ operation: 'auth.sessions.revoke' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
sendApiResponse(
response,
await revokeUserSession(
context,
request.userId!,
request.params.sessionId,
refreshToken,
buildAuthRequestContext(request),
),
);
}),
);
router.get(
'/audit-logs',
routeMeta({ operation: 'auth.audit_logs' }),
requireAuth,
asyncHandler(async (request, response) => {
sendApiResponse(
response,
await listAuthAuditLogs(context, request.userId!),
);
}),
);
router.get(
'/me',
routeMeta({ operation: 'auth.me' }),
requireAuth,
asyncHandler(async (request, response) => {
const user = await context.userRepository.findById(request.userId!);
sendApiResponse(response, await buildAuthMeResponse(context, user));
}),
);
router.post(
'/logout-all',
routeMeta({ operation: 'auth.logout_all' }),
requireAuth,
asyncHandler(async (request, response) => {
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutAllUserSessions(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);
router.post(
'/logout',
routeMeta({ operation: 'auth.logout' }),
requireAuth,
asyncHandler(async (request, response) => {
const refreshToken = readRefreshSessionToken(request, context.config);
await revokeRefreshSession(context, refreshToken);
clearRefreshSessionCookie(response, context.config);
sendApiResponse(
response,
await logoutUser(
context,
request.userId!,
buildAuthRequestContext(request),
),
);
}),
);
return router;
}