From 8d316ae52ed1b30494abbd4b70b0da257c3c7f93 Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Wed, 19 Jun 2024 10:37:10 +0200 Subject: [PATCH] feat(api): :sparkles: implement magic link api hook --- routes/api/magiclink/index.ts | 145 ++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 routes/api/magiclink/index.ts diff --git a/routes/api/magiclink/index.ts b/routes/api/magiclink/index.ts new file mode 100644 index 0000000..831e46e --- /dev/null +++ b/routes/api/magiclink/index.ts @@ -0,0 +1,145 @@ +import 'npm:iterator-polyfill' +// Polyfill AsyncIterator + +import { FreshContext, Handlers } from '$fresh/server.ts' +import { Contact, type Mail, send } from '@cohabit/cohamail/mod.ts' +import { magicLinkTemplate } from '@cohabit/cohamail/templates/mod.ts' +import { SessionStore } from '../../../src/session/mod.ts' +import { respondApi } from '../../../src/utils.ts' +import { sleep } from '@jotsr/delayed' +import { User } from '@cohabit/ressources_manager/src/models/mod.ts' +import { db } from '../../../src/db/mod.ts' + +type MagicLinkInfos = { + remoteId: string + email: string + timestamp: number +} + +export async function getUserByMail(email: string): Promise { + const [user] = await db.ressource.user + .list((user) => user.mail === email) + .take(1) + .toArray() + + return user +} + +export const handler: Handlers = { + async POST(request, ctx) { + const { email } = await request.json() as { email: string } + + // check email before continue + if (!/\S+@\S+\.\S+/.test(email)) { + return respondApi('error', new SyntaxError('empty or invalid email'), 400) + } + + const user = await getUserByMail(email) + + // generate magic link + const token = crypto.randomUUID() + const endpoint = + `${ctx.url.origin}/api/magiclink?token=${token}&redirect=/profil` + + // save token to session + const session = SessionStore.getFromRequest(request) + session?.flash(`MAGIC_LINK__${token}`, { + email, + remoteId: remoteId(ctx), + timestamp: Date.now(), + }) + + // send mail to user + try { + if (user) { + const ip = ctx.remoteAddr.hostname + const device = request.headers.get('Sec-Ch-Ua-Platform') ?? undefined + + await sendMagicLink(user, { device, ip, endpoint }) + } else { + //! perform wait to prevent time attacks + await sleep(Math.random() * 5_000 + 2_000) //between 2s and 7s + } + return respondApi('success') + } catch (error) { + console.error('MAGIC_LINK_SENDING', error) + return respondApi( + 'error', + new Error(`unable to send mail to ${email}`), + 500, + ) + } + }, + async GET(request, ctx) { + const token = ctx.url.searchParams.get('token') + const redirect = ctx.url.searchParams.get('redirect') + const session = SessionStore.getFromRequest(request) + + // no session datas + if (session === undefined) { + return respondApi('error', 'no session datas', 401) + } + + // no token + if (token === null) { + return respondApi('error', 'no token provided', 400) + } + // wrong or timeout token + const entry = session.get(`MAGIC_LINK__${token}`) + + const lifespan = Date.now() - 10 * 60 * 1_000 // ten minutes + + if (entry === undefined || entry.timestamp < lifespan) { + return respondApi('error', 'wrong token or timeout exceeded', 401) + } + + // check remote id (same user/machine that has query the token) + if (entry.remoteId === remoteId(ctx)) { + const user = await getUserByMail(entry.email) + session.set('user', user) + + if (redirect) { + return Response.redirect(new URL(redirect, ctx.basePath)) + } + + return respondApi('success', user) + } + + return respondApi( + 'error', + new Error( + 'invalid id, use the same device/ip to query token and verify token', + ), + 401, + ) + }, +} + +function remoteId( + { remoteAddr }: { remoteAddr: FreshContext['remoteAddr'] }, +): string { + return `(${remoteAddr.transport}):${remoteAddr.hostname}:${remoteAddr.port}` +} + +async function sendMagicLink( + { firstname, lastname, mail }: User, + { device, ip, endpoint }: { device?: string; ip?: string; endpoint: string }, +): Promise { + const message: Mail = { + from: Contact.expand('contact'), + to: [Contact.fromString(`${firstname} ${lastname} <${mail}>`)], + subject: 'Lien de connection pour FabLab Coh@bit', + body: magicLinkTemplate.builder({ + device, + ip, + endpoint, + })!, + options: { + cc: [], + cci: [], + attachments: [], + }, + } + + await send(message) +}