feat: add verify process in change email progress (#4306)

Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
Qi
2023-09-14 00:54:02 +08:00
committed by GitHub
parent 0b1ba6bf43
commit 0be142e4e2
19 changed files with 443 additions and 81 deletions

View File

@@ -160,6 +160,33 @@ export class MailService {
html,
});
}
async sendVerifyChangeEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your new email address',
content:
'You recently requested to change the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
buttonContent: 'Verify your new email address',
buttonUrl: url,
});
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Verify your new email for AFFiNE`,
html,
});
}
async sendNotificationChangeEmail(to: string) {
const html = emailTemplate({
title: 'Email change successful',
content: `As per your request, we have changed your email. Please make sure you're using ${to} when you log in the next time. `,
});
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Your email has been changed`,
html,
});
}
async sendAcceptedEmail(
to: string,
{

View File

@@ -131,17 +131,19 @@ export class AuthResolver {
@Auth()
async changeEmail(
@CurrentUser() user: UserType,
@Args('token') token: string,
@Args('email') email: string
@Args('token') token: string
) {
const id = await this.session.get(token);
if (!id || id !== user.id) {
// email has set token in `sendVerifyChangeEmail`
const [id, email] = (await this.session.get(token)).split(',');
if (!id || id !== user.id || !email) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changeEmail(id, email);
await this.session.delete(token);
await this.auth.sendNotificationChangeEmail(email);
return user;
}
@@ -181,6 +183,13 @@ export class AuthResolver {
return !res.rejected.length;
}
// The change email step is:
// 1. send email to primitive email `sendChangeEmail`
// 2. user open change email page from email
// 3. send verify email to new email `sendVerifyChangeEmail`
// 4. user open confirm email page from new email
// 5. user click confirm button
// 6. send notification email
@Throttle(5, 60)
@Mutation(() => Boolean)
@Auth()
@@ -198,4 +207,37 @@ export class AuthResolver {
const res = await this.auth.sendChangeEmail(email, url.toString());
return !res.rejected.length;
}
@Throttle(5, 60)
@Mutation(() => Boolean)
@Auth()
async sendVerifyChangeEmail(
@CurrentUser() user: UserType,
@Args('token') token: string,
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const id = await this.session.get(token);
if (!id || id !== user.id) {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.auth.getUserByEmail(email);
if (hasRegistered) {
throw new BadRequestException(`Invalid user email`);
}
const withEmailToken = nanoid();
await this.session.set(withEmailToken, `${user.id},${email}`);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', withEmailToken);
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
await this.session.delete(token);
return !res.rejected.length;
}
}

View File

@@ -251,4 +251,10 @@ export class AuthService {
async sendChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangeEmail(email, callbackUrl);
}
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
}
async sendNotificationChangeEmail(email: string) {
return this.mailer.sendNotificationChangeEmail(email);
}
}

View File

@@ -192,10 +192,11 @@ type Mutation {
signUp(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType!
changePassword(token: String!, newPassword: String!): UserType!
changeEmail(token: String!, email: String!): UserType!
changeEmail(token: String!): UserType!
sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendChangeEmail(email: String!, callbackUrl: String!): Boolean!
sendVerifyChangeEmail(token: String!, email: String!, callbackUrl: String!): Boolean!
}
"""The `Upload` scalar type represents a file upload."""

View File

@@ -0,0 +1,133 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../app';
import { MailService } from '../modules/auth/mailer';
import { AuthService } from '../modules/auth/service';
import {
changeEmail,
createWorkspace,
getCurrentMailMessageCount,
getLatestMailMessage,
sendChangeEmail,
sendVerifyChangeEmail,
signUp,
} from './utils';
const test = ava as TestFn<{
app: INestApplication;
client: PrismaClient;
auth: AuthService;
mail: MailService;
}>;
test.beforeEach(async t => {
const client = new PrismaClient();
t.context.client = client;
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
const auth = module.get(AuthService);
const mail = module.get(MailService);
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
});
test.afterEach(async t => {
await t.context.app.close();
});
test('change email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const tokenRegex = /token=3D([^"&\s]+)/;
const u1 = await signUp(app, 'u1', u1Email, '1');
await createWorkspace(app, u1.token.token);
const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
const afterSendChangeMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendChangeMailCount,
'failed to send change email'
);
const changeEmailContent = await getLatestMailMessage();
const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex);
const changeEmailToken = changeTokenMatch
? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '='))
: null;
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
await sendVerifyChangeEmail(
app,
u1.token.token,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
t.is(
afterSendChangeMailCount + 1,
afterSendVerifyMailCount,
'failed to send verify email'
);
const verifyEmailContent = await getLatestMailMessage();
const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex);
const verifyEmailToken = verifyTokenMatch
? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '='))
: null;
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, u1.token.token, verifyEmailToken as string);
const afterNotificationMailCount = await getCurrentMailMessageCount();
t.is(
afterSendVerifyMailCount + 1,
afterNotificationMailCount,
'failed to send notification email'
);
}
t.pass();
});

View File

@@ -81,6 +81,7 @@ test('should include callbackUrl in sending email', async t => {
'sendSetPasswordEmail',
'sendChangeEmail',
'sendChangePasswordEmail',
'sendVerifyChangeEmail',
] as const) {
const prev = await getCurrentMailMessageCount();
await auth[fn]('alexyang@example.org', 'https://test.com/callback');

View File

@@ -473,6 +473,76 @@ async function getInviteInfo(
return res.body.data.getInviteInfo;
}
async function sendChangeEmail(
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 {
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
return res.body.data.sendChangeEmail;
}
async function sendVerifyChangeEmail(
app: INestApplication,
userToken: string,
token: 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 {
sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
return res.body.data.sendVerifyChangeEmail;
}
async function changeEmail(
app: INestApplication,
userToken: string,
token: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
changeEmail(token: "${token}") {
id
name
avatarUrl
email
}
}
`,
})
.expect(200);
return res.body.data.changeEmail;
}
export class FakePrisma {
fakeUser: User = {
id: randomUUID(),
@@ -504,6 +574,7 @@ export class FakePrisma {
export {
acceptInvite,
acceptInviteById,
changeEmail,
checkBlobSize,
collectAllBlobSizes,
collectBlobSizes,
@@ -518,6 +589,8 @@ export {
listBlobs,
revokePage,
revokeUser,
sendChangeEmail,
sendVerifyChangeEmail,
setBlob,
sharePage,
signUp,