feat(M02-B): 实现微信登录与可撤销会话
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { signAccessToken, verifyAccessToken } from '../auth/jwt.js';
|
||||
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
|
||||
|
||||
const headersSchema = z.object({
|
||||
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const loginBodySchema = z.object({ code: z.string().trim().min(4).max(128) });
|
||||
|
||||
export interface AuthRouteOptions {
|
||||
repository: Pick<AuthRepository, 'resolveLoginContext' | 'loginWithWechat' | 'validateSession' | 'revokeSession'>;
|
||||
wechat: WechatCodeExchange;
|
||||
jwtSecret: string;
|
||||
accessTokenTtlSeconds: number;
|
||||
sessionTtlSeconds: number;
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise<void> {
|
||||
app.post('/app-api/auth/wechat-login', async (request, reply) => {
|
||||
const headers = headersSchema.safeParse(request.headers);
|
||||
const body = loginBodySchema.safeParse(request.body);
|
||||
if (!headers.success || !body.success) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_LOGIN_REQUEST',
|
||||
message: 'AppID, optional tenant-id and wx.login code are required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
try {
|
||||
const context = await options.repository.resolveLoginContext(
|
||||
headers.data['x-wechat-appid'],
|
||||
headers.data['tenant-id']
|
||||
);
|
||||
if (!context) {
|
||||
return reply.status(404).send({
|
||||
code: 'APP_TENANT_NOT_FOUND',
|
||||
message: 'The application and tenant binding is not active.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
const identity = await options.wechat.exchange(context.appId, body.data.code);
|
||||
const sessionId = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + options.sessionTtlSeconds * 1000);
|
||||
const session = await options.repository.loginWithWechat({
|
||||
context,
|
||||
...identity,
|
||||
sessionId,
|
||||
expiresAt,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
});
|
||||
const accessToken = signAccessToken({
|
||||
sub: session.user.id,
|
||||
sid: session.id,
|
||||
tid: session.tenantId,
|
||||
aid: session.platformAppId,
|
||||
rv: session.user.roleVersion
|
||||
}, options.jwtSecret, options.accessTokenTtlSeconds);
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
accessToken,
|
||||
expiresIn: options.accessTokenTtlSeconds,
|
||||
user: publicUser(session.user)
|
||||
},
|
||||
traceId: request.traceId
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WechatApiError) {
|
||||
return reply.status(401).send({
|
||||
code: 'WECHAT_LOGIN_FAILED',
|
||||
message: 'WeChat login code is invalid or expired.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
|
||||
return reply.status(409).send({
|
||||
code: 'TENANT_SELECTION_REQUIRED',
|
||||
message: 'tenant-id is required for an application bound to multiple tenants.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
if (error instanceof Error && error.message === 'USER_DISABLED') {
|
||||
return reply.status(403).send({
|
||||
code: 'USER_DISABLED',
|
||||
message: 'The user account is disabled.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/app-api/auth/me', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
return { code: 0, data: { user: publicUser(auth.user) }, traceId: request.traceId };
|
||||
});
|
||||
|
||||
app.post('/app-api/auth/logout', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
await options.repository.revokeSession(auth.sessionId);
|
||||
return { code: 0, data: { revoked: true }, traceId: request.traceId };
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(authorization: string | undefined, options: AuthRouteOptions) {
|
||||
if (!authorization?.startsWith('Bearer ')) return null;
|
||||
try {
|
||||
const claims = verifyAccessToken(authorization.slice(7), options.jwtSecret);
|
||||
const session = await options.repository.validateSession(claims.sid, claims.tid, claims.sub);
|
||||
if (!session || session.platformAppId !== claims.aid || session.user.roleVersion !== claims.rv) {
|
||||
return null;
|
||||
}
|
||||
return { sessionId: claims.sid, user: session.user };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(reply: { status(code: number): { send(payload: unknown): unknown } }, traceId: string) {
|
||||
return reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
message: 'The access token or server-side session is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(user: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userType: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
phone: string;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
userType: user.userType,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
phone: user.phone
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user