import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, } from '@nestjs/common'; import { ApiBearerAuth, ApiBody, ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { createHash } from 'node:crypto'; import { loginRequestSchema, registerRequestSchema, type Workspace, } from '@atlas/contracts'; import { ApiError } from '../common/api-error.js'; import { Public } from '../common/public.decorator.js'; import type { AuthenticatedRequest } from './auth.guard.js'; import { AuthService, type RequestMetadata } from './auth.service.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; const refreshCookie = 'atlas_refresh'; // 控制器只负责 HTTP 边界;账户、会话与工作空间的一致性由 AuthService 的事务保证。 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( @Inject(AuthService) private readonly authService: AuthService, ) {} @Post('register') @Public() @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') @Public() @HttpCode(200) @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') @Public() @HttpCode(200) @ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' }) public async refresh( @Req() request: HttpRequest, @Res({ passthrough: true }) response: HttpResponse, ) { try { // refresh token 不进入 JSON body,仅从浏览器自动发送的 HttpOnly cookie 读取。 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) @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') @ApiBearerAuth('access-token') @ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' }) @ApiOkResponse({ description: '当前身份和工作空间' }) @ApiUnauthorizedResponse({ description: 'access token 无效或已过期' }) public async me( @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { return { data: await this.authService.me(request.auth.userId, workspace), }; } } 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() { // cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。 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) { // 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。 return header ?.split(';') .map((part) => part.trim()) .find((part) => part.startsWith(`${name}=`)) ?.slice(name.length + 1); }