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> {
router.use(async (req, res, next) => {
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();
});
}
@ -30,13 +30,13 @@ export const REQUIRE_REQUEST_AUTH_MIDDLEWARE = async (req: Request, res: Respons
return;
}
req.models.user = await req.authGuard.getUserForRequest(req);
req.models.user = await (await req.authGuard.getProofForRequest(req))?.getResource();
next();
};
export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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();
} else {
if (!await req.authGuard.isAuthenticated(req.session!)) {
@ -47,7 +47,7 @@ export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next:
return;
}
req.models.user = await req.authGuard.getUserForSession(req.session!);
req.models.user = await (await req.authGuard.getProofForSession(req.session!))?.getResource();
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 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.');
res.redirect(req.query.redirect_uri?.toString() || '/');
}

View File

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

View File

@ -1,15 +1,40 @@
import User from "./models/User";
export default interface AuthProof {
/**
* This class is most commonly used for authentication. It can be more generically used to represent a verification
* 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>;
/**
* Was this proof authorized?
*
* Return true once the session is proven to own the associated resource.
*/
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>;
getEmail(): Promise<string>;
revoke(session: Express.Session): Promise<void>;
/**
* Manually revokes this authentication proof. Once this method is called, all of the following must be true:
* - {@code isAuthorized} returns {@code false}
* - There is no way to re-authorize this proof (i.e. {@code isAuthorized} can never return {@code true} again)
*
* 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 MagicLinkController from "./MagicLinkController";
import {MailTemplate} from "../../Mail";
import {AuthError, PendingApprovalAuthError} from "../AuthGuard";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import geoip from "geoip-lite";
import AuthController from "../AuthController";
import NunjucksComponent from "../../components/NunjucksComponent";
@ -19,7 +19,19 @@ export default abstract class MagicLinkAuthController extends AuthController {
// Auth
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) {
if (e instanceof PendingApprovalAuthError) {
res.format({

View File

@ -39,16 +39,16 @@ export default abstract class MagicLinkController extends Controller {
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
}
getRoutesPrefix(): string {
public getRoutesPrefix(): string {
return '/magic';
}
routes(): void {
public routes(): void {
this.get('/lobby', this.getLobby, 'magic_link_lobby');
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!);
if (!link) {
throw new NotFoundHttpError('magic link', req.url);
@ -66,14 +66,14 @@ export default abstract class MagicLinkController extends Controller {
}
res.render('magic_link_lobby', {
email: await link.getEmail(),
type: await link.getActionType(),
email: link.getEmail(),
type: link.getActionType(),
validUntil: link.getExpirationDate().getTime(),
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 token = <string>req.query.token;
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 {
public async install(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN main_user_email_id INT,
ADD FOREIGN KEY main_user_email_fk (main_user_email_id) REFERENCES user_emails (id)`, connection);
ADD COLUMN main_email_id INT,
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
SET u.main_user_email_id=ue.id
SET u.main_email_id=ue.id
WHERE ue.main = true`, connection);
await this.query(`ALTER TABLE user_emails
DROP COLUMN main`, connection);
@ -16,11 +16,11 @@ export default class FixUserMainEmailRelation extends Migration {
public async rollback(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE user_emails
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)
await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_user_email_fk,
DROP COLUMN main_user_email_id`, connection);
DROP COLUMN main_email_id`, connection);
}
public registerModels(): void {

View File

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

View File

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