refactor(server): improve magic link login flow (#10736)

This commit is contained in:
fengmk2
2025-03-12 06:53:29 +00:00
parent 867ae7933f
commit 43712839fd
9 changed files with 149 additions and 15 deletions

View File

@@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto';
import { HttpStatus } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; 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); session = await app.GET('/api/auth/session').expect(200);
t.falsy(session.body.user); 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.');
});

View File

@@ -21,6 +21,7 @@ import {
EarlyAccessRequired, EarlyAccessRequired,
EmailTokenNotFound, EmailTokenNotFound,
InternalServerError, InternalServerError,
InvalidAuthState,
InvalidEmail, InvalidEmail,
InvalidEmailToken, InvalidEmailToken,
Runtime, Runtime,
@@ -45,11 +46,13 @@ interface SignInCredential {
email: string; email: string;
password?: string; password?: string;
callbackUrl?: string; callbackUrl?: string;
client_nonce?: string;
} }
interface MagicLinkCredential { interface MagicLinkCredential {
email: string; email: string;
token: string; token: string;
client_nonce?: string;
} }
const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`; const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;
@@ -140,7 +143,8 @@ export class AuthController {
res, res,
credential.email, credential.email,
credential.callbackUrl, credential.callbackUrl,
redirectUri redirectUri,
credential.client_nonce
); );
} }
} }
@@ -162,7 +166,8 @@ export class AuthController {
res: Response, res: Response,
email: string, email: string,
callbackUrl = '/magic-link', callbackUrl = '/magic-link',
redirectUrl?: string redirectUrl?: string,
clientNonce?: string
) { ) {
// send email magic link // send email magic link
const user = await this.models.user.getUserByEmail(email); const user = await this.models.user.getUserByEmail(email);
@@ -210,7 +215,11 @@ export class AuthController {
const otp = this.crypto.otp(); const otp = this.crypto.otp();
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp // TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
const cacheKey = OTP_CACHE_KEY(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, { const magicLink = this.url.link(callbackUrl, {
token: otp, token: otp,
@@ -266,24 +275,37 @@ export class AuthController {
async magicLinkSignIn( async magicLinkSignIn(
@Req() req: Request, @Req() req: Request,
@Res() res: Response, @Res() res: Response,
@Body() { email, token }: MagicLinkCredential @Body()
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
) { ) {
if (!token || !email) { if (!otp || !email) {
throw new EmailTokenNotFound(); throw new EmailTokenNotFound();
} }
validators.assertValidEmail(email); validators.assertValidEmail(email);
const cacheKey = OTP_CACHE_KEY(token); const cacheKey = OTP_CACHE_KEY(otp);
const cachedToken = await this.cache.get<string>(cacheKey); 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(); throw new InvalidEmailToken();
} }
const tokenRecord = await this.models.verificationToken.verify( const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn, TokenType.SignIn,
cachedToken, token,
{ {
credential: email, credential: email,
} }

View File

@@ -17,8 +17,9 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
let endpoint = try call.getStringEnsure("endpoint") let endpoint = try call.getStringEnsure("endpoint")
let email = try call.getStringEnsure("email") let email = try call.getStringEnsure("email")
let token = try call.getStringEnsure("token") let token = try call.getStringEnsure("token")
let clientNonce = call.getString("clientNonce")
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token]) let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce])
if response.statusCode >= 400 { if response.statusCode >= 400 {
if let textBody = String(data: data, encoding: .utf8) { if let textBody = String(data: data, encoding: .utf8) {

View File

@@ -169,11 +169,12 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
const serverService = resolver.get(ServerService); const serverService = resolver.get(ServerService);
const endpoint = serverService.server.baseUrl; const endpoint = serverService.server.baseUrl;
return { return {
async signInMagicLink(email, linkToken) { async signInMagicLink(email, linkToken, clientNonce) {
const { token } = await Auth.signInMagicLink({ const { token } = await Auth.signInMagicLink({
endpoint, endpoint,
email, email,
token: linkToken, token: linkToken,
clientNonce,
}); });
await writeEndpointToken(endpoint, token); await writeEndpointToken(endpoint, token);
}, },

View File

@@ -3,6 +3,7 @@ export interface AuthPlugin {
endpoint: string; endpoint: string;
email: string; email: string;
token: string; token: string;
clientNonce?: string;
}): Promise<{ token: string }>; }): Promise<{ token: string }>;
signInOauth(options: { signInOauth(options: {
endpoint: string; endpoint: string;

View File

@@ -8,13 +8,17 @@ export function configureDefaultAuthProvider(framework: Framework) {
framework.scope(ServerScope).override(AuthProvider, resolver => { framework.scope(ServerScope).override(AuthProvider, resolver => {
const fetchService = resolver.get(FetchService); const fetchService = resolver.get(FetchService);
return { return {
async signInMagicLink(email: string, token: string) { async signInMagicLink(
email: string,
token: string,
clientNonce?: string
) {
await fetchService.fetch('/api/auth/magic-link', { await fetchService.fetch('/api/auth/magic-link', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, token }), body: JSON.stringify({ email, token, client_nonce: clientNonce }),
}); });
}, },

View File

@@ -1,7 +1,11 @@
import { createIdentifier } from '@toeverything/infra'; import { createIdentifier } from '@toeverything/infra';
export interface AuthProvider { export interface AuthProvider {
signInMagicLink(email: string, token: string): Promise<void>; signInMagicLink(
email: string,
token: string,
clientNonce?: string
): Promise<void>;
signInOauth( signInOauth(
code: string, code: string,

View File

@@ -79,6 +79,7 @@ export class AuthService extends Service {
redirectUrl?: string // url to redirect to after signed-in redirectUrl?: string // url to redirect to after signed-in
) { ) {
track.$.$.auth.signIn({ method: 'magic-link' }); track.$.$.auth.signIn({ method: 'magic-link' });
this.setClientNonce();
try { try {
const scheme = this.urlService.getClientScheme(); const scheme = this.urlService.getClientScheme();
const magicLinkUrlParams = new URLSearchParams(); const magicLinkUrlParams = new URLSearchParams();
@@ -95,6 +96,7 @@ export class AuthService extends Service {
// we call it [callbackUrl] instead of [redirect_uri] // we call it [callbackUrl] instead of [redirect_uri]
// to make it clear the url is used to finish the sign-in process instead of redirect after signed-in // to make it clear the url is used to finish the sign-in process instead of redirect after signed-in
callbackUrl: `/magic-link?${magicLinkUrlParams.toString()}`, callbackUrl: `/magic-link?${magicLinkUrlParams.toString()}`,
client_nonce: this.store.getClientNonce(),
}), }),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',

View File

@@ -74,7 +74,11 @@ export class AuthStore extends Store {
} }
async signInMagicLink(email: string, token: string) { async signInMagicLink(email: string, token: string) {
await this.authProvider.signInMagicLink(email, token); await this.authProvider.signInMagicLink(
email,
token,
this.getClientNonce()
);
} }
async signInOauth(code: string, state: string, provider: string) { async signInOauth(code: string, state: string, provider: string) {