Greatly simplify authentication system

This commit is contained in:
Alice Gaudon 2020-07-25 10:28:50 +02:00
parent 40181a973b
commit a79e2292d7
12 changed files with 137 additions and 109 deletions

View File

@ -15,7 +15,7 @@ export default class AuthComponent extends ApplicationComponent<void> {
public async init(router: Router): Promise<void> { public async init(router: Router): Promise<void> {
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
req.authGuard = this.authGuard; req.authGuard = this.authGuard;
res.locals.user = await req.authGuard.getUserForSession(req.session!); res.locals.user = await (await req.authGuard.getProofForSession(req.session!))?.getResource();
next(); next();
}); });
} }
@ -30,13 +30,13 @@ export const REQUIRE_REQUEST_AUTH_MIDDLEWARE = async (req: Request, res: Respons
return; return;
} }
req.models.user = await req.authGuard.getUserForRequest(req); req.models.user = await (await req.authGuard.getProofForRequest(req))?.getResource();
next(); next();
}; };
export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => { export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (await req.authGuard.isAuthenticatedViaRequest(req)) { if (await req.authGuard.isAuthenticatedViaRequest(req)) {
req.models.user = await req.authGuard.getUserForRequest(req); req.models.user = await (await req.authGuard.getProofForRequest(req))?.getResource();
next(); next();
} else { } else {
if (!await req.authGuard.isAuthenticated(req.session!)) { if (!await req.authGuard.isAuthenticated(req.session!)) {
@ -47,7 +47,7 @@ export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next:
return; return;
} }
req.models.user = await req.authGuard.getUserForSession(req.session!); req.models.user = await (await req.authGuard.getProofForSession(req.session!))?.getResource();
next(); next();
} }
}; };

View File

@ -26,7 +26,8 @@ export default abstract class AuthController extends Controller {
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>; protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
protected async postLogout(req: Request, res: Response, next: NextFunction): Promise<void> { protected async postLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
await req.authGuard.logout(req.session!); const proof = await req.authGuard.getProofForSession(req.session!);
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() || '/');
} }

View File

