Update codebase to latest wms-core and add recovery email add form

This commit is contained in:
Alice Gaudon 2020-07-20 16:34:35 +02:00
parent 3c2d408428
commit cb9d28128f
18 changed files with 208 additions and 251 deletions

View File

@ -24,7 +24,8 @@ export default Object.assign(require("wms-core/config/default").default, {
session: {
secret: "very_secret_not_known",
cookie: {
secure: false
secure: false,
maxAge: 30 * 24 * 3600 * 1000,
}
},
mail: {
@ -41,5 +42,4 @@ export default Object.assign(require("wms-core/config/default").default, {
cache: false
},
approval_mode: false,
'prelaunch-password': '$argon2i$v=19$m=4096,t=3,p=1$V7njt+IBmIQ/epc7tuQcfA$ypJCNauYSPrjOhtb5UqTbRlqCHkEGikBApOrYmbdYC0',
});

View File

@ -1,9 +1,8 @@
export default Object.assign(require("wms-core/config/production").default, {
'prelaunch-password': 'CHANGE ME',
log_level: "DEBUG",
db_log_level: "ERROR",
public_url: "https://watch-my.stream",
public_websocket_url: "wss://watch-my.stream",
public_url: "https://aldap.toot.party",
public_websocket_url: "wss://aldap.toot.party",
session: {
cookie: {
secure: true

View File

@ -1,101 +0,0 @@
import Application from "wms-core/Application";
import {Type} from "wms-core/Utils";
import Migration from "wms-core/db/Migration";
import ExpressAppComponent from "wms-core/components/ExpressAppComponent";
import NunjucksComponent from "wms-core/components/NunjucksComponent";
import MysqlComponent from "wms-core/components/MysqlComponent";
import LogRequestsComponent from "wms-core/components/LogRequestsComponent";
import RedisComponent from "wms-core/components/RedisComponent";
import ServeStaticDirectoryComponent from "wms-core/components/ServeStaticDirectoryComponent";
import MaintenanceComponent from "wms-core/components/MaintenanceComponent";
import MailComponent from "wms-core/components/MailComponent";
import SessionComponent from "wms-core/components/SessionComponent";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
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";
import LDAPServerComponent from "./LDAPServerComponent";
import PreLaunchWall from "./controllers/PreLaunchWall";
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
export default class Aldap extends Application {
private readonly port: number;
constructor(port: number) {
super(require('../package.json').version);
this.port = port;
}
protected getMigrations(): Type<Migration>[] {
return MIGRATIONS;
}
protected async init(): Promise<void> {
this.registerComponents();
this.registerWebSocketListeners();
this.registerControllers();
}
private registerComponents() {
const redisComponent = new RedisComponent();
const mysqlComponent = new MysqlComponent();
const expressAppComponent = new ExpressAppComponent(this.port);
this.use(expressAppComponent);
this.use(new NunjucksComponent());
this.use(new LogRequestsComponent());
// Static files
this.use(new ServeStaticDirectoryComponent('public'));
this.use(new ServeStaticDirectoryComponent('node_modules/feather-icons/dist', '/icons'));
// Maintenance
this.use(new MaintenanceComponent(this, () => {
return redisComponent.canServe() && mysqlComponent.canServe();
}));
this.use(new AutoUpdateComponent());
// Services
this.use(mysqlComponent);
this.use(new MailComponent());
// Session
this.use(redisComponent);
this.use(new SessionComponent(redisComponent));
// Utils
this.use(new RedirectBackComponent());
this.use(new FormHelperComponent());
// Middlewares
this.use(new CsrfProtectionComponent());
// Auth
this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof> {
public async getProofForSession(session: Express.Session): Promise<any | null> {
return PasswordAuthProof.getProofForSession(session);
}
}));
// WebSocket server
this.use(new WebSocketServerComponent(this, expressAppComponent, redisComponent));
// LDAP server
this.use(new LDAPServerComponent());
}
private registerWebSocketListeners() {
}
private registerControllers() {
this.use(new PreLaunchWall());
this.use(new HomeController());
this.use(new AuthController());
}
}

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";
@ -12,15 +10,30 @@ import ServeStaticDirectoryComponent from "wms-core/components/ServeStaticDirect
import MaintenanceComponent from "wms-core/components/MaintenanceComponent";
import MailComponent from "wms-core/components/MailComponent";
import SessionComponent from "wms-core/components/SessionComponent";
import RedirectBackComponent from "wms-core/components/RedirectBackComponent";
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 LDAPServerComponent from "./LDAPServerComponent";
import AutoUpdateComponent from "wms-core/components/AutoUpdateComponent";
import AccountController from "./controllers/AccountController";
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";
import CreateUsernamesTable from "./migrations/CreateUsernamesTable";
import CreateMagicLinksTable from "wms-core/auth/migrations/CreateMagicLinksTable";
import MailController from "wms-core/auth/MailController";
import MagicLinkController from "./controllers/MagicLinkController";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
export default class App extends Application {
private readonly port: number;
private magicLinkWebSocketListener?: MagicLinkWebSocketListener;
constructor(port: number) {
super(require('../package.json').version);
@ -31,6 +44,10 @@ export default class App extends Application {
return [
CreateMigrationsTable,
CreateLogsTable,
CreateUsersAndUserEmailsTable,
CreateUserPasswordsTable,
CreateUsernamesTable,
CreateMagicLinksTable,
];
}
@ -68,20 +85,42 @@ export default class App extends Application {
this.use(new SessionComponent(redisComponent));
// Utils
this.use(new RedirectBackComponent());
this.use(new FormHelperComponent());
// Middlewares
this.use(new CsrfProtectionComponent());
// Auth
this.use(new AuthComponent(new class extends AuthGuard<PasswordAuthProof> {
public async getProofForSession(session: Express.Session): Promise<any | null> {
return PasswordAuthProof.getProofForSession(session);
}
}));
// WebSocket server
this.use(new WebSocketServerComponent(this, expressAppComponent, redisComponent));
// LDAP server
this.use(new LDAPServerComponent());
}
private registerWebSocketListeners() {
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
this.use(this.magicLinkWebSocketListener);
}
private registerControllers() {
// Priority routes / interrupting middlewares
this.use(new AuthController());
this.use(new AccountController());
this.use(new MagicLinkController(this.magicLinkWebSocketListener!))
// Core functionality
this.use(new MailController());
// Other functionnality
// Semi-static routes
this.use(new HomeController());
}
}

View File

@ -1,27 +1,18 @@
import ApplicationComponent from "wms-core/ApplicationComponent";
import {Express, Router} from "express";
import ldap, {InsufficientAccessRightsError, InvalidCredentialsError, Server} from "ldapjs";
import {Express} from "express";
import ldap, {InvalidCredentialsError, Server} from "ldapjs";
import Logger from "wms-core/Logger";
import Username from "./models/Username";
import UserEmail from "wms-core/auth/models/UserEmail";
import {PasswordAuthProof} from "./models/UserPassword";
import Throttler from "wms-core/Throttler";
export default class LDAPServerComponent extends ApplicationComponent<void> {
private server?: Server;
public async start(app: Express, router: Router): Promise<void> {
public async start(app: Express): Promise<void> {
this.server = ldap.createServer({
log: console
});
let authorize = (req: any, res: any, next: any) => {
Logger.debug(req);
if (!req.connection.ldap.bindDN.equals('cn=root'))
return next(new InsufficientAccessRightsError());
return next();
};
this.server.bind('ou=users,dc=toot,dc=party', async (req: any, res: any, next: any) => {
const rdns = req.dn.toString().split(', ').map((rdn: string) => rdn.split('='));
let username;
@ -46,7 +37,7 @@ export default class LDAPServerComponent extends ApplicationComponent<void> {
const user = await Username.getUserFromUsername(username);
if (user) {
const email = await UserEmail.getMainFromUser(user.id!);
const email = await user.mainEmail.get();
if (email) {
const authProof = new PasswordAuthProof(email.email!);
if (await authProof.authorize(req.credentials)) {
@ -68,7 +59,4 @@ export default class LDAPServerComponent extends ApplicationComponent<void> {
Logger.info(`LDAP server listening on ${this.server!.url}`);
});
}
public async stop(): Promise<void> {
}
}

View File

@ -0,0 +1,21 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {NextFunction, Request, Response} from "express";
export default class AccountController extends Controller {
routes(): void {
this.get('/account', this.getAccount, 'account', REQUIRE_AUTH_MIDDLEWARE);
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
}
protected async getAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
res.render('account', {
emails: await req.models.user!.emails.get(),
});
}
protected async addRecoveryEmail(req: Request, res: Response, next: NextFunction): Promise<void> {
req.flash('warn', 'Not implemented');
res.redirectBack();
}
}

View File

@ -1,26 +1,28 @@
import Controller from "wms-core/Controller";
import {REQUIRE_AUTH_MIDDLEWARE, REQUIRE_GUEST_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import {NextFunction, 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";
import Username, {USERNAME_REGEXP} from "../models/Username";
import _AuthController from "wms-core/auth/AuthController";
import {ServerError} from "wms-core/HttpError";
export default class AuthController extends Controller {
export default class AuthController extends _AuthController {
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);
this.post('/logout', this.postLogout, 'logout', REQUIRE_AUTH_MIDDLEWARE);
}
private async getLogin(req: Request, res: Response): Promise<void> {
protected async getLogin(req: Request, res: Response): Promise<void> {
res.render('login');
}
private async postLogin(req: Request, res: Response): Promise<void> {
protected async postLogin(req: Request, res: Response): Promise<void> {
await this.validate({
email: new Validator().defined().regexp(EMAIL_REGEX),
password: new Validator().acceptUndefined(),
@ -41,11 +43,11 @@ export default class AuthController extends Controller {
res.redirect(Controller.route('home'));
}
private async getRegister(req: Request, res: Response): Promise<void> {
protected async getRegister(req: Request, res: Response): Promise<void> {
res.render('register');
}
private async postRegister(req: Request, res: Response): Promise<void> {
protected async postRegister(req: Request, res: Response): Promise<void> {
const validationMap: any = {
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(Username),
password: new Validator().defined().minLength(8),
@ -96,8 +98,11 @@ export default class AuthController extends Controller {
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'));
protected async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
throw new ServerError('Not implemented.');
}
protected async postAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
throw new ServerError('Not implemented.');
}
}

View File

@ -0,0 +1,3 @@
export enum MagicLinkActionType {
ADD_RECOVERY_EMAIL = 'Add a recovery email',
}

View File

@ -0,0 +1,35 @@
import _MagicLinkController from "wms-core/auth/magic_link/MagicLinkController";
import MagicLink from "wms-core/auth/models/MagicLink";
import {Request, Response} from "express";
import MagicLinkWebSocketListener from "wms-core/auth/magic_link/MagicLinkWebSocketListener";
import {MagicLinkActionType} from "./MagicLinkActionType";
import Controller from "wms-core/Controller";
import {BadOwnerMagicLink} from "wms-core/auth/magic_link/MagicLinkAuthController";
import UserEmail from "wms-core/auth/models/UserEmail";
export default class MagicLinkController extends _MagicLinkController {
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) {
super(magicLinkWebSocketListener);
}
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
switch (magicLink.getActionType()) {
case MagicLinkActionType.ADD_RECOVERY_EMAIL:
if (magicLink.getSessionID() !== req.sessionID!) throw new BadOwnerMagicLink();
const user = await req.authGuard.getUserForSession(req.session!);
if (!user || !(await magicLink.isOwnedBy(user.id!))) throw new BadOwnerMagicLink();
let userEmail;
await (userEmail = new UserEmail({
user_id: user.id,
email: await magicLink.getEmail(),
main: false,
})).save();
req.flash('success', `Recovery email ${userEmail.email} successfully added.`);
res.redirect(Controller.route('home'));
break;
}
}
}

View File

@ -1,51 +0,0 @@
import Controller from "wms-core/Controller";
import {Request, RequestHandler, Response} from "express";
import {ForbiddenHttpError} from "wms-core/HttpError";
import Validator from "wms-core/db/Validator";
import argon2 from "argon2";
import config from "config";
export default class PreLaunchWall extends Controller {
public getGlobalHandlers(): RequestHandler[] {
return [
(req, res, next) => {
if (!req.session) throw new ForbiddenHttpError('page', req.url);
if (!req.session.authorized) {
const route = Controller.route('prelaunch-wall');
if (req.url !== route) {
res.redirect(route);
return;
}
}
next();
}
];
}
routes(): void {
this.get('/prelaunch-wall', this.getWall, 'prelaunch-wall');
this.post('/prelaunch-wall', this.postWall, 'prelaunch-wall');
}
private async getWall(req: Request, res: Response) {
res.render('prelaunch-wall');
}
private async postWall(req: Request, res: Response) {
await this.validate({
password: new Validator().defined(),
}, req.body);
if (await argon2.verify(config.get<string>('prelaunch-password'), req.body.password)) {
req.session!.authorized = true;
req.flash('success', 'Authentication success!');
res.redirect(Controller.route('home'));
return;
}
req.flash('error', 'Invalid password.');
res.redirectBack();
}
}

View File

@ -1,13 +0,0 @@
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";
import CreateUsernamesTable from "./migrations/CreateUsernamesTable";
export const MIGRATIONS = [
CreateMigrationsTable,
CreateLogsTable,
CreateUsersAndUserEmailsTable,
CreateUserPasswordsTable,
CreateUsernamesTable,
];

View File

@ -4,22 +4,26 @@ 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";
import UserEmail from "wms-core/auth/models/UserEmail";
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;
const userEmail = await UserEmail.select('user_id')
.where('email', email)
.first();
return userEmail ? await UserPassword.select().where('user_id', userEmail.user_id).first() : null;
}
private user_id?: number;
private password?: string;
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 constructor(props: any) {
super(props);
}
protected init(): void {
this.addProperty<number>('user_id', new Validator().defined().unique(this).exists(User, 'id'));
this.addProperty<string>('password', new Validator().defined());
}
public async setUser(userID: number): Promise<void> {
@ -67,7 +71,11 @@ export class PasswordAuthProof implements AuthProof {
}
public async getUser(): Promise<User | null> {
return await User.fromEmail(await this.getEmail());
const userEmail = await UserEmail.select()
.where('email', await this.getEmail())
.with('user')
.first();
return userEmail ? await userEmail.user.get() : null;
}
public async isAuthorized(): Promise<boolean> {

View File

@ -1,32 +1,38 @@
import Model from "wms-core/db/Model";
import Validator from "wms-core/db/Validator";
import User from "wms-core/auth/models/User";
import {OneModelRelation} from "wms-core/db/ModelRelation";
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
export default class Username extends Model {
public static async fromUser(userID: number): Promise<Username | null> {
const models = await this.models<Username>(this.select().where('user_id', userID));
return models && models.length > 0 ? models[0] : null;
return await this.select().where('user_id', userID).first();
}
public static async getUserFromUsername(username: string): Promise<User | null> {
const models = await this.models<Username>(this.select().where('username', username.toLowerCase()));
if (!models || models.length === 0) return null;
return await User.getById<User>(models[0].user_id!);
let model = await this.select()
.with('user')
.where('username', username.toLowerCase())
.first();
return model ? await model.user.get() : null;
}
private user_id?: number;
public username?: string;
private readonly user_id!: number;
public readonly user: OneModelRelation<this, User> = new OneModelRelation<this, User>(this, User, {
localKey: 'user_id',
foreignKey: 'id'
});
public username!: string;
constructor(data: any) {
super(data);
}
protected defineProperties(): void {
this.defineProperty<number>('user_id', new Validator().defined().exists(User, 'id').unique(this));
this.defineProperty<number>('username', new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this));
protected init(): void {
this.addProperty<number>('user_id', new Validator().defined().exists(User, 'id').unique(this));
this.addProperty<number>('username', new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this));
}
}

31
views/account.njk Normal file
View File

@ -0,0 +1,31 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ALDAP - Welcome to the toot.party auth center!' %}
{% block body %}
<div class="container">
<main class="panel">
<h1>My account</h1>
<p>Name: {{ user.name }}</p>
<section class="sub-panel">
<h2>Email addresses</h2>
{% for email in emails %}
{% if email.main %}
<p>Main email: {{ email.email }}</p>
{% else %}
<p>Recovery email: {{ email.email }}</p>
{% endif %}
{% endfor %}
<form action="{{ route('add-recovery-email') }}" method="POST">
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email we can use to identify you in case you lose access to your account', 'required') }}
<button type="submit">Add recovery email</button>
</form>
</section>
</main>
</div>
{% endblock %}

View File

@ -16,15 +16,21 @@
<nav>
<button id="menu-button"><i data-feather="menu"></i></button>
<ul id="main-menu">
<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 %}
<li><a href="{{ route('about') }}"><i data-feather="info"></i> <span class="tip">About</span></a></li>
{% if user %}
<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('login') }}"><i data-feather="log-in"></i> <span class="tip">Login</span></a></li>
<li><a href="{{ route('register') }}"><i data-feather="user-plus"></i>
<span class="tip">Register</span></a></li>
{% endif %}
</ul>
</nav>
{% endblock %}

View File

@ -11,7 +11,7 @@
{{ 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>
<button type="submit"><i data-feather="log-in"></i> Login</button>
{{ macros.csrf(getCSRFToken) }}
</form>

View File

@ -1,19 +0,0 @@
{% extends 'layouts/base.njk' %}
{% set title = 'ALDAP - Early access' %}
{% block body %}
<div class="container">
<div class="panel">
<h1>{{ title }}</h1>
<form action="{{ route('prelaunch-wall') }}" method="POST">
{{ macros.field(_locals, 'password', 'password', null, 'Enter password') }}
<button type="submit">Authenticate</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</div>
</div>
{% endblock %}

View File

@ -26,7 +26,7 @@
{{ macros.field(_locals, 'select', 'domain', null, 'Choose your domain', null, 'disabled', ['toot.party']) }}
</div>
{{ macros.fieldError(_locals, 'email') }}
<div class="hint"><i data-feather="info"></i> This cannot be changed later.</div>
<div class="hint"><i data-feather="info"></i> You won't be able to change this again.</div>
</section>
<section class="sub-panel">
@ -42,7 +42,7 @@
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I accept the terms of services', null, 'required') }}
<button type="submit">Register</button>
<button type="submit"><i data-feather="user-plus"></i>Register</button>
{{ macros.csrf(getCSRFToken) }}
</form>