feat(api): 开发平台、门店相关代码

This commit is contained in:
R524809
2026-09-03 16:04:44 +08:00
parent 35fcf8776e
commit ada02e9135
40 changed files with 7014 additions and 5807 deletions
+28 -8
View File
@@ -3,10 +3,10 @@ import {
Controller,
Get,
HttpCode,
Inject,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
@@ -18,12 +18,19 @@ import {
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { createHash } from 'node:crypto';
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
import {
loginRequestSchema,
registerRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.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;
@@ -40,9 +47,12 @@ type HttpResponse = {
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
public constructor(private readonly authService: AuthService) {}
public constructor(
@Inject(AuthService) private readonly authService: AuthService,
) {}
@Post('register')
@Public()
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
@ApiBody({
schema: {
@@ -68,6 +78,8 @@ export class AuthController {
}
@Post('login')
@Public()
@HttpCode(200)
@ApiOperation({ summary: '使用邮箱和密码登录' })
@ApiBody({
schema: {
@@ -91,12 +103,15 @@ export class AuthController {
}
@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),
@@ -117,7 +132,6 @@ export class AuthController {
@Post('logout')
@HttpCode(204)
@UseGuards(AccessTokenGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: '撤销当前登录会话' })
@ApiNoContentResponse({ description: '会话已撤销' })
@@ -131,13 +145,17 @@ export class AuthController {
}
@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) };
public async me(
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return {
data: await this.authService.me(request.auth.userId, workspace),
};
}
}
@@ -178,6 +196,7 @@ function metadata(request: HttpRequest): RequestMetadata {
};
}
function cookieOptions() {
// cookie 路径限制在认证接口,避免无关请求携带长期会话凭证。
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
@@ -198,6 +217,7 @@ function clearRefreshCookie(response: HttpResponse) {
});
}
function readCookie(header: string | undefined, name: string) {
// 只解析本服务需要的单个 cookie,避免引入与认证无关的 cookie 状态。
return header
?.split(';')
.map((part) => part.trim())
+5 -2
View File
@@ -13,10 +13,11 @@ const scrypt = promisify(scryptCallback) as (
keyLength: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
// 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
export async function hashPassword(password: string): Promise<string> {
// The random salt and expensive KDF keep a database leak from exposing passwords directly.
// 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。
const salt = randomBytes(16).toString('base64url');
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
return `scrypt$${salt}$${derived.toString('base64url')}`;
@@ -40,6 +41,7 @@ export function createOpaqueToken(): string {
return randomBytes(48).toString('base64url');
}
export function tokenHash(token: string): string {
// refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。
return createHash('sha256').update(token).digest('base64url');
}
@@ -53,7 +55,7 @@ export function signAccessToken(
claims: AccessTokenClaims,
secret: string,
): string {
// Access tokens are short-lived; refresh sessions remain revocable in PostgreSQL.
// access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。
const header = Buffer.from(
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
).toString('base64url');
@@ -73,6 +75,7 @@ export function verifyAccessToken(
.update(`${header}.${payload}`)
.digest();
const actual = Buffer.from(signature, 'base64url');
// 恒定时间比较可避免根据签名比对耗时推测正确前缀。
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
return null;
try {
+28 -3
View File
@@ -1,18 +1,43 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Workspace } from '@atlas/contracts';
import { IS_PUBLIC_ENDPOINT } from '../common/public.decorator.js';
import { AuthService } from './auth.service.js';
export type AuthenticatedRequest = {
header: (name: string) => string | undefined;
auth: { userId: string; sessionId: string };
auth: {
userId: string;
sessionId: string;
workspace?: Workspace;
};
};
@Injectable()
export class AccessTokenGuard implements CanActivate {
public constructor(private readonly authService: AuthService) {}
public constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(AuthService) private readonly authService: AuthService,
) {}
public async canActivate(context: ExecutionContext) {
// 公开端点必须显式声明,避免新路由因忘记添加守卫而意外暴露。
if (
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ENDPOINT, [
context.getHandler(),
context.getClass(),
])
) {
return true;
}
const request = context
.switchToHttp()
.getRequest<AuthenticatedRequest>();
// 只在此处解析 Authorization,后续守卫和控制器共享已验证的会话身份。
request.auth = await this.authService.authenticate(
request.header('authorization'),
);
+9 -1
View File
@@ -1,10 +1,18 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller.js';
import { AccessTokenGuard } from './auth.guard.js';
import { AuthService } from './auth.service.js';
import { WorkspaceContextGuard } from '../workspace/workspace-context.guard.js';
@Module({
controllers: [AuthController],
providers: [AuthService, AccessTokenGuard],
providers: [
AuthService,
AccessTokenGuard,
WorkspaceContextGuard,
{ provide: APP_GUARD, useExisting: AccessTokenGuard },
{ provide: APP_GUARD, useExisting: WorkspaceContextGuard },
],
})
export class AuthModule {}
+13 -7
View File
@@ -33,12 +33,14 @@ type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
@Injectable()
export class AuthService {
// access token 短期存在于浏览器内存;refresh session 的有效期更长且可服务端撤销。
private readonly accessTtlSeconds = 900;
private readonly refreshTtlDays = 30;
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(AuthRateLimiter)
private readonly rateLimiter: AuthRateLimiter,
) {}
@@ -56,12 +58,12 @@ export class AuthService {
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
);
const email = input.email.trim().toLowerCase();
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
// 限流必须早于密码哈希和用户查询,避免攻击者消耗 CPU 或探测账户状态。
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
const passwordHash = await hashPassword(input.password);
try {
return await this.database.transaction(async (transaction) => {
// A user must never exist without its initial tenant and owner membership.
// 用户、个人工作空间和 owner membership 必须原子创建,不能留下半成品租户。
const user = await insertUser(
transaction,
email,
@@ -100,7 +102,7 @@ export class AuthService {
public async login(input: LoginRequest, metadata: RequestMetadata) {
const email = input.email.trim().toLowerCase();
// Use the same normalized value for the limiter and citext account lookup.
// 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
const result = await this.database.query<UserRow>(
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
@@ -133,7 +135,7 @@ export class AuthService {
refreshToken: string | undefined,
metadata: RequestMetadata,
) {
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
// refresh token 验证前没有可信邮箱,只能按 IP 维度限流。
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
if (!refreshToken)
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
@@ -149,7 +151,7 @@ export class AuthService {
const user = sessions.rows[0];
if (!user || user.status !== 'active')
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
// Row locking makes a refresh token single-use even when two browser requests arrive together.
// 行锁保证并发 refresh 时旧 token 只能成功使用一次。
await transaction.query(
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
[user.session_id],
@@ -180,6 +182,7 @@ export class AuthService {
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
: null;
if (!claims) throw new UnauthorizedException();
// JWT 签名有效仍不足够;必须确认对应 session 未撤销且未过期。
const active = await this.database.query(
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
[claims.sid, claims.sub],
@@ -188,8 +191,8 @@ export class AuthService {
return { userId: claims.sub, sessionId: claims.sid };
}
public async me(userId: string) {
const workspace = await this.findWorkspace(userId);
public async me(userId: string, workspace: Workspace) {
// workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。
const users = await this.database.query<UserRow>(
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
[userId],
@@ -233,6 +236,7 @@ export class AuthService {
workspace: WorkspaceRow,
metadata: RequestMetadata,
) {
// refresh token 仅通过 HttpOnly cookie 交给浏览器,响应体只暴露短期 access token。
const refreshToken = createOpaqueToken();
const session = await transaction.query<{ id: string }>(
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
@@ -284,6 +288,7 @@ function isUniqueViolation(error: unknown): error is { code: string } {
);
}
function publicUser(user: UserRow): AuthUser {
// 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。
return {
id: user.id,
email: user.email,
@@ -298,6 +303,7 @@ async function insertUser(
passwordHash: string,
displayName: string | null,
) {
// slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。
const result = await transaction.query<UserRow>(
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, email_verified_at, created_at, status, password_hash',
[email, passwordHash, displayName],