Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/frontend/src/pages/PasswordPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function ResetPasswordPage() {
return (
<section className="form-panel page-enter">
<h1 className="page-heading">Sign in with your new password.</h1>
<p className="mt-5 text-muted">Every device that was signed in has been signed out, and your access tokens were revoked.</p>
<p className="mt-5 text-muted">Every device that was signed in has been signed out. Your access tokens and private calendar link were revoked, and your webhooks were turned off.</p>
<div className="mt-7">
<ButtonLink label="Sign in" large primary to="/login" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/pages/settings/SecuritySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export function SecuritySettings() {
void password
.run(async () => {
await auth.changePassword(proofFrom(target), next);
return 'Password changed. Your other devices were signed out and your access tokens were revoked.';
return 'Password changed. Your other devices were signed out, your access tokens and private calendar link were revoked, and your webhooks were turned off.';
}, 'Could not change your password')
.then((changed) => changed && close());
}}
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/pages/settings/SessionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function SessionsSection() {
void action.run(async () => {
await auth.request(id ? `/me/sessions/${id}` : '/me/sessions', { method: 'DELETE' });
sessions.mutate((current) => current.filter((session) => (id ? session.id !== id : session.current)));
return id ? 'That device was signed out.' : 'Every other device was signed out and your access tokens were revoked.';
return id ? 'That device was signed out.' : 'Every other device was signed out, your access tokens and private calendar link were revoked, and your webhooks were turned off.';
}, 'Could not sign that device out');
}

Expand Down
5 changes: 4 additions & 1 deletion app/server/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,15 @@ export class AuthService implements OnModuleInit {
data: {
passwordHash: await this.passwords.hash(password),
verifiedAt: record.user.verifiedAt ?? now,
calendarFeedHash: null,
},
}),
this.prisma.verificationToken.deleteMany({
where: { userId: record.userId, purpose: { in: accountTokens } },
}),
this.prisma.refreshSession.deleteMany({ where: { userId: record.userId } }),
this.prisma.accessToken.updateMany({ where: { userId: record.userId, revokedAt: null }, data: { revokedAt: now } }),
this.prisma.webhook.updateMany({ where: { userId: record.userId, active: true }, data: { active: false } }),
]);
await this.attempts.clear(record.user.email);
await this.audit.record(record.userId, 'password_reset');
Expand All @@ -379,13 +381,14 @@ export class AuthService implements OnModuleInit {
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: userId },
data: { passwordHash: await this.passwords.hash(newPassword) },
data: { passwordHash: await this.passwords.hash(newPassword), calendarFeedHash: null },
}),
this.prisma.verificationToken.deleteMany({
where: { userId, purpose: { in: accountTokens } },
}),
this.prisma.refreshSession.deleteMany({ where: { userId } }),
this.prisma.accessToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.webhook.updateMany({ where: { userId, active: true }, data: { active: false } }),
]);
await this.audit.record(userId, 'password_changed');
await this.passwordChangedNotice(user);
Expand Down
2 changes: 2 additions & 0 deletions app/server/src/auth/sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export class SessionsService {
where: { userId, ...(currentSessionId ? { id: { not: currentSessionId } } : {}) },
}),
this.prisma.accessToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.user.update({ where: { id: userId }, data: { calendarFeedHash: null } }),
this.prisma.webhook.updateMany({ where: { userId, active: true }, data: { active: false } }),
]);
await this.audit.record(userId, 'other_sessions_signed_out');
}
Expand Down
10 changes: 9 additions & 1 deletion app/server/test/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ function setup(overrides: Record<string, object> = {}) {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockReturnValue('space-delete'),
},
webhook: {
updateMany: vi.fn().mockReturnValue('webhook-disable'),
},
$transaction: vi.fn((work: unknown) =>
typeof work === 'function' ? (work as (client: unknown) => unknown)(prisma) : work,
),
Expand Down Expand Up @@ -325,7 +328,7 @@ describe('Account email flows', () => {
});
});

it('resets the password, confirms the address, cancels pending email changes, signs every device out and revokes access tokens', async () => {
it('resets the password, confirms the address, cancels pending email changes, signs every device out and revokes access tokens, the calendar link and webhooks', async () => {
const { prisma, service } = setup();
prisma.verificationToken.findUnique.mockResolvedValueOnce({
userId: user.id,
Expand All @@ -342,6 +345,7 @@ describe('Account email flows', () => {
data: {
passwordHash: expect.stringMatching(/^scrypt\$32768\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/),
verifiedAt: expect.any(Date),
calendarFeedHash: null,
},
});
expect(prisma.verificationToken.deleteMany).toHaveBeenCalledWith({
Expand All @@ -353,6 +357,8 @@ describe('Account email flows', () => {
expect(prisma.refreshSession.deleteMany).toHaveBeenCalledWith({ where: { userId: user.id } });
expect(prisma.accessToken.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: expect.any(Date) } });
expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('access-token-revoke');
expect(prisma.webhook.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, active: true }, data: { active: false } });
expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('webhook-disable');

prisma.verificationToken.findUnique.mockResolvedValueOnce({
userId: user.id,
Expand Down Expand Up @@ -395,6 +401,8 @@ describe('Account email flows', () => {
expect(prisma.refreshSession.deleteMany).toHaveBeenCalledWith({ where: { userId: user.id } });
expect(prisma.accessToken.updateMany).toHaveBeenCalledWith({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: expect.any(Date) } });
expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('access-token-revoke');
expect(prisma.$transaction.mock.calls.at(-1)![0]).toContain('webhook-disable');
expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: user.id }, data: { passwordHash: expect.any(String), calendarFeedHash: null } });
expect(prisma.refreshSession.create).toHaveBeenCalledTimes(1);

await service.deleteAccount(user.id, { password: 'correct horse battery' });
Expand Down
2 changes: 2 additions & 0 deletions app/server/test/integration/access-tokens.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,11 @@ describe('Access tokens against Postgres', () => {
const owner = await member('rotator');
const created = await owner.call('POST', '/me/tokens', { name: 'Sync', scopes: ['spaces:read'], password });
expect((await withToken(created.body.token, 'GET', '/spaces')).status).toBe(200);
expect((await owner.call('POST', '/me/calendar-feed')).status).toBe(201);

expect((await owner.call('DELETE', '/me/sessions')).status).toBe(204);

expect((await owner.call('GET', '/me/calendar-feed')).body).toEqual({ enabled: false });
expect((await withToken(created.body.token, 'GET', '/spaces')).status).toBe(401);
expect((await owner.call('GET', '/me/tokens')).body).toEqual([]);
expect((await owner.call('GET', '/spaces')).status).toBe(200);
Expand Down
Loading