From 70d67c27307c66bb35558167c431df3a54a99177 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 23 Feb 2022 15:15:02 -0600 Subject: [PATCH] Add model serializer and coreid login provider --- src/auth/Authentication.ts | 36 +++++++ src/auth/config.ts | 25 +++-- src/auth/index.ts | 11 ++ src/auth/provider/LoginProvider.ts | 7 +- .../provider/oauth/CoreIDLoginProvider.ts | 100 ++++++++++++++++++ .../provider/oauth/OAuth2LoginProvider.ts | 98 +++++++++++++++++ src/cli/directive/RoutesDirective.ts | 9 +- src/http/routing/Route.ts | 4 + .../event/QueryExecutedEventSerializer.ts | 3 +- src/orm/index.ts | 3 + src/orm/model/ModelSerializer.ts | 68 ++++++++++++ src/resources/views/auth/message.pug | 10 +- src/resources/views/auth/theme.pug | 21 ---- src/support/bus/LocalBus.ts | 4 +- 14 files changed, 360 insertions(+), 39 deletions(-) create mode 100644 src/auth/provider/oauth/CoreIDLoginProvider.ts create mode 100644 src/auth/provider/oauth/OAuth2LoginProvider.ts create mode 100644 src/orm/model/ModelSerializer.ts delete mode 100644 src/resources/views/auth/theme.pug diff --git a/src/auth/Authentication.ts b/src/auth/Authentication.ts index a22a176..31f23b4 100644 --- a/src/auth/Authentication.ts +++ b/src/auth/Authentication.ts @@ -9,6 +9,10 @@ import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware' import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware' import {ViewEngine} from '../views/ViewEngine' import {SecurityContext} from './context/SecurityContext' +import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider' +import {Config} from '../service/Config' +import {ErrorWithContext, hasOwnProperty} from '../util' +import {Route} from '../http/routing/Route' @Injectable() export class Authentication extends Unit { @@ -18,6 +22,11 @@ export class Authentication extends Unit { @Inject() protected readonly middleware!: Middlewares + @Inject() + protected readonly config!: Config + + protected providers: {[name: string]: LoginProvider} = {} + async up(): Promise { this.middleware.registerNamespace('@auth', this.getMiddlewareResolver()) @@ -27,6 +36,33 @@ export class Authentication extends Unit { return () => req?.make(SecurityContext)?.getUser() }) }) + + const config = this.config.get('auth.providers', {}) + const middleware = this.config.get('auth.middleware', SessionAuthMiddleware) + + if ( !(middleware?.prototype instanceof Middleware) ) { + throw new ErrorWithContext('Auth middleware must extend Middleware base class', { + providedValue: middleware, + configKey: 'auth.middleware', + }) + } + + for ( const name in config ) { + if ( !hasOwnProperty(config, name) ) { + continue + } + + if ( this.providers[name] ) { + this.logging.warn(`Registering duplicate authentication provider: ${name}`) + } + + this.logging.verbose(`Registered authentication provider: ${name}`) + this.providers[name] = this.make(config[name].driver, name, config[name].config) + + Route.group(`/auth/${name}`, () => { + this.providers[name].routes() + }).pre(request => request.make(middleware, request).apply()) + } } protected getMiddlewareResolver(): CanonicalResolver> { diff --git a/src/auth/config.ts b/src/auth/config.ts index 2f35c29..02295bf 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -1,12 +1,17 @@ import {Instantiable, isInstantiable} from '../di' import {AuthenticatableRepository} from './types' import {hasOwnProperty} from '../util' -import {LoginProviderConfig} from './provider/LoginProvider' +import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider' +import {Middleware} from '../http/routing/Middleware' export interface AuthenticationConfig { storage: Instantiable, + middleware?: Instantiable, providers?: { - [key: string]: LoginProviderConfig + [key: string]: { + driver: Instantiable>, + config: LoginProviderConfig, + }, }, } @@ -15,7 +20,7 @@ export function isAuthenticationConfig(what: unknown): what is AuthenticationCon return false } - if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'sources') ) { + if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) { return false } @@ -23,17 +28,21 @@ export function isAuthenticationConfig(what: unknown): what is AuthenticationCon return false } - if ( typeof what.sources !== 'object' ) { + if ( typeof what.providers !== 'object' ) { return false } - for ( const key in what.sources ) { - if ( !hasOwnProperty(what.sources, key) ) { + for ( const key in what.providers ) { + if ( !hasOwnProperty(what.providers, key) ) { continue } - const source = what.sources[key] - if ( !isInstantiable(source) || !(source.prototype instanceof AuthenticatableRepository) ) { + const source = what.providers[key] + if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) { + return false + } + + if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) { return false } } diff --git a/src/auth/index.ts b/src/auth/index.ts index d5c2919..b0f0256 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -11,6 +11,17 @@ export * from './event/UserAuthenticatedEvent' export * from './event/UserAuthenticationResumedEvent' export * from './event/UserFlushedEvent' +export * from './middleware/AuthRequiredMiddleware' +export * from './middleware/GuestRequiredMiddleware' +export * from './middleware/SessionAuthMiddleware' + +export * from './provider/basic/BasicLoginAttempt' +export * from './provider/basic/BasicLoginProvider' +export * from './provider/basic/BasicRegistrationAttempt' + +export * from './provider/oauth/OAuth2LoginProvider' +export * from './provider/oauth/CoreIDLoginProvider' + export * from './serial/AuthenticationEventSerializer' export * from './repository/orm/ORMUser' diff --git a/src/auth/provider/LoginProvider.ts b/src/auth/provider/LoginProvider.ts index f58294c..8aa3d9e 100644 --- a/src/auth/provider/LoginProvider.ts +++ b/src/auth/provider/LoginProvider.ts @@ -5,6 +5,7 @@ import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware' import {Inject, Injectable} from '../../di' import {SecurityContext} from '../context/SecurityContext' import {redirect} from '../../http/response/RedirectResponseFactory' +import {RequestLocalStorage} from '../../http/RequestLocalStorage' export interface LoginProviderConfig { default: boolean, @@ -17,7 +18,11 @@ export interface LoginProviderConfig { @Injectable() export abstract class LoginProvider { @Inject() - protected readonly security!: SecurityContext + protected readonly request!: RequestLocalStorage + + protected get security(): SecurityContext { + return this.request.get().make(SecurityContext) + } constructor( protected name: string, diff --git a/src/auth/provider/oauth/CoreIDLoginProvider.ts b/src/auth/provider/oauth/CoreIDLoginProvider.ts new file mode 100644 index 0000000..0d65f01 --- /dev/null +++ b/src/auth/provider/oauth/CoreIDLoginProvider.ts @@ -0,0 +1,100 @@ +/* eslint camelcase: 0 */ +import {OAuth2LoginProvider, OAuth2LoginProviderConfig} from './OAuth2LoginProvider' +import {Authenticatable} from '../../types' +import {Request} from '../../../http/lifecycle/Request' +import {ErrorWithContext, uuid4, fetch} from '../../../util' + +/** + * OAuth2LoginProvider implementation that authenticates users against a + * Starship CoreID server. + */ +export class CoreIDLoginProvider extends OAuth2LoginProvider { + protected async callback(request: Request): Promise { + // Get authentication_code from the request + const code = String(request.input('code') || '') + if ( !code ) { + throw new ErrorWithContext('Unable to authenticate user: missing login code', { + input: request.input(), + }) + } + + // Get OAuth2 token from CoreID + const token = await this.getToken(code) + + // Get user from endpoint + const userData = await this.getUserData(token) + + // Return authenticatable instance + const existing = await this.security.repository.getByIdentifier(userData.uid) + if ( existing ) { + this.updateUser(existing, userData) + return existing + } + + const user = await this.security.repository.createFromCredentials(userData.uid, uuid4()) + this.updateUser(user, userData) + return user + } + + /** Given an access token, look up the associated user's information. */ + protected async getUserData(token: string): Promise { + const userResponse = await fetch( + this.config.userUrl, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + + const userData: any = await userResponse.json() + if ( !userData?.data?.uid ) { + throw new ErrorWithContext('Unable to extract user from response', { + userData, + }) + } + + return userData.data + } + + /** Given a login code, redeem it for an access token. */ + protected async getToken(code: string): Promise { + const body: string[] = [ + 'code=' + encodeURIComponent(code), + 'client_id=' + encodeURIComponent(this.config.clientId), + 'client_secret=' + encodeURIComponent(this.config.clientSecret), + 'grant_type=authorization_code', + ] + + const response = await fetch( + this.config.tokenUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.join('&'), + }, + ) + + const data = await response.json() + const token = (data as any).access_token + if ( !token ) { + throw new ErrorWithContext('Unable to obtain access token from response', { + data, + }) + } + + return String(token) + } + + /** Update values on the Authenticatable from user data. */ + protected updateUser(user: any, data: any): void { + user.firstName = data.first_name + user.lastName = data.last_name + user.email = data.email + user.tagline = data.tagline + user.photoUrl = data.profile_photo + } +} diff --git a/src/auth/provider/oauth/OAuth2LoginProvider.ts b/src/auth/provider/oauth/OAuth2LoginProvider.ts new file mode 100644 index 0000000..56b991f --- /dev/null +++ b/src/auth/provider/oauth/OAuth2LoginProvider.ts @@ -0,0 +1,98 @@ +import {LoginProvider, LoginProviderConfig} from '../LoginProvider' +import {ResponseObject, Route} from '../../../http/routing/Route' +import {Inject, Injectable} from '../../../di' +import {Routing} from '../../../service/Routing' +import {GuestRequiredMiddleware} from '../../middleware/GuestRequiredMiddleware' +import {redirect} from '../../../http/response/RedirectResponseFactory' +import {view} from '../../../http/response/ViewResponseFactory' +import {Request} from '../../../http/lifecycle/Request' +import {Awaitable} from '../../../util' +import {Authenticatable} from '../../types' + +export interface OAuth2LoginProviderConfig extends LoginProviderConfig { + displayName: string, + clientId: string|number + clientSecret: string + loginUrl: string + loginMessage?: string + logoutUrl?: string + tokenUrl: string, + userUrl: string, +} + +/** + * LoginProvider implementation for OAuth2-based logins. + */ +@Injectable() +export abstract class OAuth2LoginProvider extends LoginProvider { + @Inject() + protected readonly routing!: Routing + + public routes(): void { + super.routes() + + Route.any('redirect') + .alias(`@auth:${this.name}:redirect`) + .pre(GuestRequiredMiddleware) + .handledBy(() => redirect(this.getLoginUrl())) + + Route.any('callback') + .alias(`@auth:${this.name}:callback`) + .pre(GuestRequiredMiddleware) + .passingRequest() + .handledBy(this.handleCallback.bind(this)) + } + + protected async handleCallback(request: Request): Promise { + const user = await this.callback(request) + if ( user ) { + await this.security.authenticate(user) + return this.redirectToIntendedRoute() + } + + return redirect(this.routing.getNamedPath(`@auth:${this.name}:login`).toRemote) + } + + /** + * After redirecting back from the OAuth2 server, look up the user information. + * @param request + * @protected + */ + protected abstract callback(request: Request): Awaitable + + public login(): ResponseObject { + const buttonUrl = this.routing + .getNamedPath(`@auth:${this.name}:redirect`) + .toRemote + + return view('@extollo:auth:message', { + message: this.config.loginMessage ?? `Sign-in with ${this.config.displayName} to continue`, + buttonText: 'Sign-in', + buttonUrl, + }) + } + + public async logout(): Promise { + await this.security.flush() + + if ( this.config.logoutUrl ) { + return redirect(this.config.logoutUrl) + } + + return view('@extollo:auth:message', { + message: 'You have been signed-out', + }) + } + + /** + * Get the URL where the user should be redirected to sign-in. + * @protected + */ + protected getLoginUrl(): string { + const callbackRoute = this.routing.getNamedPath(`@auth:${this.name}:callback`) + + return this.config.loginUrl + .replace(/%c/g, String(this.config.clientId)) + .replace(/%r/g, callbackRoute.toRemote) + } +} diff --git a/src/cli/directive/RoutesDirective.ts b/src/cli/directive/RoutesDirective.ts index c59538f..5fab7da 100644 --- a/src/cli/directive/RoutesDirective.ts +++ b/src/cli/directive/RoutesDirective.ts @@ -23,12 +23,15 @@ export class RoutesDirective extends Directive { const maxHandlerLength = compiled.mapCall('getHandlerDisplay') .whereDefined() .max('length') + const maxNameLength = compiled.mapCall('getAlias') + .whereDefined() + .max('length') - const rows = compiled.map(route => [String(route), route.getHandlerDisplay()]) + const rows = compiled.map(route => [String(route), route.getHandlerDisplay(), route.getAlias() || '']) const table = new Table({ - head: ['Route', 'Handler'], - colWidths: [maxRouteLength + 2, maxHandlerLength + 2], + head: ['Route', 'Handler', 'Name'], + colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2], }) table.push(...rows.toArray()) diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 575ddad..e5fd4d6 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -398,6 +398,10 @@ export class Route { + return this.aliases.first() + } + isHandled(): this is HandledRoute { return Boolean(this.handler) } diff --git a/src/orm/connection/event/QueryExecutedEventSerializer.ts b/src/orm/connection/event/QueryExecutedEventSerializer.ts index 002ee0c..5b48f1a 100644 --- a/src/orm/connection/event/QueryExecutedEventSerializer.ts +++ b/src/orm/connection/event/QueryExecutedEventSerializer.ts @@ -1,9 +1,8 @@ -import {BaseSerializer} from '../../../support/bus' +import {BaseSerializer, ObjectSerializer} from '../../../support/bus' import {QueryExecutedEvent} from './QueryExecutedEvent' import {Awaitable, JSONState} from '../../../util' import {Container, Inject, Injectable} from '../../../di' import {DatabaseService} from '../../DatabaseService' -import {ObjectSerializer} from '../../../support/bus/serial/decorators' export interface QueryExecutedEventSerialPayload extends JSONState { connectionName: string diff --git a/src/orm/index.ts b/src/orm/index.ts index 5bdb45e..099ec9a 100644 --- a/src/orm/index.ts +++ b/src/orm/index.ts @@ -7,6 +7,8 @@ export * from './builder/Builder' export * from './connection/Connection' export * from './connection/PostgresConnection' +export * from './connection/event/QueryExecutedEvent' +export * from './connection/event/QueryExecutedEventSerializer' export * from './dialect/SQLDialect' export * from './dialect/PostgreSQLDialect' @@ -17,6 +19,7 @@ export * from './model/ModelBuilder' export * from './model/ModelResultIterable' export * from './model/events' export * from './model/Model' +export * from './model/ModelSerializer' export * from './model/relation/RelationBuilder' export * from './model/relation/Relation' diff --git a/src/orm/model/ModelSerializer.ts b/src/orm/model/ModelSerializer.ts new file mode 100644 index 0000000..b72eb17 --- /dev/null +++ b/src/orm/model/ModelSerializer.ts @@ -0,0 +1,68 @@ +import {BaseSerializer, ObjectSerializer} from '../../support/bus' +import {Model} from './Model' +import {Awaitable, ErrorWithContext, JSONState, Maybe} from '../../util' +import {QueryRow} from '../types' +import {Inject, Injectable, isInstantiable} from '../../di' +import {Canon} from '../../service/Canon' + +export interface ModelSerialPayload extends JSONState { + canonicalResolver: string, + primaryKey: any, + objectValues: QueryRow, +} + +@ObjectSerializer() +@Injectable() +export class ModelSerializer extends BaseSerializer, ModelSerialPayload> { + @Inject() + protected readonly canon!: Canon + + protected async decodeSerial(serial: ModelSerialPayload): Promise> { + const ModelClass = this.canon.getFromFullyQualified(serial.canonicalResolver) as typeof Model + if ( !ModelClass || !(ModelClass.prototype instanceof Model) || !isInstantiable>(ModelClass) ) { + throw new ErrorWithContext('Cannot decode serialized model as canonical resolver is invalid', { + serial, + }) + } + + let inst: Maybe> = this.make>(ModelClass) + if ( serial.primaryKey ) { + inst = await ModelClass.query() + .whereKey(serial.primaryKey) + .first() + } + + if ( !inst ) { + throw new ErrorWithContext('Could not look up serialized model', { + serial, + }) + } + + await inst.assume(serial.objectValues) + return inst + } + + protected encodeActual(actual: Model): Awaitable { + const ctor = actual.constructor as typeof Model + const canonicalResolver = ctor.getFullyQualifiedCanonicalResolver() + if ( !canonicalResolver ) { + throw new ErrorWithContext('Unable to serialize model: no Canonical resolver', { + actual, + }) + } + + return { + canonicalResolver, + primaryKey: actual.key(), + objectValues: actual.toObject(), + } + } + + protected getName(): string { + return '@extollo/lib.ModelSerializer' + } + + matchActual(some: Model): boolean { + return some instanceof Model + } +} diff --git a/src/resources/views/auth/message.pug b/src/resources/views/auth/message.pug index 48cdb01..c91335f 100644 --- a/src/resources/views/auth/message.pug +++ b/src/resources/views/auth/message.pug @@ -1,8 +1,11 @@ -extends ./theme +extends ../base block content + .offset(style='padding-top: 20vh') if heading - h3.login-heading.mb-4 #{heading} + h3.login-heading #{heading} + else + h3.login-heading #{config('app.name') || ''} if errors each error in errors @@ -10,3 +13,6 @@ block content if message p #{message} + + if buttonText && buttonUrl + a.button(href=buttonUrl) #{buttonText} diff --git a/src/resources/views/auth/theme.pug b/src/resources/views/auth/theme.pug deleted file mode 100644 index 88971c7..0000000 --- a/src/resources/views/auth/theme.pug +++ /dev/null @@ -1,21 +0,0 @@ -html - head - meta(name='viewport' content='width=device-width initial-scale=1') - - block head - - block styles - link(rel='stylesheet' href=vendor('@extollo/lib', 'lib/bootstrap.min.css')) - link(rel='stylesheet' href=vendor('@extollo/lib', 'auth/theme.css')) - body - .container-fluid - .row.no-gutter - .col-md-12.col-lg-12 - .login.d-flex.align-items-center.py-5 - .container - .row - .col-md-9.col-lg-6.mx-auto - block content - - block scripts - script(src=vendor('@extollo/lib', 'lib/bootstrap.min.js')) diff --git a/src/support/bus/LocalBus.ts b/src/support/bus/LocalBus.ts index 270203f..70d192f 100644 --- a/src/support/bus/LocalBus.ts +++ b/src/support/bus/LocalBus.ts @@ -3,14 +3,14 @@ import {BusSubscriber, Event, EventBus, EventHandler, EventHandlerReturn, EventH import {Awaitable, Collection, Pipeline, uuid4} from '../../util' import {Logging} from '../../service/Logging' import {Bus, BusInternalSubscription} from './Bus' -import {AppClass} from '../../lifecycle/AppClass' import {getEventName} from './getEventName' +import {CanonicalItemClass} from '../CanonicalReceiver' /** * Non-connectable event bus implementation. Can forward events to the main Bus instance. */ @Injectable() -export class LocalBus extends AppClass implements EventBus { +export class LocalBus extends CanonicalItemClass implements EventBus { @Inject() protected readonly logging!: Logging