feat(api): 开发auth、login等接口,规范格式化

This commit is contained in:
R524809
2026-09-03 13:11:06 +08:00
parent 195b6d25e9
commit 037928c778
82 changed files with 9199 additions and 5236 deletions
+32 -7
View File
@@ -1,12 +1,37 @@
import { Controller, Get, Module } from '@nestjs/common';
import { Controller, Get, Inject, Module } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { AuthModule } from './auth/auth.module.js';
import { ApiError } from './common/api-error.js';
import { AppConfigModule } from './config/app-config.module.js';
import { DATABASE, DatabaseModule } from './database/database.module.js';
import { SecurityModule } from './security/security.module.js';
@Controller('health')
@Controller()
class HealthController {
@Get()
check() {
return { status: 'ok', service: 'api' };
}
public constructor(@Inject(DATABASE) private readonly database: Database) {}
@Get('health')
check() {
return { status: 'ok', service: 'api' };
}
@Get('ready')
async ready() {
try {
await this.database.query('SELECT 1');
return { status: 'ready', service: 'api' };
} catch {
throw new ApiError(
503,
'DEPENDENCY_UNAVAILABLE',
'服务依赖暂不可用',
);
}
}
}
@Module({ controllers: [HealthController] })
@Module({
imports: [AppConfigModule, DatabaseModule, SecurityModule, AuthModule],
controllers: [HealthController],
})
export class AppModule {}
+206
View File
@@ -0,0 +1,206 @@
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<string, unknown>,
) => void;
clearCookie: (name: string, options: Record<string, unknown>) => 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<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() {
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);
}
+91
View File
@@ -0,0 +1,91 @@
import {
createHash,
createHmac,
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback) as (
password: string,
salt: string,
keyLength: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
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')}`;
}
export async function verifyPassword(
password: string,
encoded: string,
): Promise<boolean> {
const [algorithm, salt, expected] = encoded.split('$');
if (algorithm !== 'scrypt' || !salt || !expected) return false;
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
const expectedBuffer = Buffer.from(expected, 'base64url');
return (
expectedBuffer.length === actual.length &&
timingSafeEqual(expectedBuffer, actual)
);
}
export function createOpaqueToken(): string {
return randomBytes(48).toString('base64url');
}
export function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('base64url');
}
export type AccessTokenClaims = {
sub: string;
sid: string;
exp: number;
iat: number;
};
export function signAccessToken(
claims: AccessTokenClaims,
secret: string,
): string {
// Access tokens are short-lived; refresh sessions remain revocable in PostgreSQL.
const header = Buffer.from(
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
).toString('base64url');
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
const signature = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url');
return `${header}.${payload}.${signature}`;
}
export function verifyAccessToken(
token: string,
secret: string,
): AccessTokenClaims | null {
const [header, payload, signature] = token.split('.');
if (!header || !payload || !signature) return null;
const expected = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest();
const actual = Buffer.from(signature, 'base64url');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
return null;
try {
const claims = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
) as AccessTokenClaims;
return typeof claims.sub === 'string' &&
typeof claims.sid === 'string' &&
typeof claims.exp === 'number' &&
claims.exp > Math.floor(Date.now() / 1000)
? claims
: null;
} catch {
return null;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthService } from './auth.service.js';
export type AuthenticatedRequest = {
header: (name: string) => string | undefined;
auth: { userId: string; sessionId: string };
};
@Injectable()
export class AccessTokenGuard implements CanActivate {
public constructor(private readonly authService: AuthService) {}
public async canActivate(context: ExecutionContext) {
const request = context
.switchToHttp()
.getRequest<AuthenticatedRequest>();
request.auth = await this.authService.authenticate(
request.header('authorization'),
);
return true;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller.js';
import { AccessTokenGuard } from './auth.guard.js';
import { AuthService } from './auth.service.js';
@Module({
controllers: [AuthController],
providers: [AuthService, AccessTokenGuard],
})
export class AuthModule {}
+340
View File
@@ -0,0 +1,340 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import type {
AuthSessionResponse,
AuthUser,
LoginRequest,
RegisterRequest,
Workspace,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
import {
createOpaqueToken,
hashPassword,
signAccessToken,
tokenHash,
verifyAccessToken,
verifyPassword,
} from './auth.crypto.js';
type UserRow = {
id: string;
email: string;
display_name: string | null;
email_verified_at: Date | null;
created_at: Date;
status: 'active' | 'disabled' | 'pending_review';
password_hash: string;
};
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
@Injectable()
export class AuthService {
private readonly accessTtlSeconds = 900;
private readonly refreshTtlDays = 30;
public constructor(
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly rateLimiter: AuthRateLimiter,
) {}
public async register(input: RegisterRequest, metadata: RequestMetadata) {
if (input.password !== input.password_confirmation)
throw validationError(
'password_confirmation',
'PASSWORD_CONFIRMATION_MISMATCH',
'两次输入的密码不一致',
);
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
throw validationError(
'password',
'PASSWORD_TOO_SHORT',
`密码至少需要 ${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.
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.
const user = await insertUser(
transaction,
email,
passwordHash,
input.display_name?.trim() || null,
);
const workspace = await createPersonalWorkspace(
transaction,
user.id,
user.display_name ?? email,
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
} catch (error: unknown) {
if (isUniqueViolation(error))
throw new ApiError(
409,
'EMAIL_ALREADY_REGISTERED',
'该邮箱已注册',
[
{
field: 'email',
code: 'EMAIL_ALREADY_REGISTERED',
message: '该邮箱已注册',
},
],
);
throw error;
}
}
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.
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',
[email],
);
const user = result.rows[0];
if (
!user ||
!(await verifyPassword(input.password, user.password_hash))
)
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
if (user.status === 'disabled')
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
const workspace = await this.findWorkspace(user.id);
return this.database.transaction(async (transaction) => {
await transaction.query(
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
[user.id],
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
}
public async refresh(
refreshToken: string | undefined,
metadata: RequestMetadata,
) {
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
if (!refreshToken)
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
return this.database.transaction(async (transaction) => {
const sessions = await transaction.query<
UserRow & { session_id: string }
>(
`SELECT u.id, u.email, u.display_name, u.email_verified_at, u.created_at, u.status, u.password_hash, s.id AS session_id
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
[tokenHash(refreshToken)],
);
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.
await transaction.query(
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
[user.session_id],
);
const workspace = await findWorkspaceInTransaction(
transaction,
user.id,
);
return this.createSessionResponse(
transaction,
user,
workspace,
metadata,
);
});
}
public async logout(sessionId: string) {
await this.database.query(
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
[sessionId],
);
}
public async authenticate(accessToken: string | undefined) {
const token = accessToken?.replace(/^Bearer\s+/i, '');
const claims = token
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
: null;
if (!claims) throw new UnauthorizedException();
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],
);
if (!active.rowCount) throw new UnauthorizedException();
return { userId: claims.sub, sessionId: claims.sid };
}
public async me(userId: string) {
const workspace = await this.findWorkspace(userId);
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],
);
const user = users.rows[0];
if (!user) throw new UnauthorizedException();
const platforms = await this.database.workspaceTransaction(
workspace.id,
async (transaction) => {
const rows = await transaction.query<{
id: string;
code: string;
name: string;
}>(
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
);
return rows.rows;
},
);
return {
user: publicUser(user),
workspace,
platforms,
suggested_context: {
platform_id: platforms[0]?.id ?? null,
default_store: null,
},
};
}
private async findWorkspace(userId: string) {
const result = await this.database.query<WorkspaceRow>(
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
[userId],
);
return requireWorkspace(result.rows[0]);
}
private async createSessionResponse(
transaction: DatabaseTransaction,
user: UserRow,
workspace: WorkspaceRow,
metadata: RequestMetadata,
) {
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)
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
[
user.id,
tokenHash(refreshToken),
this.refreshTtlDays,
metadata.ipHash,
metadata.userAgent,
],
);
const now = Math.floor(Date.now() / 1000);
const accessToken = signAccessToken(
{
sub: user.id,
sid: session.rows[0].id,
iat: now,
exp: now + this.accessTtlSeconds,
},
this.config.AUTH_JWT_SECRET,
);
const payload: AuthSessionResponse = {
access_token: accessToken,
access_token_expires_in: this.accessTtlSeconds,
user: publicUser(user),
workspace,
};
return { payload, refreshToken };
}
}
export type RequestMetadata = {
ip: string | null;
ipHash: string | null;
userAgent: string | null;
};
function validationError(field: string, code: string, message: string) {
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
{ field, code, message },
]);
}
function isUniqueViolation(error: unknown): error is { code: string } {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === '23505'
);
}
function publicUser(user: UserRow): AuthUser {
return {
id: user.id,
email: user.email,
display_name: user.display_name,
email_verified_at: user.email_verified_at?.toISOString() ?? null,
created_at: user.created_at.toISOString(),
};
}
async function insertUser(
transaction: DatabaseTransaction,
email: string,
passwordHash: string,
displayName: string | null,
) {
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],
);
return result.rows[0];
}
async function createPersonalWorkspace(
transaction: DatabaseTransaction,
userId: string,
label: string,
) {
const workspace = await transaction.query<{ id: string; name: string }>(
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
[`${label} 的工作空间`, `personal-${userId}`],
);
await transaction.query(
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
[workspace.rows[0].id, userId],
);
return { ...workspace.rows[0], role: 'owner' as const };
}
async function findWorkspaceInTransaction(
transaction: DatabaseTransaction,
userId: string,
): Promise<WorkspaceRow> {
const result = await transaction.query<WorkspaceRow>(
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
[userId],
);
return requireWorkspace(result.rows[0]);
}
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
if (!workspace)
throw new ApiError(
403,
'WORKSPACE_MEMBERSHIP_REQUIRED',
'当前账户没有可用工作空间',
);
return workspace;
}
+61
View File
@@ -0,0 +1,61 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { ApiError } from './api-error.js';
import { AppLogger } from '../logging/app-logger.js';
@Catch()
export class ApiErrorFilter implements ExceptionFilter {
public constructor(private readonly logger: AppLogger) {}
public catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<{
status: (status: number) => {
json: (body: unknown) => unknown;
};
}>();
const request = host.switchToHttp().getRequest<{
requestId?: string;
method?: string;
originalUrl?: string;
}>();
const requestId = request.requestId ?? `req_${randomUUID()}`;
if (exception instanceof ApiError) {
const body = exception.getResponse() as {
error: Record<string, unknown>;
};
return response
.status(exception.getStatus())
.json({ error: { ...body.error, request_id: requestId } });
}
if (exception instanceof HttpException) {
return response.status(exception.getStatus()).json({
error: {
code: 'HTTP_ERROR',
message: '请求无法处理',
request_id: requestId,
},
});
}
this.logger.error(
'unhandled_exception',
exception instanceof Error ? exception.stack : undefined,
{
request_id: requestId,
method: request.method,
path: request.originalUrl,
},
);
return response.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: '服务器暂时无法处理请求',
request_id: requestId,
},
});
}
}
+12
View File
@@ -0,0 +1,12 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class ApiError extends HttpException {
public constructor(
status: HttpStatus,
code: string,
message: string,
details?: unknown[],
) {
super({ error: { code, message, details } }, status);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
export const APP_CONFIG = Symbol('APP_CONFIG');
function findEnvironmentFile(): string {
const directories = [
process.cwd(),
resolve(process.cwd(), '..'),
resolve(process.cwd(), '../..'),
];
const environmentFile = directories
.map((directory) => resolve(directory, '.env'))
.find((file) => existsSync(file));
return environmentFile ?? resolve(process.cwd(), '.env');
}
// This schema is the only supported source for API runtime settings. Defaults are
// intentionally limited to non-secret local-development values.
const environmentSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
WEB_ORIGIN: z.url().default('http://localhost:8100'),
DATABASE_URL: z.url(),
REDIS_URL: z.url(),
AUTH_JWT_SECRET: z.string().min(32),
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
AUTH_PASSWORD_MIN_LENGTH: z.coerce
.number()
.int()
.min(12)
.max(256)
.default(12),
API_BODY_LIMIT_BYTES: z.coerce
.number()
.int()
.min(1_024)
.max(10 * 1024 * 1024)
.default(1_048_576),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce
.number()
.int()
.min(1)
.max(86_400)
.default(900),
AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5),
AUTH_REGISTER_EMAIL_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(1000)
.default(3),
AUTH_REFRESH_IP_LIMIT: z.coerce
.number()
.int()
.min(1)
.max(10_000)
.default(30),
TRUSTED_PROXY_CIDRS: z.string().default(''),
SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'),
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'),
});
type Environment = z.infer<typeof environmentSchema>;
export type AppConfig = Omit<
Environment,
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
> & {
trustedProxyCidrs: string[];
swaggerEnabled: boolean;
};
function validateEnvironment(
environment: Record<string, unknown>,
): Environment {
const result = environmentSchema.safeParse(environment);
if (result.success) return result.data;
// Fail before Nest opens a listener; a half-configured API is unsafe to run.
throw new Error(
`Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`,
);
}
function createAppConfig(environment: Environment): AppConfig {
return {
...environment,
// Express only trusts forwarding headers when this explicit allowlist is non-empty.
trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',')
.map((value) => value.trim())
.filter(Boolean),
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
};
}
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
// Turbo runs this workspace's scripts from apps/api, while the
// shared development environment lives at the repository root.
envFilePath: findEnvironmentFile(),
}),
],
providers: [
{
provide: APP_CONFIG,
// Export a normalized immutable config object so services never read process.env directly.
useFactory: (): AppConfig =>
createAppConfig(validateEnvironment(process.env)),
},
],
exports: [APP_CONFIG],
})
export class AppConfigModule {}
+24
View File
@@ -0,0 +1,24 @@
import { Global, Inject, Module, OnModuleDestroy } from '@nestjs/common';
import { createApplicationDatabase, type Database } from '@atlas/database';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
export const DATABASE = Symbol('DATABASE');
@Global()
@Module({
providers: [
{
provide: DATABASE,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): Database =>
createApplicationDatabase(config.DATABASE_URL),
},
],
exports: [DATABASE],
})
export class DatabaseModule implements OnModuleDestroy {
public constructor(@Inject(DATABASE) private readonly database: Database) {}
public async onModuleDestroy() {
await this.database.close();
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { LoggerService } from '@nestjs/common';
type LogLevel = 'error' | 'warn' | 'log' | 'debug';
const severity: Record<LogLevel, number> = {
error: 0,
warn: 1,
log: 2,
debug: 3,
};
const sensitiveKeys = /authorization|cookie|password|secret|token|api[_-]?key/i;
export class AppLogger implements LoggerService {
public constructor(private readonly minimumLevel: LogLevel) {}
public log(message: unknown, context?: unknown): void {
this.write('log', message, context);
}
public error(message: unknown, stack?: string, context?: unknown): void {
this.write('error', message, { stack, context });
}
public warn(message: unknown, context?: unknown): void {
this.write('warn', message, context);
}
public debug(message: unknown, context?: unknown): void {
this.write('debug', message, context);
}
public verbose(message: unknown, context?: unknown): void {
this.debug(message, context);
}
private write(level: LogLevel, message: unknown, context?: unknown): void {
if (severity[level] > severity[this.minimumLevel]) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message: typeof message === 'string' ? message : sanitize(message),
context: sanitize(context),
};
const output = JSON.stringify(entry);
if (level === 'error') console.error(output);
else console.log(output);
}
}
function sanitize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child),
]),
);
}
return value;
}
+144 -5
View File
@@ -1,12 +1,151 @@
import 'reflect-metadata';
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
import { ApiErrorFilter } from './common/api-error.filter.js';
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
import { AppLogger } from './logging/app-logger.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8100' });
await app.listen(Number(process.env.API_PORT ?? 8200));
type RequestWithId = Request & {
requestId?: string;
};
function openSwaggerInDevelopment(config: AppConfig, logger: AppLogger): void {
if (
config.NODE_ENV !== 'development' ||
!config.swaggerEnabled ||
process.platform !== 'darwin'
) {
return;
}
const swaggerUrl = `http://localhost:${config.API_PORT}/api-docs`;
// 只在本机开发时打开浏览器,生产环境和非 macOS 主机不执行外部进程。
execFile('open', [swaggerUrl], (error) => {
if (error) {
logger.warn('swagger_auto_open_failed', {
error: error.message,
url: swaggerUrl,
});
}
});
}
void bootstrap();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: false,
// Keep Nest's bootstrap errors visible until AppLogger is installed below.
logger: ['error', 'warn', 'log'],
});
const config = app.get<AppConfig>(APP_CONFIG);
const logger = new AppLogger(config.LOG_LEVEL);
app.useLogger(logger);
if (config.trustedProxyCidrs.length > 0) {
// Never trust forwarded headers unless the deployment names its proxy networks explicitly.
app.getHttpAdapter()
.getInstance()
.set('trust proxy', config.trustedProxyCidrs);
}
app.enableCors({
origin: config.WEB_ORIGIN,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Authorization',
'Content-Type',
'Idempotency-Key',
'If-Match',
'X-Atlas-Platform-Id',
'X-Request-Id',
],
exposedHeaders: ['X-Request-Id'],
});
app.use(helmet({ contentSecurityPolicy: false }));
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
app.useBodyParser('urlencoded', {
limit: config.API_BODY_LIMIT_BYTES,
extended: true,
});
app.use(
(request: RequestWithId, response: Response, next: NextFunction) => {
const typedRequest = request;
const typedResponse = response;
const suppliedId = typedRequest.headers['x-request-id'];
const requestId =
typeof suppliedId === 'string' &&
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
? suppliedId
: `req_${randomUUID()}`;
const startedAt = performance.now();
typedRequest.requestId = requestId;
typedResponse.setHeader('X-Request-Id', requestId);
typedResponse.on('finish', () => {
logger.log('http_request_completed', {
request_id: requestId,
method: typedRequest.method,
path: typedRequest.originalUrl,
status_code: typedResponse.statusCode,
duration_ms: Math.round(performance.now() - startedAt),
});
});
next();
},
);
app.useGlobalFilters(new ApiErrorFilter(logger));
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
app.enableShutdownHooks();
if (config.swaggerEnabled) {
const swaggerConfig = new DocumentBuilder()
.setTitle('Atlas Seller Kit API')
.setDescription('Atlas 卖家工作台 HTTP API 文档')
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
.build();
const swaggerDocument = SwaggerModule.createDocument(
app,
swaggerConfig,
);
SwaggerModule.setup('api-docs', app, swaggerDocument, {
swaggerOptions: { persistAuthorization: true },
});
}
await app.listen(config.API_PORT);
logger.log('api_started', {
port: config.API_PORT,
swagger_enabled: config.swaggerEnabled,
});
openSwaggerInDevelopment(config, logger);
}
void bootstrap().catch((error: unknown) => {
const details =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error;
console.error(
JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
message: 'api_startup_failed',
error: details,
}),
);
process.exitCode = 1;
});
@@ -0,0 +1,102 @@
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { Redis } from 'ioredis';
import { ApiError } from '../common/api-error.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
type AuthAction = 'login' | 'register' | 'refresh';
type Limit = { ip: number; email?: number };
@Injectable()
export class AuthRateLimiter implements OnModuleDestroy {
private readonly redis: Redis;
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
// Redis makes counters shared across API processes instead of resetting per instance.
this.redis = new Redis(config.REDIS_URL, {
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
});
}
public async consume(
action: AuthAction,
identifiers: { ip: string | null; email?: string },
): Promise<void> {
const limit = this.limitFor(action);
await this.consumeBucket(
action,
'ip',
identifiers.ip ?? 'unknown',
limit.ip,
);
if (identifiers.email && limit.email) {
await this.consumeBucket(
action,
'email',
identifiers.email,
limit.email,
);
}
}
public async onModuleDestroy(): Promise<void> {
await this.redis.quit();
}
private async consumeBucket(
action: AuthAction,
dimension: 'ip' | 'email',
identifier: string,
limit: number,
): Promise<void> {
const digest = createHmac(
'sha256',
this.config.AUTH_RATE_LIMIT_KEY_SECRET,
)
.update(identifier)
.digest('base64url');
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
try {
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
const count = await this.redis.eval(
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
1,
key,
this.config.AUTH_RATE_LIMIT_WINDOW_SECONDS,
);
if (Number(count) > limit) {
throw new ApiError(
429,
'AUTH_RATE_LIMITED',
'请求过于频繁,请稍后重试',
);
}
} catch (error) {
if (error instanceof ApiError) throw error;
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
throw new ApiError(
503,
'AUTH_RATE_LIMIT_UNAVAILABLE',
'认证服务暂时不可用',
);
}
}
private limitFor(action: AuthAction): Limit {
if (action === 'login') {
return {
ip: this.config.AUTH_LOGIN_IP_LIMIT,
email: this.config.AUTH_LOGIN_EMAIL_LIMIT,
};
}
if (action === 'register') {
return {
ip: this.config.AUTH_REGISTER_IP_LIMIT,
email: this.config.AUTH_REGISTER_EMAIL_LIMIT,
};
}
return { ip: this.config.AUTH_REFRESH_IP_LIMIT };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { AuthRateLimiter } from './auth-rate-limiter.service.js';
@Global()
@Module({ providers: [AuthRateLimiter], exports: [AuthRateLimiter] })
export class SecurityModule {}