Auth: refactor to support multiple auth factors and add password factor
This commit is contained in:
parent
1fce157104
commit
efdd81b650
@ -15,14 +15,22 @@ import {Express} from "express";
|
|||||||
import MagicLinkAuthMethod from "../src/auth/magic_link/MagicLinkAuthMethod";
|
import MagicLinkAuthMethod from "../src/auth/magic_link/MagicLinkAuthMethod";
|
||||||
import PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod";
|
import PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod";
|
||||||
import {MAGIC_LINK_MAIL} from "./Mails";
|
import {MAGIC_LINK_MAIL} from "./Mails";
|
||||||
import packageJson = require('../package.json');
|
|
||||||
import CreateMigrationsTable from "./migrations/CreateMigrationsTable";
|
import CreateMigrationsTable from "./migrations/CreateMigrationsTable";
|
||||||
import CreateUsersAndUserEmailsTable from "./auth/migrations/CreateUsersAndUserEmailsTable";
|
import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration";
|
||||||
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration";
|
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration";
|
||||||
|
import AuthController from "./auth/AuthController";
|
||||||
|
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener";
|
||||||
|
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";
|
||||||
|
|
||||||
export const MIGRATIONS = [
|
export const MIGRATIONS = [
|
||||||
CreateMigrationsTable,
|
CreateMigrationsTable,
|
||||||
CreateUsersAndUserEmailsTable,
|
CreateUsersAndUserEmailsTableMigration,
|
||||||
|
AddNameToUsersMigration,
|
||||||
|
AddPasswordToUsersMigration,
|
||||||
CreateMagicLinksTableMigration,
|
CreateMagicLinksTableMigration,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -30,6 +38,7 @@ export default class TestApp extends Application {
|
|||||||
private readonly addr: string;
|
private readonly addr: string;
|
||||||
private readonly port: number;
|
private readonly port: number;
|
||||||
private expressAppComponent?: ExpressAppComponent;
|
private expressAppComponent?: ExpressAppComponent;
|
||||||
|
private magicLinkWebSocketListener?: MagicLinkWebSocketListener<this>;
|
||||||
|
|
||||||
public constructor(addr: string, port: number) {
|
public constructor(addr: string, port: number) {
|
||||||
super(packageJson.version, true);
|
super(packageJson.version, true);
|
||||||
@ -43,8 +52,8 @@ export default class TestApp extends Application {
|
|||||||
|
|
||||||
protected async init(): Promise<void> {
|
protected async init(): Promise<void> {
|
||||||
this.registerComponents();
|
this.registerComponents();
|
||||||
this.registerWebSocketListeners?.();
|
this.registerWebSocketListeners();
|
||||||
this.registerControllers?.();
|
this.registerControllers();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected registerComponents(): void {
|
protected registerComponents(): void {
|
||||||
@ -71,16 +80,27 @@ export default class TestApp extends Application {
|
|||||||
this.use(redisComponent);
|
this.use(redisComponent);
|
||||||
this.use(new SessionComponent(redisComponent));
|
this.use(new SessionComponent(redisComponent));
|
||||||
|
|
||||||
// Auth
|
|
||||||
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
|
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
this.use(new FormHelperComponent());
|
this.use(new FormHelperComponent());
|
||||||
|
|
||||||
|
// Middlewares
|
||||||
|
this.use(new CsrfProtectionComponent());
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected registerWebSocketListeners?(): void;
|
protected registerWebSocketListeners(): void {
|
||||||
|
this.magicLinkWebSocketListener = new MagicLinkWebSocketListener();
|
||||||
|
this.use(this.magicLinkWebSocketListener);
|
||||||
|
}
|
||||||
|
|
||||||
protected registerControllers?(): void;
|
protected registerControllers(): void {
|
||||||
|
this.use(new AuthController());
|
||||||
|
|
||||||
|
if (!this.magicLinkWebSocketListener) throw new Error('Magic link websocket listener not initialized.');
|
||||||
|
this.use(new MagicLinkController(this.magicLinkWebSocketListener));
|
||||||
|
}
|
||||||
|
|
||||||
public getExpressApp(): Express {
|
public getExpressApp(): Express {
|
||||||
return this.as(ExpressAppComponent).getExpressApp();
|
return this.as(ExpressAppComponent).getExpressApp();
|
||||||
|
@ -5,35 +5,37 @@ import Controller from "../Controller";
|
|||||||
import {ForbiddenHttpError} from "../HttpError";
|
import {ForbiddenHttpError} from "../HttpError";
|
||||||
import Middleware from "../Middleware";
|
import Middleware from "../Middleware";
|
||||||
import User from "./models/User";
|
import User from "./models/User";
|
||||||
|
import Application from "../Application";
|
||||||
|
import AuthMethod from "./AuthMethod";
|
||||||
import AuthProof from "./AuthProof";
|
import AuthProof from "./AuthProof";
|
||||||
|
|
||||||
export default class AuthComponent extends ApplicationComponent {
|
export default class AuthComponent extends ApplicationComponent {
|
||||||
private readonly authGuard: AuthGuard<AuthProof<User>>;
|
private readonly authGuard: AuthGuard;
|
||||||
|
|
||||||
public constructor(authGuard: AuthGuard<AuthProof<User>>) {
|
public constructor(app: Application, ...authMethods: AuthMethod<AuthProof<User>>[]) {
|
||||||
super();
|
super();
|
||||||
this.authGuard = authGuard;
|
this.authGuard = new AuthGuard(app, ...authMethods);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(): Promise<void> {
|
public async init(): Promise<void> {
|
||||||
this.use(AuthMiddleware);
|
this.use(AuthMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthGuard(): AuthGuard<AuthProof<User>> {
|
public getAuthGuard(): AuthGuard {
|
||||||
return this.authGuard;
|
return this.authGuard;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthMiddleware extends Middleware {
|
export class AuthMiddleware extends Middleware {
|
||||||
private authGuard?: AuthGuard<AuthProof<User>>;
|
private authGuard?: AuthGuard;
|
||||||
private user: User | null = null;
|
private user: User | null = null;
|
||||||
|
|
||||||
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
this.authGuard = this.app.as(AuthComponent).getAuthGuard();
|
this.authGuard = this.app.as(AuthComponent).getAuthGuard();
|
||||||
|
|
||||||
const proof = await this.authGuard.isAuthenticated(req.getSession());
|
const proofs = await this.authGuard.getProofsForSession(req.getSession());
|
||||||
if (proof) {
|
if (proofs.length > 0) {
|
||||||
this.user = await proof.getResource();
|
this.user = await proofs[0].getResource();
|
||||||
res.locals.user = this.user;
|
res.locals.user = this.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +46,7 @@ export class AuthMiddleware extends Middleware {
|
|||||||
return this.user;
|
return this.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthGuard(): AuthGuard<AuthProof<User>> {
|
public getAuthGuard(): AuthGuard {
|
||||||
if (!this.authGuard) throw new Error('AuthGuard was not initialized.');
|
if (!this.authGuard) throw new Error('AuthGuard was not initialized.');
|
||||||
return this.authGuard;
|
return this.authGuard;
|
||||||
}
|
}
|
||||||
@ -54,8 +56,8 @@ export class RequireRequestAuthMiddleware extends Middleware {
|
|||||||
private user?: User;
|
private user?: User;
|
||||||
|
|
||||||
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
const proof = await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req);
|
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req);
|
||||||
const user = await proof?.getResource();
|
const user = await proofs[0]?.getResource();
|
||||||
if (user) {
|
if (user) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
next();
|
next();
|
||||||
@ -81,8 +83,8 @@ export class RequireAuthMiddleware extends Middleware {
|
|||||||
const authGuard = req.as(AuthMiddleware).getAuthGuard();
|
const authGuard = req.as(AuthMiddleware).getAuthGuard();
|
||||||
// Via request
|
// Via request
|
||||||
|
|
||||||
let proof = await authGuard.isAuthenticatedViaRequest(req);
|
let proofs = await authGuard.getProofsForRequest(req);
|
||||||
let user = await proof?.getResource();
|
let user = await proofs[0]?.getResource();
|
||||||
if (user) {
|
if (user) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
next();
|
next();
|
||||||
@ -90,8 +92,8 @@ export class RequireAuthMiddleware extends Middleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Via session
|
// Via session
|
||||||
proof = await authGuard.isAuthenticated(req.getSession());
|
proofs = await authGuard.getProofsForSession(req.getSession());
|
||||||
user = await proof?.getResource();
|
user = await proofs[0]?.getResource();
|
||||||
if (user) {
|
if (user) {
|
||||||
this.user = user;
|
this.user = user;
|
||||||
next();
|
next();
|
||||||
@ -112,7 +114,8 @@ export class RequireAuthMiddleware extends Middleware {
|
|||||||
|
|
||||||
export class RequireGuestMiddleware extends Middleware {
|
export class RequireGuestMiddleware extends Middleware {
|
||||||
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
if (await req.as(AuthMiddleware).getAuthGuard().isAuthenticated(req.getSession())) {
|
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForSession(req.getSession());
|
||||||
|
if (proofs.length > 0) {
|
||||||
res.redirectBack();
|
res.redirectBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,100 @@
|
|||||||
import Controller from "../Controller";
|
import Controller from "../Controller";
|
||||||
import {NextFunction, Request, Response} from "express";
|
import {NextFunction, Request, Response} from "express";
|
||||||
import {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
|
import AuthComponent, {AuthMiddleware, RequireAuthMiddleware, RequireGuestMiddleware} from "./AuthComponent";
|
||||||
|
import {BadRequestError} from "../HttpError";
|
||||||
|
|
||||||
export default abstract class AuthController extends Controller {
|
export default class AuthController extends Controller {
|
||||||
public getRoutesPrefix(): string {
|
public getRoutesPrefix(): string {
|
||||||
return '/auth';
|
return '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
public routes(): void {
|
public routes(): void {
|
||||||
|
this.use(async (req, res, next) => {
|
||||||
|
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||||
|
if (await authGuard.interruptAuth(req, res)) return;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
|
this.get('/', this.getAuth, 'auth', RequireGuestMiddleware);
|
||||||
this.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
|
this.post('/login', this.postLogin, 'login', RequireGuestMiddleware);
|
||||||
this.get('/check', this.getCheckAuth, 'check_auth');
|
this.post('/register', this.postRegister, 'register', RequireGuestMiddleware);
|
||||||
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
|
this.post('/logout', this.postLogout, 'logout', RequireAuthMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
protected async getAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||||
const registerEmail = req.flash('register_confirm_email');
|
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||||
|
|
||||||
res.render('auth/auth', {
|
res.render('auth/auth', {
|
||||||
register_confirm_email: registerEmail.length > 0 ? registerEmail[0] : null,
|
auth_methods: authGuard.getAuthMethodNames(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract async postAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
|
protected async postLogin(req: Request, res: Response): Promise<void> {
|
||||||
|
return await this.handleAuth(req, res, false);
|
||||||
|
}
|
||||||
|
|
||||||
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
|
protected async postRegister(req: Request, res: Response): Promise<void> {
|
||||||
|
return await this.handleAuth(req, res, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleAuth(req: Request, res: Response, isRegistration: boolean): Promise<void> {
|
||||||
|
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||||
|
|
||||||
|
const identifier = req.body.identifier;
|
||||||
|
if (!identifier) throw new BadRequestError('Identifier not specified.', 'Please try again.', req.originalUrl);
|
||||||
|
|
||||||
|
// Get requested auth method
|
||||||
|
if (req.body.auth_method) {
|
||||||
|
const method = await authGuard.getAuthMethodByName(req.body.auth_method);
|
||||||
|
if (!method) {
|
||||||
|
throw new BadRequestError('Invalid auth method: ' + req.body.auth_method,
|
||||||
|
'Available methods are: ' + authGuard.getAuthMethodNames(), req.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await method.findUserByIdentifier(identifier);
|
||||||
|
if (!user) { // Register
|
||||||
|
return isRegistration ?
|
||||||
|
await method.attemptRegister(req, res, identifier) :
|
||||||
|
await this.redirectToRegistration(req, res, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login
|
||||||
|
return await method.attemptLogin(req, res, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {user, method} = methods[0];
|
||||||
|
return await method.attemptLogin(req, res, user);
|
||||||
|
}
|
||||||
|
|
||||||
protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
protected async postLogout(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||||
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req);
|
const userId = typeof req.body.user_id === 'string' ? parseInt(req.body.user_id) : null;
|
||||||
await proof?.revoke();
|
|
||||||
|
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofs(req);
|
||||||
|
|
||||||
|
for (const proof of proofs) {
|
||||||
|
if (userId === null || (await proof.getResource())?.id === userId) {
|
||||||
|
await proof.revoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.flash('success', 'Successfully logged out.');
|
req.flash('success', 'Successfully logged out.');
|
||||||
res.redirect(req.query.redirect_uri?.toString() || '/');
|
res.redirect(req.query.redirect_uri?.toString() || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async redirectToRegistration(req: Request, res: Response, identifier: string): Promise<void> {
|
||||||
|
req.flash('register_identifier', identifier);
|
||||||
|
req.flash('info', `User with identifier "${identifier}" not found.`);
|
||||||
|
res.redirect(Controller.route('auth', undefined, {
|
||||||
|
redirect_uri: req.query.redirect_uri?.toString() || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,62 +2,110 @@ import AuthProof from "./AuthProof";
|
|||||||
import MysqlConnectionManager from "../db/MysqlConnectionManager";
|
import MysqlConnectionManager from "../db/MysqlConnectionManager";
|
||||||
import User from "./models/User";
|
import User from "./models/User";
|
||||||
import {Connection} from "mysql";
|
import {Connection} from "mysql";
|
||||||
import {Request} from "express";
|
import {Request, Response} from "express";
|
||||||
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
|
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
|
||||||
import Mail from "../Mail";
|
import Mail from "../Mail";
|
||||||
import Controller from "../Controller";
|
import Controller from "../Controller";
|
||||||
import config from "config";
|
import config from "config";
|
||||||
import Application from "../Application";
|
import Application from "../Application";
|
||||||
import NunjucksComponent from "../components/NunjucksComponent";
|
import NunjucksComponent from "../components/NunjucksComponent";
|
||||||
|
import AuthMethod from "./AuthMethod";
|
||||||
|
|
||||||
|
export default class AuthGuard {
|
||||||
|
private readonly authMethods: AuthMethod<AuthProof<User>>[];
|
||||||
|
|
||||||
export default abstract class AuthGuard<P extends AuthProof<User>> {
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly app: Application,
|
private readonly app: Application,
|
||||||
|
...authMethods: AuthMethod<AuthProof<User>>[]
|
||||||
) {
|
) {
|
||||||
|
this.authMethods = authMethods;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>;
|
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
|
||||||
|
for (const method of this.authMethods) {
|
||||||
protected async getProofForRequest(_req: Request): Promise<P | null> {
|
if (method.interruptAuth && await method.interruptAuth(req, res)) return true;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getProof(req: Request): Promise<P | null> {
|
|
||||||
let proof = await this.isAuthenticatedViaRequest(req);
|
|
||||||
if (!proof && req.session) {
|
|
||||||
proof = await this.isAuthenticated(req.session);
|
|
||||||
}
|
}
|
||||||
return proof;
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async isAuthenticated(session: Express.Session): Promise<P | null> {
|
public getAuthMethodByName(authMethodName: string): AuthMethod<AuthProof<User>> | null {
|
||||||
if (!session.is_authenticated) return null;
|
return this.authMethods.find(m => m.getName() === authMethodName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
const proof = await this.getProofForSession(session);
|
public getAuthMethodNames(): string[] {
|
||||||
|
return this.authMethods.map(m => m.getName());
|
||||||
|
}
|
||||||
|
|
||||||
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
|
public getRegistrationMethod(): AuthMethod<AuthProof<User>> {
|
||||||
await proof?.revoke();
|
return this.authMethods[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAuthMethodsByIdentifier(
|
||||||
|
identifier: string,
|
||||||
|
): Promise<{ user: User, method: AuthMethod<AuthProof<User>> }[]> {
|
||||||
|
const methods = [];
|
||||||
|
for (const method of this.authMethods) {
|
||||||
|
const user = await method.findUserByIdentifier(identifier);
|
||||||
|
if (user) methods.push({user, method});
|
||||||
|
}
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProofs(req: Request): Promise<AuthProof<User>[]> {
|
||||||
|
const proofs = [];
|
||||||
|
if (req.session) {
|
||||||
|
proofs.push(...await this.getProofsForSession(req.session));
|
||||||
|
}
|
||||||
|
proofs.push(...await this.getProofsForRequest(req));
|
||||||
|
return proofs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProofsForSession(session: Express.Session): Promise<AuthProof<User>[]> {
|
||||||
|
if (!session.is_authenticated) return [];
|
||||||
|
|
||||||
|
const proofs = [];
|
||||||
|
for (const method of this.authMethods) {
|
||||||
|
if (method.getProofsForSession) {
|
||||||
|
const methodProofs = await method.getProofsForSession(session);
|
||||||
|
for (const proof of methodProofs) {
|
||||||
|
if (!await proof.isValid() || !await proof.isAuthorized()) {
|
||||||
|
await proof.revoke();
|
||||||
|
} else {
|
||||||
|
proofs.push(proof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proofs.length === 0) {
|
||||||
session.is_authenticated = false;
|
session.is_authenticated = false;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return proof;
|
return proofs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async isAuthenticatedViaRequest(req: Request): Promise<P | null> {
|
public async getProofsForRequest(req: Request): Promise<AuthProof<User>[]> {
|
||||||
const proof = await this.getProofForRequest(req);
|
const proofs = [];
|
||||||
|
for (const method of this.authMethods) {
|
||||||
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
|
if (method.getProofsForRequest) {
|
||||||
await proof?.revoke();
|
const methodProofs = await method.getProofsForRequest(req);
|
||||||
return null;
|
for (const proof of methodProofs) {
|
||||||
|
if (!await proof.isValid() || !await proof.isAuthorized()) {
|
||||||
|
await proof.revoke();
|
||||||
|
} else {
|
||||||
|
proofs.push(proof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return proof;
|
return proofs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async authenticateOrRegister(
|
public async authenticateOrRegister(
|
||||||
session: Express.Session,
|
session: Express.Session,
|
||||||
proof: P,
|
proof: AuthProof<User>,
|
||||||
onLogin?: (user: User) => Promise<void>,
|
onLogin?: (user: User) => Promise<void>,
|
||||||
beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
|
beforeRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
|
||||||
afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
|
afterRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>,
|
||||||
@ -106,7 +154,6 @@ export default abstract class AuthGuard<P extends AuthProof<User>> {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
|
26
src/auth/AuthMethod.ts
Normal file
26
src/auth/AuthMethod.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import User from "./models/User";
|
||||||
|
import AuthProof from "./AuthProof";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
|
||||||
|
|
||||||
|
export default interface AuthMethod<P extends AuthProof<User>> {
|
||||||
|
/**
|
||||||
|
* @return A unique name.
|
||||||
|
*/
|
||||||
|
getName(): string;
|
||||||
|
|
||||||
|
findUserByIdentifier(identifier: string): Promise<User | null>;
|
||||||
|
|
||||||
|
getProofsForSession?(session: Express.Session): Promise<P[]>;
|
||||||
|
|
||||||
|
getProofsForRequest?(req: Request): Promise<P[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code true} if interrupted, {@code false} otherwise.
|
||||||
|
*/
|
||||||
|
interruptAuth?(req: Request, res: Response): Promise<boolean>;
|
||||||
|
|
||||||
|
attemptLogin(req: Request, res: Response, user: User): Promise<void>;
|
||||||
|
|
||||||
|
attemptRegister(req: Request, res: Response, identifier: string): Promise<void>;
|
||||||
|
}
|
12
src/auth/UserNameComponent.ts
Normal file
12
src/auth/UserNameComponent.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import ModelComponent from "../db/ModelComponent";
|
||||||
|
import User from "./models/User";
|
||||||
|
|
||||||
|
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
|
||||||
|
|
||||||
|
export default class UserNameComponent extends ModelComponent<User> {
|
||||||
|
public name?: string = undefined;
|
||||||
|
|
||||||
|
public init(): void {
|
||||||
|
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
|
||||||
|
}
|
||||||
|
}
|
4
src/auth/magic_link/AuthMagicLinkActionType.ts
Normal file
4
src/auth/magic_link/AuthMagicLinkActionType.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default {
|
||||||
|
LOGIN: 'Login',
|
||||||
|
REGISTER: 'Register',
|
||||||
|
};
|
@ -2,7 +2,7 @@ import Migration from "../../db/Migration";
|
|||||||
import ModelFactory from "../../db/ModelFactory";
|
import ModelFactory from "../../db/ModelFactory";
|
||||||
import MagicLink from "../models/MagicLink";
|
import MagicLink from "../models/MagicLink";
|
||||||
|
|
||||||
export default class CreateMagicLinksTable extends Migration {
|
export default class CreateMagicLinksTableMigration extends Migration {
|
||||||
public async install(): Promise<void> {
|
public async install(): Promise<void> {
|
||||||
await this.query(`CREATE TABLE magic_links
|
await this.query(`CREATE TABLE magic_links
|
||||||
(
|
(
|
@ -1,187 +0,0 @@
|
|||||||
import {NextFunction, Request, Response} from "express";
|
|
||||||
import Controller from "../../Controller";
|
|
||||||
import MagicLink from "../models/MagicLink";
|
|
||||||
import {BadRequestError} from "../../HttpError";
|
|
||||||
import UserEmail from "../models/UserEmail";
|
|
||||||
import MagicLinkController from "./MagicLinkController";
|
|
||||||
import {MailTemplate} from "../../Mail";
|
|
||||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
|
|
||||||
import geoip from "geoip-lite";
|
|
||||||
import AuthController from "../AuthController";
|
|
||||||
import RedirectBackComponent from "../../components/RedirectBackComponent";
|
|
||||||
import {AuthMiddleware} from "../AuthComponent";
|
|
||||||
import User from "../models/User";
|
|
||||||
|
|
||||||
|
|
||||||
export default abstract class MagicLinkAuthController extends AuthController {
|
|
||||||
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {
|
|
||||||
const session = req.getSession();
|
|
||||||
if (magicLink.getOrFail('session_id') !== session.id) throw new BadOwnerMagicLink();
|
|
||||||
if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink();
|
|
||||||
if (!await magicLink.isValid()) throw new InvalidMagicLink();
|
|
||||||
|
|
||||||
// Auth
|
|
||||||
try {
|
|
||||||
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
|
|
||||||
session, magicLink, undefined, undefined, async (connection, user) => {
|
|
||||||
const callbacks: RegisterCallback[] = [];
|
|
||||||
|
|
||||||
const userEmail = UserEmail.create({
|
|
||||||
user_id: user.id,
|
|
||||||
email: magicLink.getOrFail('email'),
|
|
||||||
});
|
|
||||||
await userEmail.save(connection, c => callbacks.push(c));
|
|
||||||
user.main_email_id = userEmail.id;
|
|
||||||
await user.save(connection, c => callbacks.push(c));
|
|
||||||
|
|
||||||
return callbacks;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PendingApprovalAuthError) {
|
|
||||||
res.format({
|
|
||||||
json: () => {
|
|
||||||
res.json({
|
|
||||||
'status': 'warning',
|
|
||||||
'message': `Your account is pending review. You'll receive an email once you're approved.`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
html: () => {
|
|
||||||
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
|
|
||||||
res.redirect('/');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly loginMagicLinkActionType: string = 'Login';
|
|
||||||
protected readonly registerMagicLinkActionType: string = 'Register';
|
|
||||||
private readonly magicLinkMailTemplate: MailTemplate;
|
|
||||||
|
|
||||||
protected constructor(magicLinkMailTemplate: MailTemplate) {
|
|
||||||
super();
|
|
||||||
this.magicLinkMailTemplate = magicLinkMailTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
||||||
const link = await MagicLink.bySessionId(req.getSession().id,
|
|
||||||
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
|
|
||||||
if (link && await link.isValid()) {
|
|
||||||
res.redirect(Controller.route('magic_link_lobby', undefined, {
|
|
||||||
redirect_uri: req.query.redirect_uri?.toString() || undefined,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await super.getAuth(req, res, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async postAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
|
||||||
const email = req.body.email;
|
|
||||||
if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl);
|
|
||||||
|
|
||||||
let userEmail = await UserEmail.select().where('email', email).first();
|
|
||||||
let isRegistration = false;
|
|
||||||
|
|
||||||
if (!userEmail) {
|
|
||||||
isRegistration = true;
|
|
||||||
userEmail = UserEmail.create({
|
|
||||||
email: email,
|
|
||||||
main: true,
|
|
||||||
});
|
|
||||||
await userEmail.validate(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRegistration || req.body.confirm_register === 'confirm') {
|
|
||||||
// Register (email link)
|
|
||||||
const geo = geoip.lookup(req.ip);
|
|
||||||
await MagicLinkController.sendMagicLink(
|
|
||||||
this.getApp(),
|
|
||||||
req.getSession().id,
|
|
||||||
isRegistration ? this.registerMagicLinkActionType : this.loginMagicLinkActionType,
|
|
||||||
Controller.route('auth', undefined, {
|
|
||||||
redirect_uri: req.query.redirect_uri?.toString() || undefined,
|
|
||||||
}),
|
|
||||||
email,
|
|
||||||
this.magicLinkMailTemplate,
|
|
||||||
{
|
|
||||||
type: isRegistration ? 'register' : 'login',
|
|
||||||
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 {
|
|
||||||
// Confirm registration req
|
|
||||||
req.flash('register_confirm_email', email);
|
|
||||||
res.redirect(Controller.route('auth', undefined, {
|
|
||||||
redirect_uri: req.query.redirect_uri?.toString() || undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether a magic link is authorized, and authenticate if yes
|
|
||||||
*/
|
|
||||||
protected async getCheckAuth(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
|
||||||
const magicLink = await MagicLink.bySessionId(req.getSession().id,
|
|
||||||
[this.loginMagicLinkActionType, this.registerMagicLinkActionType]);
|
|
||||||
|
|
||||||
if (!magicLink) {
|
|
||||||
res.format({
|
|
||||||
json: () => {
|
|
||||||
throw new BadRequestError(
|
|
||||||
'No magic link were found linked with that session.',
|
|
||||||
'Please retry once you have requested a magic link.',
|
|
||||||
req.originalUrl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
default: () => {
|
|
||||||
req.flash('warning', 'No magic link found. Please try again.');
|
|
||||||
res.redirect(Controller.route('auth'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await MagicLinkAuthController.checkAndAuth(req, res, magicLink);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
// Auth success
|
|
||||||
const username = user.name;
|
|
||||||
res.format({
|
|
||||||
json: () => {
|
|
||||||
res.json({'status': 'success', 'message': `Welcome, ${username}!`});
|
|
||||||
},
|
|
||||||
default: () => {
|
|
||||||
req.flash('success', `Authentication success. Welcome, ${username}!`);
|
|
||||||
res.redirect('/');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BadOwnerMagicLink extends AuthError {
|
|
||||||
public constructor() {
|
|
||||||
super(`This magic link doesn't belong to this session.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnauthorizedMagicLink extends AuthError {
|
|
||||||
public constructor() {
|
|
||||||
super(`This magic link is unauthorized.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvalidMagicLink extends AuthError {
|
|
||||||
public constructor() {
|
|
||||||
super(`This magic link is invalid.`);
|
|
||||||
}
|
|
||||||
}
|
|
103
src/auth/magic_link/MagicLinkAuthMethod.ts
Normal file
103
src/auth/magic_link/MagicLinkAuthMethod.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import AuthMethod from "../AuthMethod";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
import User from "../models/User";
|
||||||
|
import UserEmail from "../models/UserEmail";
|
||||||
|
import MagicLink from "../models/MagicLink";
|
||||||
|
import {WhereTest} from "../../db/ModelQuery";
|
||||||
|
import Controller from "../../Controller";
|
||||||
|
import geoip from "geoip-lite";
|
||||||
|
import MagicLinkController from "./MagicLinkController";
|
||||||
|
import RedirectBackComponent from "../../components/RedirectBackComponent";
|
||||||
|
import Application from "../../Application";
|
||||||
|
import {MailTemplate} from "../../Mail";
|
||||||
|
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
|
||||||
|
|
||||||
|
export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
|
||||||
|
public constructor(
|
||||||
|
protected readonly app: Application,
|
||||||
|
protected readonly magicLinkMailTemplate: MailTemplate,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): string {
|
||||||
|
return 'magic_link';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findUserByIdentifier(identifier: string): Promise<User | null> {
|
||||||
|
return (await UserEmail.select()
|
||||||
|
.with('user')
|
||||||
|
.where('email', identifier)
|
||||||
|
.first())?.user.getOrFail() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProofsForSession(session: Express.Session): Promise<MagicLink[]> {
|
||||||
|
return await MagicLink.select()
|
||||||
|
.where('session_id', session.id)
|
||||||
|
.where('action_type', [AuthMagicLinkActionType.LOGIN, AuthMagicLinkActionType.REGISTER], WhereTest.IN)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptLogin(req: Request, res: Response, user: User): Promise<void> {
|
||||||
|
const userEmail = user.mainEmail.getOrFail();
|
||||||
|
if (!userEmail) throw new Error('No main email for user ' + user.id);
|
||||||
|
await this.auth(req, res, false, userEmail.getOrFail('email'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> {
|
||||||
|
const userEmail = UserEmail.create({
|
||||||
|
email: identifier,
|
||||||
|
main: true,
|
||||||
|
});
|
||||||
|
await userEmail.validate(true);
|
||||||
|
await this.auth(req, res, true, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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, {
|
||||||
|
redirect_uri: req.query.redirect_uri?.toString() || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,8 +9,13 @@ import config from "config";
|
|||||||
import Application from "../../Application";
|
import Application from "../../Application";
|
||||||
import {ParsedUrlQueryInput} from "querystring";
|
import {ParsedUrlQueryInput} from "querystring";
|
||||||
import NunjucksComponent from "../../components/NunjucksComponent";
|
import NunjucksComponent from "../../components/NunjucksComponent";
|
||||||
|
import User from "../models/User";
|
||||||
|
import AuthComponent, {AuthMiddleware} from "../AuthComponent";
|
||||||
|
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
|
||||||
|
import UserEmail from "../models/UserEmail";
|
||||||
|
import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
|
||||||
|
|
||||||
export default abstract class MagicLinkController<A extends Application> extends Controller {
|
export default class MagicLinkController<A extends Application> extends Controller {
|
||||||
public static async sendMagicLink(
|
public static async sendMagicLink(
|
||||||
app: Application,
|
app: Application,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -23,12 +28,11 @@ export default abstract class MagicLinkController<A extends Application> extends
|
|||||||
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
|
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
|
||||||
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
|
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
|
||||||
|
|
||||||
const link = await MagicLink.bySessionId(sessionId, actionType) ||
|
const link = MagicLink.create({
|
||||||
MagicLink.create({
|
session_id: sessionId,
|
||||||
session_id: sessionId,
|
action_type: actionType,
|
||||||
action_type: actionType,
|
original_url: original_url,
|
||||||
original_url: original_url,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const token = await link.generateToken(email);
|
const token = await link.generateToken(email);
|
||||||
await link.save();
|
await link.save();
|
||||||
@ -42,10 +46,53 @@ export default abstract class MagicLinkController<A extends Application> extends
|
|||||||
})).send(email);
|
})).send(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise<User | null> {
|
||||||
|
const session = req.getSession();
|
||||||
|
if (magicLink.getOrFail('session_id') !== session.id) throw new BadOwnerMagicLink();
|
||||||
|
if (!await magicLink.isAuthorized()) throw new UnauthorizedMagicLink();
|
||||||
|
if (!await magicLink.isValid()) throw new InvalidMagicLink();
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
try {
|
||||||
|
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
|
||||||
|
session, magicLink, undefined, undefined, async (connection, user) => {
|
||||||
|
const callbacks: RegisterCallback[] = [];
|
||||||
|
|
||||||
|
const userEmail = UserEmail.create({
|
||||||
|
user_id: user.id,
|
||||||
|
email: magicLink.getOrFail('email'),
|
||||||
|
});
|
||||||
|
await userEmail.save(connection, c => callbacks.push(c));
|
||||||
|
user.main_email_id = userEmail.id;
|
||||||
|
await user.save(connection, c => callbacks.push(c));
|
||||||
|
|
||||||
|
return callbacks;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PendingApprovalAuthError) {
|
||||||
|
res.format({
|
||||||
|
json: () => {
|
||||||
|
res.json({
|
||||||
|
'status': 'warning',
|
||||||
|
'message': `Your account is pending review. You'll receive an email once you're approved.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
html: () => {
|
||||||
|
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
|
||||||
|
res.redirect('/');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
protected readonly magicLinkWebsocketPath: string;
|
protected readonly magicLinkWebsocketPath: string;
|
||||||
|
|
||||||
protected constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
|
public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
|
||||||
super();
|
super();
|
||||||
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
|
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
|
||||||
}
|
}
|
||||||
@ -60,7 +107,10 @@ export default abstract class MagicLinkController<A extends Application> extends
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async getLobby(req: Request, res: Response): Promise<void> {
|
protected async getLobby(req: Request, res: Response): Promise<void> {
|
||||||
const link = await MagicLink.bySessionId(req.getSession().id);
|
const link = await MagicLink.select()
|
||||||
|
.where('session_id', req.getSession().id)
|
||||||
|
.sortBy('authorized')
|
||||||
|
.first();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
throw new NotFoundHttpError('magic link', req.url);
|
throw new NotFoundHttpError('magic link', req.url);
|
||||||
}
|
}
|
||||||
@ -115,5 +165,41 @@ export default abstract class MagicLinkController<A extends Application> extends
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void>;
|
protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise<void> {
|
||||||
|
switch (magicLink.getOrFail('action_type')) {
|
||||||
|
case AuthMagicLinkActionType.LOGIN:
|
||||||
|
case AuthMagicLinkActionType.REGISTER: {
|
||||||
|
await MagicLinkController.checkAndAuth(req, res, magicLink);
|
||||||
|
|
||||||
|
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||||
|
const proofs = await authGuard.getProofsForSession(req.getSession());
|
||||||
|
const user = await proofs[0]?.getResource();
|
||||||
|
|
||||||
|
if (!res.headersSent && user) {
|
||||||
|
// Auth success
|
||||||
|
req.flash('success', `Authentication success. Welcome, ${user.name}!`);
|
||||||
|
res.redirect(req.query.redirect_uri?.toString() || Controller.route('home'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BadOwnerMagicLink extends AuthError {
|
||||||
|
public constructor() {
|
||||||
|
super(`This magic link doesn't belong to this session.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedMagicLink extends AuthError {
|
||||||
|
public constructor() {
|
||||||
|
super(`This magic link is unauthorized.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidMagicLink extends AuthError {
|
||||||
|
public constructor() {
|
||||||
|
super(`This magic link is invalid.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,10 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get magic link
|
// Get magic link
|
||||||
const magicLink = await MagicLink.bySessionId(session.id);
|
const magicLink = await MagicLink.select()
|
||||||
|
.where('session_id', session.id)
|
||||||
|
.sortBy('authorized')
|
||||||
|
.first();
|
||||||
|
|
||||||
// Refresh if immediately applicable
|
// Refresh if immediately applicable
|
||||||
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
||||||
|
@ -3,7 +3,7 @@ import ModelFactory from "../../db/ModelFactory";
|
|||||||
import User from "../models/User";
|
import User from "../models/User";
|
||||||
import UserApprovedComponent from "../models/UserApprovedComponent";
|
import UserApprovedComponent from "../models/UserApprovedComponent";
|
||||||
|
|
||||||
export default class AddApprovedFieldToUsersTable extends Migration {
|
export default class AddApprovedFieldToUsersTableMigration extends Migration {
|
||||||
public async install(): Promise<void> {
|
public async install(): Promise<void> {
|
||||||
await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0');
|
await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
19
src/auth/migrations/AddNameToUsersMigration.ts
Normal file
19
src/auth/migrations/AddNameToUsersMigration.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Migration from "../../db/Migration";
|
||||||
|
import ModelFactory from "../../db/ModelFactory";
|
||||||
|
import User from "../models/User";
|
||||||
|
import UserNameComponent from "../UserNameComponent";
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rollback(): Promise<void> {
|
||||||
|
await this.query('ALTER TABLE users DROP COLUMN name');
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerModels(): void {
|
||||||
|
ModelFactory.get(User).addComponent(UserNameComponent);
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@ import ModelFactory from "../../db/ModelFactory";
|
|||||||
import User from "../models/User";
|
import User from "../models/User";
|
||||||
import UserEmail from "../models/UserEmail";
|
import UserEmail from "../models/UserEmail";
|
||||||
|
|
||||||
export default class CreateUsersAndUserEmailsTable extends Migration {
|
export default class CreateUsersAndUserEmailsTableMigration extends Migration {
|
||||||
public async install(): Promise<void> {
|
public async install(): Promise<void> {
|
||||||
await this.query(`CREATE TABLE users
|
await this.query(`CREATE TABLE users
|
||||||
(
|
(
|
@ -4,23 +4,10 @@ import Model from "../../db/Model";
|
|||||||
import AuthProof from "../AuthProof";
|
import AuthProof from "../AuthProof";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import {WhereTest} from "../../db/ModelQuery";
|
|
||||||
import UserEmail from "./UserEmail";
|
import UserEmail from "./UserEmail";
|
||||||
import {EMAIL_REGEX} from "../../db/Validator";
|
import {EMAIL_REGEX} from "../../db/Validator";
|
||||||
|
|
||||||
export default class MagicLink extends Model implements AuthProof<User> {
|
export default class MagicLink extends Model implements AuthProof<User> {
|
||||||
public static async bySessionId(sessionId: string, actionType?: string | string[]): Promise<MagicLink | null> {
|
|
||||||
let query = this.select().where('session_id', sessionId);
|
|
||||||
if (actionType !== undefined) {
|
|
||||||
if (typeof actionType === 'string') {
|
|
||||||
query = query.where('action_type', actionType);
|
|
||||||
} else {
|
|
||||||
query = query.where('action_type', actionType, WhereTest.IN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await query.first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static validityPeriod(): number {
|
public static validityPeriod(): number {
|
||||||
return config.get<number>('magic_link.validity_period') * 1000;
|
return config.get<number>('magic_link.validity_period') * 1000;
|
||||||
}
|
}
|
||||||
@ -30,7 +17,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
|||||||
private email?: string = undefined;
|
private email?: string = undefined;
|
||||||
private token?: string = undefined;
|
private token?: string = undefined;
|
||||||
public readonly action_type?: string = undefined;
|
public readonly action_type?: string = undefined;
|
||||||
private original_url?: string = undefined;
|
public readonly original_url?: string = undefined;
|
||||||
private generated_at?: Date = undefined;
|
private generated_at?: Date = undefined;
|
||||||
private authorized: boolean = false;
|
private authorized: boolean = false;
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Model from "../../db/Model";
|
import Model from "../../db/Model";
|
||||||
import MysqlConnectionManager from "../../db/MysqlConnectionManager";
|
import MysqlConnectionManager from "../../db/MysqlConnectionManager";
|
||||||
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable";
|
import AddApprovedFieldToUsersTableMigration from "../migrations/AddApprovedFieldToUsersTableMigration";
|
||||||
import config from "config";
|
import config from "config";
|
||||||
import {ManyModelRelation} from "../../db/ModelRelation";
|
import {ManyModelRelation} from "../../db/ModelRelation";
|
||||||
import UserEmail from "./UserEmail";
|
import UserEmail from "./UserEmail";
|
||||||
@ -9,7 +9,7 @@ import UserApprovedComponent from "./UserApprovedComponent";
|
|||||||
export default class User extends Model {
|
export default class User extends Model {
|
||||||
public static isApprovalMode(): boolean {
|
public static isApprovalMode(): boolean {
|
||||||
return config.get<boolean>('approval_mode') &&
|
return config.get<boolean>('approval_mode') &&
|
||||||
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
|
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly id?: number = undefined;
|
public readonly id?: number = undefined;
|
||||||
|
20
src/auth/password/AddPasswordToUsersMigration.ts
Normal file
20
src/auth/password/AddPasswordToUsersMigration.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Migration from "../../db/Migration";
|
||||||
|
import ModelFactory from "../../db/ModelFactory";
|
||||||
|
import User from "../models/User";
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rollback(): Promise<void> {
|
||||||
|
await this.query(`ALTER TABLE users
|
||||||
|
DROP COLUMN password`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerModels(): void {
|
||||||
|
ModelFactory.get(User).addComponent(UserPasswordComponent);
|
||||||
|
}
|
||||||
|
}
|
128
src/auth/password/PasswordAuthMethod.ts
Normal file
128
src/auth/password/PasswordAuthMethod.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import AuthMethod from "../AuthMethod";
|
||||||
|
import PasswordAuthProof from "./PasswordAuthProof";
|
||||||
|
import User from "../models/User";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
import UserEmail from "../models/UserEmail";
|
||||||
|
import AuthComponent from "../AuthComponent";
|
||||||
|
import Application from "../../Application";
|
||||||
|
import Throttler from "../../Throttler";
|
||||||
|
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
|
||||||
|
import Validator, {InvalidFormatValidationError, ValidationBag} from "../../db/Validator";
|
||||||
|
import Controller from "../../Controller";
|
||||||
|
import UserPasswordComponent from "./UserPasswordComponent";
|
||||||
|
import UserNameComponent, {USERNAME_REGEXP} from "../UserNameComponent";
|
||||||
|
import ModelFactory from "../../db/ModelFactory";
|
||||||
|
import {WhereOperator, WhereTest} from "../../db/ModelQuery";
|
||||||
|
import {ServerError} from "../../HttpError";
|
||||||
|
|
||||||
|
export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof> {
|
||||||
|
public constructor(
|
||||||
|
protected readonly app: Application,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): string {
|
||||||
|
return 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findUserByIdentifier(identifier: string): Promise<User | null> {
|
||||||
|
const query = UserEmail.select()
|
||||||
|
.with('user')
|
||||||
|
.where('email', identifier);
|
||||||
|
|
||||||
|
if (ModelFactory.get(User).hasComponent(UserNameComponent)) {
|
||||||
|
query.where('name', identifier, WhereTest.EQ, WhereOperator.OR);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await query
|
||||||
|
.first())?.user.getOrFail() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getProofsForSession(session: Express.Session): Promise<PasswordAuthProof[]> {
|
||||||
|
const proof = PasswordAuthProof.getProofForSession(session);
|
||||||
|
return proof ? [proof] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptLogin(req: Request, res: Response, user: User): Promise<void> {
|
||||||
|
const passwordAuthProof = PasswordAuthProof.createProofForLogin(req.getSession());
|
||||||
|
passwordAuthProof.setResource(user);
|
||||||
|
|
||||||
|
await passwordAuthProof.authorize(req.body.password);
|
||||||
|
try {
|
||||||
|
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
Throttler.throttle('login_failed_attempts_user', 3, 180000,
|
||||||
|
<string>user.getOrFail('name'), 1000, 60000);
|
||||||
|
Throttler.throttle('login_failed_attempts_ip', 5, 60000,
|
||||||
|
req.ip, 1000, 60000);
|
||||||
|
|
||||||
|
if (e instanceof PendingApprovalAuthError) {
|
||||||
|
req.flash('error', 'Your account is still being reviewed.');
|
||||||
|
res.redirectBack();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const bag = new ValidationBag();
|
||||||
|
const err = new InvalidFormatValidationError('Invalid password.');
|
||||||
|
err.thingName = 'password';
|
||||||
|
bag.addMessage(err);
|
||||||
|
throw bag;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash('success', `Welcome, ${user.name}.`);
|
||||||
|
res.redirect(Controller.route('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async attemptRegister(req: Request, res: Response, identifier: string): Promise<void> {
|
||||||
|
if (!ModelFactory.get(User).hasComponent(UserNameComponent))
|
||||||
|
throw new ServerError('Cannot register with password without UserNameComponent.');
|
||||||
|
|
||||||
|
Throttler.throttle('register_password', 10, 30000, req.ip);
|
||||||
|
|
||||||
|
req.body.username = identifier;
|
||||||
|
|
||||||
|
await Validator.validate({
|
||||||
|
username: new Validator().defined().between(3, 64).regexp(USERNAME_REGEXP).unique(User, 'name'),
|
||||||
|
password: new Validator().defined().minLength(UserPasswordComponent.PASSWORD_MIN_LENGTH),
|
||||||
|
password_confirmation: new Validator().defined().sameAs('password', req.body.password),
|
||||||
|
terms: new Validator().defined(),
|
||||||
|
}, req.body);
|
||||||
|
|
||||||
|
const passwordAuthProof = PasswordAuthProof.createAuthorizedProofForRegistration(req.getSession());
|
||||||
|
try {
|
||||||
|
await this.app.as(AuthComponent).getAuthGuard().authenticateOrRegister(req.getSession(), passwordAuthProof,
|
||||||
|
undefined, async (connection, user) => {
|
||||||
|
const callbacks: RegisterCallback[] = [];
|
||||||
|
|
||||||
|
// Password
|
||||||
|
await user.as(UserPasswordComponent).setPassword(req.body.password);
|
||||||
|
|
||||||
|
// Username
|
||||||
|
user.as(UserNameComponent).name = req.body.username;
|
||||||
|
|
||||||
|
return callbacks;
|
||||||
|
}, async (connection, user) => {
|
||||||
|
passwordAuthProof.setResource(user);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PendingApprovalAuthError) {
|
||||||
|
req.flash('info', `Your account was successfully created and is pending review from an administrator.`);
|
||||||
|
res.redirect(Controller.route('home'));
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await passwordAuthProof.getResource();
|
||||||
|
|
||||||
|
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).name}.`);
|
||||||
|
res.redirect(Controller.route('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
82
src/auth/password/PasswordAuthProof.ts
Normal file
82
src/auth/password/PasswordAuthProof.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import AuthProof from "../AuthProof";
|
||||||
|
import User from "../models/User";
|
||||||
|
import UserPasswordComponent from "./UserPasswordComponent";
|
||||||
|
|
||||||
|
|
||||||
|
export default class PasswordAuthProof implements AuthProof<User> {
|
||||||
|
public static getProofForSession(session: Express.Session): PasswordAuthProof | null {
|
||||||
|
return session.auth_password_proof ? new PasswordAuthProof(session) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createAuthorizedProofForRegistration(session: Express.Session): PasswordAuthProof {
|
||||||
|
const proofForSession = new PasswordAuthProof(session);
|
||||||
|
proofForSession.authorized = true;
|
||||||
|
proofForSession.forRegistration = true;
|
||||||
|
proofForSession.save();
|
||||||
|
return proofForSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createProofForLogin(session: Express.Session): PasswordAuthProof {
|
||||||
|
return new PasswordAuthProof(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly session: Express.Session;
|
||||||
|
private authorized: boolean;
|
||||||
|
private forRegistration: boolean = false;
|
||||||
|
private userId: number | null;
|
||||||
|
private userPassword: UserPasswordComponent | null = null;
|
||||||
|
|
||||||
|
private constructor(session: Express.Session) {
|
||||||
|
this.session = session;
|
||||||
|
this.authorized = session.auth_password_proof?.authorized || false;
|
||||||
|
this.forRegistration = session.auth_password_proof?.forRegistration || false;
|
||||||
|
this.userId = session.auth_password_proof?.userId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getResource(): Promise<User | null> {
|
||||||
|
if (typeof this.userId !== 'number') return null;
|
||||||
|
return await User.getById(this.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setResource(user: User): void {
|
||||||
|
this.userId = user.getOrFail('id');
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isAuthorized(): Promise<boolean> {
|
||||||
|
return this.authorized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isValid(): Promise<boolean> {
|
||||||
|
return (this.forRegistration || Boolean(await this.getResource())) &&
|
||||||
|
await this.isAuthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke(): Promise<void> {
|
||||||
|
this.session.auth_password_proof = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserPassword(): Promise<UserPasswordComponent | null> {
|
||||||
|
if (!this.userPassword) {
|
||||||
|
this.userPassword = (await User.getById(this.userId))?.as(UserPasswordComponent) || null;
|
||||||
|
}
|
||||||
|
return this.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authorize(passwordGuess: string): Promise<boolean> {
|
||||||
|
const password = await this.getUserPassword();
|
||||||
|
if (!password || !await password.verifyPassword(passwordGuess)) return false;
|
||||||
|
|
||||||
|
this.authorized = true;
|
||||||
|
this.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private save() {
|
||||||
|
this.session.auth_password_proof = {
|
||||||
|
authorized: this.authorized,
|
||||||
|
forRegistration: this.forRegistration,
|
||||||
|
userId: this.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
32
src/auth/password/UserPasswordComponent.ts
Normal file
32
src/auth/password/UserPasswordComponent.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import argon2, {argon2id} from "argon2";
|
||||||
|
import ModelComponent from "../../db/ModelComponent";
|
||||||
|
import User from "../models/User";
|
||||||
|
import Validator from "../../db/Validator";
|
||||||
|
|
||||||
|
export default class UserPasswordComponent extends ModelComponent<User> {
|
||||||
|
public static readonly PASSWORD_MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
private password?: string = undefined;
|
||||||
|
|
||||||
|
public init(): void {
|
||||||
|
this.setValidation('password').acceptUndefined().maxLength(128);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
|
||||||
|
await new Validator<string>().defined().minLength(12).maxLength(512)
|
||||||
|
.execute(fieldName, rawPassword, true);
|
||||||
|
this.password = await argon2.hash(rawPassword, {
|
||||||
|
timeCost: 10,
|
||||||
|
memoryCost: 65536,
|
||||||
|
parallelism: 4,
|
||||||
|
type: argon2id,
|
||||||
|
hashLength: 32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyPassword(passwordGuess: string): Promise<boolean> {
|
||||||
|
if (!this.password) return false;
|
||||||
|
|
||||||
|
return await argon2.verify(this.password, passwordGuess);
|
||||||
|
}
|
||||||
|
}
|
@ -31,7 +31,7 @@ export default class CsrfProtectionComponent extends ApplicationComponent {
|
|||||||
|
|
||||||
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
|
if (!['GET', 'HEAD', 'OPTIONS'].find(s => s === req.method)) {
|
||||||
try {
|
try {
|
||||||
if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) {
|
if ((await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req)).length === 0) {
|
||||||
if (session.csrf === undefined) {
|
if (session.csrf === undefined) {
|
||||||
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
|
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
|
||||||
} else if (req.body.csrf === undefined) {
|
} else if (req.body.csrf === undefined) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{% extends './barebone.njk' %}
|
{% extends 'layouts/barebone.njk' %}
|
||||||
|
|
||||||
{% block _stylesheets %}
|
{% block _stylesheets %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
@ -48,4 +48,4 @@
|
|||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}
|
{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}
|
||||||
|
Loading…
Reference in New Issue
Block a user