Skip to content

Commit

Permalink
feat: revoke token after sensitive operations (#6993)
Browse files Browse the repository at this point in the history
fix #6914
  • Loading branch information
darkskygit committed May 20, 2024
1 parent 4c77ffd commit df73b6d
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 12 deletions.
2 changes: 2 additions & 0 deletions packages/backend/server/src/core/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class AuthResolver {
}

await this.auth.changePassword(user.id, newPassword);
await this.auth.revokeUserSessions(user.id);

return user;
}
Expand All @@ -121,6 +122,7 @@ export class AuthResolver {
email = decodeURIComponent(email);

await this.auth.changeEmail(user.id, email);
await this.auth.revokeUserSessions(user.id);
await this.auth.sendNotificationChangeEmail(email);

return user;
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/server/src/core/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,15 @@ export class AuthService implements OnApplicationBootstrap {
}
}

async revokeUserSessions(userId: string, sessionId?: string) {
return this.db.userSession.deleteMany({
where: {
userId,
sessionId,
},
});
}

async setCookie(_req: Request, res: Response, user: { id: string }) {
const session = await this.createUserSession(
user
Expand Down
133 changes: 121 additions & 12 deletions packages/backend/server/tests/auth.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto';

import {
getCurrentMailMessageCount,
getLatestMailMessage,
getTokenFromLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
Expand All @@ -10,8 +12,11 @@ import { AuthService } from '../src/core/auth/service';
import { MailService } from '../src/fundamentals/mailer';
import {
changeEmail,
changePassword,
createTestingApp,
currentUser,
sendChangeEmail,
sendSetPasswordEmail,
sendVerifyChangeEmail,
signUp,
} from './utils';
Expand Down Expand Up @@ -40,7 +45,6 @@ test('change email', async t => {
if (mail.hasConfigured()) {
const u1Email = '[email protected]';
const u2Email = '[email protected]';
const tokenRegex = /token=3D([^"&]+)/;

const u1 = await signUp(app, 'u1', u1Email, '1');

Expand All @@ -54,12 +58,8 @@ test('change email', async t => {
afterSendChangeMailCount,
'failed to send change email'
);
const changeEmailContent = await getLatestMailMessage();

const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex);
const changeEmailToken = changeTokenMatch
? decodeURIComponent(changeTokenMatch[1].replace(/=\r\n/, ''))
: null;
const changeEmailToken = await getTokenFromLatestMailMessage();

t.not(
changeEmailToken,
Expand All @@ -82,12 +82,8 @@ test('change email', async t => {
afterSendVerifyMailCount,
'failed to send verify email'
);
const verifyEmailContent = await getLatestMailMessage();

const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex);
const verifyEmailToken = verifyTokenMatch
? decodeURIComponent(verifyTokenMatch[1].replace(/=\r\n/, ''))
: null;
const verifyEmailToken = await getTokenFromLatestMailMessage();

t.not(
verifyEmailToken,
Expand All @@ -107,3 +103,116 @@ test('change email', async t => {
}
t.pass();
});

test('set and change password', async t => {
const { mail, app, auth } = t.context;
if (mail.hasConfigured()) {
const u1Email = '[email protected]';

const u1 = await signUp(app, 'u1', u1Email, '1');

const primitiveMailCount = await getCurrentMailMessageCount();

await sendSetPasswordEmail(app, u1.token.token, u1Email, 'affine.pro');

const afterSendSetMailCount = await getCurrentMailMessageCount();

t.is(
primitiveMailCount + 1,
afterSendSetMailCount,
'failed to send set email'
);

const setPasswordToken = await getTokenFromLatestMailMessage();

t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);

const newPassword = randomBytes(16).toString('hex');
const userId = await changePassword(
app,
u1.token.token,
setPasswordToken as string,
newPassword
);
t.is(u1.id, userId, 'failed to set password');

const ret = auth.signIn(u1Email, newPassword);
t.notThrowsAsync(ret, 'failed to check password');
t.is((await ret).id, u1.id, 'failed to check password');
}
t.pass();
});
test('should revoke token after change user identify', async t => {
const { mail, app, auth } = t.context;
if (mail.hasConfigured()) {
// change email
{
const u1Email = '[email protected]';
const u2Email = '[email protected]';

const u1 = await signUp(app, 'u1', u1Email, '1');

{
const user = await currentUser(app, u1.token.token);
t.is(user?.email, u1Email, 'failed to get current user');
}

await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');

const changeEmailToken = await getTokenFromLatestMailMessage();
await sendVerifyChangeEmail(
app,
u1.token.token,
changeEmailToken as string,
u2Email,
'affine.pro'
);

const verifyEmailToken = await getTokenFromLatestMailMessage();
await changeEmail(
app,
u1.token.token,
verifyEmailToken as string,
u2Email
);

const user = await currentUser(app, u1.token.token);
t.is(user, null, 'token should be revoked');

const newUserSession = await auth.signIn(u2Email, '1');
t.is(newUserSession?.email, u2Email, 'failed to sign in with new email');
}

// change password
{
const u3Email = '[email protected]';

const u3 = await signUp(app, 'u1', u3Email, '1');

{
const user = await currentUser(app, u3.token.token);
t.is(user?.email, u3Email, 'failed to get current user');
}

await sendSetPasswordEmail(app, u3.token.token, u3Email, 'affine.pro');
const token = await getTokenFromLatestMailMessage();
const newPassword = randomBytes(16).toString('hex');
await changePassword(app, u3.token.token, token as string, newPassword);

const user = await currentUser(app, u3.token.token);
t.is(user, null, 'token should be revoked');

const newUserSession = await auth.signIn(u3Email, newPassword);
t.is(
newUserSession?.email,
u3Email,
'failed to sign in with new password'
);
}
}
t.pass();
});
47 changes: 47 additions & 0 deletions packages/backend/server/tests/utils/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,53 @@ export async function sendChangeEmail(
return res.body.data.sendChangeEmail;
}

export async function sendSetPasswordEmail(
app: INestApplication,
userToken: string,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendSetPasswordEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);

return res.body.data.sendChangeEmail;
}

export async function changePassword(
app: INestApplication,
userToken: string,
token: string,
password: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation changePassword($token: String!, $password: String!) {
changePassword(token: $token, newPassword: $password) {
id
}
}
`,
variables: { token, password },
})
.expect(200);
console.log(JSON.stringify(res.body));
return res.body.data.changePassword.id;
}

export async function sendVerifyChangeEmail(
app: INestApplication,
userToken: string,
Expand Down
14 changes: 14 additions & 0 deletions tests/kit/utils/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { faker } from '@faker-js/faker';
import { hash } from '@node-rs/argon2';
import type { BrowserContext, Cookie, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Assertions } from 'ava';
import { z } from 'zod';

export async function getCurrentMailMessageCount() {
Expand All @@ -26,6 +27,19 @@ export async function getLatestMailMessage() {
return data.items[0];
}

export async function getTokenFromLatestMailMessage<A extends Assertions>(
test?: A
) {
const tokenRegex = /token=3D([^"&]+)/;
const emailContent = await getLatestMailMessage();
const tokenMatch = emailContent.Content.Body.match(tokenRegex);
const token = tokenMatch
? decodeURIComponent(tokenMatch[1].replace(/=\r\n/, ''))
: null;
test?.truthy(token);
return token;
}

export async function getLoginCookie(
context: BrowserContext
): Promise<Cookie | undefined> {
Expand Down

0 comments on commit df73b6d

Please sign in to comment.