@ -1,7 +1,6 @@
import AuthProof from "./AuthProof"; 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 UserEmail from "./models/UserEmail";
import {Connection} from "mysql"; import {Connection} from "mysql";
import {Request} from "express"; import {Request} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails"; import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
@ -9,42 +8,34 @@ import Mail from "../Mail";
import Controller from "../Controller"; import Controller from "../Controller";
import config from "config"; import config from "config";
export default abstract class AuthGuard<P extends AuthProof> { export default abstract class AuthGuard<P extends AuthProof<User>> {
public abstract async getProofForSession(session: Express.Session): Promise<P | null>; public abstract async getProofForSession(session: Express.Session): Promise<P | null>;
public async getProofForRequest(req: Request): Promise<P | null> { public async getProofForRequest(req: Request): Promise<P | null> {
return null; return null;
} }
public async getUserForSession(session: Express.Session): Promise<User | null> { public async authenticateOrRegister(
if (!await this.isAuthenticated(session)) return null; session: Express.Session,
return await User.getById<User>(session.auth_id); proof: P,
} onLogin?: (user: User) => Promise<void>,
onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>
): Promise<void> {
if (!await proof.isValid()) throw new InvalidAuthProofError();
if (!await proof.isAuthorized()) throw new UnauthorizedAuthProofError();
public async authenticateOrRegister(session: Express.Session, proof: P, registerCallback?: (connection: Connection, userID: number) => Promise<(() => Promise<void>)[]>): Promise<void> { let user = await proof.getResource();
if (!await proof.isAuthorized()) {
throw new AuthError('Invalid argument: cannot authenticate with an unauthorized proof.');
}
let user = await proof.getUser(); // Register if user doesn't exist
if (!user) { // Register if (!user) {
const callbacks: (() => Promise<void>)[] = []; const callbacks: RegisterCallback[] = [];
await MysqlConnectionManager.wrapTransaction(async connection => { await MysqlConnectionManager.wrapTransaction(async connection => {
const email = await proof.getEmail(); user = new User({});
user = new User({
name: email.split('@')[0],
});
await user.save(connection, c => callbacks.push(c)); await user.save(connection, c => callbacks.push(c));
const userEmail = new UserEmail({ if (onRegister) {
user_id: user.id, (await onRegister(connection, user)).forEach(c => callbacks.push(c));
email: email,
main: true,
});
await userEmail.save(connection, c => callbacks.push(c));
if (registerCallback) {
(await registerCallback(connection, user.id!)).forEach(c => callbacks.push(c));
} }
}); });
@ -62,36 +53,25 @@ export default abstract class AuthGuard<P extends AuthProof> {
} else { } else {
throw new Error('Unable to register user.'); throw new Error('Unable to register user.');
} }
} else if (registerCallback) {
throw new UserAlreadyExistsAuthError(await proof.getEmail());
} }
// Don't login if user isn't approved
if (!user.isApproved()) { if (!user.isApproved()) {
throw new PendingApprovalAuthError(); throw new PendingApprovalAuthError();
} }
// Login
session.auth_id = user.id; session.auth_id = user.id;
if (onLogin) await onLogin(user);
} }
public async isAuthenticated(session: Express.Session): Promise<boolean> { public async isAuthenticated(session: Express.Session): Promise<boolean> {
return await this.checkCurrentSessionProofValidity(session);
}
public async logout(session: Express.Session): Promise<void> {
const proof = await this.getProofForSession(session);
if (proof) {
await proof.revoke(session);
}
session.auth_id = undefined;
}
private async checkCurrentSessionProofValidity(session: Express.Session): Promise<boolean> {
if (typeof session.auth_id !== 'number') return false; if (typeof session.auth_id !== 'number') return false;
const proof = await this.getProofForSession(session); const proof = await this.getProofForSession(session);
if (!proof || !await proof.isValid() || !await proof.isAuthorized() || !await proof.isOwnedBy(session.auth_id)) { if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
await this.logout(session); await proof?.revoke();
return false; return false;
} }
@ -100,16 +80,7 @@ export default abstract class AuthGuard<P extends AuthProof> {
public async isAuthenticatedViaRequest(req: Request): Promise<boolean> { public async isAuthenticatedViaRequest(req: Request): Promise<boolean> {
const proof = await this.getProofForRequest(req); const proof = await this.getProofForRequest(req);
if (proof && await proof.isValid() && await proof.isAuthorized()) { return Boolean(proof && await proof.isValid() && await proof.isAuthorized());
return true;
} else {
return false;
}
}
public async getUserForRequest(req: Request): Promise<User | null> {
const proof = await this.getProofForRequest(req);
return proof ? await proof.getUser() : null;
} }
} }
@ -120,12 +91,18 @@ export class AuthError extends Error {
} }
} }
export class UserAlreadyExistsAuthError extends AuthError { export class AuthProofError extends AuthError {
public readonly email: string; }
constructor(userEmail: string) { export class InvalidAuthProofError extends AuthProofError {
super(`User with email ${userEmail} already exists.`); constructor() {
this.email = userEmail; super('Invalid auth proof.');
}
}
export class UnauthorizedAuthProofError extends AuthProofError {
constructor() {
super('Unauthorized auth proof.');
} }
} }
@ -134,3 +111,5 @@ export class PendingApprovalAuthError extends AuthError {
super(`User is not approved.`); super(`User is not approved.`);
} }
} }
export type RegisterCallback = () => Promise<void>;

View File

