Merge branch 'develop'

This commit is contained in:
Alice Gaudon 2020-04-25 11:31:34 +02:00
commit 4f885f74d4
21 changed files with 10473 additions and 19 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",

View File

@ -1,5 +1,6 @@
import './external_links';
import './message_icons';
import './forms';
import '../sass/app.scss';

11
assets/js/forms.js Normal file
View File

@ -0,0 +1,11 @@
// For labels to update their state (css selectors based on the value attribute)
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('input').forEach(el => {
if (el.type !== 'checkbox') {
el.setAttribute('value', el.value);
el.addEventListener('change', () => {
el.setAttribute('value', el.value);
});
}
});
});

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

@ -179,6 +179,15 @@ form {
cursor: text;
}
[disabled] {
opacity: 0.5;
& ~ label {
opacity: 0.5;
cursor: default;
}
}
input, select, .input-group {
border: 0;
border-bottom: 2px solid #0008;
@ -272,7 +281,32 @@ form {
}
}
}
}
.inline-fields {
display: flex;
flex-direction: row;
align-items: center;
margin: 16px auto;
.form-field {
flex: 1;
margin: 0;
}
> :not(.form-field) {
padding: 32px 8px 8px 8px;
}
+ {
.error, .hint {
margin-top: -16px;
margin-bottom: 16px;
}
}
}
.form-field, .inline-fields + {
.error, .hint {
padding: 2px;
text-align: left;
@ -308,6 +342,11 @@ button, .button {
margin-right: 8px;
}
.feather.last {
margin-right: 0;
margin-left: 8px;
}
&, &.primary {
color: $primaryForeground;
background-color: $secondary;

View File

@ -1,2 +1,15 @@
export default Object.assign(require("wms-core/config/default").default, {
mysql: {
connectionLimit: 10,
host: "localhost",
user: "root",
password: "",
database: "aldap",
create_database_automatically: false
},
redis: {
host: "127.0.0.1",
port: 6379,
prefix: 'aldap'
}
});

View File

@ -1,2 +1,6 @@
export default Object.assign(require("wms-core/config/test").default, {
mysql: {
database: "aldap_test",
create_database_automatically: true
},
});

View File

@ -1,6 +1,6 @@
{
"name": "aldap",
"version": "0.1.0",
"version": "0.2.0",
"description": "Authentication LDAP server",
"repository": "git@gitlab.com:ArisuOngaku/aldap.git",
"author": "Alice Gaudon <alice@gaudon.pro>",
@ -16,6 +16,7 @@
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5",
"@types/argon2": "^0.15.0",
"@types/config": "^0.0.36",
"@types/express": "^4.17.6",
"@types/jest": "^25.2.1",
@ -41,9 +42,10 @@
"uglifyjs-webpack-plugin": "^2.2.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"wms-core": "^0.2.0"
"wms-core": "^0"
},
"dependencies": {
"argon2": "^0.26.2",
"config": "^3.3.1",
"express": "^4.17.1"
}

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,94 @@
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().sameAs('password', 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).unique(UserEmail, 'email');
email = req.body.email = req.body.username + '@' + req.body.domain;
validationMap['email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
} else {
validationMap['recovery_email'] = new Validator().defined().regexp(EMAIL_REGEX).unique(UserEmail, 'email');
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> {
}
}

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

@ -0,0 +1,116 @@
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";
import {UserAlreadyExistsAuthError} from "wms-core/auth/AuthGuard";
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 UserAlreadyExistsAuthError(await this.getEmail());
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,
};
}
}

2
views/errors/429.njk Normal file
View File

@ -0,0 +1,2 @@
{% extends './error.njk' %}
{% import 'macros.njk' as macros %}

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 %}

View File

@ -63,7 +63,7 @@
{% endfor %}
</div>
{% elseif type == 'select' %}
<select name="{{ name }}" id="field-{{ name }}">
<select name="{{ name }}" id="field-{{ name }}" {{ validation_attributes|safe }}>
{% for option in extraData %}
<option value="{{ option }}" {{ 'selected' if value == option }}>{{ option }}</option>
{% endfor %}
@ -77,9 +77,7 @@
{% endif %}
<label for="field-{{ name }}{{ '-' + extraData[0] if type == 'duration' }}">{{ placeholder }}</label>
{% if validation %}
<div class="error"><i data-feather="x-circle"></i> {{ validation.message }}</div>
{% endif %}
{{ fieldError(_locals, name) }}
{% if hint %}
<div class="hint"><i data-feather="info"></i> {{ hint }}</div>
{% endif %}
@ -87,6 +85,14 @@
{% endif %}
{% endmacro %}
{% macro fieldError(_locals, name) %}
{% set validation = _locals.validation() %}
{% set validation = validation[name] if validation[name] or null %}
{% if validation %}
<div class="error"><i data-feather="x-circle"></i> {{ validation.message }}</div>
{% endif %}
{% endmacro %}
{% macro websocket(websocketUrl, listener, reconnectOnClose = 1, checkFunction = 0) %}
<script>
document.addEventListener('DOMContentLoaded', () => {

46
views/register.njk Normal file
View File

@ -0,0 +1,46 @@
{% 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="inline-fields">
{{ macros.field(_locals, 'text', 'username', null, 'Choose your username', null, 'disabled') }}
<span>@</span>
{{ macros.field(_locals, 'select', 'domain', null, 'Choose your domain', null, 'disabled', ['toot.party']) }}
</div>
{{ macros.fieldError(_locals, 'email') }}
</section>
<section class="sub-panel">
<h2>Recovery email</h2>
{{ macros.field(_locals, 'email', 'recovery_email', null, 'Your email address', 'Optional') }}
</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>
{{ 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 %}

10030
yarn.lock Normal file

File diff suppressed because it is too large Load Diff