feat(api): 账户资料与密码安全
This commit is contained in:
@@ -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