1
This commit is contained in:
@@ -1,51 +1,497 @@
|
||||
import { Router } from 'express';
|
||||
import { type Request, Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { entryWithPassword, logoutUser } from '../auth/authService.js';
|
||||
import type {
|
||||
AuthEntryRequest,
|
||||
AuthPhoneChangeRequest,
|
||||
AuthPhoneLoginRequest,
|
||||
AuthPhoneSendCodeRequest,
|
||||
AuthWechatBindPhoneRequest,
|
||||
} from '../../../packages/shared/src/contracts/auth.js';
|
||||
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
|
||||
import {
|
||||
bindWechatPhone,
|
||||
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 } from '../http.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.post(
|
||||
'/entry',
|
||||
routeMeta({ operation: 'auth.entry' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = authEntrySchema.parse(request.body);
|
||||
response.json(
|
||||
await entryWithPassword(context, payload.username, payload.password),
|
||||
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 callbackUrl = new URL(
|
||||
context.config.wechatAuth.callbackPath,
|
||||
resolveRequestOrigin(request),
|
||||
).toString();
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
await startWechatLogin(context, callbackUrl, redirectPath),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
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, {
|
||||
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 = context.userRepository.findById(request.userId!);
|
||||
response.json({
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
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) => {
|
||||
response.json(await logoutUser(context, request.userId!));
|
||||
const refreshToken = readRefreshSessionToken(request, context.config);
|
||||
await revokeRefreshSession(context, refreshToken);
|
||||
clearRefreshSessionCookie(response, context.config);
|
||||
sendApiResponse(
|
||||
response,
|
||||
await logoutUser(
|
||||
context,
|
||||
request.userId!,
|
||||
buildAuthRequestContext(request),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user