Add required username to magic link authentication and fix many errors

This commit is contained in:
Alice Gaudon 2020-11-14 17:24:42 +01:00
parent acc5233185
commit b75b227ca1
24 changed files with 208 additions and 122 deletions

View File

@ -46,5 +46,8 @@
view: {
cache: false
},
magic_link: {
validity_period: 20,
},
approval_mode: false,
}

View File

@ -14,5 +14,8 @@
mail: {
secure: true,
allow_invalid_tls: false
}
},
magic_link: {
validity_period: 900,
},
}

View File

@ -4,6 +4,6 @@
user: "root",
password: "",
database: "swaf_test",
create_database_automatically: true
}
create_database_automatically: true,
},
}

View File

@ -1,5 +1,5 @@
import config from "config";
import {MailTemplate} from "./Mail";
import {MailTemplate} from "./mail/Mail";
export const MAGIC_LINK_MAIL = new MailTemplate(
'magic_link',

View File

@ -23,22 +23,23 @@ import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketList
import MagicLinkController from "./auth/magic_link/MagicLinkController";
import AddPasswordToUsersMigration from "./auth/password/AddPasswordToUsersMigration";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration";
import packageJson = require('../package.json');
import CsrfProtectionComponent from "./components/CsrfProtectionComponent";
import MailController from "./mail/MailController";
import WebSocketServerComponent from "./components/WebSocketServerComponent";
import Controller from "./Controller";
import packageJson = require('../package.json');
export const MIGRATIONS = [
CreateMigrationsTable,
CreateUsersAndUserEmailsTableMigration,
AddNameToUsersMigration,
AddPasswordToUsersMigration,
CreateMagicLinksTableMigration,
AddNameToUsersMigration,
];
export default class TestApp extends Application {
private readonly addr: string;
private readonly port: number;
private expressAppComponent?: ExpressAppComponent;
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
public constructor(addr: string, port: number) {
super(packageJson.version, true);
@ -57,12 +58,8 @@ export default class TestApp extends Application {
}
protected registerComponents(): void {
this.expressAppComponent = new ExpressAppComponent(this.addr, this.port);
const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent();
// Base
this.use(this.expressAppComponent);
this.use(new ExpressAppComponent(this.addr, this.port));
this.use(new LogRequestsComponent());
// Static files
@ -73,12 +70,12 @@ export default class TestApp extends Application {
this.use(new RedirectBackComponent());
// Services
this.use(mysqlComponent);
this.use(new MysqlComponent());
this.use(new MailComponent());
// Session
this.use(redisComponent);
this.use(new SessionComponent(redisComponent));
this.use(new RedisComponent());
this.use(new SessionComponent(this.as(RedisComponent)));
// Utils
this.use(new FormHelperComponent());
@ -88,18 +85,29 @@ export default class TestApp extends Application {
// Auth
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
// WebSocket server
this.use(new WebSocketServerComponent(this, this.as(ExpressAppComponent), this.as(RedisComponent)));
}
protected registerWebSocketListeners(): void {
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
this.use(this.magicLinkWebSocketListener);
this.use(new MagicLinkWebSocketListener());
}
protected registerControllers(): void {
this.use(new MailController());
this.use(new AuthController());
if (!this.magicLinkWebSocketListener) throw new Error('Magic link websocket listener not initialized.');
this.use(new MagicLinkController(this.magicLinkWebSocketListener));
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
// Special home controller
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.render('home');
}, 'home');
}
}());
}
public getExpressApp(): Express {

View File

@ -2,6 +2,11 @@ import Controller from "../Controller";
import {NextFunction, Request, Response} from "express";
import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
import {BadRequestError} from "../HttpError";
import ModelFactory from "../db/ModelFactory";
import User from "./models/User";
import UserPasswordComponent from "./password/UserPasswordComponent";
import UserNameComponent from "./models/UserNameComponent";
import {log} from "../Logger";
export default class AuthController extends Controller {
public getRoutesPrefix(): string {
@ -9,6 +14,8 @@ export default class AuthController extends Controller {
}
public routes(): void {
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
this.use(async (req, res, next) => {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
if (await authGuard.interruptAuth(req, res)) return;
@ -18,14 +25,17 @@ export default class AuthController extends Controller {
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
this.post('/login', this.postLogin, 'login', RequireGuestMiddleware);
this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
}
protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
res.render('auth/auth', {
auth_methods: authGuard.getAuthMethodNames(),
has_username: hasUsername,
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
});
}
@ -53,9 +63,10 @@ export default class AuthController extends Controller {
const user = await method.findUserByIdentifier(identifier);
if (!user) { // Register
return isRegistration ?
await method.attemptRegister(req, res, identifier) :
await this.redirectToRegistration(req, res, identifier);
if (!isRegistration) return await this.redirectToRegistration(req, res, identifier);
await method.attemptRegister(req, res, identifier);
return;
}
// Login
@ -66,9 +77,10 @@ export default class AuthController extends Controller {
const methods = await authGuard.getAuthMethodsByIdentifier(identifier);
if (methods.length === 0) { // Register
return isRegistration ?
await authGuard.getRegistrationMethod().attemptRegister(req, res, identifier) :
await this.redirectToRegistration(req, res, identifier);
if (!isRegistration) return await this.redirectToRegistration(req, res, identifier);
await authGuard.getRegistrationMethod().attemptRegister(req, res, identifier);
return;
}
const {user, method} = methods[0];

View File

@ -4,7 +4,7 @@ import User from "./models/User";
import {Connection} from "mysql";
import {Request, Response} from "express";
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
import Mail from "../Mail";
import Mail from "../mail/Mail";
import Controller from "../Controller";
import config from "config";
import Application from "../Application";

View File

@ -1,4 +1,4 @@
export default {
LOGIN: 'Login',
REGISTER: 'Register',
LOGIN: 'login',
REGISTER: 'register',
};

View File

@ -9,7 +9,7 @@ import geoip from "geoip-lite";
import MagicLinkController from "./MagicLinkController";
import RedirectBackComponent from "../../components/RedirectBackComponent";
import Application from "../../Application";
import {MailTemplate} from "../../Mail";
import {MailTemplate} from "../../mail/Mail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
@ -25,7 +25,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public async findUserByIdentifier(identifier: string): Promise<User | null> {
return (await UserEmail.select()
.with('user')
.with('user.mainEmail')
.where('email', identifier)
.first())?.user.getOrFail() || null;
}
@ -40,15 +40,17 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
const pendingLink = await MagicLink.select()
.where('session_id', req.getSession().id)
.where('action_type', [AuthMagicLinkActionType.LOGIN, AuthMagicLinkActionType.REGISTER], WhereTest.IN)
.where('authorized', false)
.first();
if (pendingLink && await pendingLink.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || pendingLink.original_url || undefined,
}));
return true;
if (pendingLink) {
if (await pendingLink.isValid()) {
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || pendingLink.original_url || undefined,
}));
return true;
} else {
await pendingLink.delete();
}
}
return false;
@ -70,34 +72,30 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
}
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> {
if (!isRegistration || req.body.confirm_register === 'confirm') {
const geo = geoip.lookup(req.ip);
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
const geo = geoip.lookup(req.ip);
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
await MagicLinkController.sendMagicLink(
this.app,
req.getSession().id,
actionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}),
email,
this.magicLinkMailTemplate,
{
type: actionType,
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || RedirectBackComponent.getPreviousURL(req),
}));
} else {
req.flash('register_identifier', email);
res.redirect(Controller.route('auth', undefined, {
await MagicLinkController.sendMagicLink(
this.app,
req.getSession().id,
actionType,
Controller.route('auth', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || undefined,
}));
}
}),
email,
this.magicLinkMailTemplate,
{
type: actionType,
ip: req.ip,
geo: geo ? `${geo.city}, ${geo.country}` : 'Unknown location',
},
{
username: req.body.name,
},
);
res.redirect(Controller.route('magic_link_lobby', undefined, {
redirect_uri: req.query.redirect_uri?.toString() || RedirectBackComponent.getPreviousURL(req),
}));
}
}

