Auth: refactor to support multiple auth factors and add password factor

This commit is contained in:
Alice Gaudon 2020-11-11 19:08:33 +01:00
parent 1fce157104
commit efdd81b650
23 changed files with 735 additions and 285 deletions

View File

@ -15,14 +15,22 @@ import {Express} from "express";
import MagicLinkAuthMethod from "../src/auth/magic_link/MagicLinkAuthMethod"; import MagicLinkAuthMethod from "../src/auth/magic_link/MagicLinkAuthMethod";
import PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod"; import PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod";
import {MAGIC_LINK_MAIL} from "./Mails"; import {MAGIC_LINK_MAIL} from "./Mails";
import packageJson = require('../package.json');
import CreateMigrationsTable from "./migrations/CreateMigrationsTable"; import CreateMigrationsTable from "./migrations/CreateMigrationsTable";
import CreateUsersAndUserEmailsTable from "./auth/migrations/CreateUsersAndUserEmailsTable"; import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration";
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration"; import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration";
import AuthController from "./auth/AuthController";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener";
import MagicLinkController from "./auth/magic_link/MagicLinkController";
import AddPasswordToUsersMigration from "./auth/password/AddPasswordToUsersMigration";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration";
import packageJson = require('../package.json');
import CsrfProtectionComponent from "./components/CsrfProtectionComponent";
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
CreateUsersAndUserEmailsTable, CreateUsersAndUserEmailsTableMigration,
AddNameToUsersMigration,
AddPasswordToUsersMigration,
CreateMagicLinksTableMigration, CreateMagicLinksTableMigration,
]; ];
@ -30,6 +38,7 @@ export default class TestApp extends Application {
private readonly addr: string; private readonly addr: string;
private readonly port: number; private readonly port: number;
private expressAppComponent?: ExpressAppComponent; private expressAppComponent?: ExpressAppComponent;
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
public constructor(addr: string, port: number) { public constructor(addr: string, port: number) {
super(packageJson.version, true); super(packageJson.version, true);
@ -43,8 +52,8 @@ export default class TestApp extends Application {
protected async init(): Promise<void> { protected async init(): Promise<void> {
this.registerComponents(); this.registerComponents();
this.registerWebSocketListeners?.(); this.registerWebSocketListeners();
this.registerControllers?.(); this.registerControllers();
} }
protected registerComponents(): void { protected registerComponents(): void {
@ -71,16 +80,27 @@ export default class TestApp extends Application {
this.use(redisComponent); this.use(redisComponent);
this.use(new SessionComponent(redisComponent)); this.use(new SessionComponent(redisComponent));
// Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
// Utils // Utils
this.use(new FormHelperComponent()); this.use(new FormHelperComponent());
// Middlewares
this.use(new CsrfProtectionComponent());
// Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
} }
protected registerWebSocketListeners?(): void; protected registerWebSocketListeners(): void {
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
this.use(this.magicLinkWebSocketListener);
}
protected registerControllers?(): void; protected registerControllers(): void {
this.use(new AuthController());
if (!this.magicLinkWebSocketListener) throw new Error('Magic link websocket listener not initialized.');
this.use(new MagicLinkController(this.magicLinkWebSocketListener));
}
public getExpressApp(): Express { public getExpressApp(): Express {
return this.as(ExpressAppComponent).getExpressApp(); return this.as(ExpressAppComponent).getExpressApp();

View File

@ -5,35 +5,37 @@ import Controller from "../Controller";
import {ForbiddenHttpError} from "../HttpError"; import {ForbiddenHttpError} from "../HttpError";
import Middleware from "../Middleware"; import Middleware from "../Middleware";
import User from "./models/User"; import User from "./models/User";
import Application from "../Application";
import AuthMethod from "./AuthMethod";
import AuthProof from "./AuthProof"; import AuthProof from "./AuthProof";
export default class AuthComponent extends ApplicationComponent { export default class AuthComponent extends ApplicationComponent {
private readonly authGuard: AuthGuard<AuthProof<User>>; private readonly authGuard: AuthGuard;
public constructor(authGuard: AuthGuard<AuthProof<User>>) { public constructor(app: Application, ...authMethods: AuthMethod<AuthProof<User>>[]) {
super(); super();
this.authGuard = authGuard; this.authGuard = new AuthGuard(app, ...authMethods);
} }
public async init(): Promise<void> { public async init(): Promise<void> {
this.use(AuthMiddleware); this.use(AuthMiddleware);
} }
public getAuthGuard(): AuthGuard<AuthProof<User>> { public getAuthGuard(): AuthGuard {
return this.authGuard; return this.authGuard;
} }
} }
export class AuthMiddleware extends Middleware { export class AuthMiddleware extends Middleware {
private authGuard?: AuthGuard<AuthProof<User>>; private authGuard?: AuthGuard;
private user: User | null = null; private user: User | null = null;
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
this.authGuard = this.app.as(AuthComponent).getAuthGuard(); this.authGuard = this.app.as(AuthComponent).getAuthGuard();
const proof = await this.authGuard.isAuthenticated(req.getSession()); const proofs = await this.authGuard.getProofsForSession(req.getSession());
if (proof) { if (proofs.length > 0) {
this.user = await proof.getResource(); this.user = await proofs[0].getResource();
res.locals.user = this.user; res.locals.user = this.user;
} }
@ -44,7 +46,7 @@ export class AuthMiddleware extends Middleware {
return this.user; return this.user;
} }
public getAuthGuard(): AuthGuard<AuthProof<User>> { public getAuthGuard(): AuthGuard {
if (!this.authGuard) throw new Error('AuthGuard was not initialized.'); if (!this.authGuard) throw new Error('AuthGuard was not initialized.');
return this.authGuard; return this.authGuard;
} }
@ -54,8 +56,8 @@ export class RequireRequestAuthMiddleware extends Middleware {
private user?: User; private user?: User;
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const proof = await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req); const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req);
const user = await proof?.getResource(); const user = await proofs[0]?.getResource();
if (user) { if (user) {
this.user = user; this.user = user;
next(); next();
@ -81,8 +83,8 @@ export class RequireAuthMiddleware extends Middleware {
const authGuard = req.as(AuthMiddleware).getAuthGuard(); const authGuard = req.as(AuthMiddleware).getAuthGuard();
// Via request // Via request
let proof = await authGuard.isAuthenticatedViaRequest(req); let proofs = await authGuard.getProofsForRequest(req);
let user = await proof?.getResource(); let user = await proofs[0]?.getResource();
if (user) { if (user) {
this.user = user; this.user = user;
next(); next();
@ -90,8 +92,8 @@ export class RequireAuthMiddleware extends Middleware {
} }
// Via session // Via session
proof = await authGuard.isAuthenticated(req.getSession()); proofs = await authGuard.getProofsForSession(req.getSession());
user = await proof?.getResource(); user = await proofs[0]?.getResource();
if (user) { if (user) {
this.user = user; this.user = user;
next(); next();
@ -112,7 +114,8 @@ export class RequireAuthMiddleware extends Middleware {
export class RequireGuestMiddleware extends Middleware { export class RequireGuestMiddleware extends Middleware {
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> { protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
if (await req.as(AuthMiddleware).getAuthGuard().isAuthenticated(req.getSession())) { const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession());
if (proofs.length > 0) {
res.redirectBack(); res.redirectBack();
return; return;
} }

View File

@ -1,35 +1,100 @@
import Controller from "../Controller"; import Controller from "../Controller";
import {NextFunction, Request, Response} from "express"; import {NextFunction, Request, Response} from "express";
import {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent"; import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
import {BadRequestError} from "../HttpError";
export default abstract class AuthController extends Controller { export default class AuthController extends Controller {
public getRoutesPrefix(): string { public getRoutesPrefix(): string {
return '/auth'; return '/auth';
} }
public routes(): void { public routes(): void {
this.use(async (req, res, next) => {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
if (await authGuard.interruptAuth(req, res)) return;
next();
});
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware); this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
this.post('/', this.postAuth, 'auth', RequireGuestMiddleware); this.post('/login', this.postLogin, 'login', RequireGuestMiddleware);
this.get('/check', this.getCheckAuth, 'check_auth'); this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware); this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
} }
protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> { protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const registerEmail = req.flash('register_confirm_email'); const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
res.render('auth/auth', { res.render('auth/auth', {
register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null, auth_methods: authGuard.getAuthMethodNames(),
}); });
} }
protected abstract async postAuth(req: Request, res: Response, next: NextFunction): Promise<void>; protected async postLogin(req: Request, res: Response): Promise<void> {
return await this.handleAuth(req, res, false);
}
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>; protected async postRegister(req: Request, res: Response): Promise<void> {
return await this.handleAuth(req, res, true);
}
protected async handleAuth(req: Request, res: Response, isRegistration: boolean): Promise<void> {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const identifier = req.body.identifier;
if (!identifier) throw new BadRequestError('Identifier not specified.', 'Please try again.', req.originalUrl);
// Get requested auth method
if (req.body.auth_method) {
const method = await authGuard.getAuthMethodByName(req.body.auth_method);
if (!method) {
throw new BadRequestError('Invalid auth method: ' + req.body.auth_method,
'Available methods are: ' + authGuard.getAuthMethodNames(), req.url);
}
const user = await method.findUserByIdentifier(identifier);
if (!user) { // Register
return isRegistration ?
await method.attemptRegister(req, res, identifier) :
await this.redirectToRegistration(req, res, identifier);
}
// Login
return await method.attemptLogin(req, res, user);
}
const methods = await authGuard.getAuthMethodsByIdentifier(identifier);
if (methods.length === 0) { // Register
return isRegistration ?
await authGuard.getRegistrationMethod().attemptRegister(req, res, identifier) :
await this.redirectToRegistration(req, res, identifier);
}
const {user, method} = methods[0];
return await method.attemptLogin(req, res, user);
}
protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> { protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> {
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req); const userId = typeof req.body.user_id === 'string' ? parseInt(req.body.user_id) : null;
await proof?.revoke();
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofs(req);
for (const proof of proofs) {
if (userId === null || (await proof.getResource())?.id === userId) {
await proof.revoke();
}
}
req.flash('success', 'Successfully logged out.'); req.flash('success', 'Successfully logged out.');
res.redirect(req.query.redirect_uri?.toString() || '/'); res.redirect(req.query.redirect_uri?.toString() || '/');
} }
protected async redirectToRegistration(req: Request, res: Response, identifier: string): Promise<void> {
req.flash('register_identifier', identifier);
req.flash('info', `User with identifier "${identifier}" not found.`);
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
}
} }

View File

@ -2,62 +2,110 @@ import AuthProof from "./AuthProof";
import MysqlConnectionManager from "../db/MysqlConnectionManager"; import MysqlConnectionManager from "../db/MysqlConnectionManager";
import User from "./models/User"; import User from "./models/User";
import {Connection} from "mysql"; import {Connection} from "mysql";
import {Request} from "express"; import {Request, Response} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails"; import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
import Mail from "../Mail"; import Mail from "../Mail";
import Controller from "../Controller"; import Controller from "../Controller";
import config from "config"; import config from "config";
import Application from "../Application"; import Application from "../Application";
import NunjucksComponent from "../components/NunjucksComponent"; import NunjucksComponent from "../components/NunjucksComponent";
import AuthMethod from "./AuthMethod";
export default class AuthGuard {
private readonly authMethods: AuthMethod<AuthProof<User>>[];
export default abstract class AuthGuard<P extends AuthProof<User>> {
public constructor( public constructor(
private readonly app: Application, private readonly app: Application,
...authMethods: AuthMethod<AuthProof<User>>[]
) { ) {
this.authMethods = authMethods;
} }
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>; public async interruptAuth(req: Request, res: Response): Promise<boolean> {
for (const method of this.authMethods) {
protected async getProofForRequest(_req: Request): Promise<P | null> { if (method.interruptAuth && await method.interruptAuth(req, res)) return true;
return null;
}
public async getProof(req: Request): Promise<P | null> {
let proof = await this.isAuthenticatedViaRequest(req);
if (!proof && req.session) {
proof = await this.isAuthenticated(req.session);
} }
return proof;
return false;
} }
public async isAuthenticated(session: Express.Session): Promise<P | null> { public getAuthMethodByName(authMethodName: string): AuthMethod<AuthProof<User>> | null {
if (!session.is_authenticated) return null; return this.authMethods.find(m => m.getName() === authMethodName) || null;
}
const proof = await this.getProofForSession(session); public getAuthMethodNames(): string[] {
return this.authMethods.map(m => m.getName());
}
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) { public getRegistrationMethod(): AuthMethod<AuthProof<User>> {
await proof?.revoke(); return this.authMethods[0];
}
public async getAuthMethodsByIdentifier(
identifier: string,
): Promise<{ user: User, method: AuthMethod<AuthProof<User>> }[]> {
const methods = [];
for (const method of this.authMethods) {
const user = await method.findUserByIdentifier(identifier);
if (user) methods.push({user, method});
}
return methods;
}
public async getProofs(req: Request): Promise<AuthProof<User>[]> {
const proofs = [];
if (req.session) {
proofs.push(...await this.getProofsForSession(req.session));
}
proofs.push(...await this.getProofsForRequest(req));
return proofs;
}
public async getProofsForSession(session: Express.Session): Promise<AuthProof<User>[]> {
if (!session.is_authenticated) return [];
const proofs = [];
for (const method of this.authMethods) {
if (method.getProofsForSession) {
const methodProofs = await method.getProofsForSession(session);
for (const proof of methodProofs) {
if (!await proof.isValid() || !await proof.isAuthorized()) {
await proof.revoke();
} else {
proofs.push(proof);
}
}
}
}
if (proofs.length === 0) {
session.is_authenticated = false; session.is_authenticated = false;
return null;
} }
return proof; return proofs;
} }
public async isAuthenticatedViaRequest(req: Request): Promise<P | null> { public async getProofsForRequest(req: Request): Promise<AuthProof<User>[]> {
const proof = await this.getProofForRequest(req); const proofs = [];
for (const method of this.authMethods) {
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) { if (method.getProofsForRequest) {
await proof?.revoke(); const methodProofs = await method.getProofsForRequest(req);
return null; for (const proof of methodProofs) {
if (!await proof.isValid() || !await proof.isAuthorized()) {
await proof.revoke();
} else {
proofs.push(proof);
}
}
}
} }
return proof; return proofs;
} }
public async authenticateOrRegister( public async authenticateOrRegister(
session: Express.Session, session: Express.Session,
proof: P, proof: AuthProof<User>,
onLogin?: (user: User) => Promise<void>, onLogin?: (user: User) => Promise<void>,
beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>, beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>, afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
@ -106,7 +154,6 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
return user; return user;
} }
} }
export class AuthError extends Error { export class AuthError extends Error {

26
src/auth/AuthMethod.ts Normal file
View File

@ -0,0 +1,26 @@
import User from "./models/User";
import AuthProof from "./AuthProof";
import {Request, Response} from "express";
export default interface AuthMethod<P extends AuthProof<User>> {
/**
* @return A unique name.
*/
getName(): string;
findUserByIdentifier(identifier: string): Promise<User | null>;
getProofsForSession?(session: Express.Session): Promise<P[]>;
getProofsForRequest?(req: Request): Promise<P[]>;
/**
* @return {@code true} if interrupted, {@code false} otherwise.
*/
interruptAuth?(req: Request, res: Response): Promise<boolean>;
attemptLogin(req: Request, res: Response, user: User): Promise<void>;
attemptRegister(req: Request, res: Response, identifier: string): Promise<void>;
}

View File

@ -0,0 +1,12 @@
import ModelComponent from "../db/ModelComponent";
import User from "./models/User";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
export default class UserNameComponent extends ModelComponent<User> {
public name?: string = undefined;
public init(): void {
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
}
}

View File

@ -0,0 +1,4 @@
export default {
LOGIN: 'Login',
REGISTER: 'Register',
};

View File

@ -2,7 +2,7 @@ import Migration from "../../db/Migration";
import ModelFactory from "../../db/ModelFactory"; import ModelFactory from "../../db/ModelFactory";
import MagicLink from "../models/MagicLink"; import MagicLink from "../models/MagicLink";
export default class CreateMagicLinksTable extends Migration { export default class CreateMagicLinksTableMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {
await this.query(`CREATE TABLE magic_links await this.query(`CREATE TABLE magic_links
( (

View File

@ -1,187 +0,0 @@
import {NextFunction, Request, Response} from "express";
import Controller from "../../Controller";
import MagicLink from "../models/MagicLink";
import {BadRequestError} from "../../HttpError";
import UserEmail from "../models/UserEmail";
import MagicLinkController from "./MagicLinkController";
import {MailTemplate} from "../../Mail";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import geoip from "geoip-lite";
import AuthController from "../AuthController";
import RedirectBackComponent from "../../components/RedirectBackComponent";
import {AuthMiddleware} from "../AuthComponent";
import User from "../models/User";
export default abstract class MagicLinkAuthController extends AuthController {
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, undefined, undefined, 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 loginMagicLinkActionType: string = 'Login';
protected readonly registerMagicLinkActionType: string = 'Register';
private readonly magicLinkMailTemplate: MailTemplate;
protected constructor(magicLinkMailTemplate: MailTemplate) {
super();
this.magicLinkMailTemplate = magicLinkMailTemplate;
}
protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const link = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (link && await link.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
return;
}
await super.getAuth(req, res, next);
}
protected async postAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const email = req.body.email;
if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl);
let userEmail = await UserEmail.select().where('email', email).first();
let isRegistration = false;
if (!userEmail) {
isRegistration = true;
userEmail = UserEmail.create({
email: email,
main: true,
});
await userEmail.validate(true);
}
if (!isRegistration || req.body.confirm_register === 'confirm') {
// Register (email link)
const geo = geoip.lookup(req.ip);
await MagicLinkController.sendMagicLink(
this.getApp(),
req.getSession().id,
isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}),
email,
this.magicLinkMailTemplate,
{
type: isRegistration ? 'register' : 'login',
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || RedirectBackComponent.getPreviousURL(req),
}));
} else {
// Confirm registration req
req.flash('register_confirm_email', email);
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
}
}
/**
* Check whether a magic link is authorized, and authenticate if yes
*/
protected async getCheckAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const magicLink = await MagicLink.bySessionId(req.getSession().id,
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
if (!magicLink) {
res.format({
json: () => {
throw new BadRequestError(
'No magic link were found linked with that session.',
'Please retry once you have requested a magic link.',
req.originalUrl,
);
},
default: () => {
req.flash('warning', 'No magic link found. Please try again.');
res.redirect(Controller.route('auth'));
},
});
return;
}
const user = await MagicLinkAuthController.checkAndAuth(req, res, magicLink);
if (user) {
// Auth success
const username = user.name;
res.format({
json: () => {
res.json({'status': 'success', 'message': `Welcome, ${username}!`});
},
default: () => {
req.flash('success', `Authentication success. Welcome, ${username}!`);
res.redirect('/');
},
});
}
}
}
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.`);
}
}

View File

@ -0,0 +1,103 @@
import AuthMethod from "../AuthMethod";
import {Request, Response} from "express";
import User from "../models/User";
import UserEmail from "../models/UserEmail";
import MagicLink from "../models/MagicLink";
import {WhereTest} from "../../db/ModelQuery";
import Controller from "../../Controller";
import geoip from "geoip-lite";
import MagicLinkController from "./MagicLinkController";
import RedirectBackComponent from "../../components/RedirectBackComponent";
import Application from "../../Application";
import {MailTemplate} from "../../Mail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public constructor(
protected readonly app: Application,
protected readonly magicLinkMailTemplate: MailTemplate,
) {
}
public getName(): string {
return 'magic_link';
}
public async findUserByIdentifier(identifier: string): Promise<User | null> {
return (await UserEmail.select()
.with('user')
.where('email', identifier)
.first())?.user.getOrFail() || null;
}
public async getProofsForSession(session: Express.Session): Promise<MagicLink[]> {
return await MagicLink.select()
.where('session_id', session.id)
.where('action_type', [AuthMagicLinkActionType.LOGIN, AuthMagicLinkActionType.REGISTER], WhereTest.IN)
.get();
}
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
const pendingLink = await MagicLink.select()
.where('session_id', req.getSession().id)
.where('action_type', [AuthMagicLinkActionType.LOGIN, AuthMagicLinkActionType.REGISTER], WhereTest.IN)
.where('authorized', false)
.first();
if (pendingLink && await pendingLink.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || pendingLink.original_url || undefined,
}));
return true;
}
return false;
}
public async attemptLogin(req: Request, res: Response, user: User): Promise<void> {
const userEmail = user.mainEmail.getOrFail();
if (!userEmail) throw new Error('No main email for user ' + user.id);
await this.auth(req, res, false, userEmail.getOrFail('email'));
}
public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> {
const userEmail = UserEmail.create({
email: identifier,
main: true,
});
await userEmail.validate(true);
await this.auth(req, res, true, identifier);
}
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> {
if (!isRegistration || req.body.confirm_register === 'confirm') {
const geo = geoip.lookup(req.ip);
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
await MagicLinkController.sendMagicLink(
this.app,
req.getSession().id,
actionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}),
email,
this.magicLinkMailTemplate,
{
type: actionType,
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || RedirectBackComponent.getPreviousURL(req),
}));
} else {
req.flash('register_identifier', email);
res.redirect(Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
}
}
}

View File

@ -9,8 +9,13 @@ import config from "config";
import Application from "../../Application"; import Application from "../../Application";
import {ParsedUrlQueryInput} from "querystring"; import {ParsedUrlQueryInput} from "querystring";
import NunjucksComponent from "../../components/NunjucksComponent"; 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";
export default abstract class MagicLinkController<A extends Application> extends Controller { export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink( public static async sendMagicLink(
app: Application, app: Application,
sessionId: string, sessionId: string,
@ -23,12 +28,11 @@ export default abstract class MagicLinkController<A extends Application> extends
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0); Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0); Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
const link = await MagicLink.bySessionId(sessionId, actionType) || const link = MagicLink.create({
MagicLink.create({ session_id: sessionId,
session_id: sessionId, action_type: actionType,
action_type: actionType, original_url: original_url,
original_url: original_url, });
});
const token = await link.generateToken(email); const token = await link.generateToken(email);
await link.save(); await link.save();
@ -42,10 +46,53 @@ export default abstract class MagicLinkController<A extends Application> extends
})).send(email); })).send(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, undefined, undefined, 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; protected readonly magicLinkWebsocketPath: string;
protected constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) { public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
super(); super();
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path(); this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
} }
@ -60,7 +107,10 @@ export default abstract class MagicLinkController<A extends Application> extends
} }
protected async getLobby(req: Request, res: Response): Promise<void> { protected async getLobby(req: Request, res: Response): Promise<void> {
const link = await MagicLink.bySessionId(req.getSession().id); const link = await MagicLink.select()
.where('session_id', req.getSession().id)
.sortBy('authorized')
.first();
if (!link) { if (!link) {
throw new NotFoundHttpError('magic link', req.url); throw new NotFoundHttpError('magic link', req.url);
} }
@ -115,5 +165,41 @@ export default abstract class MagicLinkController<A extends Application> extends
}); });
} }
protected abstract async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void>; 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
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.`);
}
} }

View File

@ -27,7 +27,10 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
}); });
// Get magic link // Get magic link
const magicLink = await MagicLink.bySessionId(session.id); const magicLink = await MagicLink.select()
.where('session_id', session.id)
.sortBy('authorized')
.first();
// Refresh if immediately applicable // Refresh if immediately applicable
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) { if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {

View File

@ -3,7 +3,7 @@ import ModelFactory from "../../db/ModelFactory";
import User from "../models/User"; import User from "../models/User";
import UserApprovedComponent from "../models/UserApprovedComponent"; import UserApprovedComponent from "../models/UserApprovedComponent";
export default class AddApprovedFieldToUsersTable extends Migration { export default class AddApprovedFieldToUsersTableMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {
await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0'); await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0');
} }

View File

@ -0,0 +1,19 @@
import Migration from "../../db/Migration";
import ModelFactory from "../../db/ModelFactory";
import User from "../models/User";
import UserNameComponent from "../UserNameComponent";
export default class AddNameToUsersMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN name');
}
public registerModels(): void {
ModelFactory.get(User).addComponent(UserNameComponent);
}
}

View File

@ -3,7 +3,7 @@ import ModelFactory from "../../db/ModelFactory";
import User from "../models/User"; import User from "../models/User";
import UserEmail from "../models/UserEmail"; import UserEmail from "../models/UserEmail";
export default class CreateUsersAndUserEmailsTable extends Migration { export default class CreateUsersAndUserEmailsTableMigration extends Migration {
public async install(): Promise<void> { public async install(): Promise<void> {
await this.query(`CREATE TABLE users await this.query(`CREATE TABLE users
( (

View File

@ -4,23 +4,10 @@ import Model from "../../db/Model";
import AuthProof from "../AuthProof"; import AuthProof from "../AuthProof";
import User from "./User"; import User from "./User";
import argon2 from "argon2"; import argon2 from "argon2";
import {WhereTest} from "../../db/ModelQuery";
import UserEmail from "./UserEmail"; import UserEmail from "./UserEmail";
import {EMAIL_REGEX} from "../../db/Validator"; import {EMAIL_REGEX} from "../../db/Validator";
export default class MagicLink extends Model implements AuthProof<User> { export default class MagicLink extends Model implements AuthProof<User> {
public static async bySessionId(sessionId: string, actionType?: string | string[]): Promise<MagicLink | null> {
let query = this.select().where('session_id', sessionId);
if (actionType !== undefined) {
if (typeof actionType === 'string') {
query = query.where('action_type', actionType);
} else {
query = query.where('action_type', actionType, WhereTest.IN);
}
}
return await query.first();
}
public static validityPeriod(): number { public static validityPeriod(): number {
return config.get<number>('magic_link.validity_period') * 1000; return config.get<number>('magic_link.validity_period') * 1000;
} }
@ -30,7 +17,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
private email?: string = undefined; private email?: string = undefined;
private token?: string = undefined; private token?: string = undefined;
public readonly action_type?: string = undefined; public readonly action_type?: string = undefined;
private original_url?: string = undefined; public readonly original_url?: string = undefined;
private generated_at?: Date = undefined; private generated_at?: Date = undefined;
private authorized: boolean = false; private authorized: boolean = false;

View File

@ -1,6 +1,6 @@
import Model from "../../db/Model"; import Model from "../../db/Model";
import MysqlConnectionManager from "../../db/MysqlConnectionManager"; import MysqlConnectionManager from "../../db/MysqlConnectionManager";
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable"; import AddApprovedFieldToUsersTableMigration from "../migrations/AddApprovedFieldToUsersTableMigration";
import config from "config"; import config from "config";
import {ManyModelRelation} from "../../db/ModelRelation"; import {ManyModelRelation} from "../../db/ModelRelation";
import UserEmail from "./UserEmail"; import UserEmail from "./UserEmail";
@ -9,7 +9,7 @@ import UserApprovedComponent from "./UserApprovedComponent";
export default class User extends Model { export default class User extends Model {
public static isApprovalMode(): boolean { public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') && return config.get<boolean>('approval_mode') &&
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable); MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
} }
public readonly id?: number = undefined; public readonly id?: number = undefined;

View File

@ -0,0 +1,20 @@
import Migration from "../../db/Migration";
import ModelFactory from "../../db/ModelFactory";
import User from "../models/User";
import UserPasswordComponent from "./UserPasswordComponent";
export default class AddPasswordToUsersMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN password VARCHAR(128) NOT NULL`);
}
public async rollback(): Promise<void> {
await this.query(`ALTER TABLE users
DROP COLUMN password`);
}
public registerModels(): void {
ModelFactory.get(User).addComponent(UserPasswordComponent);
}
}

View File

@ -0,0 +1,128 @@
import AuthMethod from "../AuthMethod";
import PasswordAuthProof from "./PasswordAuthProof";
import User from "../models/User";
import {Request, Response} from "express";
import UserEmail from "../models/UserEmail";
import AuthComponent from "../AuthComponent";
import Application from "../../Application";
import Throttler from "../../Throttler";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import Validator, {InvalidFormatValidationError, ValidationBag} from "../../db/Validator";
import Controller from "../../Controller";
import UserPasswordComponent from "./UserPasswordComponent";
import UserNameComponent, {USERNAME_REGEXP} from "../UserNameComponent";
import ModelFactory from "../../db/ModelFactory";
import {WhereOperator, WhereTest} from "../../db/ModelQuery";
import {ServerError} from "../../HttpError";
export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof> {
public constructor(
protected readonly app: Application,
) {
}
public getName(): string {
return 'password';
}
public async findUserByIdentifier(identifier: string): Promise<User | null> {
const query = UserEmail.select()
.with('user')
.where('email', identifier);
if (ModelFactory.get(User).hasComponent(UserNameComponent)) {
query.where('name', identifier, WhereTest.EQ, WhereOperator.OR);
}
return (await query
.first())?.user.getOrFail() || null;
}
public async getProofsForSession(session: Express.Session): Promise<PasswordAuthProof[]> {
const proof = PasswordAuthProof.getProofForSession(session);
return proof ? [proof] : [];
}
public async attemptLogin(req: Request, res: Response, user: User): Promise<void> {
const passwordAuthProof = PasswordAuthProof.createProofForLogin(req.getSession());
passwordAuthProof.setResource(user);
await passwordAuthProof.authorize(req.body.password);
try {
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof);
} catch (e) {
if (e instanceof AuthError) {
Throttler.throttle('login_failed_attempts_user', 3, 180000,
<string>user.getOrFail('name'), 1000, 60000);
Throttler.throttle('login_failed_attempts_ip', 5, 60000,
req.ip, 1000, 60000);
if (e instanceof PendingApprovalAuthError) {
req.flash('error', 'Your account is still being reviewed.');
res.redirectBack();
return;
} else {
const bag = new ValidationBag();
const err = new InvalidFormatValidationError('Invalid password.');
err.thingName = 'password';
bag.addMessage(err);
throw bag;
}
} else {
throw e;
}
}
req.flash('success', `Welcome, ${user.name}.`);
res.redirect(Controller.route('home'));
}
public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> {
if (!ModelFactory.get(User).hasComponent(UserNameComponent))
throw new ServerError('Cannot register with password without UserNameComponent.');
Throttler.throttle('register_password', 10, 30000, req.ip);
req.body.username = identifier;
await Validator.validate({
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name'),
password: new Validator().defined().minLength(UserPasswordComponent.PASSWORD_MIN_LENGTH),
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
terms: new Validator().defined(),
}, req.body);
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.getSession());
try {
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof,
undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
// Password
await user.as(UserPasswordComponent).setPassword(req.body.password);
// Username
user.as(UserNameComponent).name = req.body.username;
return callbacks;
}, async (connection, user) => {
passwordAuthProof.setResource(user);
return [];
});
} catch (e) {
if (e instanceof PendingApprovalAuthError) {
req.flash('info', `Your account was successfully created and is pending review from an administrator.`);
res.redirect(Controller.route('home'));
return;
} else {
throw e;
}
}
const user = await passwordAuthProof.getResource();
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).name}.`);
res.redirect(Controller.route('home'));
}
}

View File

@ -0,0 +1,82 @@
import AuthProof from "../AuthProof";
import User from "../models/User";
import UserPasswordComponent from "./UserPasswordComponent";
export default class PasswordAuthProof implements AuthProof<User> {
public static getProofForSession(session: Express.Session): PasswordAuthProof | null {
return session.auth_password_proof ? new PasswordAuthProof(session) : null;
}
public static createAuthorizedProofForRegistration(session: Express.Session): PasswordAuthProof {
const proofForSession = new PasswordAuthProof(session);
proofForSession.authorized = true;
proofForSession.forRegistration = true;
proofForSession.save();
return proofForSession;
}
public static createProofForLogin(session: Express.Session): PasswordAuthProof {
return new PasswordAuthProof(session);
}
private readonly session: Express.Session;
private authorized: boolean;
private forRegistration: boolean = false;
private userId: number | null;
private userPassword: UserPasswordComponent | null = null;
private constructor(session: Express.Session) {
this.session = session;
this.authorized = session.auth_password_proof?.authorized || false;
this.forRegistration = session.auth_password_proof?.forRegistration || false;
this.userId = session.auth_password_proof?.userId || null;
}
public async getResource(): Promise<User | null> {
if (typeof this.userId !== 'number') return null;
return await User.getById(this.userId);
}
public setResource(user: User): void {
this.userId = user.getOrFail('id');
this.save();
}
public async isAuthorized(): Promise<boolean> {
return this.authorized;
}
public async isValid(): Promise<boolean> {
return (this.forRegistration || Boolean(await this.getResource())) &&
await this.isAuthorized();
}
public async revoke(): Promise<void> {
this.session.auth_password_proof = undefined;
}
private async getUserPassword(): Promise<UserPasswordComponent | null> {
if (!this.userPassword) {
this.userPassword = (await User.getById(this.userId))?.as(UserPasswordComponent) || null;
}
return this.userPassword;
}
public async authorize(passwordGuess: string): Promise<boolean> {
const password = await this.getUserPassword();
if (!password || !await password.verifyPassword(passwordGuess)) return false;
this.authorized = true;
this.save();
return true;
}
private save() {
this.session.auth_password_proof = {
authorized: this.authorized,
forRegistration: this.forRegistration,
userId: this.userId,
};
}
}

View File

@ -0,0 +1,32 @@
import argon2, {argon2id} from "argon2";
import ModelComponent from "../../db/ModelComponent";
import User from "../models/User";
import Validator from "../../db/Validator";
export default class UserPasswordComponent extends ModelComponent<User> {
public static readonly PASSWORD_MIN_LENGTH = 12;
private password?: string = undefined;
public init(): void {
this.setValidation('password').acceptUndefined().maxLength(128);
}
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
await new Validator<string>().defined().minLength(12).maxLength(512)
.execute(fieldName, rawPassword, true);
this.password = await argon2.hash(rawPassword, {
timeCost: 10,
memoryCost: 65536,
parallelism: 4,
type: argon2id,
hashLength: 32,
});
}
public async verifyPassword(passwordGuess: string): Promise<boolean> {
if (!this.password) return false;
return await argon2.verify(this.password, passwordGuess);
}
}

View File

@ -31,7 +31,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
try { try {
if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) { if ((await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req)).length === 0) {
if (session.csrf === undefined) { if (session.csrf === undefined) {
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`)); return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
} else if (req.body.csrf === undefined) { } else if (req.body.csrf === undefined) {

View File

@ -1,4 +1,4 @@
{% extends './barebone.njk' %} {% extends 'layouts/barebone.njk' %}
{% block _stylesheets %} {% block _stylesheets %}
{{ super() }} {{ super() }}