feat(api): 账户资料与密码安全
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user