import Controller from "../../Controller"; import {Request, Response} from "express"; import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener"; import {BadRequestError, NotFoundHttpError} from "../../HttpError"; import Throttler from "../../Throttler"; import Mail, {MailTemplate} from "../../mail/Mail"; import MagicLink from "../models/MagicLink"; import config from "config"; import Application from "../../Application"; import {ParsedUrlQueryInput} from "querystring"; import NunjucksComponent from "../../components/NunjucksComponent"; import User from "../models/User"; import AuthComponent, {AuthMiddleware} from "../AuthComponent"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard"; import UserEmail from "../models/UserEmail"; import AuthMagicLinkActionType from "./AuthMagicLinkActionType"; import {QueryVariable} from "../../db/MysqlConnectionManager"; import UserNameComponent from "../models/UserNameComponent"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent"; export default class MagicLinkController extends Controller { public static async sendMagicLink( app: Application, sessionId: string, actionType: string, original_url: string, email: string, mailTemplate: MailTemplate, data: ParsedUrlQueryInput, magicLinkData: Record = {}, ): Promise { Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0); Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0); const link = MagicLink.create(Object.assign(magicLinkData, { session_id: sessionId, action_type: actionType, original_url: original_url, })); const token = await link.generateToken(email); await link.save(); // Send email await new Mail(app.as(NunjucksComponent).getEnvironment(), mailTemplate, Object.assign(data, { link: `${config.get('public_url')}${Controller.route('magic_link', undefined, { id: link.id, token: token, })}`, })).send(email); } public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise { const session = req.getSession(); if (magicLink.getOrFail('session_id') !== session.id) throw new BadOwnerMagicLink(); if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink(); if (!await magicLink.isValid()) throw new InvalidMagicLink(); // Auth try { return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister( session, magicLink, undefined, async (connection, user) => { const userNameComponent = user.asOptional(UserNameComponent); if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username; return []; }, async (connection, user) => { const callbacks: RegisterCallback[] = []; const userEmail = UserEmail.create({ user_id: user.id, email: magicLink.getOrFail('email'), }); await userEmail.save(connection, c => callbacks.push(c)); user.main_email_id = userEmail.id; await user.save(connection, c => callbacks.push(c)); return callbacks; }); } catch (e) { if (e instanceof PendingApprovalAuthError) { res.format({ json: () => { res.json({ 'status': 'warning', 'message': `Your account is pending review. You'll receive an email once you're approved.`, }); }, html: () => { req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`); res.redirect('/'); }, }); return null; } else { throw e; } } } protected readonly magicLinkWebsocketPath: string; public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener) { super(); this.magicLinkWebsocketPath = magicLinkWebsocketListener.path(); } public getRoutesPrefix(): string { return '/magic'; } public routes(): void { this.get('/lobby', this.getLobby, 'magic_link_lobby'); this.get('/link', this.getMagicLink, 'magic_link'); } protected async getLobby(req: Request, res: Response): Promise { const link = await MagicLink.select() .where('session_id', req.getSession().id) .sortBy('authorized') .first(); if (!link) { throw new NotFoundHttpError('magic link', req.url); } if (!await link.isValid()) { req.flash('error', 'This magic link has expired. Please try again.'); res.redirect(link.getOrFail('original_url')); return; } if (await link.isAuthorized()) { await this.performAction(link, req, res); return; } res.render('magic_link_lobby', { email: link.getOrFail('email'), type: link.getOrFail('action_type'), validUntil: link.getExpirationDate().getTime(), websocketUrl: config.get('public_websocket_url') + this.magicLinkWebsocketPath, }); } protected async getMagicLink(req: Request, res: Response): Promise { const id = parseInt(req.query.id); const token = req.query.token; if (!id || !token) throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl); let success = true; let err; const magicLink = await MagicLink.getById(id); if (!magicLink) { res.status(404); err = `Couldn't find this magic link. Perhaps it has already expired.`; success = false; } else if (!await magicLink.isAuthorized()) { err = await magicLink.verifyToken(token); if (err === null) { // Validation success, authenticate the user magicLink.authorize(); await magicLink.save(); this.getApp().as>(MagicLinkWebSocketListener) .refreshMagicLink(magicLink.getOrFail('session_id')); } } res.render('magic_link', { magicLink: magicLink, err: err, success: success && err === null, }); } protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise { switch (magicLink.getOrFail('action_type')) { case AuthMagicLinkActionType.LOGIN: case AuthMagicLinkActionType.REGISTER: { await MagicLinkController.checkAndAuth(req, res, magicLink); const authGuard = this.getApp().as(AuthComponent).getAuthGuard(); const proofs = await authGuard.getProofsForSession(req.getSession()); const user = await proofs[0]?.getResource(); if (!res.headersSent && user) { // Auth success req.flash('success', `Authentication success. Welcome, ${user.name}!`); res.redirect(req.query.redirect_uri?.toString() || Controller.route('home')); } break; } } } } export class BadOwnerMagicLink extends AuthError { public constructor() { super(`This magic link doesn't belong to this session.`); } } export class UnauthorizedMagicLink extends AuthError { public constructor() { super(`This magic link is unauthorized.`); } } export class InvalidMagicLink extends AuthError { public constructor() { super(`This magic link is invalid.`); } }