feat: revoke token after sensitive operations (#6993)

fix #6914
This commit is contained in:
darkskygit 2024-05-20 06:38:48 +00:00
parent 4c77ffd469
commit df73b6ddc7
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
5 changed files with 193 additions and 12 deletions

View File

@ -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;

View File

@ -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

View File

@ -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();
});

View File

@ -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,

View File

@ -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<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> {