api-server/src/common/guards/admin-auth.guard.ts
wangdl 7da4145d0a
Some checks failed
Deploy API Server / build (push) Failing after 31s
Deploy API Server / deploy (push) Has been skipped
feat: admin auth 支持 httpOnly cookie
- 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>
2026-07-04 11:19:15 +08:00

155 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}