feat(api): 账户资料与密码安全

This commit is contained in:
R524809
2026-09-03 16:56:20 +08:00
parent 46d98ab11e
commit a5b89ddb1d
13 changed files with 552 additions and 14 deletions
+4
View File
@@ -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=
+97
View File
@@ -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]);
}
+2
View File
@@ -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 },
+170 -4
View File
@@ -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<UserRow>(
'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<UserRow>(
'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<UserRow>(
`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<UserRow>(
`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<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`,
@@ -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<UserRow> {
const result = await transaction.query<UserRow>(
`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<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',
'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];
+12
View File
@@ -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'),
@@ -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<void> {
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<void> {
@@ -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<void> {
@@ -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 };
}
}
+1
View File
@@ -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,
+155 -1
View File
@@ -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<SessionPayload>(
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<SessionPayload['user']>(
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<SessionPayload>(
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);
});
});
+41 -5
View File
@@ -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": {
+1
View File
@@ -100,6 +100,7 @@ X-Atlas-Platform-Id: 57ad…-uuid
## 5. 写操作的幂等、并发和异步
- 创建店铺、上传意图、删除水印模板等可重试写操作应携带 `Idempotency-Key: <UUID>`。服务端按“当前用户 + 工作空间 + 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: "<revision>"`。版本已变化返回 `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 不得作为任务存在或最终业务结果的唯一事实。
+2
View File
@@ -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)
+40
View File
@@ -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<typeof registerRequestSchema>;
export type LoginRequest = z.infer<typeof loginRequestSchema>;
export type UpdateCurrentUserRequest = z.infer<
typeof updateCurrentUserRequestSchema
>;
export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
export type AuthUser = z.infer<typeof authUserSchema>;
export type Workspace = z.infer<typeof workspaceSchema>;
export type AuthSessionResponse = z.infer<typeof authSessionResponseSchema>;
export type AuthRefreshResponse = z.infer<typeof authRefreshResponseSchema>;
export type AuthMeResponse = z.infer<typeof authMeResponseSchema>;
@@ -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;