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 PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod";
import {MAGIC_LINK_MAIL} from "./Mails";
import packageJson = require('../package.json');
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 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 = [
CreateMigrationsTable,
CreateUsersAndUserEmailsTable,
CreateUsersAndUserEmailsTableMigration,
AddNameToUsersMigration,
AddPasswordToUsersMigration,
CreateMagicLinksTableMigration,
];
@ -30,6 +38,7 @@ export default class TestApp extends Application {
private readonly addr: string;
private readonly port: number;
private expressAppComponent?: ExpressAppComponent;
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
public constructor(addr: string, port: number) {
super(packageJson.version, true);
@ -43,8 +52,8 @@ export default class TestApp extends Application {
protected async init(): Promise<void> {
this.registerComponents();
this.registerWebSocketListeners?.();
this.registerControllers?.();
this.registerWebSocketListeners();
this.registerControllers();
}
protected registerComponents(): void {
@ -71,16 +80,27 @@ export default class TestApp extends Application {
this.use(redisComponent);
this.use(new SessionComponent(redisComponent));
// Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
// Utils
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 {
return this.as(ExpressAppComponent).getExpressApp();

View File

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

View File

@ -1,35 +1,100 @@
import Controller from "../Controller";
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 {
return '/auth';
}
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.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
this.get('/check', this.getCheckAuth, 'check_auth');
this.post('/login', this.postLogin, 'login', RequireGuestMiddleware);
this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
}
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', {
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> {
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req);
await proof?.revoke();
const userId = typeof req.body.user_id === 'string' ? parseInt(req.body.user_id) : null;
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.');
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 User from "./models/User";
import {Connection} from "mysql";
import {Request} from "express";
import {Request, Response} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
import Mail from "../Mail";
import Controller from "../Controller";
import config from "config";
import Application from "../Application";
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(
private readonly app: Application,
...authMethods: AuthMethod<AuthProof<User>>[]
) {
this.authMethods = authMethods;
}
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>;
protected async getProofForRequest(_req: Request): Promise<P | null> {
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);
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
for (const method of this.authMethods) {
if (method.interruptAuth && await method.interruptAuth(req, res)) return true;
}
return proof;
return false;
}
public async isAuthenticated(session: Express.Session): Promise<P | null> {
if (!session.is_authenticated) return null;
public getAuthMethodByName(authMethodName: string): AuthMethod<AuthProof<User>> | 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()) {
await proof?.revoke();
public getRegistrationMethod(): AuthMethod<AuthProof<User>> {
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;
return null;
}
return proof;
return proofs;
}
public async isAuthenticatedViaRequest(req: Request): Promise<P | null> {
const proof = await this.getProofForRequest(req);
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
await proof?.revoke();
return null;
public async getProofsForRequest(req: Request): Promise<AuthProof<User>[]> {
const proofs = [];
for (const method of this.authMethods) {
if (method.getProofsForRequest) {
const methodProofs = await method.getProofsForRequest(req);
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(
session: Express.Session,
proof: P,
proof: AuthProof<User>,
onLogin?: (user: User) => Promise<void>,
beforeRegister?: (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;
}
}
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 MagicLink from "../models/MagicLink";
export default class CreateMagicLinksTable extends Migration {
export default class CreateMagicLinksTableMigration extends Migration {
public async install(): Promise<void> {
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 {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";
export default abstract class MagicLinkController<A extends Application> extends Controller {
export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(
app: Application,
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', 1, MagicLink.validityPeriod(), email, 0, 0);
const link = await MagicLink.bySessionId(sessionId, actionType) ||
MagicLink.create({
session_id: sessionId,
action_type: actionType,
original_url: original_url,
});
const link = MagicLink.create({
session_id: sessionId,
action_type: actionType,
original_url: original_url,
});
const token = await link.generateToken(email);
await link.save();
@ -42,10 +46,53 @@ export default abstract class MagicLinkController<A extends Application> extends
})).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 constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
super();
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> {
const link = await MagicLink.bySessionId(req.getSession().id);
const link = await MagicLink.select()
.where('session_id', req.getSession().id)
.sortBy('authorized')
.first();
if (!link) {
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
const magicLink = await MagicLink.bySessionId(session.id);
const magicLink = await MagicLink.select()
.where('session_id', session.id)
.sortBy('authorized')
.first();
// Refresh if immediately applicable
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 UserApprovedComponent from "../models/UserApprovedComponent";
export default class AddApprovedFieldToUsersTable extends Migration {
export default class AddApprovedFieldToUsersTableMigration extends Migration {
public async install(): Promise<void> {
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 UserEmail from "../models/UserEmail";
export default class CreateUsersAndUserEmailsTable extends Migration {
export default class CreateUsersAndUserEmailsTableMigration extends Migration {
public async install(): Promise<void> {
await this.query(`CREATE TABLE users
(

View File

@ -4,23 +4,10 @@ import Model from "../../db/Model";
import AuthProof from "../AuthProof";
import User from "./User";
import argon2 from "argon2";
import {WhereTest} from "../../db/ModelQuery";
import UserEmail from "./UserEmail";
import {EMAIL_REGEX} from "../../db/Validator";
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 {
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 token?: 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 authorized: boolean = false;

View File

@ -1,6 +1,6 @@
import Model from "../../db/Model";
import MysqlConnectionManager from "../../db/MysqlConnectionManager";
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable";
import AddApprovedFieldToUsersTableMigration from "../migrations/AddApprovedFieldToUsersTableMigration";
import config from "config";
import {ManyModelRelation} from "../../db/ModelRelation";
import UserEmail from "./UserEmail";
@ -9,7 +9,7 @@ import UserApprovedComponent from "./UserApprovedComponent";
export default class User extends Model {
public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') &&
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
}
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)) {
try {
if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) {
if ((await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req)).length === 0) {
if (session.csrf === undefined) {
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
} else if (req.body.csrf === undefined) {

View File

@ -1,4 +1,4 @@
{% extends './barebone.njk' %}
{% extends 'layouts/barebone.njk' %}
{% block _stylesheets %}
{{ super() }}
@ -48,4 +48,4 @@
</main>
{% endblock %}
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}