2020-05-09 23:19:47 +02:00
|
|
|
import config from "config";
|
2021-05-03 19:29:22 +02:00
|
|
|
import {Request, Response} from "express";
|
2020-09-25 23:42:15 +02:00
|
|
|
import {ParsedUrlQueryInput} from "querystring";
|
2021-05-03 19:29:22 +02:00
|
|
|
|
|
|
|
import Application from "../../Application.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";
|
2020-09-25 23:42:15 +02:00
|
|
|
|
2020-11-11 19:08:33 +01:00
|
|
|
export default class MagicLinkController<A extends Application> extends Controller {
|
2020-09-25 23:42:15 +02:00
|
|
|
public static async sendMagicLink(
|
2020-11-03 10:29:36 +01:00
|
|
|
app: Application,
|
2020-09-25 23:42:15 +02:00
|
|
|
sessionId: string,
|
|
|
|
actionType: string,
|
|
|
|
original_url: string,
|
|
|
|
email: string,
|
|
|
|
mailTemplate: MailTemplate,
|
|
|
|
data: ParsedUrlQueryInput,
|
2020-11-14 17:24:42 +01:00
|
|
|
magicLinkData: Record<string, QueryVariable> = {},
|
2020-09-25 23:42:15 +02:00
|
|
|
): Promise<void> {
|
2021-01-21 15:58:03 +01:00
|
|
|
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);
|
2020-05-09 23:19:47 +02:00
|
|
|
|
2020-11-14 17:24:42 +01:00
|
|
|
const link = MagicLink.create(Object.assign(magicLinkData, {
|
2020-11-11 19:08:33 +01:00
|
|
|
session_id: sessionId,
|
|
|
|
action_type: actionType,
|
|
|
|
original_url: original_url,
|
2020-11-14 17:24:42 +01:00
|
|
|
}));
|
2020-05-09 23:19:47 +02:00
|
|
|
|
|
|
|
const token = await link.generateToken(email);
|
|
|
|
await link.save();
|
|
|
|
|
|
|
|
// Send email
|
2021-04-28 14:53:46 +02:00
|
|
|
await app.as(MailComponent).sendMail(new Mail(mailTemplate, Object.assign(data, {
|
2021-01-21 17:12:59 +01:00
|
|
|
link: `${config.get<string>('public_url')}${Controller.route('magic_link', undefined, {
|
2020-05-09 23:19:47 +02:00
|
|
|
id: link.id,
|
|
|
|
token: token,
|
|
|
|
})}`,
|
2021-04-28 14:53:46 +02:00
|
|
|
})), email);
|
2020-05-09 23:19:47 +02:00
|
|
|
}
|
|
|
|
|
2020-11-11 19:08:33 +01:00
|
|
|
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(
|
2021-01-24 16:29:23 +01:00
|
|
|
session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => {
|
2020-11-14 17:24:42 +01:00
|
|
|
const userNameComponent = user.asOptional(UserNameComponent);
|
2021-02-23 17:42:25 +01:00
|
|
|
const magicLinkUserNameComponent = magicLink.asOptional(MagicLinkUserNameComponent);
|
|
|
|
|
|
|
|
if (userNameComponent && magicLinkUserNameComponent?.username) {
|
|
|
|
userNameComponent.setName(magicLinkUserNameComponent.username);
|
|
|
|
}
|
|
|
|
|
2020-11-14 17:24:42 +01:00
|
|
|
return [];
|
|
|
|
}, async (connection, user) => {
|
2020-11-11 19:08:33 +01:00
|
|
|
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) {
|
2021-04-22 15:38:24 +02:00
|
|
|
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
|
|
|
|
res.redirect(Controller.route('auth'));
|
2020-11-11 19:08:33 +01:00
|
|
|
return null;
|
|
|
|
} else {
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-09 23:19:47 +02:00
|
|
|
|
|
|
|
protected readonly magicLinkWebsocketPath: string;
|
|
|
|
|
2020-11-11 19:08:33 +01:00
|
|
|
public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
|
2020-05-09 23:19:47 +02:00
|
|
|
super();
|
|
|
|
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
|
|
|
|
}
|
|
|
|
|
2020-07-25 10:28:50 +02:00
|
|
|
public getRoutesPrefix(): string {
|
2020-05-09 23:19:47 +02:00
|
|
|
return '/magic';
|
|
|
|
}
|
|
|
|
|
2020-07-25 10:28:50 +02:00
|
|
|
public routes(): void {
|
2020-05-09 23:19:47 +02:00
|
|
|
this.get('/lobby', this.getLobby, 'magic_link_lobby');
|
|
|
|
this.get('/link', this.getMagicLink, 'magic_link');
|
|
|
|
}
|
|
|
|
|
2020-07-25 10:28:50 +02:00
|
|
|
protected async getLobby(req: Request, res: Response): Promise<void> {
|
2020-11-11 19:08:33 +01:00
|
|
|
const link = await MagicLink.select()
|
|
|
|
.where('session_id', req.getSession().id)
|
|
|
|
.sortBy('authorized')
|
2021-01-21 15:58:03 +01:00
|
|
|
.where('used', 0)
|
2020-11-11 19:08:33 +01:00
|
|
|
.first();
|
2020-05-09 23:19:47 +02:00
|
|
|
if (!link) {
|
|
|
|
throw new NotFoundHttpError('magic link', req.url);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!await link.isValid()) {
|
|
|
|
req.flash('error', 'This magic link has expired. Please try again.');
|
2020-09-25 23:42:15 +02:00
|
|
|
res.redirect(link.getOrFail('original_url'));
|
2020-05-09 23:19:47 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (await link.isAuthorized()) {
|
2021-01-21 15:58:03 +01:00
|
|
|
link.use();
|
|
|
|
await link.save();
|
2020-05-09 23:19:47 +02:00
|
|
|
await this.performAction(link, req, res);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
res.render('magic_link_lobby', {
|
2020-09-25 23:42:15 +02:00
|
|
|
email: link.getOrFail('email'),
|
|
|
|
type: link.getOrFail('action_type'),
|
2020-05-09 23:19:47 +02:00
|
|
|
validUntil: link.getExpirationDate().getTime(),
|
|
|
|
websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-07-25 10:28:50 +02:00
|
|
|
protected async getMagicLink(req: Request, res: Response): Promise<void> {
|
2020-05-09 23:19:47 +02:00
|
|
|
const id = parseInt(<string>req.query.id);
|
|
|
|
const token = <string>req.query.token;
|
2020-09-25 23:42:15 +02:00
|
|
|
if (!id || !token)
|
|
|
|
throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl);
|
2020-05-09 23:19:47 +02:00
|
|
|
|
|
|
|
let success = true;
|
|
|
|
let err;
|
2020-06-27 14:36:50 +02:00
|
|
|
const magicLink = await MagicLink.getById<MagicLink>(id);
|
2020-05-09 23:19:47 +02:00
|
|
|
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();
|
2020-09-25 23:42:15 +02:00
|
|
|
this.getApp().as<MagicLinkWebSocketListener<A>>(MagicLinkWebSocketListener)
|
|
|
|
.refreshMagicLink(magicLink.getOrFail('session_id'));
|
2020-05-09 23:19:47 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
res.render('magic_link', {
|
2020-05-10 00:26:15 +02:00
|
|
|
magicLink: magicLink,
|
2020-05-09 23:19:47 +02:00
|
|
|
err: err,
|
|
|
|
success: success && err === null,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-11-11 19:08:33 +01:00
|
|
|
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
|
2021-02-23 17:42:25 +01:00
|
|
|
const name = user.asOptional(UserNameComponent)?.getName();
|
2021-01-25 12:47:10 +01:00
|
|
|
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
|
2021-01-24 22:24:04 +01:00
|
|
|
res.redirect(req.getIntendedUrl() || Controller.route('home'));
|
2020-11-11 19:08:33 +01:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
2021-01-21 15:58:03 +01:00
|
|
|
|
|
|
|
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(Controller.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(Controller.route('account'));
|
|
|
|
break;
|
|
|
|
}
|
2021-02-20 20:18:03 +01:00
|
|
|
|
|
|
|
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(Controller.route('account'));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
2021-01-21 15:58:03 +01:00
|
|
|
default:
|
2021-01-22 15:54:26 +01:00
|
|
|
logger.warn('Unknown magic link action type ' + magicLink.action_type);
|
2021-01-21 15:58:03 +01:00
|
|
|
break;
|
2020-11-11 19:08:33 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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.`);
|
|
|
|
}
|
2020-09-25 23:42:15 +02:00
|
|
|
}
|