diff --git a/src/TestApp.ts b/src/TestApp.ts index 9ff5fed..8a387fc 100644 --- a/src/TestApp.ts +++ b/src/TestApp.ts @@ -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; public constructor(addr: string, port: number) { super(packageJson.version, true); @@ -43,8 +52,8 @@ export default class TestApp extends Application { protected async init(): Promise { 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(); diff --git a/src/auth/AuthComponent.ts b/src/auth/AuthComponent.ts index bad574d..dc1141e 100644 --- a/src/auth/AuthComponent.ts +++ b/src/auth/AuthComponent.ts @@ -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>; + private readonly authGuard: AuthGuard; - public constructor(authGuard: AuthGuard>) { + public constructor(app: Application, ...authMethods: AuthMethod>[]) { super(); - this.authGuard = authGuard; + this.authGuard = new AuthGuard(app, ...authMethods); } public async init(): Promise { this.use(AuthMiddleware); } - public getAuthGuard(): AuthGuard> { + public getAuthGuard(): AuthGuard { return this.authGuard; } } export class AuthMiddleware extends Middleware { - private authGuard?: AuthGuard>; + private authGuard?: AuthGuard; private user: User | null = null; protected async handle(req: Request, res: Response, next: NextFunction): Promise { 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> { + 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 { - 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 { - 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; } diff --git a/src/auth/AuthController.ts b/src/auth/AuthController.ts index 32fc242..33e6a6a 100644 --- a/src/auth/AuthController.ts +++ b/src/auth/AuthController.ts @@ -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 { - 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; + protected async postLogin(req: Request, res: Response): Promise { + return await this.handleAuth(req, res, false); + } - protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise; + protected async postRegister(req: Request, res: Response): Promise { + return await this.handleAuth(req, res, true); + } + + protected async handleAuth(req: Request, res: Response, isRegistration: boolean): Promise { + 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 { - 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 { + 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, + })); + } } diff --git a/src/auth/AuthGuard.ts b/src/auth/AuthGuard.ts index 9db2ddc..1733879 100644 --- a/src/auth/AuthGuard.ts +++ b/src/auth/AuthGuard.ts @@ -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>[]; -export default abstract class AuthGuard

> { public constructor( private readonly app: Application, + ...authMethods: AuthMethod>[] ) { + this.authMethods = authMethods; } - protected abstract async getProofForSession(session: Express.Session): Promise

; - - protected async getProofForRequest(_req: Request): Promise

{ - return null; - } - - public async getProof(req: Request): Promise

{ - let proof = await this.isAuthenticatedViaRequest(req); - if (!proof && req.session) { - proof = await this.isAuthenticated(req.session); + public async interruptAuth(req: Request, res: Response): Promise { + 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

{ - if (!session.is_authenticated) return null; + public getAuthMethodByName(authMethodName: string): AuthMethod> | 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> { + return this.authMethods[0]; + } + + public async getAuthMethodsByIdentifier( + identifier: string, + ): Promise<{ user: User, method: AuthMethod> }[]> { + 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[]> { + 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[]> { + 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

{ - 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[]> { + 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, onLogin?: (user: User) => Promise, beforeRegister?: (connection: Connection, user: User) => Promise, afterRegister?: (connection: Connection, user: User) => Promise, @@ -106,7 +154,6 @@ export default abstract class AuthGuard

> { return user; } - } export class AuthError extends Error { diff --git a/src/auth/AuthMethod.ts b/src/auth/AuthMethod.ts new file mode 100644 index 0000000..cc25101 --- /dev/null +++ b/src/auth/AuthMethod.ts @@ -0,0 +1,26 @@ +import User from "./models/User"; +import AuthProof from "./AuthProof"; +import {Request, Response} from "express"; + + +export default interface AuthMethod

> { + /** + * @return A unique name. + */ + getName(): string; + + findUserByIdentifier(identifier: string): Promise; + + getProofsForSession?(session: Express.Session): Promise; + + getProofsForRequest?(req: Request): Promise; + + /** + * @return {@code true} if interrupted, {@code false} otherwise. + */ + interruptAuth?(req: Request, res: Response): Promise; + + attemptLogin(req: Request, res: Response, user: User): Promise; + + attemptRegister(req: Request, res: Response, identifier: string): Promise; +} diff --git a/src/auth/UserNameComponent.ts b/src/auth/UserNameComponent.ts new file mode 100644 index 0000000..e3bff05 --- /dev/null +++ b/src/auth/UserNameComponent.ts @@ -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 { + public name?: string = undefined; + + public init(): void { + this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model); + } +} diff --git a/src/auth/magic_link/AuthMagicLinkActionType.ts b/src/auth/magic_link/AuthMagicLinkActionType.ts new file mode 100644 index 0000000..99a72e0 --- /dev/null +++ b/src/auth/magic_link/AuthMagicLinkActionType.ts @@ -0,0 +1,4 @@ +export default { + LOGIN: 'Login', + REGISTER: 'Register', +}; diff --git a/src/auth/migrations/CreateMagicLinksTable.ts b/src/auth/magic_link/CreateMagicLinksTableMigration.ts similarity index 92% rename from src/auth/migrations/CreateMagicLinksTable.ts rename to src/auth/magic_link/CreateMagicLinksTableMigration.ts index 14b0c05..680bf5a 100644 --- a/src/auth/migrations/CreateMagicLinksTable.ts +++ b/src/auth/magic_link/CreateMagicLinksTableMigration.ts @@ -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 { await this.query(`CREATE TABLE magic_links ( diff --git a/src/auth/magic_link/MagicLinkAuthController.ts b/src/auth/magic_link/MagicLinkAuthController.ts deleted file mode 100644 index ea988d9..0000000 --- a/src/auth/magic_link/MagicLinkAuthController.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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.`); - } -} diff --git a/src/auth/magic_link/MagicLinkAuthMethod.ts b/src/auth/magic_link/MagicLinkAuthMethod.ts new file mode 100644 index 0000000..73a5da6 --- /dev/null +++ b/src/auth/magic_link/MagicLinkAuthMethod.ts @@ -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 { + public constructor( + protected readonly app: Application, + protected readonly magicLinkMailTemplate: MailTemplate, + ) { + } + + public getName(): string { + return 'magic_link'; + } + + public async findUserByIdentifier(identifier: string): Promise { + return (await UserEmail.select() + .with('user') + .where('email', identifier) + .first())?.user.getOrFail() || null; + } + + public async getProofsForSession(session: Express.Session): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + })); + } + } +} diff --git a/src/auth/magic_link/MagicLinkController.ts b/src/auth/magic_link/MagicLinkController.ts index b26de2a..67f5926 100644 --- a/src/auth/magic_link/MagicLinkController.ts +++ b/src/auth/magic_link/MagicLinkController.ts @@ -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 extends Controller { +export default class MagicLinkController extends Controller { public static async sendMagicLink( app: Application, sessionId: string, @@ -23,12 +28,11 @@ export default abstract class MagicLinkController 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 extends })).send(email); } + public static async checkAndAuth(req: Request, res: Response, magicLink: MagicLink): Promise { + 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) { + public constructor(magicLinkWebsocketListener: MagicLinkWebSocketListener) { super(); this.magicLinkWebsocketPath = magicLinkWebsocketListener.path(); } @@ -60,7 +107,10 @@ export default abstract class MagicLinkController extends } protected async getLobby(req: Request, res: Response): Promise { - 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 extends }); } - protected abstract async performAction(magicLink: MagicLink, req: Request, res: Response): Promise; + protected async performAction(magicLink: MagicLink, req: Request, res: Response): Promise { + 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.`); + } } diff --git a/src/auth/magic_link/MagicLinkWebSocketListener.ts b/src/auth/magic_link/MagicLinkWebSocketListener.ts index d0600d9..be982e1 100644 --- a/src/auth/magic_link/MagicLinkWebSocketListener.ts +++ b/src/auth/magic_link/MagicLinkWebSocketListener.ts @@ -27,7 +27,10 @@ export default class MagicLinkWebSocketListener 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()) { diff --git a/src/auth/migrations/AddApprovedFieldToUsersTable.ts b/src/auth/migrations/AddApprovedFieldToUsersTableMigration.ts similarity index 87% rename from src/auth/migrations/AddApprovedFieldToUsersTable.ts rename to src/auth/migrations/AddApprovedFieldToUsersTableMigration.ts index b1db7de..65d199b 100644 --- a/src/auth/migrations/AddApprovedFieldToUsersTable.ts +++ b/src/auth/migrations/AddApprovedFieldToUsersTableMigration.ts @@ -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 { await this.query('ALTER TABLE users ADD COLUMN approved BOOLEAN NOT NULL DEFAULT 0'); } diff --git a/src/auth/migrations/AddNameToUsersMigration.ts b/src/auth/migrations/AddNameToUsersMigration.ts new file mode 100644 index 0000000..cd895c0 --- /dev/null +++ b/src/auth/migrations/AddNameToUsersMigration.ts @@ -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 { + await this.query(`ALTER TABLE users + ADD COLUMN name VARCHAR(64) UNIQUE NOT NULL`); + } + + public async rollback(): Promise { + await this.query('ALTER TABLE users DROP COLUMN name'); + } + + public registerModels(): void { + ModelFactory.get(User).addComponent(UserNameComponent); + } +} diff --git a/src/auth/migrations/CreateUsersAndUserEmailsTable.ts b/src/auth/migrations/CreateUsersAndUserEmailsTableMigration.ts similarity index 94% rename from src/auth/migrations/CreateUsersAndUserEmailsTable.ts rename to src/auth/migrations/CreateUsersAndUserEmailsTableMigration.ts index ba22b88..5aaf814 100644 --- a/src/auth/migrations/CreateUsersAndUserEmailsTable.ts +++ b/src/auth/migrations/CreateUsersAndUserEmailsTableMigration.ts @@ -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 { await this.query(`CREATE TABLE users ( diff --git a/src/auth/models/MagicLink.ts b/src/auth/models/MagicLink.ts index 2fcacef..e8c0e45 100644 --- a/src/auth/models/MagicLink.ts +++ b/src/auth/models/MagicLink.ts @@ -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 { - public static async bySessionId(sessionId: string, actionType?: string | string[]): Promise { - 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('magic_link.validity_period') * 1000; } @@ -30,7 +17,7 @@ export default class MagicLink extends Model implements AuthProof { 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; diff --git a/src/auth/models/User.ts b/src/auth/models/User.ts index d2652c4..8695137 100644 --- a/src/auth/models/User.ts +++ b/src/auth/models/User.ts @@ -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('approval_mode') && - MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable); + MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration); } public readonly id?: number = undefined; diff --git a/src/auth/password/AddPasswordToUsersMigration.ts b/src/auth/password/AddPasswordToUsersMigration.ts new file mode 100644 index 0000000..227644c --- /dev/null +++ b/src/auth/password/AddPasswordToUsersMigration.ts @@ -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 { + await this.query(`ALTER TABLE users + ADD COLUMN password VARCHAR(128) NOT NULL`); + } + + public async rollback(): Promise { + await this.query(`ALTER TABLE users + DROP COLUMN password`); + } + + public registerModels(): void { + ModelFactory.get(User).addComponent(UserPasswordComponent); + } +} diff --git a/src/auth/password/PasswordAuthMethod.ts b/src/auth/password/PasswordAuthMethod.ts new file mode 100644 index 0000000..3c6c495 --- /dev/null +++ b/src/auth/password/PasswordAuthMethod.ts @@ -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 { + public constructor( + protected readonly app: Application, + ) { + } + + public getName(): string { + return 'password'; + } + + public async findUserByIdentifier(identifier: string): Promise { + 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 { + const proof = PasswordAuthProof.getProofForSession(session); + return proof ? [proof] : []; + } + + public async attemptLogin(req: Request, res: Response, user: User): Promise { + 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, + 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 { + 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')); + } + +} diff --git a/src/auth/password/PasswordAuthProof.ts b/src/auth/password/PasswordAuthProof.ts new file mode 100644 index 0000000..f03a10e --- /dev/null +++ b/src/auth/password/PasswordAuthProof.ts @@ -0,0 +1,82 @@ +import AuthProof from "../AuthProof"; +import User from "../models/User"; +import UserPasswordComponent from "./UserPasswordComponent"; + + +export default class PasswordAuthProof implements AuthProof { + 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 { + 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 { + return this.authorized; + } + + public async isValid(): Promise { + return (this.forRegistration || Boolean(await this.getResource())) && + await this.isAuthorized(); + } + + public async revoke(): Promise { + this.session.auth_password_proof = undefined; + } + + private async getUserPassword(): Promise { + if (!this.userPassword) { + this.userPassword = (await User.getById(this.userId))?.as(UserPasswordComponent) || null; + } + return this.userPassword; + } + + public async authorize(passwordGuess: string): Promise { + 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, + }; + } +} diff --git a/src/auth/password/UserPasswordComponent.ts b/src/auth/password/UserPasswordComponent.ts new file mode 100644 index 0000000..da1e295 --- /dev/null +++ b/src/auth/password/UserPasswordComponent.ts @@ -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 { + 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 { + await new Validator().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 { + if (!this.password) return false; + + return await argon2.verify(this.password, passwordGuess); + } +} diff --git a/src/components/CsrfProtectionComponent.ts b/src/components/CsrfProtectionComponent.ts index 3965c58..31ba87f 100644 --- a/src/components/CsrfProtectionComponent.ts +++ b/src/components/CsrfProtectionComponent.ts @@ -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) { diff --git a/test/views/layouts/base.njk b/test/views/layouts/base.njk index d73ca22..48bd2e5 100644 --- a/test/views/layouts/base.njk +++ b/test/views/layouts/base.njk @@ -1,4 +1,4 @@ -{% extends './barebone.njk' %} +{% extends 'layouts/barebone.njk' %} {% block _stylesheets %} {{ super() }} @@ -48,4 +48,4 @@ {% endblock %} -{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %} \ No newline at end of file +{% block footer %}{{ app.name }} v{{ app_version }} - all rights reserved.{% endblock %}