From df73b6ddc763ca426720e654c1d850076eb117ab Mon Sep 17 00:00:00 2001 From: darkskygit Date: Mon, 20 May 2024 06:38:48 +0000 Subject: [PATCH] feat: revoke token after sensitive operations (#6993) fix #6914 --- .../backend/server/src/core/auth/resolver.ts | 2 + .../backend/server/src/core/auth/service.ts | 9 ++ packages/backend/server/tests/auth.e2e.ts | 133 ++++++++++++++++-- packages/backend/server/tests/utils/user.ts | 47 +++++++ tests/kit/utils/cloud.ts | 14 ++ 5 files changed, 193 insertions(+), 12 deletions(-) diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 74e13e3e96..3e18e961e4 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -98,6 +98,7 @@ export class AuthResolver { } await this.auth.changePassword(user.id, newPassword); + await this.auth.revokeUserSessions(user.id); return user; } @@ -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; diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 639b2a5f9b..69751bcee8 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -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 diff --git a/packages/backend/server/tests/auth.e2e.ts b/packages/backend/server/tests/auth.e2e.ts index d8f6b34970..571829684b 100644 --- a/packages/backend/server/tests/auth.e2e.ts +++ b/packages/backend/server/tests/auth.e2e.ts @@ -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'; @@ -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'; @@ -40,7 +45,6 @@ test('change email', async t => { if (mail.hasConfigured()) { const u1Email = 'u1@affine.pro'; const u2Email = 'u2@affine.pro'; - const tokenRegex = /token=3D([^"&]+)/; const u1 = await signUp(app, 'u1', u1Email, '1'); @@ -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, @@ -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, @@ -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 = 'u1@affine.pro'; + + 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 = 'u1@affine.pro'; + const u2Email = 'u2@affine.pro'; + + 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 = 'u3@affine.pro'; + + 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(); +}); diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index dbc8465bd5..a30db699a8 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -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 { + 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 { + 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, diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index c61b9cd565..24fa82bae5 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -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() { @@ -26,6 +27,19 @@ export async function getLatestMailMessage() { return data.items[0]; } +export async function getTokenFromLatestMailMessage( + 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 {