@ -1,15 +1,40 @@
import User from "./models/User"; /**
* This class is most commonly used for authentication. It can be more generically used to represent a verification
export default interface AuthProof { * state of whether a given resource is owned by a session.
*
* Any auth system should consider this auth proof valid if and only if both {@code isValid()} and {@code isAuthorized()}
* both return {@code true}.
*
* @type <R> The resource type this AuthProof authorizes.
*/
export default interface AuthProof<R> {
/**
* Is this auth proof valid in time (and context)?
*
* For example, it can return true for an initial short validity time period then false, and increase that time
* period if {@code isAuthorized()} returns true.
*/
isValid(): Promise<boolean>; isValid(): Promise<boolean>;
/**
* Was this proof authorized?
*
* Return true once the session is proven to own the associated resource.
*/
isAuthorized(): Promise<boolean>; isAuthorized(): Promise<boolean>;
isOwnedBy(userId: number): Promise<boolean>; /**
* Retrieve the resource this auth proof is supposed to authorize.
* If this resource doesn't exist yet, return {@code null}.
*/
getResource(): Promise<R | null>;
getUser(): Promise<User | null>; /**
* Manually revokes this authentication proof. Once this method is called, all of the following must be true:
getEmail(): Promise<string>; * - {@code isAuthorized} returns {@code false}
* - There is no way to re-authorize this proof (i.e. {@code isAuthorized} can never return {@code true} again)
revoke(session: Express.Session): Promise<void>; *
* Additionally, this method should delete any stored data that could lead to restoration of this AuthProof instance.
*/
revoke(): Promise<void>;
} }

View File

