import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res, UnauthorizedException, UseGuards, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthService } from './auth.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto'; import type { AuthenticatedUser } from './strategies/jwt.strategy'; const ACCESS_TOKEN_COOKIE = 'ir_at'; const REFRESH_TOKEN_COOKIE = 'ir_rt'; const isProd = process.env.NODE_ENV === 'production'; @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') @HttpCode(HttpStatus.CREATED) @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: 'Register the first admin user (bootstrap only)' }) @ApiResponse({ status: 201, type: UserPublicDto }) @ApiResponse({ status: 409, description: 'Username already exists' }) @ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' }) register(@Body() dto: RegisterDto): Promise { return this.authService.register(dto) as unknown as Promise; } @Post('login') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: 'Login and obtain access + refresh tokens' }) @ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) async login( @Body() dto: LoginDto, @Res({ passthrough: true }) res: Response, ): Promise { const result = await this.authService.login(dto); // HttpOnly cookies are the primary session channel for the admin SPA // (XSS cannot read them). The access token is also returned in the // body for non-browser API clients. res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, { httpOnly: true, sameSite: 'lax', secure: isProd, path: '/', }); res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, { httpOnly: true, sameSite: 'lax', secure: isProd, // 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path // 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线 path: '/', }); // The refresh token deliberately stays HttpOnly-only. return { accessToken: result.accessToken, user: result.user, } as unknown as LoginResponseDto; } @Post('refresh') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: 'Rotate the refresh token cookie' }) @ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 401, description: 'Invalid refresh token' }) async refresh( @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { const token = req.cookies?.[REFRESH_TOKEN_COOKIE]; if (!token) { res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' }); throw new UnauthorizedException('Missing refresh token'); } const result = await this.authService.refresh(token); res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, { httpOnly: true, sameSite: 'lax', secure: isProd, path: '/', }); res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, { httpOnly: true, sameSite: 'lax', secure: isProd, path: '/', }); return { accessToken: result.accessToken, user: result.user, } as unknown as LoginResponseDto; } @Get('me') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Current authenticated user' }) @ApiResponse({ status: 200, type: UserPublicDto }) me(@Req() req: Request & { user: AuthenticatedUser }): Promise { return this.authService.me(req.user.id) as unknown as Promise; } @Post('logout') @HttpCode(HttpStatus.OK) @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Revoke all tokens of the current user' }) async logout( @Req() req: Request & { user: AuthenticatedUser }, @Res({ passthrough: true }) res: Response, ): Promise<{ success: true }> { await this.authService.logout(req.user.id); res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' }); res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' }); return { success: true }; } }