Add auth utils parts

This commit is contained in:
Alice Gaudon 2020-04-24 12:12:27 +02:00
parent 7db6c0e0c7
commit ad20894565
9 changed files with 270 additions and 1 deletions

View File

@ -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 <alice@gaudon.pro>",

52
src/auth/AuthComponent.ts Normal file
View File

@ -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<void> {
private readonly authGuard: AuthGuard<any>;
public constructor(authGuard: AuthGuard<any>) {
super();
this.authGuard = authGuard;
}
public async start(app: Express, router: Router): Promise<void> {
router.use(async (req, res, next) => {
req.authGuard = this.authGuard;
res.locals.user = await req.authGuard.getUserForSession(req.session!);
next();
});
}
public async stop(): Promise<void> {
}
}
export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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<void> => {
if (await req.authGuard.isAuthenticated(req.session!)) {
res.redirectBack('/');
return;
}
next();
};
export const REQUIRE_ADMIN_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.models.user || !req.models.user.isAdmin()) {
throw new ForbiddenHttpError('secret tool', req.url);
}
next();
};

80
src/auth/AuthGuard.ts Normal file
View File

@ -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<P extends AuthProof> {
public abstract async getProofForSession(sessionID: string): Promise<P | 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): Promise<void> {
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<void>)[] = [];
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<boolean> {
return await this.checkCurrentSessionProofValidity(session);
}
public async logout(session: Express.Session): Promise<void> {
const proof = await this.getProofForSession(session.id);
if (proof) {
await proof.revoke();
}
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.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);
}
}

15
src/auth/AuthProof.ts Normal file
View File

@ -0,0 +1,15 @@
import User from "./models/User";
export default interface AuthProof {
isValid(): Promise<boolean>;
isAuthorized(): Promise<boolean>;
isOwnedBy(userId: number): Promise<boolean>;
getUser(): Promise<User | null>;
getEmail(): Promise<string>;
revoke(): Promise<void>;
}

View File

@ -0,0 +1,29 @@
import Migration from "../../db/Migration";
import {query} from "../../db/MysqlConnectionManager";
export default class CreateUsersAndUserEmailsTable extends Migration {
async install(): Promise<void> {
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<void> {
await query('DROP TABLE user_emails');
await query('DROP TABLE users');
}
}

27
src/auth/models/User.ts Normal file
View File

@ -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<User | null> {
const users = await this.models<User>(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<string>('name', new Validator().acceptUndefined().between(3, 64));
this.defineProperty<boolean>('is_admin', new Validator());
this.defineProperty<Date>('created_at');
this.defineProperty<Date>('updated_at');
}
public isAdmin(): boolean {
// @ts-ignore
return this.is_admin === true || this.is_admin === 1;
}
}

View File

@ -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<UserEmail | null> {
const emails = await this.models<UserEmail>(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<number>('user_id', new Validator().acceptUndefined().exists(User, 'id'));
this.defineProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this));
this.defineProperty<boolean>('main', new Validator().defined());
this.defineProperty<Date>('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<void> {
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;
}
}
}

View File

@ -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<any>;
flash(): { [key: string]: string[] };

View File

@ -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,
];