Add optional user approval mode

This commit is contained in:
Alice Gaudon 2020-06-16 11:12:58 +02:00
parent 0a5fcfa7a4
commit c8157b7bb0
9 changed files with 95 additions and 13 deletions

View File

@ -34,5 +34,6 @@ export default {
}, },
view: { view: {
cache: false cache: false
} },
approval_mode: false,
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "wms-core", "name": "wms-core",
"version": "0.8.12", "version": "0.9.3",
"description": "Node web framework", "description": "Node web framework",
"repository": "git@gitlab.com:ArisuOngaku/wms-core.git", "repository": "git@gitlab.com:ArisuOngaku/wms-core.git",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",

View File

@ -30,7 +30,7 @@ export const REQUIRE_REQUEST_AUTH_MIDDLEWARE = async (req: Request, res: Respons
res.redirect(Controller.route('auth') || '/'); res.redirect(Controller.route('auth') || '/');
return; return;
} }
req.models.user = await req.authGuard.getUserForRequest(req); req.models.user = await req.authGuard.getUserForRequest(req);
next(); next();
}; };
@ -59,7 +59,7 @@ export const REQUIRE_GUEST_MIDDLEWARE = async (req: Request, res: Response, next
next(); next();
}; };
export const REQUIRE_ADMIN_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => { export const REQUIRE_ADMIN_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.models.user || !req.models.user.isAdmin()) { if (!req.models.user || !req.models.user.is_admin) {
throw new ForbiddenHttpError('secret tool', req.url); throw new ForbiddenHttpError('secret tool', req.url);
} }

View File

@ -55,6 +55,8 @@ export default abstract class AuthGuard<P extends AuthProof> {
throw new UserAlreadyExistsAuthError(await proof.getEmail()); throw new UserAlreadyExistsAuthError(await proof.getEmail());
} }
if (!user.isApproved()) throw new PendingApprovalAuthError();
session.auth_id = user.id; session.auth_id = user.id;
} }
@ -112,4 +114,10 @@ export class UserAlreadyExistsAuthError extends AuthError {
super(`User with email ${userEmail} already exists.`); super(`User with email ${userEmail} already exists.`);
this.email = userEmail; this.email = userEmail;
} }
}
export class PendingApprovalAuthError extends AuthError {
constructor() {
super(`User is not approved.`);
}
} }

View File

@ -6,11 +6,11 @@ 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} from "../AuthGuard"; import {AuthError, PendingApprovalAuthError} from "../AuthGuard";
import geoip from "geoip-lite"; import geoip from "geoip-lite";
export default abstract class AuthController extends Controller { export default abstract class MagicLinkAuthController extends Controller {
public static async checkAndAuth(req: Request, magicLink: MagicLink): Promise<void> { public static async checkAndAuth(req: Request, magicLink: MagicLink): Promise<void> {
if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink(); if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink();
if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink(); if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink();
@ -114,7 +114,27 @@ export default abstract class AuthController extends Controller {
return; return;
} }
await AuthController.checkAndAuth(req, magicLink); try {
await MagicLinkAuthController.checkAndAuth(req, magicLink);
} 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.`
});
},
default: () => {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirectBack('/');
}
});
return;
} else {
throw e;
}
}
// Auth success // Auth success
const username = req.models.user?.name; const username = req.models.user?.name;

View File

@ -0,0 +1,12 @@
import Migration from "../../db/Migration";
import {Connection} from "mysql";
export default class AddApprovedFieldToUsersTable extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0', connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN approved', connection);
}
}

View File

@ -1,6 +1,9 @@
import UserEmail from "./UserEmail"; import UserEmail from "./UserEmail";
import Model from "../../db/Model"; import Model from "../../db/Model";
import Validator from "../../db/Validator"; import Validator from "../../db/Validator";
import MysqlConnectionManager from "../../db/MysqlConnectionManager";
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable";
import config from "config";
export default class User extends Model { export default class User extends Model {
public static async fromEmail(email: string): Promise<User | null> { public static async fromEmail(email: string): Promise<User | null> {
@ -8,20 +11,47 @@ export default class User extends Model {
return users.length > 0 ? users[0] : null; return users.length > 0 ? users[0] : null;
} }
public static async countAccountsToApprove(): Promise<number> {
if (!this.isApprovalMode()) return 0;
return (await this.select('COUNT(*) as c').where('approved', false).execute())
.results[0]['c'];
}
public static async getUsersToApprove(): Promise<User[]> {
if (!this.isApprovalMode()) return [];
return await this.models<User>(this.select('users.*', 'ue.email as main_email')
.where('approved', false)
.leftJoin('user_emails as ue').on('ue.user_id', 'users.id')
.where('ue.main', '1'));
}
public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') && MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
}
public static async getAdminAccounts(): Promise<User[]> {
return await this.models<User>(this.select('users.*', '')
.where('is_admin', true)
.leftJoin('user_emails as ue').on('ue.user_id', 'users.id')
.where('ue.main', true));
}
public name?: string; public name?: string;
private is_admin?: boolean; public approved: boolean = false;
public is_admin: boolean = false;
public created_at?: Date; public created_at?: Date;
public updated_at?: Date; public updated_at?: Date;
protected defineProperties(): void { protected defineProperties(): void {
this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 64)); this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 64));
this.defineProperty<boolean>('is_admin', new Validator()); if (User.isApprovalMode()) this.defineProperty<boolean>('approved', new Validator().defined());
this.defineProperty<boolean>('is_admin', new Validator().defined());
this.defineProperty<Date>('created_at'); this.defineProperty<Date>('created_at');
this.defineProperty<Date>('updated_at'); this.defineProperty<Date>('updated_at');
} }
public isAdmin(): boolean { public isApproved(): boolean {
// @ts-ignore return !User.isApprovalMode() || this.approved!;
return this.is_admin === true || this.is_admin === 1;
} }
} }

View File

@ -32,6 +32,13 @@ export default class MysqlConnectionManager {
this.migrations.push(migration(this.migrations.length + 1)); this.migrations.push(migration(this.migrations.length + 1));
} }
public static hasMigration(migration: Type<Migration>) {
for (const m of this.migrations) {
if (m.constructor === migration) return true;
}
return false;
}
public static async prepare(runMigrations: boolean = true) { public static async prepare(runMigrations: boolean = true) {
if (config.get('mysql.create_database_automatically') === true) { if (config.get('mysql.create_database_automatically') === true) {
const dbName = config.get('mysql.database'); const dbName = config.get('mysql.database');

View File

@ -2,12 +2,16 @@ import {Environment} from "nunjucks";
import Model from "../db/Model"; import Model from "../db/Model";
import AuthGuard from "../auth/AuthGuard"; import AuthGuard from "../auth/AuthGuard";
import {Files} from "formidable"; import {Files} from "formidable";
import User from "../auth/models/User";
declare global { declare global {
namespace Express { namespace Express {
export interface Request { export interface Request {
env: Environment; env: Environment;
models: { [p: string]: Model | null }; models: {
user?: User | null,
[p: string]: Model | null | undefined,
};
modelCollections: { [p: string]: Model[] | null }; modelCollections: { [p: string]: Model[] | null };
authGuard: AuthGuard<any>; authGuard: AuthGuard<any>;
files: Files; files: Files;