From ad20894565ecef6bc0fb2be33a8485a1f2681f36 Mon Sep 17 00:00:00 2001 From: Alice Gaudon Date: Fri, 24 Apr 2020 12:12:27 +0200 Subject: [PATCH] Add auth utils parts --- package.json | 2 +- src/auth/AuthComponent.ts | 52 ++++++++++++ src/auth/AuthGuard.ts | 80 +++++++++++++++++++ src/auth/AuthProof.ts | 15 ++++ .../CreateUsersAndUserEmailsTable.ts | 29 +++++++ src/auth/models/User.ts | 27 +++++++ src/auth/models/UserEmail.ts | 62 ++++++++++++++ src/types/Express.d.ts | 2 + test/_migrations.ts | 2 + 9 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/auth/AuthComponent.ts create mode 100644 src/auth/AuthGuard.ts create mode 100644 src/auth/AuthProof.ts create mode 100644 src/auth/migrations/CreateUsersAndUserEmailsTable.ts create mode 100644 src/auth/models/User.ts create mode 100644 src/auth/models/UserEmail.ts diff --git a/package.json b/package.json index 5dd39b6..a52d2dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wms-core", - "version": "0.2.11", + "version": "0.2.16", "description": "Node web framework", "repository": "git@gitlab.com:ArisuOngaku/wms-core.git", "author": "Alice Gaudon ", diff --git a/src/auth/AuthComponent.ts b/src/auth/AuthComponent.ts new file mode 100644 index 0000000..1a99e48 --- /dev/null +++ b/src/auth/AuthComponent.ts @@ -0,0 +1,52 @@ +import ApplicationComponent from "../ApplicationComponent"; +import {Express, NextFunction, Request, Response, Router} from "express"; +import AuthGuard from "./AuthGuard"; +import Controller from "../Controller"; +import {ForbiddenHttpError} from "../HttpError"; + +export default class AuthComponent extends ApplicationComponent { + private readonly authGuard: AuthGuard; + + public constructor(authGuard: AuthGuard) { + super(); + this.authGuard = authGuard; + } + + public async start(app: Express, router: Router): Promise { + router.use(async (req, res, next) => { + req.authGuard = this.authGuard; + res.locals.user = await req.authGuard.getUserForSession(req.session!); + next(); + }); + } + + public async stop(): Promise { + } +} + + +export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise => { + if (!await req.authGuard.isAuthenticated(req.session!)) { + req.flash('error', `You must be logged in to access ${req.url}.`); + res.redirect(Controller.route('auth') || '/'); + return; + } + + req.models.user = await req.authGuard.getUserForSession(req.session!); + next(); +}; +export const REQUIRE_GUEST_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise => { + if (await req.authGuard.isAuthenticated(req.session!)) { + res.redirectBack('/'); + return; + } + + next(); +}; +export const REQUIRE_ADMIN_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.models.user || !req.models.user.isAdmin()) { + throw new ForbiddenHttpError('secret tool', req.url); + } + + next(); +}; \ No newline at end of file diff --git a/src/auth/AuthGuard.ts b/src/auth/AuthGuard.ts new file mode 100644 index 0000000..da91bfe --- /dev/null +++ b/src/auth/AuthGuard.ts @@ -0,0 +1,80 @@ +import AuthProof from "./AuthProof"; +import MysqlConnectionManager from "../db/MysqlConnectionManager"; +import User from "./models/User"; +import UserEmail from "./models/UserEmail"; + +export default abstract class AuthGuard

