mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 02:13:00 +08:00
refactor(server): improve magic link login flow (#10736)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
@@ -249,3 +251,96 @@ test('should be able to sign out multiple accounts in one session', async t => {
|
||||
session = await app.GET('/api/auth/session').expect(200);
|
||||
t.falsy(session.body.user);
|
||||
});
|
||||
|
||||
test('should be able to sign in with email and client nonce', async t => {
|
||||
const { app, mailer } = t.context;
|
||||
|
||||
const clientNonce = randomUUID();
|
||||
const u1 = await app.createUser();
|
||||
// @ts-expect-error mock
|
||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, client_nonce: clientNonce })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.email, u1.email);
|
||||
t.true(mailer.sendSignInMail.calledOnce);
|
||||
|
||||
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
|
||||
const url = new URL(signInLink);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token, client_nonce: clientNonce })
|
||||
.expect(201);
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.is(session?.id, u1.id);
|
||||
});
|
||||
|
||||
test('should not be able to sign in with email and client nonce if invalid', async t => {
|
||||
const { app, mailer } = t.context;
|
||||
|
||||
const clientNonce = randomUUID();
|
||||
const u1 = await app.createUser();
|
||||
// @ts-expect-error mock
|
||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, client_nonce: clientNonce })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.email, u1.email);
|
||||
t.true(mailer.sendSignInMail.calledOnce);
|
||||
|
||||
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
|
||||
const url = new URL(signInLink);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
// invalid client nonce
|
||||
await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token, client_nonce: randomUUID() })
|
||||
.expect(400)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'INVALID_AUTH_STATE',
|
||||
message:
|
||||
'Invalid auth state. You might start the auth progress from another device.',
|
||||
});
|
||||
// no client nonce
|
||||
await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token })
|
||||
.expect(400)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'INVALID_AUTH_STATE',
|
||||
message:
|
||||
'Invalid auth state. You might start the auth progress from another device.',
|
||||
});
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.falsy(session);
|
||||
});
|
||||
|
||||
test('should not be able to sign in if token is invalid', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email: 'u1@affine.pro', token: 'invalid' })
|
||||
.expect(400);
|
||||
|
||||
t.is(res.body.message, 'An invalid email token provided.');
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InternalServerError,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
InvalidEmailToken,
|
||||
Runtime,
|
||||
@@ -45,11 +46,13 @@ interface SignInCredential {
|
||||
email: string;
|
||||
password?: string;
|
||||
callbackUrl?: string;
|
||||
client_nonce?: string;
|
||||
}
|
||||
|
||||
interface MagicLinkCredential {
|
||||
email: string;
|
||||
token: string;
|
||||
client_nonce?: string;
|
||||
}
|
||||
|
||||
const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;
|
||||
@@ -140,7 +143,8 @@ export class AuthController {
|
||||
res,
|
||||
credential.email,
|
||||
credential.callbackUrl,
|
||||
redirectUri
|
||||
redirectUri,
|
||||
credential.client_nonce
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -162,7 +166,8 @@ export class AuthController {
|
||||
res: Response,
|
||||
email: string,
|
||||
callbackUrl = '/magic-link',
|
||||
redirectUrl?: string
|
||||
redirectUrl?: string,
|
||||
clientNonce?: string
|
||||
) {
|
||||
// send email magic link
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
@@ -210,7 +215,11 @@ export class AuthController {
|
||||
const otp = this.crypto.otp();
|
||||
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
|
||||
const cacheKey = OTP_CACHE_KEY(otp);
|
||||
await this.cache.set(cacheKey, token, { ttl: ttlInSec * 1000 });
|
||||
await this.cache.set(
|
||||
cacheKey,
|
||||
{ token, clientNonce },
|
||||
{ ttl: ttlInSec * 1000 }
|
||||
);
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, {
|
||||
token: otp,
|
||||
@@ -266,24 +275,37 @@ export class AuthController {
|
||||
async magicLinkSignIn(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() { email, token }: MagicLinkCredential
|
||||
@Body()
|
||||
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
|
||||
) {
|
||||
if (!token || !email) {
|
||||
if (!otp || !email) {
|
||||
throw new EmailTokenNotFound();
|
||||
}
|
||||
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const cacheKey = OTP_CACHE_KEY(token);
|
||||
const cachedToken = await this.cache.get<string>(cacheKey);
|
||||
const cacheKey = OTP_CACHE_KEY(otp);
|
||||
const cachedToken = await this.cache.get<
|
||||
{ token: string; clientNonce: string } | string
|
||||
>(cacheKey);
|
||||
let token: string | undefined;
|
||||
// TODO(@fengmk2): this is a temporary compatible with cache token is string value, should be removed in 0.22
|
||||
if (typeof cachedToken === 'string') {
|
||||
token = cachedToken;
|
||||
} else if (cachedToken) {
|
||||
token = cachedToken.token;
|
||||
if (cachedToken.clientNonce && cachedToken.clientNonce !== clientNonce) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
if (!cachedToken) {
|
||||
if (!token) {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const tokenRecord = await this.models.verificationToken.verify(
|
||||
TokenType.SignIn,
|
||||
cachedToken,
|
||||
token,
|
||||
{
|
||||
credential: email,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user