Add basic register and auth system

This commit is contained in:
Alice Gaudon 2020-04-25 09:41:22 +02:00
parent 8f8111faae
commit b5fedda02b
12 changed files with 356 additions and 13 deletions

View File

@ -1,6 +1,7 @@
{
"bundles": {
"app": "js/app.js",
"register": "js/register.js",
"layout": "sass/layout.scss",
"error": "sass/error.scss",
"logo": "img/logo.svg",

22
assets/js/register.js Normal file
View File

@ -0,0 +1,22 @@
document.addEventListener('DOMContentLoaded', () => {
const createEmailAddress = document.getElementById('field-create_email');
const username = document.getElementById('field-username');
const domain = document.getElementById('field-domain');
const recovery_email = document.getElementById('field-recovery_email');
const recovery_email_label = recovery_email.parentElement.querySelector('.hint');
function updateForm() {
if (createEmailAddress.checked) {
recovery_email.removeAttribute('required');
recovery_email_label.style.display = 'block';
username.disabled = domain.disabled = false;
} else {
recovery_email.setAttribute('required', 'required');
recovery_email_label.style.display = 'none';
username.disabled = domain.disabled = true;
}
}
createEmailAddress.addEventListener('change', updateForm);
updateForm();
});

View File

@ -1,8 +1,6 @@
import Application from "wms-core/Application";
import {Type} from "wms-core/Utils";
import Migration from "wms-core/db/Migration";
import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
import CreateLogsTable from "wms-core/migrations/CreateLogsTable";
import ExpressAppComponent from "wms-core/components/ExpressAppComponent";
import NunjucksComponent from "wms-core/components/NunjucksComponent";
import MysqlComponent from "wms-core/components/MysqlComponent";
@ -17,8 +15,13 @@ import FormHelperComponent from "wms-core/components/FormHelperComponent";
import CsrfProtectionComponent from "wms-core/components/CsrfProtectionComponent";
import WebSocketServerComponent from "wms-core/components/WebSocketServerComponent";
import HomeController from "./controllers/HomeController";
import AuthController from "./controllers/AuthController";
import AuthComponent from "wms-core/auth/AuthComponent";
import AuthGuard from "wms-core/auth/AuthGuard";
import {PasswordAuthProof} from "./models/UserPassword";
import {MIGRATIONS} from "./migrations";
export default class ExampleApp extends Application {
export default class Aldap extends Application {
private readonly port: number;
constructor(port: number) {
@ -27,10 +30,7 @@ export default class ExampleApp extends Application {
}
protected getMigrations(): Type<Migration>[] {
return [
CreateMigrationsTable,
CreateLogsTable,
];
return MIGRATIONS;
}
protected async init(): Promise<void> {
@ -74,6 +74,13 @@ export default class ExampleApp extends Application {
// WebSocket server
this.use(new WebSocketServerComponent(this, expressAppComponent, redisComponent));
// Auth
this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof> {
public async getProofForSession(session: Express.Session): Promise<any | null> {
return PasswordAuthProof.getProofForSession(session);
}
}));
}
private registerWebSocketListeners() {
@ -81,5 +88,6 @@ export default class ExampleApp extends Application {
private registerControllers() {
this.use(new HomeController());
this.use(new AuthController());
}
}

View File

@ -0,0 +1,93 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_GUEST_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import Validator from "wms-core/db/Validator";
import {EMAIL_REGEX} from "wms-core/db/Model";
import {PasswordAuthProof} from "../models/UserPassword";
import UserEmail from "wms-core/auth/models/UserEmail";
export default class AuthController extends Controller {
routes(): void {
this.get('/login', this.getLogin, 'login', REQUIRE_GUEST_MIDDLEWARE);
this.post('/login', this.postLogin, 'login', REQUIRE_GUEST_MIDDLEWARE);
this.get('/register', this.getRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
this.post('/register', this.postRegister, 'register', REQUIRE_GUEST_MIDDLEWARE);
this.get('/logout', this.getLogout, 'logout', REQUIRE_AUTH_MIDDLEWARE);
}
private async getLogin(req: Request, res: Response): Promise<void> {
res.render('login');
}
private async postLogin(req: Request, res: Response): Promise<void> {
await this.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
password: new Validator().acceptUndefined(),
}, req.body);
const passwordAuthProof = new PasswordAuthProof(req.body.email);
const user = await passwordAuthProof.getUser();
if (!user) {
req.flash('error', 'Unknown email address');
res.redirect(Controller.route('login'));
return;
}
await passwordAuthProof.authorize(req.session!, req.body.password);
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof);
req.flash('success', `Welcome, ${user.name}.`);
res.redirect(Controller.route('home'));
}
private async getRegister(req: Request, res: Response): Promise<void> {
res.render('register');
}
private async postRegister(req: Request, res: Response): Promise<void> {
const validationMap: any = {
password: new Validator().defined().minLength(8),
password_confirmation: new Validator().defined().equals(req.body.password),
terms: new Validator().defined(),
};
let email: string;
if (req.body.create_email) {
validationMap['username'] = new Validator().defined().minLength(3).regexp(/^[0-9a-zA-Z_-]+$/);
validationMap['domain'] = new Validator().defined().regexp(/^(toot\.party)$/);
validationMap['recovery_email'] = new Validator().acceptUndefined().regexp(EMAIL_REGEX);
email = req.body.username + '@' + req.body.domain;
} else {
validationMap['recovery_email'] = new Validator().defined().regexp(EMAIL_REGEX);
email = req.body.recovery_email;
}
await this.validate(validationMap, req.body);
const passwordAuthProof = new PasswordAuthProof(email, false);
const userPassword = await passwordAuthProof.register(req.body.password);
await passwordAuthProof.authorize(req.session!, req.body.password_confirmation);
await req.authGuard.authenticateOrRegister(req.session!, passwordAuthProof, async (connection, userID) => {
const callbacks: (() => Promise<void>)[] = [];
await userPassword.setUser(userID);
await userPassword.save(connection, c => callbacks.push(c));
if (req.body.create_email && req.body.recovery_email) {
await new UserEmail({
user_id: userID,
email: req.body.recovery_email,
main: false,
}).save(connection, c => callbacks.push(c));
}
return callbacks;
});
const user = (await passwordAuthProof.getUser())!;
req.flash('success', `Your account was successfully created! Welcome, ${user.name}.`);
res.redirect(Controller.route('home'));
}
private async getLogout(req: Request, res: Response): Promise<void> {
await req.authGuard.logout(req.session!);
res.redirect(Controller.route('home'));
}
}

View File

@ -1,8 +1,8 @@
import Logger from "wms-core/Logger";
import ExampleApp from "./ExampleApp";
import Aldap from "./Aldap";
(async () => {
const app = new ExampleApp(4899);
const app = new Aldap(4899);
await app.start();
})().catch(err => {
Logger.error(err);

11
src/migrations.ts Normal file
View File

@ -0,0 +1,11 @@
import CreateMigrationsTable from "wms-core/migrations/CreateMigrationsTable";
import CreateLogsTable from "wms-core/migrations/CreateLogsTable";
import CreateUsersAndUserEmailsTable from "wms-core/auth/migrations/CreateUsersAndUserEmailsTable";
import CreateUserPasswordsTable from "./migrations/CreateUserPasswordsTable";
export const MIGRATIONS = [
CreateMigrationsTable,
CreateLogsTable,
CreateUsersAndUserEmailsTable,
CreateUserPasswordsTable,
];

View File

@ -0,0 +1,17 @@
import Migration from "wms-core/db/Migration";
import {query} from "wms-core/db/MysqlConnectionManager";
export default class CreateUserPasswordsTable extends Migration {
public async install(): Promise<void> {
await query('CREATE TABLE user_passwords(' +
'id INT NOT NULL AUTO_INCREMENT,' +
'user_id INT NOT NULL,' +
'password VARCHAR(256) NOT NULL,' +
'PRIMARY KEY(id),' +
'FOREIGN KEY user_pwd_fk (user_id) REFERENCES users (id) ON DELETE CASCADE' +
')');
}
public async rollback(): Promise<void> {
}
}

115
src/models/UserPassword.ts Normal file
View File

@ -0,0 +1,115 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User";
import argon2 from "argon2";
import AuthProof from "wms-core/auth/AuthProof";
export default class UserPassword extends Model {
public static async getByEmail(email: string): Promise<UserPassword | null> {
const user = await User.fromEmail(email);
if (!user) return null;
const result = await this.models<UserPassword>(this.select().where('user_id', user.id).first());
return result && result.length > 0 ? result[0] : null;
}
private user_id?: number;
private password?: string;
protected defineProperties(): void {
this.defineProperty<number>('user_id', new Validator().defined().unique(this).exists(User, 'id'));
this.defineProperty<string>('password', new Validator().defined());
}
public async setUser(userID: number): Promise<void> {
if (this.user_id) throw new Error(`Cannot change this password's user.`);
this.user_id = userID;
}
public async setPassword(rawPassword: string): Promise<void> {
this.password = await argon2.hash(rawPassword, {
timeCost: 10,
memoryCost: 4096,
parallelism: 4,
});
}
public async verifyPassword(passwordGuess: string): Promise<boolean> {
if (!this.password) return false;
return await argon2.verify(this.password, passwordGuess);
}
public isOwnedBy(userId: number): boolean {
return this.user_id === userId;
}
}
export class PasswordAuthProof implements AuthProof {
public static getProofForSession(session: Express.Session): PasswordAuthProof | null {
const authPasswordProof = session.auth_password_proof;
if (!authPasswordProof) return null;
return new PasswordAuthProof(authPasswordProof.email, authPasswordProof.authorized);
}
private readonly email?: string;
private authorized: boolean;
private userPassword?: UserPassword;
public constructor(email: string, authorized: boolean = false) {
this.email = email;
this.authorized = authorized;
}
public async getEmail(): Promise<string> {
return this.email!;
}
public async getUser(): Promise<User | null> {
return await User.fromEmail(await this.getEmail());
}
public async isAuthorized(): Promise<boolean> {
return this.authorized;
}
public async isOwnedBy(userId: number): Promise<boolean> {
const password = await this.getUserPassword();
return password !== null && password.isOwnedBy(userId);
}
public async isValid(): Promise<boolean> {
return await this.getUserPassword() !== null;
}
public async revoke(session: Express.Session): Promise<void> {
session.auth_password_proof = undefined;
}
private async getUserPassword(): Promise<UserPassword | null> {
return this.userPassword ? this.userPassword : await UserPassword.getByEmail(await this.getEmail());
}
public async authorize(session: Express.Session, passwordGuess: string): Promise<boolean> {
const password = await this.getUserPassword();
if (!password || !await password.verifyPassword(passwordGuess)) return false;
this.authorized = true;
this.save(session);
return true;
}
public async register(password: string): Promise<UserPassword> {
if (await this.getUserPassword()) throw new Error(`Cannot register; I already have a user.`);
this.userPassword = new UserPassword({});
await this.userPassword.setPassword(password);
return this.userPassword;
}
private save(session: Express.Session) {
session.auth_password_proof = {
email: this.email,
authorized: this.authorized,
};
}
}

View File

@ -1,7 +1,7 @@
{% extends 'layouts/base.njk' %}
{% set title = 'Example App - Hello world!' %}
{% set title = 'ALDAP - Welcome to the toot.party auth center!' %}
{% block body %}
<h1>Hello world!</h1>
<h1>{{ title }}</h1>
{% endblock %}

View File

@ -12,10 +12,17 @@
{% endblock %}
{% block header %}
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> Example app</a>
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> ALDAP</a>
<nav>
<ul>
<li><a href="{{ route('about') }}"><i data-feather="info"></i> About</a></li>
{% if user %}
<li><a href="{{ route('home') }}"><i data-feather="user"></i> {{ user.name }}</a></li>
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> Logout</a></li>
{% else %}
<li><a href="{{ route('login') }}"><i data-feather="log-in"></i> Login</a></li>
<li><a href="{{ route('register') }}"><i data-feather="user-plus"></i> Register</a></li>
{% endif %}
</ul>
</nav>
{% endblock %}
@ -37,4 +44,4 @@
</main>
{% endblock %}
{% block footer %}Example app v{{ app_version }} - all rights reserved.{% endblock %}
{% block footer %}ALDAP v{{ app_version }} - all rights reserved.{% endblock %}

24
views/login.njk Normal file
View File

@ -0,0 +1,24 @@
{% extends 'layouts/base.njk' %}
{% set title = 'Example App - Hello world!' %}
{% block body %}
<h1>Login</h1>
<div class="container">
<div class="panel center">
<form action="{{ route('login') }}" method="POST">
{{ macros.field(_locals, 'email', 'email', null, 'Your email address', null, 'required') }}
{{ macros.field(_locals, 'password', 'password', null, 'Your password', null, 'required') }}
<button type="submit">Login</button>
{{ macros.csrf(getCSRFToken) }}
</form>
<p>
<a href="{{ route('register') }}">I don't have an account</a>
</p>
</div>
</div>
{% endblock %}

45
views/register.njk Normal file
View File

@ -0,0 +1,45 @@
{% extends 'layouts/base.njk' %}
{% set title = 'Example App - Hello world!' %}
{% block scripts %}
<script src="/js/register.js"></script>
{% endblock %}
{% block body %}
<h1>Register</h1>
<div class="container">
<div class="panel center">
<form action="{{ route('register') }}" method="POST">
<section class="sub-panel">
<h2>Email</h2>
{{ macros.field(_locals, 'checkbox', 'create_email', null, 'Create an email address') }}
<div class="input-group">
{{ macros.field(_locals, 'text', 'username', null, 'Choose your username', null, 'disabled') }}
@
{{ macros.field(_locals, 'select', 'domain', null, 'Choose your domain', null, 'disabled', ['toot.party']) }}
</div>
</section>
<section class="sub-panel">
<h2>Password</h2>
{{ macros.field(_locals, 'password', 'password', null, 'Choose a password', null, 'required') }}
{{ macros.field(_locals, 'password', 'password_confirmation', null, 'Confirm your password', null, 'required') }}
</section>
<section class="sub-panel">
<h2>Recovery email</h2>
{{ macros.field(_locals, 'email', 'recovery_email', null, 'Your email address', 'Optional') }}
</section>
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the terms of services', null, 'required') }}
<button type="submit">Register</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</div>
</div>
{% endblock %}