{ + public abstract async getProofForSession(sessionID: string): Promise

; + + public async getUserForSession(session: Express.Session): Promise { + if (!await this.isAuthenticated(session)) return null; + return await User.getById(session.auth_id); + } + + public async authenticateOrRegister(session: Express.Session, proof: P): Promise { + if (!await proof.isAuthorized()) { + throw new AuthError('Invalid argument: cannot authenticate with an unauthorized proof.'); + } + + let user = await proof.getUser(); + if (!user) { // Register + const callbacks: (() => Promise)[] = []; + + await MysqlConnectionManager.wrapTransaction(async connection => { + const email = await proof.getEmail(); + user = new User({ + name: email.split('@')[0], + }); + 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)); + }); + + for (const callback of callbacks) { + await callback(); + } + + if (!user) { + throw new Error('Unable to register user.'); + } + } + + session.auth_id = user.id; + } + + public async isAuthenticated(session: Express.Session): Promise { + return await this.checkCurrentSessionProofValidity(session); + } + + public async logout(session: Express.Session): Promise { + const proof = await this.getProofForSession(session.id); + if (proof) { + await proof.revoke(); + } + session.auth_id = undefined; + } + + private async checkCurrentSessionProofValidity(session: Express.Session): Promise { + if (typeof session.auth_id !== 'number') return false; + + const proof = await this.getProofForSession(session.id); + + if (!proof || !await proof.isValid() || !await proof.isAuthorized() || !await proof.isOwnedBy(session.auth_id)) { + await this.logout(session); + return false; + } + + return true; + } +} + +export class AuthError extends Error { + constructor(message: string) { + super(message); + } +} \ No newline at end of file diff --git a/src/auth/AuthProof.ts b/src/auth/AuthProof.ts new file mode 100644 index 0000000..a4a3220 --- /dev/null +++ b/src/auth/AuthProof.ts @@ -0,0 +1,15 @@ +import User from "./models/User"; + +export default interface AuthProof { + isValid(): Promise; + + isAuthorized(): Promise; + + isOwnedBy(userId: number): Promise; + + getUser(): Promise; + + getEmail(): Promise; + + revoke(): Promise; +} \ No newline at end of file diff --git a/src/auth/migrations/CreateUsersAndUserEmailsTable.ts b/src/auth/migrations/CreateUsersAndUserEmailsTable.ts new file mode 100644 index 0000000..0266ad1 --- /dev/null +++ b/src/auth/migrations/CreateUsersAndUserEmailsTable.ts @@ -0,0 +1,29 @@ +import Migration from "../../db/Migration"; +import {query} from "../../db/MysqlConnectionManager"; + +export default class CreateUsersAndUserEmailsTable extends Migration { + async install(): Promise { + await query('CREATE TABLE users(' + + 'id INT NOT NULL AUTO_INCREMENT,' + + 'name VARCHAR(64),' + + 'is_admin BOOLEAN NOT NULL DEFAULT false,' + + 'created_at DATETIME NOT NULL DEFAULT NOW(),' + + 'updated_at DATETIME NOT NULL DEFAULT NOW(),' + + 'PRIMARY KEY(id)' + + ')'); + await query('CREATE TABLE user_emails(' + + 'id INT NOT NULL AUTO_INCREMENT,' + + 'user_id INT,' + + 'email VARCHAR(254) UNIQUE NOT NULL,' + + 'main BOOLEAN NOT NULL,' + + 'created_at DATETIME NOT NULL DEFAULT NOW(),' + + 'PRIMARY KEY(id),' + + 'FOREIGN KEY user_fk (user_id) REFERENCES users (id) ON DELETE CASCADE' + + ')'); + } + + async rollback(): Promise { + await query('DROP TABLE user_emails'); + await query('DROP TABLE users'); + } +} \ No newline at end of file diff --git a/src/auth/models/User.ts b/src/auth/models/User.ts new file mode 100644 index 0000000..1a2dbec --- /dev/null +++ b/src/auth/models/User.ts @@ -0,0 +1,27 @@ +import UserEmail from "./UserEmail"; +import Model from "../../db/Model"; +import Validator from "../../db/Validator"; + +export default class User extends Model { + public static async fromEmail(email: string): Promise { + const users = await this.models(this.select().where('id', UserEmail.select('user_id').where('email', email).first()).first()); + return users.length > 0 ? users[0] : null; + } + + public name?: string; + private is_admin?: boolean; + public created_at?: Date; + public updated_at?: Date; + + protected defineProperties(): void { + this.defineProperty('name', new Validator().acceptUndefined().between(3, 64)); + this.defineProperty('is_admin', new Validator()); + this.defineProperty('created_at'); + this.defineProperty('updated_at'); + } + + public isAdmin(): boolean { + // @ts-ignore + return this.is_admin === true || this.is_admin === 1; + } +} \ No newline at end of file diff --git a/src/auth/models/UserEmail.ts b/src/auth/models/UserEmail.ts new file mode 100644 index 0000000..8ef284c --- /dev/null +++ b/src/auth/models/UserEmail.ts @@ -0,0 +1,62 @@ +import User from "./User"; +import {Connection} from "mysql"; +import Model, {EMAIL_REGEX, ModelCache} from "../../db/Model"; +import Validator from "../../db/Validator"; +import {query} from "../../db/MysqlConnectionManager"; + +export default class UserEmail extends Model { + public static async fromEmail(email: any): Promise { + const emails = await this.models(this.select().where('email', email).first()); + return emails.length > 0 ? emails[0] : null; + } + + public user_id?: number; + public email?: string; + private main?: boolean; + public created_at?: Date; + + private wasSetToMain: boolean = false; + + constructor(data: any) { + super(data); + } + + protected defineProperties(): void { + this.defineProperty('user_id', new Validator().acceptUndefined().exists(User, 'id')); + this.defineProperty('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this)); + this.defineProperty('main', new Validator().defined()); + this.defineProperty('created_at', new Validator()); + } + + async beforeSave(exists: boolean, connection: Connection) { + if (this.wasSetToMain) { + await query(`UPDATE ${this.table} SET main=false WHERE user_id=${this.user_id}`, null, connection); + } + } + + protected async afterSave(): Promise { + if (this.wasSetToMain) { + this.wasSetToMain = false; + const emails = ModelCache.all(this.table); + if (emails) { + for (const id in emails) { + const otherEmail = emails[id]; + if (otherEmail.id !== this.id && otherEmail.user_id === this.user_id) { + otherEmail.main = false; + } + } + } + } + } + + public isMain(): boolean { + return !!this.main; + } + + public setMain() { + if (!this.isMain()) { + this.main = true; + this.wasSetToMain = true; + } + } +} \ No newline at end of file diff --git a/src/types/Express.d.ts b/src/types/Express.d.ts index ce55917..564c153 100644 --- a/src/types/Express.d.ts +++ b/src/types/Express.d.ts @@ -1,5 +1,6 @@ import {Environment} from "nunjucks"; import Model from "../db/Model"; +import AuthGuard from "../auth/AuthGuard"; declare global { namespace Express { @@ -7,6 +8,7 @@ declare global { env: Environment; models: { [p: string]: Model | null }; modelCollections: { [p: string]: Model[] | null }; + authGuard: AuthGuard; flash(): { [key: string]: string[] }; diff --git a/test/_migrations.ts b/test/_migrations.ts index 020829c..aa64fdd 100644 --- a/test/_migrations.ts +++ b/test/_migrations.ts @@ -1,7 +1,9 @@ import CreateMigrationsTable from "../src/migrations/CreateMigrationsTable"; import CreateLogsTable from "../src/migrations/CreateLogsTable"; +import CreateUsersAndUserEmailsTable from "../src/auth/migrations/CreateUsersAndUserEmailsTable"; export const MIGRATIONS = [ CreateMigrationsTable, CreateLogsTable, + CreateUsersAndUserEmailsTable, ]; \ No newline at end of file