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 PasswordAuthMethod from "../src/auth/password/PasswordAuthMethod";
|
||||
import {MAGIC_LINK_MAIL} from "./Mails";
|
||||
import packageJson = require('../package.json');
|
||||
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 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 = [
|
||||
CreateMigrationsTable,
|
||||
CreateUsersAndUserEmailsTable,
|
||||
CreateUsersAndUserEmailsTableMigration,
|
||||
AddNameToUsersMigration,
|
||||
AddPasswordToUsersMigration,
|
||||
CreateMagicLinksTableMigration,
|
||||
];
|
||||
|
||||
@ -30,6 +38,7 @@ 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);
|
||||
@ -43,8 +52,8 @@ export default class TestApp extends Application {
|
||||
|
||||
protected async init(): Promise<void> {
|
||||
this.registerComponents();
|
||||
this.registerWebSocketListeners?.();
|
||||
this.registerControllers?.();
|
||||
this.registerWebSocketListeners();
|
||||
this.registerControllers();
|
||||
}
|
||||
|
||||
protected registerComponents(): void {
|
||||
@ -71,16 +80,27 @@ export default class TestApp extends Application {
|
||||
this.use(redisComponent);
|
||||
this.use(new SessionComponent(redisComponent));
|
||||
|
||||
// Auth
|
||||
this.use(new AuthComponent(this, new MagicLinkAuthMethod(this, MAGIC_LINK_MAIL), new PasswordAuthMethod(this)));
|
||||
|
||||
// Utils
|
||||
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 {
|
||||
return this.as(ExpressAppComponent).getExpressApp();
|
||||
|
@ -5,35 +5,37 @@ import Controller from "../Controller";
|
||||
import {ForbiddenHttpError} from "../HttpError";
|
||||
import Middleware from "../Middleware";
|
||||
import User from "./models/User";
|
||||
import Application from "../Application";
|
||||
import AuthMethod from "./AuthMethod";
|
||||
import AuthProof from "./AuthProof";
|
||||
|
||||
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();
|
||||
this.authGuard = authGuard;
|
||||
this.authGuard = new AuthGuard(app, ...authMethods);
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
this.use(AuthMiddleware);
|
||||
}
|
||||
|
||||
public getAuthGuard(): AuthGuard<AuthProof<User>> {
|
||||
public getAuthGuard(): AuthGuard {
|
||||
return this.authGuard;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthMiddleware extends Middleware {
|
||||
private authGuard?: AuthGuard<AuthProof<User>>;
|
||||
private authGuard?: AuthGuard;
|
||||
private user: User | null = null;
|
||||
|
||||
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
this.authGuard = this.app.as(AuthComponent).getAuthGuard();
|
||||
|
||||
const proof = await this.authGuard.isAuthenticated(req.getSession());
|
||||
if (proof) {
|
||||
this.user = await proof.getResource();
|
||||
const proofs = await this.authGuard.getProofsForSession(req.getSession());
|
||||
if (proofs.length > 0) {
|
||||
this.user = await proofs[0].getResource();
|
||||
res.locals.user = this.user;
|
||||
}
|
||||
|
||||
@ -44,7 +46,7 @@ export class AuthMiddleware extends Middleware {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public getAuthGuard(): AuthGuard<AuthProof<User>> {
|
||||
public getAuthGuard(): AuthGuard {
|
||||
if (!this.authGuard) throw new Error('AuthGuard was not initialized.');
|
||||
return this.authGuard;
|
||||
}
|
||||
@ -54,8 +56,8 @@ export class RequireRequestAuthMiddleware extends Middleware {
|
||||
private user?: User;
|
||||
|
||||
protected async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const proof = await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req);
|
||||
const user = await proof?.getResource();
|
||||
const proofs = await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req);
|
||||
const user = await proofs[0]?.getResource();
|
||||
if (user) {
|
||||
this.user = user;
|
||||
next();
|
||||
@ -81,8 +83,8 @@ export class RequireAuthMiddleware extends Middleware {
|
||||
const authGuard = req.as(AuthMiddleware).getAuthGuard();
|
||||
// Via request
|
||||
|
||||
let proof = await authGuard.isAuthenticatedViaRequest(req);
|
||||
let user = await proof?.getResource();
|
||||
let proofs = await authGuard.getProofsForRequest(req);
|
||||
let user = await proofs[0]?.getResource();
|
||||
if (user) {
|
||||
this.user = user;
|
||||
next();
|
||||
@ -90,8 +92,8 @@ export class RequireAuthMiddleware extends Middleware {
|
||||
}
|
||||
|
||||
// Via session
|
||||
proof = await authGuard.isAuthenticated(req.getSession());
|
||||
user = await proof?.getResource();
|
||||
proofs = await authGuard.getProofsForSession(req.getSession());
|
||||
user = await proofs[0]?.getResource();
|
||||
if (user) {
|
||||
this.user = user;
|
||||
next();
|
||||
@ -112,7 +114,8 @@ export class RequireAuthMiddleware extends Middleware {
|
||||
|
||||
export class RequireGuestMiddleware extends Middleware {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
@ -1,35 +1,100 @@
|
||||
import Controller from "../Controller";
|
||||
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 {
|
||||
return '/auth';
|
||||
}
|
||||
|
||||
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.post('/', this.postAuth, 'auth', RequireGuestMiddleware);
|
||||
this.get('/check', this.getCheckAuth, 'check_auth');
|
||||
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 registerEmail = req.flash('register_confirm_email');
|
||||
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||
|
||||
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> {
|
||||
const proof = await req.as(AuthMiddleware).getAuthGuard().getProof(req);
|
||||
await proof?.revoke();
|
||||
const userId = typeof req.body.user_id === 'string' ? parseInt(req.body.user_id) : null;
|
||||
|
||||
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.');
|
||||
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 User from "./models/User";
|
||||
import {Connection} from "mysql";
|
||||
import {Request} from "express";
|
||||
import {Request, Response} from "express";
|
||||
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
|
||||
import Mail from "../Mail";
|
||||
import Controller from "../Controller";
|
||||
import config from "config";
|
||||
import Application from "../Application";
|
||||
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(
|
||||
private readonly app: Application,
|
||||
...authMethods: AuthMethod<AuthProof<User>>[]
|
||||
) {
|
||||
this.authMethods = authMethods;
|
||||
}
|
||||
|
||||
protected abstract async getProofForSession(session: Express.Session): Promise<P | null>;
|
||||
|
||||
protected async getProofForRequest(_req: Request): Promise<P | null> {
|
||||
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);
|
||||
public async interruptAuth(req: Request, res: Response): Promise<boolean> {
|
||||
for (const method of this.authMethods) {
|
||||
if (method.interruptAuth && await method.interruptAuth(req, res)) return true;
|
||||
}
|
||||
return proof;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async isAuthenticated(session: Express.Session): Promise<P | null> {
|
||||
if (!session.is_authenticated) return null;
|
||||
public getAuthMethodByName(authMethodName: string): AuthMethod<AuthProof<User>> | 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()) {
|
||||
await proof?.revoke();
|
||||
public getRegistrationMethod(): AuthMethod<AuthProof<User>> {
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
|
||||
return proof;
|
||||
return proofs;
|
||||
}
|
||||
|
||||
public async isAuthenticatedViaRequest(req: Request): Promise<P | null> {
|
||||
const proof = await this.getProofForRequest(req);
|
||||
|
||||
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
|
||||
await proof?.revoke();
|
||||
return null;
|
||||
public async getProofsForRequest(req: Request): Promise<AuthProof<User>[]> {
|
||||
const proofs = [];
|
||||
for (const method of this.authMethods) {
|
||||
if (method.getProofsForRequest) {
|
||||
const methodProofs = await method.getProofsForRequest(req);
|
||||
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(
|
||||
session: Express.Session,
|
||||
proof: P,
|
||||
proof: AuthProof<User>,
|
||||
onLogin?: (user: User) => Promise<void>,
|
||||
beforeRegister?: (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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 MagicLink from "../models/MagicLink";
|
||||
|
||||
export default class CreateMagicLinksTable extends Migration {
|
||||
export default class CreateMagicLinksTableMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
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 {ParsedUrlQueryInput} from "querystring";
|
||||
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(
|
||||
app: Application,
|
||||
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', 1, MagicLink.validityPeriod(), email, 0, 0);
|
||||
|
||||
const link = await MagicLink.bySessionId(sessionId, actionType) ||
|
||||
MagicLink.create({
|
||||
session_id: sessionId,
|
||||
action_type: actionType,
|
||||
original_url: original_url,
|
||||
});
|
||||
const link = MagicLink.create({
|
||||
session_id: sessionId,
|
||||
action_type: actionType,
|
||||
original_url: original_url,
|
||||
});
|
||||
|
||||
const token = await link.generateToken(email);
|
||||
await link.save();
|
||||
@ -42,10 +46,53 @@ export default abstract class MagicLinkController<A extends Application> extends
|
||||
})).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 constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
|
||||
public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener<A>) {
|
||||
super();
|
||||
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> {
|
||||
const link = await MagicLink.bySessionId(req.getSession().id);
|
||||
const link = await MagicLink.select()
|
||||
.where('session_id', req.getSession().id)
|
||||
.sortBy('authorized')
|
||||
.first();
|
||||
if (!link) {
|
||||
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
|
||||
const magicLink = await MagicLink.bySessionId(session.id);
|
||||
const magicLink = await MagicLink.select()
|
||||
.where('session_id', session.id)
|
||||
.sortBy('authorized')
|
||||
.first();
|
||||
|
||||
// Refresh if immediately applicable
|
||||
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
|
||||
|
@ -3,7 +3,7 @@ import ModelFactory from "../../db/ModelFactory";
|
||||
import User from "../models/User";
|
||||
import UserApprovedComponent from "../models/UserApprovedComponent";
|
||||
|
||||
export default class AddApprovedFieldToUsersTable extends Migration {
|
||||
export default class AddApprovedFieldToUsersTableMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
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 UserEmail from "../models/UserEmail";
|
||||
|
||||
export default class CreateUsersAndUserEmailsTable extends Migration {
|
||||
export default class CreateUsersAndUserEmailsTableMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query(`CREATE TABLE users
|
||||
(
|
@ -4,23 +4,10 @@ import Model from "../../db/Model";
|
||||
import AuthProof from "../AuthProof";
|
||||
import User from "./User";
|
||||
import argon2 from "argon2";
|
||||
import {WhereTest} from "../../db/ModelQuery";
|
||||
import UserEmail from "./UserEmail";
|
||||
import {EMAIL_REGEX} from "../../db/Validator";
|
||||
|
||||
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 {
|
||||
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 token?: 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 authorized: boolean = false;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Model from "../../db/Model";
|
||||
import MysqlConnectionManager from "../../db/MysqlConnectionManager";
|
||||
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable";
|
||||
import AddApprovedFieldToUsersTableMigration from "../migrations/AddApprovedFieldToUsersTableMigration";
|
||||
import config from "config";
|
||||
import {ManyModelRelation} from "../../db/ModelRelation";
|
||||
import UserEmail from "./UserEmail";
|
||||
@ -9,7 +9,7 @@ import UserApprovedComponent from "./UserApprovedComponent";
|
||||
export default class User extends Model {
|
||||
public static isApprovalMode(): boolean {
|
||||
return config.get<boolean>('approval_mode') &&
|
||||
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
|
||||
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
|
||||
}
|
||||
|
||||
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)) {
|
||||
try {
|
||||
if (!await req.as(AuthMiddleware).getAuthGuard().isAuthenticatedViaRequest(req)) {
|
||||
if ((await req.as(AuthMiddleware).getAuthGuard().getProofsForRequest(req)).length === 0) {
|
||||
if (session.csrf === undefined) {
|
||||
return next(new InvalidCsrfTokenError(req.baseUrl, `You weren't assigned any CSRF token.`));
|
||||
} else if (req.body.csrf === undefined) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends './barebone.njk' %}
|
||||
{% extends 'layouts/barebone.njk' %}
|
||||
|
||||
{% block _stylesheets %}
|
||||
{{ super() }}
|
||||
@ -48,4 +48,4 @@
|
||||
</main>
|
||||
{% 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