feat: admin auth 支持 httpOnly cookie
Some checks failed
Deploy API Server / build (push) Failing after 31s
Deploy API Server / deploy (push) Has been skipped

- 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>
This commit is contained in:
wangdl 2026-07-04 11:19:15 +08:00
parent a8ac081490
commit 7da4145d0a
3 changed files with 51 additions and 8 deletions

View File

@ -136,6 +136,11 @@ export class AdminAuthGuard implements CanActivate {
}
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];

View File

@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
@ -15,6 +16,7 @@ async function bootstrap() {
app.set("trust proxy", 1);
app.use(helmet());
app.use(cookieParser());
app.setGlobalPrefix('api', { exclude: ['health', 'admin-api/(.*)', 'internal/(.*)'] });

View File

@ -1,12 +1,28 @@
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Controller, Post, Body, Get, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
import { Controller, Post, Body, Get, HttpCode, HttpStatus, Req, Res, UseGuards } from '@nestjs/common';
import { AdminAuthService } from './admin-auth.service';
import { AdminLoginDto, AdminRefreshDto } from './dto';
import { AdminPublic } from '../../common/decorators/admin-public.decorator';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
import { AdminLoginRateLimit } from '../../common/decorators/rate-limit.decorator';
import type { Request } from 'express';
import type { Request, Response } from 'express';
const ACCESS_COOKIE = {
httpOnly: true,
secure: true,
sameSite: 'strict' as const,
path: '/admin-api',
maxAge: 60 * 60 * 1000, // 1 小时
} as const;
const REFRESH_COOKIE = {
httpOnly: true,
secure: true,
sameSite: 'strict' as const,
path: '/admin-api/auth',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
} as const;
@ApiTags('admin-auth')
@Controller('admin-api/auth')
@ -22,9 +38,14 @@ export class AdminAuthController {
@ApiResponse({ status: 200, description: '登录成功' })
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
@ApiResponse({ status: 403, description: '账号已禁用或锁定' })
async login(@Body() dto: AdminLoginDto, @Req() req: Request) {
async login(@Body() dto: AdminLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
const ip = (req.ip || '').replace('::ffff:', '');
return this.adminAuthService.login(dto.email, dto.password, ip, req.headers['user-agent']);
const result = await this.adminAuthService.login(dto.email, dto.password, ip, req.headers['user-agent']);
res.cookie('admin_access_token', result.accessToken, ACCESS_COOKIE);
res.cookie('admin_refresh_token', result.refreshToken, REFRESH_COOKIE);
return result;
}
@AdminPublic()
@ -33,9 +54,16 @@ export class AdminAuthController {
@ApiOperation({ summary: '刷新管理员令牌' })
@ApiResponse({ status: 200, description: '刷新成功' })
@ApiResponse({ status: 401, description: '刷新令牌无效' })
async refresh(@Body() dto: AdminRefreshDto, @Req() req: Request) {
async refresh(@Body() dto: AdminRefreshDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
const refreshIp = (req.ip || '').replace('::ffff:', '');
return this.adminAuthService.refresh(dto.refreshToken, refreshIp, req.headers['user-agent']);
// 支持 body旧客户端或 cookie新前端
const token = dto.refreshToken || req.cookies?.admin_refresh_token;
const result = await this.adminAuthService.refresh(token, refreshIp, req.headers['user-agent']);
res.cookie('admin_access_token', result.accessToken, ACCESS_COOKIE);
res.cookie('admin_refresh_token', result.refreshToken, REFRESH_COOKIE);
return result;
}
@Post('logout')
@ -43,9 +71,17 @@ export class AdminAuthController {
@ApiBearerAuth()
@ApiOperation({ summary: '管理员退出登录' })
@ApiResponse({ status: 200, description: '退出成功' })
async logout(@Req() req: Request, @Body() dto: AdminRefreshDto) {
async logout(@Req() req: Request, @Body() dto: AdminRefreshDto, @Res({ passthrough: true }) res: Response) {
const adminUser = (req as any).adminUser;
await this.adminAuthService.logout(adminUser.id, dto.refreshToken);
// 支持 body旧客户端或 cookie新前端
const token = dto.refreshToken || req.cookies?.admin_refresh_token;
if (token) {
await this.adminAuthService.logout(adminUser.id, token);
}
res.clearCookie('admin_access_token', { path: '/admin-api' });
res.clearCookie('admin_refresh_token', { path: '/admin-api/auth' });
return { success: true, message: '已退出登录' };
}