import { Body, Controller, Get, HttpCode, Post, Req, Res, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { createHash } from 'node:crypto'; import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js'; import { AuthService, type RequestMetadata } from './auth.service.js'; const refreshCookie = 'atlas_refresh'; type HttpRequest = { header: (name: string) => string | undefined; ip?: string; }; type HttpResponse = { cookie: ( name: string, value: string, options: Record, ) => void; clearCookie: (name: string, options: Record) => void; }; @ApiTags('Authentication') @Controller('auth') export class AuthController { public constructor(private readonly authService: AuthService) {} @Post('register') @ApiOperation({ summary: '注册账户并创建个人工作空间' }) @ApiBody({ schema: { example: { email: 'owner@example.com', password: 'a-strong-password', password_confirmation: 'a-strong-password', display_name: 'Joey', }, }, }) public async register( @Body() body: unknown, @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, ) { const result = await this.authService.register( parse(registerRequestSchema, body), metadata(request), ); setRefreshCookie(response, result.refreshToken); return { data: result.payload }; } @Post('login') @ApiOperation({ summary: '使用邮箱和密码登录' }) @ApiBody({ schema: { example: { email: 'owner@example.com', password: 'a-strong-password', }, }, }) public async login( @Body() body: unknown, @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, ) { const result = await this.authService.login( parse(loginRequestSchema, body), metadata(request), ); setRefreshCookie(response, result.refreshToken); return { data: result.payload }; } @Post('refresh') @ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' }) public async refresh( @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, ) { try { const result = await this.authService.refresh( readCookie(request.header('cookie'), refreshCookie), metadata(request), ); setRefreshCookie(response, result.refreshToken); return { data: { access_token: result.payload.access_token, access_token_expires_in: result.payload.access_token_expires_in, }, }; } catch (error) { clearRefreshCookie(response); throw error; } } @Post('logout') @HttpCode(204) @UseGuards(AccessTokenGuard) @ApiBearerAuth('access-token') @ApiOperation({ summary: '撤销当前登录会话' }) @ApiNoContentResponse({ description: '会话已撤销' }) @ApiUnauthorizedResponse({ description: 'access token 无效或已过期' }) public async logout( @Req() request: AuthenticatedRequest, @Res({ passthrough: true }) response: HttpResponse, ) { await this.authService.logout(request.auth.sessionId); clearRefreshCookie(response); } @Get('me') @UseGuards(AccessTokenGuard) @ApiBearerAuth('access-token') @ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' }) @ApiOkResponse({ description: '当前身份和工作空间' }) @ApiUnauthorizedResponse({ description: 'access token 无效或已过期' }) public async me(@Req() request: AuthenticatedRequest) { return { data: await this.authService.me(request.auth.userId) }; } } function parse< T extends { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> }; }; }, >(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as ReturnType extends { data: infer Data; } ? Data : never; throw new ApiError( 422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((issue) => ({ field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message, })), ); } function metadata(request: HttpRequest): RequestMetadata { return { ip: request.ip ?? null, ipHash: request.ip ? createHash('sha256').update(request.ip).digest('base64url') : null, userAgent: request.header('user-agent') ?? null, }; } function cookieOptions() { return { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/api/auth', maxAge: 30 * 24 * 60 * 60 * 1000, }; } function setRefreshCookie(response: HttpResponse, token: string) { response.cookie(refreshCookie, token, cookieOptions()); } function clearRefreshCookie(response: HttpResponse) { response.clearCookie(refreshCookie, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/api/auth', }); } function readCookie(header: string | undefined, name: string) { return header ?.split(';') .map((part) => part.trim()) .find((part) => part.startsWith(`${name}=`)) ?.slice(name.length + 1); }