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 { 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.');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user