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
+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);
});
});