# 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
516 lines
14 KiB
TypeScript
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;
|
|
}
|