import config from "config"; import {Request, Response} from "express"; import Application from "../../Application.js"; import {QueryParamsRecord, route} from "../../common/Routing.js"; import MailComponent from "../../components/MailComponent.js"; import Controller from "../../Controller.js"; import {QueryVariable} from "../../db/MysqlConnectionManager.js"; import {BadRequestError, NotFoundHttpError} from "../../HttpError.js"; import {logger} from "../../Logger.js"; import Mail from "../../mail/Mail.js"; import MailTemplate from "../../mail/MailTemplate.js"; import Throttler from "../../Throttler.js"; import AuthComponent, {AuthMiddleware} from "../AuthComponent.js"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js"; import MagicLink from "../models/MagicLink.js"; import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js"; import User from "../models/User.js"; import UserEmail from "../models/UserEmail.js"; import UserNameComponent from "../models/UserNameComponent.js"; import UserPasswordComponent from "../password/UserPasswordComponent.js"; import AuthMagicLinkActionType from "./AuthMagicLinkActionType.js"; import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener.js"; export default class MagicLinkController extends Controller { public static async sendMagicLink( app: Application, sessionId: string, actionType: string, original_url: string, email: string, mailTemplate: MailTemplate, data: QueryParamsRecord, magicLinkData: Record = {}, ): Promise { Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 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 app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, { link: `${route('magic_link', undefined, { id: link.id, token: token, }, true)}`, })), 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, !!session.wantsSessionPersistence, undefined, async (connection, user) => { const userNameComponent = user.asOptional(UserNameComponent); const magicLinkUserNameComponent = magicLink.asOptional(MagicLinkUserNameComponent); if (userNameComponent && magicLinkUserNameComponent?.username) { userNameComponent.setName(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) { req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`); res.redirect(route('auth')); 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') .where('used', 0) .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()) { link.use(); await link.save(); 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 const name = user.asOptional(UserNameComponent)?.getName(); req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`); res.redirect(req.getIntendedUrl() || route('home')); } break; } case AuthMagicLinkActionType.ADD_EMAIL: { const session = req.getSessionOptional(); if (!session || magicLink.session_id !== session.id) throw new BadOwnerMagicLink(); await magicLink.delete(); const authGuard = this.getApp().as(AuthComponent).getAuthGuard(); const proofs = await authGuard.getProofsForSession(session); const user = await proofs[0]?.getResource(); if (!user) return; const email = await magicLink.getOrFail('email'); if (await UserEmail.select().with('user').where('email', email).first()) { req.flash('error', 'An account already exists with this email address.' + ' Please first remove it there before adding it here.'); res.redirect(route('account')); return; } const userEmail = UserEmail.create({ user_id: user.id, email: email, main: false, }); await userEmail.save(); if (!user.main_email_id) { user.main_email_id = userEmail.id; await user.save(); } req.flash('success', `Email address ${userEmail.email} successfully added.`); res.redirect(route('account')); break; } case AuthMagicLinkActionType.REMOVE_PASSWORD: { const session = req.getSessionOptional(); if (!session || magicLink.session_id !== session.id) throw new BadOwnerMagicLink(); await magicLink.delete(); const authGuard = this.getApp().as(AuthComponent).getAuthGuard(); const proofs = await authGuard.getProofsForSession(session); const user = await proofs[0]?.getResource(); if (!user) return; const passwordComponent = user.asOptional(UserPasswordComponent); if (passwordComponent) { passwordComponent.removePassword(); await user.save(); } req.flash('success', `Password successfully removed.`); res.redirect(route('account')); break; } default: logger.warn('Unknown magic link action type ' + magicLink.action_type); 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.`); } }