feat(api): ✨ implement magic link api hook
This commit is contained in:
parent
b01bbfdb5b
commit
8d316ae52e
145
routes/api/magiclink/index.ts
Normal file
145
routes/api/magiclink/index.ts
Normal file
|
@ -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<User | undefined> {
|
||||
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<MagicLinkInfos>(`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<MagicLinkInfos>(`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<void> {
|
||||
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)
|
||||
}
|
Loading…
Reference in a new issue