@ -5,7 +5,7 @@ import {BadRequestError} from "../../HttpError";
import UserEmail from "../models/UserEmail"; import UserEmail from "../models/UserEmail";
import MagicLinkController from "./MagicLinkController"; import MagicLinkController from "./MagicLinkController";
import {MailTemplate} from "../../Mail"; import {MailTemplate} from "../../Mail";
import {AuthError, PendingApprovalAuthError} from "../AuthGuard"; import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import geoip from "geoip-lite"; import geoip from "geoip-lite";
import AuthController from "../AuthController"; import AuthController from "../AuthController";
import NunjucksComponent from "../../components/NunjucksComponent"; import NunjucksComponent from "../../components/NunjucksComponent";
@ -19,7 +19,19 @@ export default abstract class MagicLinkAuthController extends AuthController {
// Auth // Auth
try { try {
await req.authGuard.authenticateOrRegister(req.session!, magicLink); await req.authGuard.authenticateOrRegister(req.session!, magicLink, undefined, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
const userEmail = new UserEmail({
user_id: user.id,
email: magicLink.getEmail(),
});
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) { } catch (e) {
if (e instanceof PendingApprovalAuthError) { if (e instanceof PendingApprovalAuthError) {
res.format({ res.format({

View File

@ -39,16 +39,16 @@ export default abstract class MagicLinkController extends Controller {
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path(); this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
} }
getRoutesPrefix(): string { public getRoutesPrefix(): string {
return '/magic'; return '/magic';
} }
routes(): void { public routes(): void {
this.get('/lobby', this.getLobby, 'magic_link_lobby'); this.get('/lobby', this.getLobby, 'magic_link_lobby');
this.get('/link', this.getMagicLink, 'magic_link'); this.get('/link', this.getMagicLink, 'magic_link');
} }
private async getLobby(req: Request, res: Response): Promise<void> { protected async getLobby(req: Request, res: Response): Promise<void> {
const link = await MagicLink.bySessionID(req.sessionID!); const link = await MagicLink.bySessionID(req.sessionID!);
if (!link) { if (!link) {
throw new NotFoundHttpError('magic link', req.url); throw new NotFoundHttpError('magic link', req.url);
@ -66,14 +66,14 @@ export default abstract class MagicLinkController extends Controller {
} }
res.render('magic_link_lobby', { res.render('magic_link_lobby', {
email: await link.getEmail(), email: link.getEmail(),
type: await link.getActionType(), type: link.getActionType(),
validUntil: link.getExpirationDate().getTime(), validUntil: link.getExpirationDate().getTime(),
websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath, websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath,
}); });
} }
private async getMagicLink(req: Request, res: Response): Promise<void> { protected async getMagicLink(req: Request, res: Response): Promise<void> {
const id = parseInt(<string>req.query.id); const id = parseInt(<string>req.query.id);
const token = <string>req.query.token; const token = <string>req.query.token;
if (!id || !token) throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl); if (!id || !token) throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl);

View File

@ -0,0 +1,15 @@
import Migration from "../../db/Migration";
import {Connection} from "mysql";
export default class DropNameFromUsers extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN name', connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE users ADD COLUMN name VARCHAR(64)', connection);
}
public registerModels(): void {
}
}

View File

@ -4,10 +4,10 @@ import {Connection} from "mysql";
export default class FixUserMainEmailRelation extends Migration { export default class FixUserMainEmailRelation extends Migration {
public async install(connection: Connection): Promise<void> { public async install(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
ADD COLUMN main_user_email_id INT, ADD COLUMN main_email_id INT,
ADD FOREIGN KEY main_user_email_fk (main_user_email_id) REFERENCES user_emails (id)`, connection); ADD FOREIGN KEY main_user_email_fk (main_email_id) REFERENCES user_emails (id)`, connection);
await this.query(`UPDATE users u LEFT JOIN user_emails ue ON u.id = ue.user_id await this.query(`UPDATE users u LEFT JOIN user_emails ue ON u.id = ue.user_id
SET u.main_user_email_id=ue.id SET u.main_email_id=ue.id
WHERE ue.main = true`, connection); WHERE ue.main = true`, connection);
await this.query(`ALTER TABLE user_emails await this.query(`ALTER TABLE user_emails
DROP COLUMN main`, connection); DROP COLUMN main`, connection);
@ -16,11 +16,11 @@ export default class FixUserMainEmailRelation extends Migration {
public async rollback(connection: Connection): Promise<void> { public async rollback(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE user_emails await this.query(`ALTER TABLE user_emails
ADD COLUMN main BOOLEAN DEFAULT false`, connection); ADD COLUMN main BOOLEAN DEFAULT false`, connection);
await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_user_email_id await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_email_id
SET ue.main = true`, connection) SET ue.main = true`, connection)
await this.query(`ALTER TABLE users await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_user_email_fk, DROP FOREIGN KEY main_user_email_fk,
DROP COLUMN main_user_email_id`, connection); DROP COLUMN main_email_id`, connection);
} }
public registerModels(): void { public registerModels(): void {

View File

@ -7,7 +7,7 @@ import argon2 from "argon2";
import {WhereTest} from "../../db/ModelQuery"; import {WhereTest} from "../../db/ModelQuery";
import UserEmail from "./UserEmail"; import UserEmail from "./UserEmail";
export default class MagicLink extends Model implements AuthProof { export default class MagicLink extends Model implements AuthProof<User> {
public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> { public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> {
let query = this.select().where('session_id', sessionID); let query = this.select().where('session_id', sessionID);
if (actionType !== undefined) { if (actionType !== undefined) {
@ -31,15 +31,12 @@ export default class MagicLink extends Model implements AuthProof {
private action_type?: string = undefined; private action_type?: string = undefined;
private original_url?: string = undefined; private original_url?: string = undefined;
private generated_at?: Date = undefined; private generated_at?: Date = undefined;
private authorized?: boolean = undefined; private authorized: boolean = false;
constructor(data: any) { constructor(data: any) {
super(data); super(data);
if (this.action_type === undefined) throw new Error('Action type must always be defined.'); if (this.action_type === undefined) throw new Error('Action type must always be defined.');
if (this.original_url === undefined) throw new Error('Origin url must always be defined.'); if (this.original_url === undefined) throw new Error('Origin url must always be defined.');
if (this.authorized === undefined) {
this.authorized = false;
}
} }
protected init(): void { protected init(): void {
@ -51,13 +48,9 @@ export default class MagicLink extends Model implements AuthProof {
this.setValidation('authorized').defined(); this.setValidation('authorized').defined();
} }
public async isOwnedBy(userId: number): Promise<boolean> { public async getResource(): Promise<User | null> {
const user = await this.getUser();
return user !== null && user.id === userId;
}
public async getUser(): Promise<User | null> {
const email = await UserEmail.select() const email = await UserEmail.select()
.with('user')
.where('email', await this.getEmail()) .where('email', await this.getEmail())
.first(); .first();
return email ? email.user.get() : null; return email ? email.user.get() : null;
@ -68,15 +61,18 @@ export default class MagicLink extends Model implements AuthProof {
} }
public async isValid(): Promise<boolean> { public async isValid(): Promise<boolean> {
if (await this.isAuthorized()) return true; return await this.isAuthorized() ||
new Date().getTime() < this.getExpirationDate().getTime();
return new Date().getTime() < this.getExpirationDate().getTime();
} }
public async isAuthorized(): Promise<boolean> { public async isAuthorized(): Promise<boolean> {
return this.authorized!; return this.authorized!;
} }
public authorize() {
this.authorized = true;
}
public async generateToken(email: string): Promise<string> { public async generateToken(email: string): Promise<string> {
const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64 const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64
this.email = email; this.email = email;
@ -84,7 +80,8 @@ export default class MagicLink extends Model implements AuthProof {
this.token = await argon2.hash(rawToken, { this.token = await argon2.hash(rawToken, {
timeCost: 10, timeCost: 10,
memoryCost: 4096, memoryCost: 4096,
parallelism: 4 parallelism: 4,
hashLength: 32,
}); });
return rawToken; return rawToken;
} }
@ -104,7 +101,7 @@ export default class MagicLink extends Model implements AuthProof {
return this.session_id!; return this.session_id!;
} }
public async getEmail(): Promise<string> { public getEmail(): string {
return this.email!; return this.email!;
} }
@ -121,8 +118,4 @@ export default class MagicLink extends Model implements AuthProof {
return new Date(this.generated_at.getTime() + MagicLink.validityPeriod()); return new Date(this.generated_at.getTime() + MagicLink.validityPeriod());
} }
public authorize() {
this.authorized = true;
}
} }

View File

@ -13,8 +13,7 @@ export default class User extends Model {
} }
public readonly id?: number = undefined; public readonly id?: number = undefined;
public name?: string = undefined; public main_email_id?: number = undefined;
public main_user_email_id?: number = undefined;
public is_admin: boolean = false; public is_admin: boolean = false;
public created_at?: Date = undefined; public created_at?: Date = undefined;
public updated_at?: Date = undefined; public updated_at?: Date = undefined;
@ -24,7 +23,7 @@ export default class User extends Model {
foreignKey: 'user_id' foreignKey: 'user_id'
}); });
public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_user_email_id)); public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_email_id));
public constructor(data: any) { public constructor(data: any) {
super(data); super(data);
@ -32,7 +31,7 @@ export default class User extends Model {
protected init(): void { protected init(): void {
this.setValidation('name').acceptUndefined().between(3, 64); this.setValidation('name').acceptUndefined().between(3, 64);
this.setValidation('main_user_email_id').acceptUndefined().exists(UserEmail, 'id'); this.setValidation('main_email_id').acceptUndefined().exists(UserEmail, 'id');
if (User.isApprovalMode()) { if (User.isApprovalMode()) {
this.setValidation('approved').defined(); this.setValidation('approved').defined();
} }

View File

@ -19,8 +19,8 @@ export default abstract class ModelComponent<T extends Model> {
} }
} }
protected setValidation<T>(propertyName: keyof this): Validator<T> { protected setValidation<V>(propertyName: keyof this): Validator<V> {
const validator = new Validator<T>(); const validator = new Validator<V>();
this._validators[propertyName as string] = validator; this._validators[propertyName as string] = validator;
return validator; return validator;
} }

View File

@ -2,10 +2,14 @@ import CreateMigrationsTable from "../src/migrations/CreateMigrationsTable";
import CreateLogsTable from "../src/migrations/CreateLogsTable"; import CreateLogsTable from "../src/migrations/CreateLogsTable";
import CreateUsersAndUserEmailsTable from "../src/auth/migrations/CreateUsersAndUserEmailsTable"; import CreateUsersAndUserEmailsTable from "../src/auth/migrations/CreateUsersAndUserEmailsTable";
import FixUserMainEmailRelation from "../src/auth/migrations/FixUserMainEmailRelation"; import FixUserMainEmailRelation from "../src/auth/migrations/FixUserMainEmailRelation";
import DropNameFromUsers from "../src/auth/migrations/DropNameFromUsers";
import CreateMagicLinksTable from "../src/auth/migrations/CreateMagicLinksTable";
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
CreateLogsTable, CreateLogsTable,
CreateUsersAndUserEmailsTable, CreateUsersAndUserEmailsTable,
FixUserMainEmailRelation, FixUserMainEmailRelation,
DropNameFromUsers,
CreateMagicLinksTable,
]; ];