swaf/src/auth/magic_link/MagicLinkController.ts

281 lines
11 KiB
TypeScript

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<A extends Application> extends Controller {
public static async sendMagicLink(
app: Application,
sessionId: string,
actionType: string,
original_url: string,
email: string,
mailTemplate: MailTemplate,
data: QueryParamsRecord,
magicLinkData: Record<string, QueryVariable> = {},
): Promise<void> {
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<User | null> {
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<A>) {
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<void> {
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<string>('public_websocket_url') + this.magicLinkWebsocketPath,
});
}
protected async getMagicLink(req: Request, res: Response): Promise<void> {
const id = parseInt(<string>req.query.id);
const token = <string>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<MagicLink>(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<A>>(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<void> {
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.`);
}
}