Files
atlas-seller-kit/apps/api/src/auth/auth.controller.ts
T
2026-09-03 16:04:44 +08:00

227 lines
6.9 KiB
TypeScript

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<string, unknown>,
) => void;
clearCookie: (name: string, options: Record<string, unknown>) => 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<T['safeParse']> 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);
}