- main.ts: 添加 cookie-parser 中间件 - admin-auth.controller.ts: 登录/刷新设置 httpOnly cookie - admin-auth.controller.ts: 登出清除 cookie - admin-auth.controller.ts: 刷新支持 cookie 读取 token(兼容旧 body 方式) - admin-auth.guard.ts: 优先从 cookie 读取 token,兼容 Authorization header 向后兼容: Authorization header 仍可用(iOS/鸿蒙客户端) Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import {
|
||
Injectable,
|
||
CanActivate,
|
||
ExecutionContext,
|
||
UnauthorizedException,
|
||
} from '@nestjs/common';
|
||
import { Reflector } from '@nestjs/core';
|
||
import { JwtService } from '@nestjs/jwt';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||
import { Request } from 'express';
|
||
import { ADMIN_PUBLIC_KEY } from '../decorators/admin-public.decorator';
|
||
import * as crypto from 'crypto';
|
||
|
||
function sha256(input: string): string {
|
||
return crypto.createHash('sha256').update(input).digest('hex');
|
||
}
|
||
|
||
@Injectable()
|
||
export class AdminAuthGuard implements CanActivate {
|
||
constructor(
|
||
private readonly reflector: Reflector,
|
||
private readonly jwtService: JwtService,
|
||
private readonly configService: ConfigService,
|
||
private readonly prisma: PrismaService,
|
||
) {}
|
||
|
||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||
const isPublic = this.reflector.getAllAndOverride<boolean>(ADMIN_PUBLIC_KEY, [
|
||
context.getHandler(),
|
||
context.getClass(),
|
||
]);
|
||
if (isPublic) return true;
|
||
|
||
const request = context.switchToHttp().getRequest<Request>();
|
||
|
||
// Try JWT Bearer token first, then x-api-key
|
||
const token = this.extractToken(request);
|
||
if (token) {
|
||
return this.authenticateByJwt(request, token);
|
||
}
|
||
|
||
const apiKey = this.extractApiKey(request);
|
||
if (apiKey) {
|
||
return this.authenticateByApiKey(request, apiKey);
|
||
}
|
||
|
||
throw new UnauthorizedException('请先登录');
|
||
}
|
||
|
||
private async authenticateByJwt(request: Request, token: string): Promise<boolean> {
|
||
try {
|
||
const payload = await this.jwtService.verifyAsync(token, {
|
||
secret: this.configService.get<string>('jwt.adminSecret'),
|
||
});
|
||
|
||
if (payload.type !== 'admin') {
|
||
throw new UnauthorizedException('无效的管理员令牌');
|
||
}
|
||
|
||
const adminUser = await this.prisma.adminUser.findUnique({
|
||
where: { id: payload.sub },
|
||
});
|
||
|
||
if (!adminUser || adminUser.deletedAt) {
|
||
throw new UnauthorizedException('管理员账号不存在');
|
||
}
|
||
|
||
if (adminUser.status !== 'ACTIVE') {
|
||
throw new UnauthorizedException('管理员账号已被禁用');
|
||
}
|
||
|
||
if (adminUser.lockedUntil && new Date(adminUser.lockedUntil) > new Date()) {
|
||
throw new UnauthorizedException('管理员账号已被锁定,请稍后再试');
|
||
}
|
||
|
||
if (payload.sessionId) {
|
||
const session = await this.prisma.adminSession.findUnique({
|
||
where: { id: payload.sessionId },
|
||
});
|
||
|
||
if (!session || session.revokedAt) {
|
||
throw new UnauthorizedException('会话已失效');
|
||
}
|
||
|
||
if (new Date(session.expiresAt) < new Date()) {
|
||
throw new UnauthorizedException('会话已过期,请重新登录');
|
||
}
|
||
}
|
||
|
||
(request as any).adminUser = adminUser;
|
||
return true;
|
||
} catch (err) {
|
||
if (err instanceof UnauthorizedException) throw err;
|
||
throw new UnauthorizedException('登录已过期,请重新登录');
|
||
}
|
||
}
|
||
|
||
private async authenticateByApiKey(request: Request, rawKey: string): Promise<boolean> {
|
||
const keyHash = sha256(rawKey);
|
||
const apiKey = await this.prisma.adminApiKey.findUnique({ where: { keyHash } });
|
||
|
||
if (!apiKey) {
|
||
throw new UnauthorizedException('无效的 API Key');
|
||
}
|
||
|
||
if (apiKey.expiresAt && new Date(apiKey.expiresAt) < new Date()) {
|
||
throw new UnauthorizedException('API Key 已过期');
|
||
}
|
||
|
||
const adminUser = await this.prisma.adminUser.findUnique({
|
||
where: { id: apiKey.adminUserId },
|
||
});
|
||
|
||
if (!adminUser || adminUser.deletedAt) {
|
||
throw new UnauthorizedException('管理员账号不存在');
|
||
}
|
||
|
||
if (adminUser.status !== 'ACTIVE') {
|
||
throw new UnauthorizedException('管理员账号已被禁用');
|
||
}
|
||
|
||
if (adminUser.lockedUntil && new Date(adminUser.lockedUntil) > new Date()) {
|
||
throw new UnauthorizedException('管理员账号已被锁定');
|
||
}
|
||
|
||
// Update lastUsedAt async (don't block the request)
|
||
this.prisma.adminApiKey.update({
|
||
where: { id: apiKey.id },
|
||
data: { lastUsedAt: new Date() },
|
||
}).catch(() => {});
|
||
|
||
(request as any).adminUser = adminUser;
|
||
(request as any).apiKeyId = apiKey.id;
|
||
return true;
|
||
}
|
||
|
||
private extractToken(request: Request): string | undefined {
|
||
// 优先从 httpOnly cookie 读取(新前端)
|
||
const cookieToken = (request as any).cookies?.admin_access_token;
|
||
if (cookieToken) return cookieToken;
|
||
|
||
// 兼容 Authorization: Bearer header(旧客户端、移动端)
|
||
const authHeader = request.headers.authorization;
|
||
if (!authHeader?.startsWith('Bearer ')) return undefined;
|
||
return authHeader.split(' ')[1];
|
||
}
|
||
|
||
private extractApiKey(request: Request): string | undefined {
|
||
const header = request.headers['x-api-key'];
|
||
if (typeof header === 'string' && header.length > 0) return header;
|
||
return undefined;
|
||
}
|
||
}
|