From f00233d49a2e0d3a02df6f29c502952589898b1f Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sat, 5 Jun 2021 13:24:12 -0500 Subject: [PATCH] Add middleware and logic for bootstrapping the session auth --- src/auth/Authentication.ts | 22 +++++++++++- src/auth/NotAuthorizedError.ts | 11 ++++++ src/auth/SecurityContext.ts | 21 ++++++++++++ src/auth/config.ts | 25 ++++++++++++++ src/auth/contexts/SessionSecurityContext.ts | 8 +++++ .../event/UserAuthenticationResumedEvent.ts | 27 +++++++++++++++ src/auth/index.ts | 8 +++++ src/auth/middleware/AuthRequiredMiddleware.ts | 19 +++++++++++ .../middleware/GuestRequiredMiddleware.ts | 19 +++++++++++ src/auth/middleware/SessionAuthMiddleware.ts | 34 +++++++++++++++++++ src/auth/orm/ORMUserRepository.ts | 4 +-- src/di/Container.ts | 4 +-- src/di/ScopedContainer.ts | 4 +-- src/service/Middlewares.ts | 4 +-- 14 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 src/auth/NotAuthorizedError.ts create mode 100644 src/auth/config.ts create mode 100644 src/auth/event/UserAuthenticationResumedEvent.ts create mode 100644 src/auth/middleware/AuthRequiredMiddleware.ts create mode 100644 src/auth/middleware/GuestRequiredMiddleware.ts create mode 100644 src/auth/middleware/SessionAuthMiddleware.ts diff --git a/src/auth/Authentication.ts b/src/auth/Authentication.ts index 2b5cc49..bdcc555 100644 --- a/src/auth/Authentication.ts +++ b/src/auth/Authentication.ts @@ -1,6 +1,12 @@ -import {Inject, Injectable} from '../di' +import {Inject, Injectable, Instantiable, StaticClass} from '../di' import {Unit} from '../lifecycle/Unit' import {Logging} from '../service/Logging' +import {CanonicalResolver} from '../service/Canonical' +import {Middleware} from '../http/routing/Middleware' +import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware' +import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware' +import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware' +import {Middlewares} from '../service/Middlewares' /** * Unit class that bootstraps the authentication framework. @@ -10,7 +16,21 @@ export class Authentication extends Unit { @Inject() protected readonly logging!: Logging + @Inject() + protected readonly middleware!: Middlewares + async up(): Promise { this.container() + this.middleware.registerNamespace('@auth', this.getMiddlewareResolver()) + } + + protected getMiddlewareResolver(): CanonicalResolver>> { + return (key: string) => { + return ({ + web: SessionAuthMiddleware, + required: AuthRequiredMiddleware, + guest: GuestRequiredMiddleware, + })[key] + } } } diff --git a/src/auth/NotAuthorizedError.ts b/src/auth/NotAuthorizedError.ts new file mode 100644 index 0000000..0d454b3 --- /dev/null +++ b/src/auth/NotAuthorizedError.ts @@ -0,0 +1,11 @@ +import {HTTPError} from '../http/HTTPError' +import {HTTPStatus} from '../util' + +/** + * Error thrown when a user attempts an action that they are not authorized to perform. + */ +export class NotAuthorizedError extends HTTPError { + constructor(message = 'Not Authorized') { + super(HTTPStatus.FORBIDDEN, message) + } +} diff --git a/src/auth/SecurityContext.ts b/src/auth/SecurityContext.ts index e694050..2027ab8 100644 --- a/src/auth/SecurityContext.ts +++ b/src/auth/SecurityContext.ts @@ -4,6 +4,7 @@ import {Awaitable, Maybe} from '../util' import {Authenticatable, AuthenticatableRepository} from './types' import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent' import {UserFlushedEvent} from './event/UserFlushedEvent' +import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent' /** * Base-class for a context that authenticates users and manages security. @@ -101,6 +102,19 @@ export abstract class SecurityContext { } } + /** + * Assuming a user is still authenticated in the context, + * try to look up and fill in the user. + */ + async resume(): Promise { + const credentials = await this.getCredentials() + const user = await this.repository.getByCredentials(credentials) + if ( user ) { + this.authenticatedUser = user + await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this)) + } + } + /** * Write the current state of the security context to whatever storage * medium the context's host provides. @@ -119,4 +133,11 @@ export abstract class SecurityContext { getUser(): Maybe { return this.authenticatedUser } + + /** + * Returns true if there is a currently authenticated user. + */ + hasUser(): boolean { + return Boolean(this.authenticatedUser) + } } diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..e51d2ac --- /dev/null +++ b/src/auth/config.ts @@ -0,0 +1,25 @@ +import {Instantiable} from '../di' +import {ORMUserRepository} from './orm/ORMUserRepository' + +/** + * Inferface for type-checking the AuthenticatableRepositories values. + */ +export interface AuthenticatableRepositoryMapping { + orm: Instantiable, +} + +/** + * String mapping of AuthenticatableRepository implementations. + */ +export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = { + orm: ORMUserRepository, +} + +/** + * Interface for making the auth config type-safe. + */ +export interface AuthConfig { + repositories: { + session: keyof AuthenticatableRepositoryMapping, + } +} diff --git a/src/auth/contexts/SessionSecurityContext.ts b/src/auth/contexts/SessionSecurityContext.ts index a58382b..4832bd9 100644 --- a/src/auth/contexts/SessionSecurityContext.ts +++ b/src/auth/contexts/SessionSecurityContext.ts @@ -2,6 +2,7 @@ import {SecurityContext} from '../SecurityContext' import {Inject, Injectable} from '../../di' import {Session} from '../../http/session/Session' import {Awaitable} from '../../util' +import {AuthenticatableRepository} from '../types' /** * Security context implementation that uses the session as storage. @@ -11,6 +12,13 @@ export class SessionSecurityContext extends SecurityContext { @Inject() protected readonly session!: Session + constructor( + /** The repository from which to draw users. */ + protected readonly repository: AuthenticatableRepository, + ) { + super(repository, 'session') + } + getCredentials(): Awaitable> { return { securityIdentifier: this.session.get('extollo.auth.securityIdentifier'), diff --git a/src/auth/event/UserAuthenticationResumedEvent.ts b/src/auth/event/UserAuthenticationResumedEvent.ts new file mode 100644 index 0000000..3750965 --- /dev/null +++ b/src/auth/event/UserAuthenticationResumedEvent.ts @@ -0,0 +1,27 @@ +import {Event} from '../../event/Event' +import {SecurityContext} from '../SecurityContext' +import {Awaitable, JSONState} from '../../util' +import {Authenticatable} from '../types' + +/** + * Event fired when a security context for a given user is resumed. + */ +export class UserAuthenticationResumedEvent extends Event { + constructor( + public readonly user: Authenticatable, + public readonly context: SecurityContext, + ) { + super() + } + + async dehydrate(): Promise { + return { + user: await this.user.dehydrate(), + contextName: this.context.name, + } + } + + rehydrate(state: JSONState): Awaitable { // eslint-disable-line @typescript-eslint/no-unused-vars + // TODO fill this in + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index f98447b..5cded5e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,13 +1,21 @@ export * from './types' +export * from './NotAuthorizedError' export * from './SecurityContext' export * from './event/UserAuthenticatedEvent' export * from './event/UserFlushedEvent' +export * from './event/UserAuthenticationResumedEvent' export * from './contexts/SessionSecurityContext' export * from './orm/ORMUser' export * from './orm/ORMUserRepository' +export * from './middleware/AuthRequiredMiddleware' +export * from './middleware/GuestRequiredMiddleware' +export * from './middleware/SessionAuthMiddleware' + export * from './Authentication' + +export * from './config' diff --git a/src/auth/middleware/AuthRequiredMiddleware.ts b/src/auth/middleware/AuthRequiredMiddleware.ts new file mode 100644 index 0000000..aed1b67 --- /dev/null +++ b/src/auth/middleware/AuthRequiredMiddleware.ts @@ -0,0 +1,19 @@ +import {Middleware} from '../../http/routing/Middleware' +import {Inject, Injectable} from '../../di' +import {SecurityContext} from '../SecurityContext' +import {ResponseObject} from '../../http/routing/Route' +import {error} from '../../http/response/ErrorResponseFactory' +import {NotAuthorizedError} from '../NotAuthorizedError' +import {HTTPStatus} from '../../util' + +@Injectable() +export class AuthRequiredMiddleware extends Middleware { + @Inject() + protected readonly security!: SecurityContext + + async apply(): Promise { + if ( !this.security.hasUser() ) { + return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + } + } +} diff --git a/src/auth/middleware/GuestRequiredMiddleware.ts b/src/auth/middleware/GuestRequiredMiddleware.ts new file mode 100644 index 0000000..e623062 --- /dev/null +++ b/src/auth/middleware/GuestRequiredMiddleware.ts @@ -0,0 +1,19 @@ +import {Middleware} from '../../http/routing/Middleware' +import {Inject, Injectable} from '../../di' +import {SecurityContext} from '../SecurityContext' +import {ResponseObject} from '../../http/routing/Route' +import {error} from '../../http/response/ErrorResponseFactory' +import {NotAuthorizedError} from '../NotAuthorizedError' +import {HTTPStatus} from '../../util' + +@Injectable() +export class GuestRequiredMiddleware extends Middleware { + @Inject() + protected readonly security!: SecurityContext + + async apply(): Promise { + if ( this.security.hasUser() ) { + return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + } + } +} diff --git a/src/auth/middleware/SessionAuthMiddleware.ts b/src/auth/middleware/SessionAuthMiddleware.ts new file mode 100644 index 0000000..7100325 --- /dev/null +++ b/src/auth/middleware/SessionAuthMiddleware.ts @@ -0,0 +1,34 @@ +import {Middleware} from '../../http/routing/Middleware' +import {Inject, Injectable} from '../../di' +import {ResponseObject} from '../../http/routing/Route' +import {Config} from '../../service/Config' +import {AuthenticatableRepository} from '../types' +import {SessionSecurityContext} from '../contexts/SessionSecurityContext' +import {SecurityContext} from '../SecurityContext' +import {ORMUserRepository} from '../orm/ORMUserRepository' +import {AuthConfig} from '../config' + +/** + * Injects a SessionSecurityContext into the request and attempts to + * resume the user's authentication. + */ +@Injectable() +export class SessionAuthMiddleware extends Middleware { + @Inject() + protected readonly config!: Config + + async apply(): Promise { + const context = this.make(SessionSecurityContext, this.getRepository()) + this.request.registerSingletonInstance(SecurityContext, context) + await context.resume() + } + + /** + * Build the correct AuthenticatableRepository based on the auth config. + * @protected + */ + protected getRepository(): AuthenticatableRepository { + const config: AuthConfig | undefined = this.config.get('auth') + return this.make(config?.repositories?.session ?? ORMUserRepository) + } +} diff --git a/src/auth/orm/ORMUserRepository.ts b/src/auth/orm/ORMUserRepository.ts index 5655105..d6dae16 100644 --- a/src/auth/orm/ORMUserRepository.ts +++ b/src/auth/orm/ORMUserRepository.ts @@ -1,12 +1,12 @@ import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types' import {Awaitable, Maybe} from '../../util' import {ORMUser} from './ORMUser' -import {Singleton} from '../../di' +import {Injectable} from '../../di' /** * A user repository implementation that looks up users stored in the database. */ -@Singleton() +@Injectable() export class ORMUserRepository extends AuthenticatableRepository { /** Look up the user by their username. */ getByIdentifier(id: AuthenticatableIdentifier): Awaitable> { diff --git a/src/di/Container.ts b/src/di/Container.ts index 263f9c8..34efd19 100644 --- a/src/di/Container.ts +++ b/src/di/Container.ts @@ -1,4 +1,4 @@ -import {DependencyKey, InstanceRef, Instantiable, isInstantiable} from './types' +import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types' import {AbstractFactory} from './factory/AbstractFactory' import {collect, Collection, globalRegistry, logIfDebugging} from '../util' import {Factory} from './factory/Factory' @@ -132,7 +132,7 @@ export class Container { * @param staticClass * @param instance */ - registerSingletonInstance(staticClass: Instantiable, instance: T): this { + registerSingletonInstance(staticClass: StaticClass | Instantiable, instance: T): this { if ( this.resolve(staticClass) ) { throw new DuplicateFactoryKeyError(staticClass) } diff --git a/src/di/ScopedContainer.ts b/src/di/ScopedContainer.ts index f91606d..0b12e0a 100644 --- a/src/di/ScopedContainer.ts +++ b/src/di/ScopedContainer.ts @@ -1,5 +1,5 @@ import {Container, MaybeDependency, MaybeFactory} from './Container' -import {DependencyKey, Instantiable} from './types' +import {DependencyKey, Instantiable, StaticClass} from './types' import {AbstractFactory} from './factory/AbstractFactory' /** @@ -113,7 +113,7 @@ export class ScopedContainer extends Container { * @param staticClass * @param instance */ - registerSingletonInstance(staticClass: Instantiable, instance: T): this { + registerSingletonInstance(staticClass: StaticClass | Instantiable, instance: T): this { return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance)) } diff --git a/src/service/Middlewares.ts b/src/service/Middlewares.ts index 8f8f69b..e67a4e3 100644 --- a/src/service/Middlewares.ts +++ b/src/service/Middlewares.ts @@ -7,14 +7,14 @@ import {Middleware} from '../http/routing/Middleware' * A canonical unit that loads the middleware classes from `app/http/middlewares`. */ @Singleton() -export class Middlewares extends CanonicalStatic, Middleware> { +export class Middlewares extends CanonicalStatic> { protected appPath = ['http', 'middlewares'] protected canonicalItem = 'middleware' protected suffix = '.middleware.js' - public async initCanonicalItem(definition: CanonicalDefinition): Promise, Middleware>> { + public async initCanonicalItem(definition: CanonicalDefinition): Promise>> { const item = await super.initCanonicalItem(definition) if ( !(item.prototype instanceof Middleware) ) { throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.http.routing.Middleware.`)