fix(server): use post request to consume magic link token (#6656)

This commit is contained in:
forehalo
2024-04-22 07:15:25 +00:00
parent f288e3ee25
commit e8bcb75602
6 changed files with 76 additions and 24 deletions

View File

@@ -31,6 +31,11 @@ class SignInCredential {
password?: string;
}
class MagicLinkCredential {
email!: string;
token!: string;
}
@Throttle('strict')
@Controller('/api/auth')
export class AuthController {
@@ -90,7 +95,7 @@ export class AuthController {
) {
const token = await this.token.createToken(TokenType.SignIn, email);
const magicLink = this.url.link('/api/auth/magic-link', {
const magicLink = this.url.link('/magic-link', {
token,
email,
redirect_uri: redirectUri,
@@ -129,20 +134,16 @@ export class AuthController {
}
@Public()
@Get('/magic-link')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Query('token') token?: string,
@Query('email') email?: string,
@Query('redirect_uri') redirectUri = this.url.home
@Body() { email, token }: MagicLinkCredential
) {
if (!token || !email) {
throw new BadRequestException('Invalid Sign-in mail Token');
throw new BadRequestException('Missing sign-in mail token');
}
email = decodeURIComponent(email);
token = decodeURIComponent(token);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
@@ -150,7 +151,7 @@ export class AuthController {
});
if (!valid) {
throw new BadRequestException('Invalid Sign-in mail Token');
throw new BadRequestException('Invalid sign-in mail token');
}
const user = await this.user.fulfillUser(email, {
@@ -160,7 +161,7 @@ export class AuthController {
await this.auth.setCookie(req, res, user);
return this.url.safeRedirect(res, redirectUri);
res.send({ id: user.id, email: user.email, name: user.name });
}
@Throttle('default', { limit: 1200 })

View File

@@ -70,14 +70,17 @@ export class TokenService {
!expired && (!record.credential || record.credential === credential);
if ((expired || valid) && !keep) {
await this.db.verificationToken.delete({
const deleted = await this.db.verificationToken.deleteMany({
where: {
type_token: {
token,
type,
},
},
});
// already deleted, means token has been used
if (!deleted.count) {
return null;
}
}
return valid ? record : null;

View File

@@ -26,7 +26,7 @@ export function getRequestResponseFromHost(host: ArgumentsHost) {
const ws = host.switchToWs();
const req = ws.getClient<Socket>().client.conn.request as Request;
const cookieStr = req?.headers?.cookie;
const cookieStr = req?.headers?.cookie ?? '';
// patch cookies to match auth guard logic
if (typeof cookieStr === 'string') {
req.cookies = cookieStr.split(';').reduce(

View File

@@ -69,13 +69,15 @@ test('should be able to sign in with email', async t => {
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
let [signInLink] = mailer.sendSignInMail.firstCall.args;
const [signInLink] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
signInLink = url.pathname + url.search;
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
const signInRes = await request(app.getHttpServer())
.get(signInLink)
.expect(302);
.post('/api/auth/magic-link')
.send({ email, token })
.expect(201);
const session = await getSession(app, signInRes);
t.is(session.user!.id, u1.id);
@@ -95,13 +97,15 @@ test('should be able to sign up with email', async t => {
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
let [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
signUpLink = url.pathname + url.search;
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
const signInRes = await request(app.getHttpServer())
.get(signUpLink)
.expect(302);
.post('/api/auth/magic-link')
.send({ email, token })
.expect(201);
const session = await getSession(app, signInRes);
t.is(session.user!.email, 'u2@affine.pro');

View File

@@ -0,0 +1,40 @@
import { type LoaderFunction, redirect } from 'react-router-dom';
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const email = queries.get('email');
const token = queries.get('token');
const redirectUri = queries.get('redirect_uri');
if (!email || !token) {
return redirect('/404');
}
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, token }),
});
if (!res.ok) {
let error: string;
try {
const { message } = await res.json();
error = message;
} catch (e) {
error = 'failed to verify sign-in token';
}
return redirect(`/signIn?error=${encodeURIComponent(error)}`);
}
location.href = redirectUri || '/';
return null;
};
export const Component = () => {
// TODO: loading ui
return null;
};

View File

@@ -76,6 +76,10 @@ export const topLevelRoutes = [
path: '/signIn',
lazy: () => import('./pages/sign-in'),
},
{
path: '/magic-link',
lazy: () => import('./pages/magic-link'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),