Skip to content

Commit

Permalink
feat(core): support one time password (#9798)
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Jan 22, 2025
1 parent bf797c7 commit 5828eb5
Show file tree
Hide file tree
Showing 16 changed files with 362 additions and 131 deletions.
134 changes: 124 additions & 10 deletions packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.md
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ Generated by [AVA](https://avajs.dev).
<p␊
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Sign in to AFFiNE␊
Sign in to AFFiNE Cloud
</p>␊
</td>␊
</tr>␊
Expand Down Expand Up @@ -962,8 +962,44 @@ Generated by [AVA](https://avajs.dev).
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Click the button below to securely sign in. The magic link will␊
expire in 30 minutes.␊
You are signing in to AFFiNE. Here is your code:␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<pre␊
style="font-size:15px;font-weight:400;line-height:24px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#141414;white-space:nowrap;border:1px solid rgba(0,0,0,.1);padding:8px 10px;border-radius:4px;background-color:#F5F5F5"␊
>␊
123456</pre␊
>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Alternatively, you can sign in directly by clicking the magic␊
link below:␊
</p>␊
</tr>␊
</tbody>␊
Expand All @@ -979,6 +1015,7 @@ Generated by [AVA](https://avajs.dev).
<tbody style="width:100%">␊
<tr style="width:100%">␊
<a␊
href="https://app.affine.pro/magic-link?token=123456&amp;[email protected]"␊
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
target="_blank"␊
><span␊
Expand All @@ -989,7 +1026,7 @@ Generated by [AVA](https://avajs.dev).
[endif]--></span␊
><span␊
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
>Sign in to AFFiNE</span␊
>Sign in with Magic Link</span␊
><span␊
><!--[if mso␊
]><i style="mso-font-width:450%" hidden␊
Expand All @@ -1001,6 +1038,27 @@ Generated by [AVA](https://avajs.dev).
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
<span␊
style="font-size:14px;font-weight:400;line-height:22px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#7A7A7A"␊
>This code and link will expire in 30 minutes.</span␊
>␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
</td>␊
</tr>␊
</tbody>␊
Expand All @@ -1026,7 +1084,7 @@ Generated by [AVA](https://avajs.dev).
<p␊
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Create AFFiNE Account
Sign up to AFFiNE Cloud
</p>␊
</td>␊
</tr>␊
Expand Down Expand Up @@ -1056,9 +1114,44 @@ Generated by [AVA](https://avajs.dev).
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Click the button below to complete your account creation and␊
sign in. This magic link will expire in␊
<span style="font-weight:600">30 minutes</span>.␊
You are signing up to AFFiNE. Here is your code:␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<pre␊
style="font-size:15px;font-weight:400;line-height:24px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#141414;white-space:nowrap;border:1px solid rgba(0,0,0,.1);padding:8px 10px;border-radius:4px;background-color:#F5F5F5"␊
>␊
123456</pre␊
>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Alternatively, you can sign up directly by clicking the magic␊
link below:␊
</p>␊
</tr>␊
</tbody>␊
Expand All @@ -1074,7 +1167,7 @@ Generated by [AVA](https://avajs.dev).
<tbody style="width:100%">␊
<tr style="width:100%">␊
<a␊
href="https://app.affine.pro"␊
href="https://app.affine.pro/magic-link?token=123456&amp;[email protected]"␊
style="line-height:24px;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;font-size:15px;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#FFFFFF;background-color:#1E96EB;padding:8px 18px 8px 18px;border-radius:8px;border:1px solid rgba(0,0,0,.1);margin-right:4px"␊
target="_blank"␊
><span␊
Expand All @@ -1085,7 +1178,7 @@ Generated by [AVA](https://avajs.dev).
[endif]--></span␊
><span␊
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px"␊
>Create account and sign in</span␊
>Sign up with Magic Link</span␊
><span␊
><!--[if mso␊
]><i style="mso-font-width:450%" hidden␊
Expand All @@ -1097,6 +1190,27 @@ Generated by [AVA](https://avajs.dev).
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
<span␊
style="font-size:14px;font-weight:400;line-height:22px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#7A7A7A"␊
>This code and link will expire in 30 minutes.</span␊
>␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
</td>␊
</tr>␊
</tbody>␊
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ test('should be able to handle unknown internal error in graphql query', async t
t.is(err.message, 'An internal error occurred.');
t.is(err.extensions.status, HttpStatus.INTERNAL_SERVER_ERROR);
t.is(err.extensions.name, 'INTERNAL_SERVER_ERROR');
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
});

test('should be able to respond request', async t => {
Expand All @@ -166,7 +166,7 @@ test('should be able to handle unknown internal error in http request', async t
.expect(HttpStatus.INTERNAL_SERVER_ERROR);
t.is(res.body.message, 'An internal error occurred.');
t.is(res.body.name, 'INTERNAL_SERVER_ERROR');
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
});

// Hard to test through websocket, will call event handler directly
Expand Down Expand Up @@ -196,5 +196,5 @@ test('should be able to handle unknown internal error in websocket event', async
};
t.is(error.message, 'An internal error occurred.');
t.is(error.name, 'INTERNAL_SERVER_ERROR');
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
t.true(t.context.logger.error.calledOnceWith('internal_server_error'));
});
8 changes: 6 additions & 2 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ export class UserFriendlyError extends Error {
return;
}

new Logger(context).error(
'Internal server error',
const logger = new Logger(context);
const fn = this.status >= 500 ? logger.error : logger.log;

fn.call(
logger,
this.name,
this.cause ? ((this.cause as any).stack ?? this.cause) : this.stack
);
}
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/server/src/base/helpers/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createSign,
createVerify,
randomBytes,
randomInt,
timingSafeEqual,
} from 'node:crypto';

Expand Down Expand Up @@ -109,6 +110,20 @@ export class CryptoHelper {
return randomBytes(length);
}

randomInt(min: number, max: number) {
return randomInt(min, max);
}

otp(length = 6) {
let otp = '';

for (let i = 0; i < length; i++) {
otp += this.randomInt(0, 9).toString();
}

return otp;
}

sha256(data: string) {
return createHash('sha256').update(data).digest();
}
Expand Down
35 changes: 30 additions & 5 deletions packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
import type { Request, Response } from 'express';

import {
Cache,
Config,
CryptoHelper,
EarlyAccessRequired,
EmailTokenNotFound,
InternalServerError,
Expand Down Expand Up @@ -49,6 +51,8 @@ interface MagicLinkCredential {
token: string;
}

const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;

@Throttle('strict')
@Controller('/api/auth')
export class AuthController {
Expand All @@ -57,7 +61,9 @@ export class AuthController {
private readonly auth: AuthService,
private readonly models: Models,
private readonly config: Config,
private readonly runtime: Runtime
private readonly runtime: Runtime,
private readonly cache: Cache,
private readonly crypto: CryptoHelper
) {
if (config.node.dev) {
// set DNS servers in dev mode
Expand Down Expand Up @@ -190,13 +196,20 @@ export class AuthController {
}
}

const ttlInSec = 30 * 60;
const token = await this.models.verificationToken.create(
TokenType.SignIn,
email
email,
ttlInSec
);

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

const magicLink = this.url.link(callbackUrl, {
token,
token: otp,
email,
...(redirectUrl
? {
Expand All @@ -205,7 +218,12 @@ export class AuthController {
: {}),
});

const result = await this.auth.sendSignInEmail(email, magicLink, !user);
const result = await this.auth.sendSignInEmail(
email,
magicLink,
otp,
!user
);

if (result.rejected.length) {
throw new InternalServerError('Failed to send sign-in email.');
Expand Down Expand Up @@ -247,9 +265,16 @@ export class AuthController {

validators.assertValidEmail(email);

const cacheKey = OTP_CACHE_KEY(token);
const cachedToken = await this.cache.get<string>(cacheKey);

if (!cachedToken) {
throw new InvalidEmailToken();
}

const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn,
token,
cachedToken,
{
credential: email,
}
Expand Down
11 changes: 8 additions & 3 deletions packages/backend/server/src/core/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,14 @@ export class AuthService implements OnApplicationBootstrap {
});
}

async sendSignInEmail(email: string, link: string, signUp: boolean) {
async sendSignInEmail(
email: string,
link: string,
otp: string,
signUp: boolean
) {
return signUp
? await this.mailer.sendSignUpMail(email, { url: link })
: await this.mailer.sendSignInMail(email, { url: link });
? await this.mailer.sendSignUpMail(email, { url: link, otp })
: await this.mailer.sendSignInMail(email, { url: link, otp });
}
}
Loading

0 comments on commit 5828eb5

Please sign in to comment.