Add basic register and auth system
This commit is contained in:
parent
8f8111faae
commit
b5fedda02b
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
];
|
|
@ -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> {
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
Loading…
Reference in New Issue