Add basic account approval

This commit is contained in:
Alice Gaudon 2020-06-27 16:31:36 +02:00
parent aaf03a7ca7
commit 6f7b56d104
16 changed files with 637 additions and 495 deletions

View File

@ -407,6 +407,14 @@ button, .button {
thead tr:hover {
background-color: transparent;
}
th.shrink-col {
width: 0;
}
}
.max-content {
width: max-content;
}
// ---

View File

@ -18,4 +18,5 @@ export default Object.assign(require("wms-core/config/default").default, {
newlyGeneratedSlugSize: 3,
default_file_ttl: 30, // 30 seconds
max_upload_size: 1, // MB
approval_mode: false,
});

View File

@ -16,4 +16,5 @@ export default Object.assign(require("wms-core/config/production").default, {
newlyGeneratedSlugSize: 5,
default_file_ttl: 30 * 24 * 3600, // 30 days
max_upload_size: 8192, // MB
approval_mode: true,
});

View File

@ -34,6 +34,8 @@ import {MagicLinkActionType} from "./controllers/MagicLinkActionType";
import {Request} from "express";
import CreateFilesTable from "./migrations/CreateFilesTable";
import IncreaseFilesSizeField from "./migrations/IncreaseFilesSizeField";
import AddApprovedFieldToUsersTable from "wms-core/auth/migrations/AddApprovedFieldToUsersTable";
import BackendController from "./controllers/BackendController";
export default class App extends Application {
private readonly port: number;
@ -53,6 +55,7 @@ export default class App extends Application {
CreateAuthTokensTable,
CreateFilesTable,
IncreaseFilesSizeField,
AddApprovedFieldToUsersTable,
];
}
@ -96,7 +99,7 @@ export default class App extends Application {
public async getProofForRequest(req: Request): Promise<MagicLink | AuthToken | null> {
const authorization = req.header('Authorization');
if (authorization) {
const token = await AuthToken.getBySecret(authorization);
const token = await AuthToken.select().where('secret', authorization).first();
if (token) {
token.use();
await token.save();
@ -128,6 +131,7 @@ export default class App extends Application {
// Priority
this.use(new AuthController());
this.use(new MagicLinkController(this.magicLinkWebSocketListener!));
this.use(new BackendController());
// Core functionality
this.use(new MailController());

12
src/Mails.ts Normal file
View File

@ -0,0 +1,12 @@
import {MailTemplate} from "wms-core/Mail";
import config from "config";
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'account_review_notice',
data => `Your account was ${data.approved ? 'approved' : 'rejected'}.`
);
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
'pending_account_review',
() => 'A new account is pending review on ' + config.get<string>('domain'),
);

View File

@ -0,0 +1,69 @@
import Controller from "wms-core/Controller";
import {REQUIRE_ADMIN_MIDDLEWARE, REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import User from "wms-core/auth/models/User";
import {NotFoundHttpError} from "wms-core/HttpError";
import {ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE} from "../Mails";
import Mail from "wms-core/Mail";
import config from "config";
export default class BackendController extends Controller {
getRoutesPrefix(): string {
return '/backend';
}
routes(): void {
this.get('/', this.getIndex, 'backend', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
if (User.isApprovalMode()) {
this.get('/accounts-approval', this.getAccountApproval, 'accounts-approval', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/approve/:id', this.getApproveAccount, 'approve-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/accounts-approval/reject/:id', this.getRejectAccount, 'reject-account', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
}
}
public async getIndex(req: Request, res: Response): Promise<void> {
res.render('backend/index', {
approval_mode: User.isApprovalMode(),
accounts_to_approve: User.isApprovalMode() ? await User.select().count() : 0,
});
}
public async getAccountApproval(req: Request, res: Response): Promise<void> {
const accounts = await User.select().where('approved', 0).with('mainEmail').get();
res.render('backend/accounts_approval', {
accounts: User.isApprovalMode() ? accounts : 0,
});
}
public async getApproveAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
account.approved = true;
await account.save();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: true,
link: config.get<string>('base_url') + Controller.route('auth'),
}).send(email!.email!);
req.flash('success', `Account successfully approved.`);
res.redirectBack(Controller.route('accounts-approval'));
}
public async getRejectAccount(req: Request, res: Response): Promise<void> {
const account = await User.select().where('id', req.params.id).with('mainEmail').first();
if (!account) throw new NotFoundHttpError('User', req.url);
const email = await account.mainEmail.get();
await account.delete();
await new Mail(ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE, {
approved: false,
}).send(email!.email!);
req.flash('success', `Account successfully deleted.`);
res.redirectBack(Controller.route('accounts-approval'));
}
}

View File

@ -30,7 +30,7 @@ export default class FileController extends Controller {
protected async getFileUploader(req: Request, res: Response): Promise<void> {
res.render('file-upload', {
max_upload_size: config.get<string>('max_upload_size'),
auth_tokens: await AuthToken.getForUser(req.models.user!.id!),
auth_tokens: await AuthToken.select().where('user_id', req.models.user!.id!),
});
}
@ -54,7 +54,7 @@ export default class FileController extends Controller {
const id = req.params.id;
if (!id) throw new BadRequestError('Cannot revoke token without an id.', 'Please provide an id.', req.url);
const authToken = await AuthToken.getById<AuthToken>(`${id}`);
const authToken = await AuthToken.getById<AuthToken>(parseInt(id));
if (!authToken) throw new NotFoundHttpError('Auth token', req.url);
if (!authToken.canDelete(req.models.user!.id!)) throw new ForbiddenHttpError('auth token', req.url);

View File

@ -15,12 +15,14 @@ export default class MagicLinkController extends _MagicLinkController {
switch (magicLink.getActionType()) {
case MagicLinkActionType.LOGIN:
case MagicLinkActionType.REGISTER:
await AuthController.checkAndAuth(req, magicLink);
await AuthController.checkAndAuth(req, res, magicLink);
if (!res.headersSent) {
// Auth success
const user = await req.authGuard.getUserForSession(req.session!);
req.flash('success', `Authentication success. Welcome, ${user?.name}!`);
res.redirect(Controller.route('home'));
}
break;
}
}

View File

@ -6,15 +6,6 @@ import Validator from "wms-core/db/Validator";
import {cryptoRandomDictionary} from "wms-core/Utils";
export default class AuthToken extends Model implements AuthProof {
public static async getBySecret(secret: string): Promise<AuthToken | null> {
const models = await this.models<AuthToken>(this.select().where('secret', secret).first());
return models.length > 0 ? models[0] : null;
}
public static async getForUser(user_id: number): Promise<AuthToken[]> {
return await this.models<AuthToken>(this.select().where('user_id', user_id));
}
protected readonly user_id!: number;
protected readonly secret!: string;
protected created_at?: Date;
@ -30,12 +21,12 @@ export default class AuthToken extends Model implements AuthProof {
}
protected defineProperties() {
this.defineProperty('user_id', new Validator().defined().exists(User, 'id'));
this.defineProperty('secret', new Validator().defined().between(32, 64));
this.defineProperty('created_at', new Validator());
this.defineProperty('used_at', new Validator());
this.defineProperty('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('secret', new Validator().defined().between(32, 64));
this.addProperty('created_at', new Validator());
this.addProperty('used_at', new Validator());
this.addProperty('ttl', new Validator().defined().min(1).max(5 * 365 * 24 * 3600)); // max 5 years
}
public use() {
@ -53,13 +44,13 @@ export default class AuthToken extends Model implements AuthProof {
}
public async getEmail(): Promise<string> {
let userEmail = await UserEmail.getMainFromUser(this.user_id);
let userEmail = await UserEmail.select().where('user_id', this.user_id).first();
if (!userEmail) throw new Error("Cannot find main user email for user " + this.user_id);
return userEmail.email;
}
public async getUser(): Promise<User | null> {
return await User.getById<User>(`${this.user_id}`);
return await User.getById<User>(this.user_id);
}
public async isAuthorized(): Promise<boolean> {

View File

@ -11,8 +11,7 @@ export default class FileModel extends Model {
}
public static async getBySlug(slug: string): Promise<FileModel | null> {
const models = await this.models<FileModel>(this.select().where('slug', slug).first());
return models.length > 0 ? models[0] : null;
return await this.select().where('slug', slug).first();
}
public static async paginateForUser(req: Request, perPage: number, user_id: number): Promise<FileModel[]> {
@ -28,15 +27,15 @@ export default class FileModel extends Model {
public created_at?: Date;
public readonly ttl!: number;
protected defineProperties() {
this.defineProperty('user_id', new Validator().defined().exists(User, 'id'));
this.defineProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(this, 'slug'));
this.defineProperty('real_name', new Validator().defined().minLength(1).maxLength(259));
this.defineProperty('storage_type', new Validator().defined().maxLength(64));
this.defineProperty('storage_path', new Validator().defined().maxLength(1745));
this.defineProperty('size', new Validator().defined().min(0));
this.defineProperty('created_at', new Validator());
this.defineProperty('ttl', new Validator().defined().min(0).max(4294967295));
protected init() {
this.addProperty('user_id', new Validator().defined().exists(User, 'id'));
this.addProperty('slug', new Validator().defined().minLength(1).maxLength(259).unique(this, 'slug'));
this.addProperty('real_name', new Validator().defined().minLength(1).maxLength(259));
this.addProperty('storage_type', new Validator().defined().maxLength(64));
this.addProperty('storage_path', new Validator().defined().maxLength(1745));
this.addProperty('size', new Validator().defined().min(0));
this.addProperty('created_at', new Validator());
this.addProperty('ttl', new Validator().defined().min(0).max(4294967295));
}
public getURL(): string {

View File

@ -0,0 +1,41 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ily.li - Review accounts' %}
{% block body %}
<h1>Accounts pending review</h1>
<div class="panel">
<table class="data-table">
<thead>
<tr>
<th class="shrink-col">#</th>
<th>Name</th>
<th>Main email</th>
<th>Registered at</th>
<th class="shrink-col">Action</th>
</tr>
</thead>
<tbody>
{% for user in accounts %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.mainEmail.getOrFail().email }}</td>
<td>{{ user.created_at.toISOString() }}</td>
<td>
<div class="max-content">
<a href="{{ route('approve-account', user.id) }}"
class="button success"><i data-feather="check"></i> Approve</a>
<a href="{{ route('reject-account', user.id) }}"
onclick="return confirm(`This will irrevocably delete the ${user.main_email} account.`)"
class="button danger"><i data-feather="x"></i> Reject</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

21
views/backend/index.njk Normal file
View File

@ -0,0 +1,21 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ily.li - Backend' %}
{% block body %}
<h1>App administration</h1>
<div class="container">
<div class="panel">
<nav>
<ul>
{% if approval_mode %}
<li>
<a href="{{ route('accounts-approval') }}">Accounts approval ({{ accounts_to_approve }})</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endblock %}

View File

@ -19,6 +19,9 @@
{% if user %}
<li><a href="{{ route('file-upload') }}"><i data-feather="upload"></i> File uploader</a></li>
<li><a href="{{ route('file-manager') }}"><i data-feather="folder"></i> File manager</a></li>
{% if user.is_admin %}
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> Backend</a></li>
{% endif %}
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> Logout</a></li>
{% else %}
<li><a href="{{ route('auth') }}"><i data-feather="user"></i> Login / Register</a></li>

View File

@ -0,0 +1,41 @@
{% extends './base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
{% if approved %}
Your registration was approved!
{% else %}
Sorry, your registration was rejected.
{% endif %}
</mj-text>
<mj-text>
{% if approved %}
An administrator approved your registration. You can now log in to your account.
{% else %}
Your registration was rejected and your account was deleted from our database.
If you believe that this is an error, please contact us via email.
{% endif %}
</mj-text>
{% if approved %}
<mj-button href="{{ link | safe }}">Login</mj-button>
{% endif %}
</mj-column>
</mj-section>
{% endblock %}
{% block text %}
{% if approved %}
Hi
Your registration was approved!
You can now log in to your account by follwing this link: {{ link|safe }}
{% else %}
Hi
Sorry, your registration was rejected. Your account was deleted from our database.
If you believe that this is an error, please contact us via email.
{% endif %}
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends './base_layout.mjml.njk' %}
{% block body %}
<mj-section>
<mj-column>
<mj-text mj-class="title">
{% if type == 'register' %}
Register an account on (app)
{% else %}
Log in to (app)
{% endif %}
</mj-text>
<mj-text>
{% if type == 'register' %}
Someone has requested an account registration for <strong>{{ mail_to }}</strong>. If it was not you,
please ignore this message.
{% else %}
Someone is attempting to log in to your account <strong>{{ mail_to }}</strong>.
{% endif %}
</mj-text>
{% if type == 'register' %}
<mj-button href="{{ link | safe }}">Finalize my account registration</mj-button>
{% else %}
<mj-text>If it is not you, <strong>DO NOT CLICK ON THIS BUTTON</strong>.</mj-text>
{% endif %}
</mj-column>
</mj-section>
{% if type == 'login' %}
<mj-section background-color="#1b252d">
<mj-column>
<mj-text mj-class="important-line" padding-bottom="0px">
IP: <strong>{{ ip }}</strong>
</mj-text>
<mj-text mj-class="important-line">
Location: <strong>{{ geo }}</strong>
</mj-text>
</mj-column>
<mj-column>
<mj-button href="{{ link | safe }}" padding="20px 0" background-color="#caa200">
Authorize log in
</mj-button>
</mj-column>
</mj-section>
{% endif %}
{% endblock %}
{% block text %}
{% if type == 'register' %}
Hi!
Someone requested an account registration for {{ mail_to }}. If it was not you,
please ignore this message.
To finalize your account registration, please follow this link: {{ link|safe }}
{% else %}
Hi!
Someone is attempting to log in to your account {{ mail_to }}.
If it is not you, DO NOT FOLLOW THIS LINK.
IP: {{ ip }}
Location: {{ geo }}
To authorize this log in, please follow this link: {{ link|safe }}
{% endif %}
{% endblock %}

797
yarn.lock

File diff suppressed because it is too large Load Diff