import 'npm:iterator-polyfill' // Polyfill AsyncIterator import { FreshContext } from '$fresh/server.ts' import { db } from ':src/db/mod.ts' import { SessionHandlers, SessionStore } from ':src/session/mod.ts' import { respondApi } from ':src/utils.ts' import { Contact, type Mail, send } from '@cohabit/mailer' import { magicLinkTemplate } from '@cohabit/mailer/templates' import { User } from '@cohabit/resources-manager/models' import { sleep } from '@jotsr/delayed' type MagicLinkInfos = { remoteId: string email: string timestamp: number } export async function getUserByMail(email: string): Promise { const [user] = await db.resource.user .list((user) => user.mail === email) .take(1) .toArray() return user } export const handler: SessionHandlers = { 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}&session=${ctx.state.session.uuid}&redirect=/profil` // save token to session ctx.state.session.flash(`MAGIC_LINK__${token}`, { email, remoteId: remoteId(request, ctx), timestamp: Date.now(), }) // send mail to user try { if (user) { // Get user ip through proxy else from tcp connection const ip = request.headers.get('X-FORWARDED-FOR') ?? 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 sessionId = ctx.url.searchParams.get('session') // no token or sessionId if (token === null || sessionId === null) { return respondApi('error', 'no token or session provided', 400) } // set session if 3rd party cookies was blocked ctx.state.session = ctx.state.session ?? SessionStore.getSession(sessionId) // no session available if (ctx.state.session === null) { return respondApi('error', 'no session datas', 401) } // wrong or timeout token const entry = ctx.state.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(request, ctx)) { const user = await getUserByMail(entry.email) ctx.state.session.set('user', user) if (redirect) { return Response.redirect(new URL(redirect, ctx.url.origin)) } 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( { headers }: { headers: Headers }, { remoteAddr }: { remoteAddr: FreshContext['remoteAddr'] }, ): string { const forwardedAddress = headers.get('X-FORWARDED-FOR') const forwardedProto = headers.get('X-FORWARDED-PROTO') if (forwardedAddress && forwardedProto) { return `${forwardedProto}://${forwardedAddress}` } 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) }