diff --git a/.env.example b/.env.example index 41957a5..9af60b1 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,10 @@ AUTH_REGISTER_IP_LIMIT=5 AUTH_REGISTER_EMAIL_LIMIT=3 # Maximum refresh attempts per IP inside the window; refresh tokens have no trusted email before validation. AUTH_REFRESH_IP_LIMIT=30 +# 密码修改在同一限流窗口内允许的每 IP 最大次数。 +AUTH_CHANGE_PASSWORD_IP_LIMIT=10 +# 密码修改在同一限流窗口内允许的每已认证用户最大次数。 +AUTH_CHANGE_PASSWORD_USER_LIMIT=5 # Comma-separated trusted reverse-proxy IPs/CIDRs. Keep empty for direct local access; never set to true. TRUSTED_PROXY_CIDRS= diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 78899ee..345af41 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -2,8 +2,10 @@ import { Body, Controller, Get, + Headers, HttpCode, Inject, + Patch, Post, Req, Res, @@ -19,10 +21,13 @@ import { } from '@nestjs/swagger'; import { createHash } from 'node:crypto'; import { + changePasswordRequestSchema, loginRequestSchema, registerRequestSchema, + updateCurrentUserRequestSchema, type Workspace, } from '@atlas/contracts'; +import { z } from 'zod'; import { ApiError } from '../common/api-error.js'; import { Public } from '../common/public.decorator.js'; import type { AuthenticatedRequest } from './auth.guard.js'; @@ -157,6 +162,70 @@ export class AuthController { data: await this.authService.me(request.auth.userId, workspace), }; } + + @Patch('me') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: '按版本更新当前账户的显示名称或头像素材引用' }) + @ApiBody({ + schema: { + example: { + display_name: 'Joey', + avatar_asset_id: 'b2b592e8-96b0-4d2b-afc4-2c6462f2065b', + }, + }, + }) + public async updateProfile( + @Body() body: unknown, + @Headers('if-match') ifMatch: string | undefined, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + ) { + return ( + await this.authService.updateProfile( + workspace, + request.auth.userId, + requireIdempotencyKey(idempotencyKey), + parseRevision(ifMatch), + parse(updateCurrentUserRequestSchema, body), + ) + ).body; + } + + @Post('change-password') + @HttpCode(200) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: '修改密码、撤销旧会话并轮换当前会话' }) + @ApiBody({ + schema: { + example: { + old_password: 'old-strong-password', + password: 'new-strong-password', + password_confirmation: 'new-strong-password', + }, + }, + }) + public async changePassword( + @Body() body: unknown, + @Req() request: AuthenticatedRequest & HttpRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: HttpResponse, + ) { + const result = await this.authService.changePassword( + workspace, + request.auth.userId, + request.auth.sessionId, + parse(changePasswordRequestSchema, body), + metadata(request), + ); + setRefreshCookie(response, result.refreshToken); + return { + data: { + access_token: result.payload.access_token, + access_token_expires_in: result.payload.access_token_expires_in, + }, + }; + } } function parse< @@ -224,3 +293,31 @@ function readCookie(header: string | undefined, name: string) { .find((part) => part.startsWith(`${name}=`)) ?.slice(name.length + 1); } +function requireIdempotencyKey(value: string | undefined): string { + if (!value) { + throw new ApiError( + 400, + 'IDEMPOTENCY_KEY_REQUIRED', + '请求缺少 Idempotency-Key', + ); + } + if (z.uuid().safeParse(value).success) return value; + throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [ + { + field: 'Idempotency-Key', + code: 'INVALID_FIELD', + message: 'Idempotency-Key 必须是 UUID', + }, + ]); +} +function parseRevision(value: string | undefined): number { + const match = value?.match(/^"([1-9]\d*)"$/); + if (!match) { + throw new ApiError( + 400, + 'IF_MATCH_REQUIRED', + '请求必须携带形如 "1" 的 If-Match', + ); + } + return Number(match[1]); +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 60cfd35..ee68230 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -4,11 +4,13 @@ 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'; +import { IdempotencyService } from '../common/idempotency.service.js'; @Module({ controllers: [AuthController], providers: [ AuthService, + IdempotencyService, AccessTokenGuard, WorkspaceContextGuard, { provide: APP_GUARD, useExisting: AccessTokenGuard }, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 828b8e4..52ac2ca 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -2,12 +2,15 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import type { AuthSessionResponse, AuthUser, + ChangePasswordRequest, LoginRequest, RegisterRequest, + UpdateCurrentUserRequest, Workspace, } from '@atlas/contracts'; import type { Database, DatabaseTransaction } from '@atlas/database'; import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.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'; @@ -24,8 +27,10 @@ type UserRow = { id: string; email: string; display_name: string | null; + avatar_asset_id: string | null; email_verified_at: Date | null; created_at: Date; + revision: number; status: 'active' | 'disabled' | 'pending_review'; password_hash: string; }; @@ -42,6 +47,8 @@ export class AuthService { @Inject(APP_CONFIG) private readonly config: AppConfig, @Inject(AuthRateLimiter) private readonly rateLimiter: AuthRateLimiter, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, ) {} public async register(input: RegisterRequest, metadata: RequestMetadata) { @@ -105,7 +112,7 @@ export class AuthService { // 限流与 citext 查询使用同一规范化邮箱,避免大小写绕过限流桶。 await this.rateLimiter.consume('login', { ip: metadata.ip, email }); const result = await this.database.query( - 'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1', + 'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE email = $1', [email], ); const user = result.rows[0]; @@ -143,7 +150,7 @@ export class AuthService { 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 + `SELECT u.id, u.email, u.display_name, u.avatar_asset_id, u.email_verified_at, u.created_at, u.revision, 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)], @@ -194,7 +201,7 @@ export class AuthService { public async me(userId: string, workspace: Workspace) { // workspace 由全局守卫解析,避免控制器或服务各自选择租户造成边界不一致。 const users = await this.database.query( - 'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1', + 'SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash FROM atlas.users WHERE id = $1', [userId], ); const user = users.rows[0]; @@ -223,6 +230,139 @@ export class AuthService { }; } + public async updateProfile( + workspace: Workspace, + userId: string, + idempotencyKey: string, + revision: number, + input: UpdateCurrentUserRequest, + ) { + return this.idempotency.run( + { + workspaceId: workspace.id, + userId, + method: 'PATCH', + path: '/api/auth/me', + key: idempotencyKey, + request: { if_match: revision, ...input }, + }, + async (transaction) => { + const user = await findUserInTransaction( + transaction, + userId, + true, + ); + if (user.revision !== revision) { + throw versionConflict(user.revision); + } + if (input.avatar_asset_id) { + // 头像只能引用当前工作空间内已完成入库的素材,不能接受任意对象路径或 URL。 + const asset = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.media_assets WHERE id = $1`, + [input.avatar_asset_id], + ); + if (!asset.rows[0]) { + throw new ApiError( + 422, + 'AVATAR_ASSET_UNAVAILABLE', + '头像素材不存在或不属于当前工作空间', + ); + } + } + const updated = await transaction.query( + `UPDATE atlas.users + SET display_name = CASE WHEN $1 THEN $2 ELSE display_name END, + avatar_asset_id = CASE WHEN $3 THEN $4::uuid ELSE avatar_asset_id END, + revision = revision + 1, updated_at = now() + WHERE id = $5 + RETURNING id, email, display_name, avatar_asset_id, email_verified_at, + created_at, revision, status, password_hash`, + [ + Object.hasOwn(input, 'display_name'), + input.display_name ?? null, + Object.hasOwn(input, 'avatar_asset_id'), + input.avatar_asset_id ?? null, + userId, + ], + ); + return { + status: 200, + body: { data: publicUser(updated.rows[0]) }, + }; + }, + ); + } + + public async changePassword( + workspace: Workspace, + userId: string, + sessionId: string, + input: ChangePasswordRequest, + 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} 个字符`, + ); + } + // 修改密码按已认证用户和来源 IP 双维度限流,避免已泄露会话被用于高频猜测旧密码。 + await this.rateLimiter.consume('change_password', { + ip: metadata.ip, + userId, + }); + return this.database.transaction(async (transaction) => { + const session = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.user_sessions + WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now() + FOR UPDATE`, + [sessionId, userId], + ); + if (!session.rows[0]) throw new UnauthorizedException(); + const user = await findUserInTransaction(transaction, userId, true); + if (user.status !== 'active') { + throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用'); + } + if ( + !(await verifyPassword(input.old_password, user.password_hash)) + ) { + throw validationError( + 'old_password', + 'CURRENT_PASSWORD_INVALID', + '当前密码错误', + ); + } + const passwordHash = await hashPassword(input.password); + const updated = await transaction.query( + `UPDATE atlas.users SET password_hash = $1, updated_at = now() + WHERE id = $2 + RETURNING id, email, display_name, avatar_asset_id, email_verified_at, + created_at, revision, status, password_hash`, + [passwordHash, userId], + ); + // 先撤销所有旧 session,再创建新 session;旧 access token 会立即失效。 + await transaction.query( + `UPDATE atlas.user_sessions SET revoked_at = now() + WHERE user_id = $1 AND revoked_at IS NULL`, + [userId], + ); + return this.createSessionResponse( + transaction, + updated.rows[0], + workspace, + metadata, + ); + }); + } + private async findWorkspace(userId: string) { const result = await this.database.query( `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`, @@ -287,14 +427,40 @@ function isUniqueViolation(error: unknown): error is { code: string } { (error as { code: string }).code === '23505' ); } +async function findUserInTransaction( + transaction: DatabaseTransaction, + userId: string, + lock: boolean, +): Promise { + const result = await transaction.query( + `SELECT id, email, display_name, avatar_asset_id, email_verified_at, created_at, + revision, status, password_hash + FROM atlas.users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, + [userId], + ); + if (!result.rows[0]) throw new UnauthorizedException(); + return result.rows[0]; +} +function versionConflict(currentRevision: number) { + return new ApiError(409, 'VERSION_CONFLICT', '账户资料已被其他操作更新', [ + { + field: 'If-Match', + code: 'VERSION_CONFLICT', + message: `当前版本为 ${currentRevision}`, + current_revision: currentRevision, + }, + ]); +} function publicUser(user: UserRow): AuthUser { // 白名单映射防止 password_hash 等内部字段随查询结果泄露给客户端。 return { id: user.id, email: user.email, display_name: user.display_name, + avatar_asset_id: user.avatar_asset_id, email_verified_at: user.email_verified_at?.toISOString() ?? null, created_at: user.created_at.toISOString(), + revision: user.revision, }; } async function insertUser( @@ -305,7 +471,7 @@ async function insertUser( ) { // slug 使用用户 UUID,显示名称变化不会影响个人工作空间的稳定标识。 const result = await transaction.query( - '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', + 'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, avatar_asset_id, email_verified_at, created_at, revision, status, password_hash', [email, passwordHash, displayName], ); return result.rows[0]; diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts index 52134da..eab9c5d 100644 --- a/apps/api/src/config/app-config.module.ts +++ b/apps/api/src/config/app-config.module.ts @@ -70,6 +70,18 @@ const environmentSchema = z.object({ .min(1) .max(10_000) .default(30), + AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce + .number() + .int() + .min(1) + .max(1_000) + .default(10), + AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce + .number() + .int() + .min(1) + .max(1_000) + .default(5), TRUSTED_PROXY_CIDRS: z.string().default(''), SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'), LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'), diff --git a/apps/api/src/security/auth-rate-limiter.service.ts b/apps/api/src/security/auth-rate-limiter.service.ts index b0f5995..f315be8 100644 --- a/apps/api/src/security/auth-rate-limiter.service.ts +++ b/apps/api/src/security/auth-rate-limiter.service.ts @@ -9,8 +9,8 @@ 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 }; +type AuthAction = 'login' | 'register' | 'refresh' | 'change_password'; +type Limit = { ip: number; email?: number; user?: number }; @Injectable() export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { @@ -32,7 +32,7 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { public async consume( action: AuthAction, - identifiers: { ip: string | null; email?: string }, + identifiers: { ip: string | null; email?: string; userId?: string }, ): Promise { const limit = this.limitFor(action); await this.consumeBucket( @@ -49,6 +49,14 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { limit.email, ); } + if (identifiers.userId && limit.user) { + await this.consumeBucket( + action, + 'user', + identifiers.userId, + limit.user, + ); + } } public async onModuleDestroy(): Promise { @@ -61,7 +69,7 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { private async consumeBucket( action: AuthAction, - dimension: 'ip' | 'email', + dimension: 'ip' | 'email' | 'user', identifier: string, limit: number, ): Promise { @@ -112,6 +120,12 @@ export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy { email: this.config.AUTH_REGISTER_EMAIL_LIMIT, }; } + if (action === 'change_password') { + return { + ip: this.config.AUTH_CHANGE_PASSWORD_IP_LIMIT, + user: this.config.AUTH_CHANGE_PASSWORD_USER_LIMIT, + }; + } return { ip: this.config.AUTH_REFRESH_IP_LIMIT }; } } diff --git a/apps/api/src/stores/stores.service.ts b/apps/api/src/stores/stores.service.ts index 7aa5ead..a30e3c1 100644 --- a/apps/api/src/stores/stores.service.ts +++ b/apps/api/src/stores/stores.service.ts @@ -50,6 +50,7 @@ type WarehouseRow = { @Injectable() export class StoresService { public constructor( + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database, @Inject(APP_CONFIG) private readonly config: AppConfig, diff --git a/apps/api/test/auth.e2e.test.ts b/apps/api/test/auth.e2e.test.ts index 8e47f5f..8fc5e8e 100644 --- a/apps/api/test/auth.e2e.test.ts +++ b/apps/api/test/auth.e2e.test.ts @@ -19,7 +19,13 @@ type ErrorResponse = { type SessionPayload = { access_token: string; access_token_expires_in: number; - user: { id: string; email: string }; + user: { + id: string; + email: string; + display_name: string | null; + avatar_asset_id: string | null; + revision: number; + }; workspace: { id: string; name: string; role: string }; }; @@ -90,6 +96,15 @@ describe('认证 HTTP 集成测试', () => { afterAll(async () => { for (const user of testUsers) { + await database.workspaceTransaction( + user.workspaceId, + async (tx) => { + await tx.query( + 'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', + [user.workspaceId], + ); + }, + ); await database.query( 'DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [user.userId], @@ -273,4 +288,143 @@ describe('认证 HTTP 集成测试', () => { } } }); + + it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => { + const accountEmail = email('profile-password'); + const registration = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ + email: accountEmail, + password, + password_confirmation: password, + display_name: '原始名称', + }), + }); + const registered = expectSuccess( + registration.body, + ).data; + testUsers.push({ + userId: registered.user.id, + workspaceId: registered.workspace.id, + }); + + const profileKey = randomUUID(); + const update = await request('/api/auth/me', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${registered.access_token}`, + 'If-Match': `"${registered.user.revision}"`, + 'Idempotency-Key': profileKey, + }, + body: JSON.stringify({ display_name: '更新后的名称' }), + }); + assert.equal(update.response.status, 200); + const updatedUser = expectSuccess( + update.body, + ).data; + assert.equal(updatedUser.display_name, '更新后的名称'); + assert.equal(updatedUser.avatar_asset_id, null); + assert.equal(updatedUser.revision, registered.user.revision + 1); + + const replay = await request('/api/auth/me', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${registered.access_token}`, + 'If-Match': `"${registered.user.revision}"`, + 'Idempotency-Key': profileKey, + }, + body: JSON.stringify({ display_name: '更新后的名称' }), + }); + assert.equal(replay.response.status, 200); + assert.deepEqual(replay.body, update.body); + + const stale = await request('/api/auth/me', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${registered.access_token}`, + 'If-Match': `"${registered.user.revision}"`, + 'Idempotency-Key': randomUUID(), + }, + body: JSON.stringify({ display_name: '过期修改' }), + }); + assert.equal(stale.response.status, 409); + assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT'); + + const invalidAvatar = await request('/api/auth/me', { + method: 'PATCH', + headers: { + Authorization: `Bearer ${registered.access_token}`, + 'If-Match': `"${updatedUser.revision}"`, + 'Idempotency-Key': randomUUID(), + }, + body: JSON.stringify({ avatar_asset_id: randomUUID() }), + }); + assert.equal(invalidAvatar.response.status, 422); + assert.equal( + expectError(invalidAvatar.body).error.code, + 'AVATAR_ASSET_UNAVAILABLE', + ); + + const secondLogin = await request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email: accountEmail, password }), + }); + const otherSession = expectSuccess( + secondLogin.body, + ).data; + + const changed = await request('/api/auth/change-password', { + method: 'POST', + headers: { Authorization: `Bearer ${registered.access_token}` }, + body: JSON.stringify({ + old_password: password, + password: 'new-correct-horse-battery-staple-456', + password_confirmation: 'new-correct-horse-battery-staple-456', + }), + }); + assert.equal(changed.response.status, 200); + const rotated = expectSuccess<{ + access_token: string; + access_token_expires_in: number; + }>(changed.body).data; + assert.ok(rotated.access_token); + refreshCookie(changed.response); + + const formerCurrent = await request('/api/auth/me', { + headers: { Authorization: `Bearer ${registered.access_token}` }, + }); + assert.equal(formerCurrent.response.status, 401); + const revokedOther = await request('/api/auth/me', { + headers: { Authorization: `Bearer ${otherSession.access_token}` }, + }); + assert.equal(revokedOther.response.status, 401); + const current = await request('/api/auth/me', { + headers: { Authorization: `Bearer ${rotated.access_token}` }, + }); + assert.equal(current.response.status, 200); + + const oldPasswordChange = await request('/api/auth/change-password', { + method: 'POST', + headers: { Authorization: `Bearer ${rotated.access_token}` }, + body: JSON.stringify({ + old_password: password, + password: 'another-correct-horse-battery-staple-789', + password_confirmation: + 'another-correct-horse-battery-staple-789', + }), + }); + assert.equal(oldPasswordChange.response.status, 422); + assert.equal( + expectError(oldPasswordChange.body).error.code, + 'VALIDATION_FAILED', + ); + const newPasswordLogin = await request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: accountEmail, + password: 'new-correct-horse-battery-staple-456', + }), + }); + assert.equal(newPasswordLogin.response.status, 200); + }); }); diff --git a/docs/api/auth.md b/docs/api/auth.md index ec8c5ad..1950f81 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -10,8 +10,10 @@ "id": "uuid", "email": "owner@example.com", "display_name": null, + "avatar_asset_id": null, "email_verified_at": null, - "created_at": "2026-09-02T08:00:00Z" + "created_at": "2026-09-02T08:00:00Z", + "revision": 1 }, "workspace": { "id": "uuid", @@ -21,7 +23,7 @@ } ``` -`email` 返回规范化后的地址;密码散列、会话哈希、手机号及任何风控信息永不返回。 +`email` 返回规范化后的地址;`avatar_asset_id` 是当前头像的素材引用,普通账户响应不返回对象存储 URL。密码散列、会话哈希、手机号及任何风控信息永不返回。 ## 2. 注册 @@ -50,7 +52,7 @@ "data": { "access_token": "eyJ…", "access_token_expires_in": 900, - "user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z" }, + "user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "created_at": "2026-09-02T08:00:00Z", "revision": 1 }, "workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" } } } @@ -86,7 +88,41 @@ 要求 Bearer access token;撤销当前 refresh session、清除 refresh cookie,返回 `204 No Content`。首期不提供“退出所有设备”。 -## 5. 当前用户与初始页头数据 +## 5. 修改账户资料 + +`PATCH /api/auth/me` + +Headers:`If-Match: "{revision}"` 与 `Idempotency-Key: {uuid}` 必填。 + +```json +{ "display_name": "Joey", "avatar_asset_id": "uuid" } +``` + +两个字段至少提交一个;传 `null` 可清除对应资料。成功返回 `200` 与更新后的 `user` 资源,`revision` 加一。`avatar_asset_id` 必须是当前工作空间中已完成素材校验的图片资产;接口不接受 URL、对象存储 key 或 base64 内容。版本过期返回 `409 VERSION_CONFLICT`,相同幂等键遵循统一重放规则。 + +头像上传复用 `POST /api/media/upload-intents` 的预签名直传和完成校验流程,并使用 `purpose: "user_avatar"`;在素材模块实现前,头像只能保持 `null`,不能以 base64 上传作为替代通道。 + +## 6. 修改密码 + +`POST /api/auth/change-password` + +```json +{ + "old_password": "old-strong-password", + "password": "new-strong-password", + "password_confirmation": "new-strong-password" +} +``` + +此接口要求 Bearer access token,并按来源 IP 和当前用户双维度限流。服务端在一个事务内锁定当前会话和用户、校验旧密码、更新密码散列、撤销该用户全部旧 session,并创建新的当前 session。成功返回 `200`、新的 `HttpOnly` refresh cookie 和: + +```json +{ "data": { "access_token": "eyJ…", "access_token_expires_in": 900 } } +``` + +旧 access token 和其他设备的 session 会立即失效。此接口不使用 `Idempotency-Key`:密码变更及 session 轮换不能安全地将 access token 持久化为幂等回放响应;客户端收到网络中断后应以新旧密码重新登录或刷新会话,不得盲目自动重试。 + +## 7. 当前用户与初始页头数据 `GET /api/auth/me` @@ -95,7 +131,7 @@ ```json { "data": { - "user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "email_verified_at": null }, + "user": { "id": "uuid", "email": "owner@example.com", "display_name": "Joey", "avatar_asset_id": null, "email_verified_at": null, "revision": 1 }, "workspace": { "id": "uuid", "name": "Joey 的工作空间", "role": "owner" }, "platforms": [{ "id": "uuid", "code": "OZON", "name": "Ozon" }], "suggested_context": { diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 34f7d77..abd7884 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -100,6 +100,7 @@ X-Atlas-Platform-Id: 57ad…-uuid ## 5. 写操作的幂等、并发和异步 - 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: `。服务端按“当前用户 + 工作空间 + HTTP 方法 + 规范化实际路径 + key”保存至少 24 小时(首期默认 48 小时);请求摘要保存为服务端 HMAC,而非明文请求体。相同键、相同请求返回已保存响应;相同键、不同请求返回 `409 IDEMPOTENCY_KEY_REUSED`;原请求仍在执行返回 `409 IDEMPOTENCY_REQUEST_IN_PROGRESS`。 +- `PATCH /api/auth/me` 同样要求幂等键。`POST /api/auth/change-password` 是例外:其成功响应包含新 access token,不能将该 token 持久化到幂等回放表;客户端不得自动重试此操作。 - 更新可编辑资源时,读取响应携带 `revision`;客户端在 `PATCH`/`PUT` 中提交 `If-Match: ""`。版本已变化返回 `409 VERSION_CONFLICT` 和最新 `current_revision` 摘要。`revision` 是并发控制版本;不要把水印的渲染 `current_version` 当作它。 - 耗时动作(店铺凭据验证、仓库同步、媒体处理)先返回 `202 Accepted`,并在资源中返回 `operation`。客户端轮询 `GET /api/operations/{operation_id}`;后续可追加 SSE,不改变资源接口。 - 所有启动异步工作的业务写入,必须在同一 PostgreSQL 事务中写入业务资源/`operation` 和 `outbox_events`;独立 Dispatcher 再投递 BullMQ。BullMQ 和 Redis 不得作为任务存在或最终业务结果的唯一事实。 diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 684626e..991f705 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -102,6 +102,8 @@ User → Workspace → Membership / Role 4. 接着实现素材直传和水印管理,再开始采集箱与商品编辑;浏览器扩展仅在采集 API 稳定后实现实际解析器。 5. 在开始商品编辑/在线同步模块前,关闭评审文档中的 P1 事项:同商品归属约束、未映射属性持久化、乐观锁 Repository 规则、平台能力 Schema;并确认在线商品归档策略和 Client ID 编辑边界。 +账户资料的已确认补充:`users.avatar_asset_id` 只保存素材引用,绝不保存公开 URL;`users.revision` 用于 `PATCH /api/auth/me` 的 `If-Match`。资料更新已实现幂等与版本冲突检测;修改密码已实现旧密码校验、按 IP/用户限流、撤销旧会话并轮换新会话。头像上传必须复用素材模块的预签名直传与校验流程,不引入 base64 直传;素材模块尚未实现时头像只能为 `null`。 + ## 7. 文档导航 - [文档总索引](../README.md) diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index c24e26e..db830b7 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -10,13 +10,32 @@ export const loginRequestSchema = z.object({ email: z.string().trim().email().max(254), password: z.string().min(1), }); +export const updateCurrentUserRequestSchema = z + .object({ + display_name: z.string().trim().min(1).max(80).nullable().optional(), + avatar_asset_id: z.uuid().nullable().optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: '至少需要提交一个可修改字段', + }); +export const changePasswordRequestSchema = z + .object({ + old_password: z.string().min(1), + password: z.string().min(1), + password_confirmation: z.string().min(1), + }) + .strict(); export const authUserSchema = z.object({ id: z.uuid(), email: z.string().email(), display_name: z.string().nullable(), + // 头像文件由素材模块管理,账户接口只提供稳定素材引用,绝不保存对象存储 URL。 + avatar_asset_id: z.uuid().nullable(), email_verified_at: z.iso.datetime().nullable(), created_at: z.iso.datetime(), + revision: z.number().int().positive(), }); export const workspaceSchema = z.object({ id: z.uuid(), @@ -29,8 +48,29 @@ export const authSessionResponseSchema = z.object({ user: authUserSchema, workspace: workspaceSchema, }); +export const authRefreshResponseSchema = z.object({ + access_token: z.string(), + access_token_expires_in: z.number().int().positive(), +}); +export const authMeResponseSchema = z.object({ + user: authUserSchema, + workspace: workspaceSchema, + platforms: z.array( + z.object({ id: z.uuid(), code: z.string(), name: z.string() }), + ), + suggested_context: z.object({ + platform_id: z.uuid().nullable(), + default_store: z.null(), + }), +}); export type RegisterRequest = z.infer; export type LoginRequest = z.infer; +export type UpdateCurrentUserRequest = z.infer< + typeof updateCurrentUserRequestSchema +>; +export type ChangePasswordRequest = z.infer; export type AuthUser = z.infer; export type Workspace = z.infer; export type AuthSessionResponse = z.infer; +export type AuthRefreshResponse = z.infer; +export type AuthMeResponse = z.infer; diff --git a/packages/database/migrations/004_user_profile_avatar.sql b/packages/database/migrations/004_user_profile_avatar.sql new file mode 100644 index 0000000..c6d6679 --- /dev/null +++ b/packages/database/migrations/004_user_profile_avatar.sql @@ -0,0 +1,9 @@ +-- 用户头像是账户资料的一部分;素材文件仍由受工作空间隔离的 media_assets 管理。 +SET search_path TO atlas, public; + +ALTER TABLE users + ADD COLUMN avatar_asset_id UUID REFERENCES media_assets(id) ON DELETE SET NULL, + ADD COLUMN revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0); + +CREATE INDEX ix_users_avatar_asset ON users (avatar_asset_id) + WHERE avatar_asset_id IS NOT NULL;