View File

@ -3,7 +3,7 @@ import {Request, Response} from "express";
import MagicLinkWebSocketListener from "./MagicLinkWebSocketListener";
import {BadRequestError, NotFoundHttpError} from "../../HttpError";
import Throttler from "../../Throttler";
import Mail, {MailTemplate} from "../../Mail";
import Mail, {MailTemplate} from "../../mail/Mail";
import MagicLink from "../models/MagicLink";
import config from "config";
import Application from "../../Application";
@ -14,6 +14,9 @@ import AuthComponent, {AuthMiddleware} from "../AuthComponent";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
import UserEmail from "../models/UserEmail";
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
import {QueryVariable} from "../../db/MysqlConnectionManager";
import UserNameComponent from "../models/UserNameComponent";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
export default class MagicLinkController<A extends Application> extends Controller {
public static async sendMagicLink(
@ -24,15 +27,16 @@ export default class MagicLinkController<A extends Application> extends Controll
email: string,
mailTemplate: MailTemplate,
data: ParsedUrlQueryInput,
magicLinkData: Record<string, QueryVariable> = {},
): Promise<void> {
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
const link = MagicLink.create({
const link = MagicLink.create(Object.assign(magicLinkData, {
session_id: sessionId,
action_type: actionType,
original_url: original_url,
});
}));
const token = await link.generateToken(email);
await link.save();
@ -55,7 +59,11 @@ export default class MagicLinkController<A extends Application> extends Controll
// Auth
try {
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
session, magicLink, undefined, undefined, async (connection, user) => {
session, magicLink, undefined, async (connection, user) => {
const userNameComponent = user.asOptional(UserNameComponent);
if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username;
return [];
}, async (connection, user) => {
const callbacks: RegisterCallback[] = [];
const userEmail = UserEmail.create({

View File

@ -1,19 +1,25 @@
import Migration from "../../db/Migration";
import ModelFactory from "../../db/ModelFactory";
import User from "../models/User";
import UserNameComponent from "../UserNameComponent";
import UserNameComponent from "../models/UserNameComponent";
import MagicLink from "../models/MagicLink";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
export default class AddNameToUsersMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`);
await this.query(`ALTER TABLE magic_links
ADD COLUMN username VARCHAR(64) DEFAULT NULL`);
}
public async rollback(): Promise<void> {
await this.query('ALTER TABLE users DROP COLUMN name');
await this.query('ALTER TABLE magic_links DROP COLUMN username');
}
public registerModels(): void {
ModelFactory.get(User).addComponent(UserNameComponent);
ModelFactory.get(MagicLink).addComponent(MagicLinkUserNameComponent);
}
}

View File

@ -0,0 +1,12 @@
import ModelComponent from "../../db/ModelComponent";
import MagicLink from "./MagicLink";
import {USERNAME_REGEXP} from "./UserNameComponent";
import User from "./User";
export default class MagicLinkUserNameComponent extends ModelComponent<MagicLink> {
public readonly username?: string = undefined;
protected init(): void {
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name');
}
}

View File

@ -1,5 +1,5 @@
import ModelComponent from "../db/ModelComponent";
import User from "./models/User";
import ModelComponent from "../../db/ModelComponent";
import User from "../models/User";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;

View File

@ -6,7 +6,7 @@ import UserPasswordComponent from "./UserPasswordComponent";
export default class AddPasswordToUsersMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE users
ADD COLUMN password VARCHAR(128) NOT NULL`);
ADD COLUMN password VARCHAR(128) DEFAULT NULL`);
}
public async rollback(): Promise<void> {

View File

@ -10,7 +10,7 @@ import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuar
import Validator, {InvalidFormatValidationError, ValidationBag} from "../../db/Validator";
import Controller from "../../Controller";
import UserPasswordComponent from "./UserPasswordComponent";
import UserNameComponent, {USERNAME_REGEXP} from "../UserNameComponent";
import UserNameComponent, {USERNAME_REGEXP} from "../models/UserNameComponent";
import ModelFactory from "../../db/ModelFactory";
import {WhereOperator, WhereTest} from "../../db/ModelQuery";
import {ServerError} from "../../HttpError";

View File

@ -1,6 +1,6 @@
import ApplicationComponent from "../ApplicationComponent";
import {Express} from "express";
import Mail from "../Mail";
import Mail from "../mail/Mail";
import config from "config";
import SecurityError from "../SecurityError";

View File

@ -3,7 +3,7 @@ import Controller from "../Controller";
import User from "../auth/models/User";
import {Request, Response} from "express";
import {BadRequestError, NotFoundHttpError} from "../HttpError";
import Mail from "../Mail";
import Mail from "../mail/Mail";
import {ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE} from "../Mails";
import UserEmail from "../auth/models/UserEmail";
import UserApprovedComponent from "../auth/models/UserApprovedComponent";

View File

@ -3,10 +3,10 @@ import config from "config";
import {Options} from "nodemailer/lib/mailer";
import {Environment} from 'nunjucks';
import * as util from "util";
import {WrappingError} from "./Utils";
import {WrappingError} from "../Utils";
import mjml2html from "mjml";
import {log} from "./Logger";
import Controller from "./Controller";
import {log} from "../Logger";
import Controller from "../Controller";
import {ParsedUrlQueryInput} from "querystring";
export default class Mail {

View File

@ -1,6 +1,6 @@
import {Request, Response} from "express";
import Controller from "../Controller";
import Mail from "../Mail";
import Mail from "./Mail";
import NunjucksComponent from "../components/NunjucksComponent";
export default class MailController extends Controller {

7
test/views/home.njk Normal file
View File

@ -0,0 +1,7 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Home' %}
{% block body %}
<h1>{{ title }}</h1>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends 'layouts/barebone.njk' %}
{% import 'macros.njk' as macros %}
{% block _stylesheets %}
{{ super() }}
@ -16,14 +17,18 @@
<button id="menu-button"><i data-feather="menu"></i></button>
<ul id="main-menu">
{% if user %}
<li class="dropdown"><a href="{{ route('widgets') }}"><i data-feather="zap"></i> <span class="tip">Widgets</span></a></li>
<div class="separator"></div>
<li><a href="{{ route('account') }}"><i data-feather="user"></i> <span class="tip">{{ user.name }} - Account</span></a></li>
{% if user.is_admin %}
<li><a href="{{ route('early-access-backend') }}"><i data-feather="sliders"></i> <span class="tip">Backend</span></a></li>
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
{% endif %}
<li><a href="{{ route('logout') }}"><i data-feather="log-out"></i> <span class="tip">Log out</span></a></li>
{# <li><a href="{{ route('account') }}"><i data-feather="user"></i>#}
{# <span class="tip">{{ user.name }}</span></a></li>#}
<li>
<form action="{{ route('logout') }}" method="POST">
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
{{ macros.csrf(getCsrfToken) }}
</form>
</li>
{% else %}
<li><a href="{{ route('auth') }}"><i data-feather="log-in"></i> Log in / Register</a></li>
{% endif %}

View File

@ -7,42 +7,68 @@
{% block body %}
<div class="container">
<div class="panel">
{% set queryStr = '' %}
{% if query.redirect_uri | length %}
{% set queryStr = '?' + querystring.stringify({redirect_uri: query.redirect_uri}) %}
{% endif %}
{% set action = route('auth') + queryStr %}
{% set queryStr = '' %}
{% if query.redirect_uri | length %}
{% set queryStr = '?' + querystring.stringify({redirect_uri: query.redirect_uri}) %}
{% endif %}
{% if register_confirm_email %}
<form action="{{ action }}" method="POST" id="register-form">
<h2>Register</h2>
{{ macros.message('question', 'Do you wish to create a new account with ' + register_confirm_email + '?', false, false) }}
{{ macros.message('warning', 'If you already have an account, please log in with your existing email first and then add your new email in the Account page.', false, true) }}
<input type="hidden" name="email" value="{{ register_confirm_email }}">
<input type="hidden" name="confirm_register" value="confirm">
<section class="panel">
<h2>Log in</h2>
<div class="form-field">
<div class="form-display">Email: {{ register_confirm_email }}</div>
</div>
<form action="{{ route('login') + queryStr }}" method="POST" id="login-form">
{{ macros.field(_locals, 'text', 'identifier', query.identifier or '', 'Your email address or username', null, 'required') }}
{{ macros.field(_locals, 'password', 'password', null, 'Your password', 'Do not fill to log in via magic link.') }}
<button type="submit">Authenticate</button>
{{ macros.csrf(getCsrfToken) }}
</form>
</section>
<section class="panel">
<h2>Register with email</h2>
<form action="{{ route('register') + queryStr }}" method="POST" id="register-form">
<input type="hidden" name="auth_method" value="magic_link">
{{ macros.csrf(getCsrfToken) }}
{% if has_username %}
{{ macros.field(_locals, 'text', 'name', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
{% endif %}
{{ macros.field(_locals, 'email', 'identifier', null, 'Your email address', null, 'required') }}
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the <a href="/terms-of-services" target="_blank">Terms Of Services</a>.' | safe, null, 'required') }}
<button type="submit" class="primary">Register</button>
</form>
</section>
{% if register_with_password %}
<section class="panel">
<h2>Register with password</h2>
<form action="{{ route('register') + queryStr }}" method="POST" id="register-form">
<input type="hidden" name="auth_method" value="password">
{{ macros.csrf(getCsrfToken) }}
<section class="sub-panel">
<h3>Username</h3>
{{ macros.field(_locals, 'text', 'identifier', null, 'Choose your username', 'This cannot be changed later.', 'pattern="[0-9a-z_-]+" required') }}
</section>
<section class="sub-panel">
<h3>Password</h3>
{{ 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 <a href="/terms-of-services" target="_blank">Terms Of Services</a>.' | safe, null, 'required') }}
<a href="/auth" class="button transparent">Go back</a>
<button type="submit" class="primary">Register</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% else %}
<form action="{{ action }}" method="POST" id="login-form">
<h2>Log in or register</h2>
{# {{ macros.message('info', 'If we don\'t find your email address in our database, you will be able to register.', false, true) }} #}
<div class="input-field">
{{ macros.field(_locals, 'email', 'email', query.email or '', 'Your email address', "If we don't find your email address in our database, you will be able to register.", 'required') }}
</div>
<button type="submit">Authenticate</button>
{{ macros.csrf(getCsrfToken) }}
</form>
{% endif %}
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@ -1,5 +1,4 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set actionType = magicLink.action_type %}
{% set title = app.name + 'Magic Link' + (' - ' + actionType if actionType) %}

View File

@ -1,5 +1,4 @@
{% extends 'layouts/base.njk' %}
{% import 'macros.njk' as macros %}
{% set title = 'Authentication lobby' %}
{% set h1 = 'Authentication lobby' %}
@ -55,4 +54,4 @@
</script>
{{ macros.websocket(websocketUrl, 'websocketListen', 1, 'isValid') }}
{% endblock %}
{% endblock %}