From e86cf420df11e3f18726349b630b632f98573753 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sat, 17 Jul 2021 12:49:07 -0500 Subject: [PATCH] Named routes & basic login framework --- src/auth/basic-ui/BasicLoginFormRequest.ts | 24 ++++ src/auth/index.ts | 2 + src/auth/middleware/AuthRequiredMiddleware.ts | 17 ++- .../middleware/GuestRequiredMiddleware.ts | 11 +- src/http/kernel/HTTPKernel.ts | 4 +- .../module/ParseIncomingBodyHTTPModule.ts | 1 - .../PoweredByHeaderInjectionHTTPModule.ts | 1 + src/http/lifecycle/Response.ts | 3 + src/http/response/RedirectResponseFactory.ts | 31 +++++ src/http/response/RouteResponseFactory.ts | 40 ++++++ .../TemporaryRedirectResponseFactory.ts | 2 +- src/http/routing/Route.ts | 12 ++ src/http/servers/static.ts | 12 +- src/http/session/MemorySession.ts | 8 ++ src/http/session/Session.ts | 3 + src/index.ts | 2 + src/orm/support/ORMSession.ts | 10 ++ src/resources/assets/auth/theme.css | 116 ++++++++++++++++++ src/resources/assets/lib/bootstrap.min.css | 7 ++ src/resources/assets/lib/bootstrap.min.js | 7 ++ src/resources/views/auth/login.pug | 10 +- src/resources/views/auth/message.pug | 12 ++ src/resources/views/auth/theme.pug | 11 +- src/service/HTTPServer.ts | 61 +-------- src/service/Routing.ts | 79 +++++++++++- src/views/ViewEngine.ts | 3 + 26 files changed, 412 insertions(+), 77 deletions(-) create mode 100644 src/auth/basic-ui/BasicLoginFormRequest.ts create mode 100644 src/http/response/RedirectResponseFactory.ts create mode 100644 src/http/response/RouteResponseFactory.ts create mode 100644 src/resources/assets/auth/theme.css create mode 100644 src/resources/assets/lib/bootstrap.min.css create mode 100644 src/resources/assets/lib/bootstrap.min.js create mode 100644 src/resources/views/auth/message.pug diff --git a/src/auth/basic-ui/BasicLoginFormRequest.ts b/src/auth/basic-ui/BasicLoginFormRequest.ts new file mode 100644 index 0000000..b83d45a --- /dev/null +++ b/src/auth/basic-ui/BasicLoginFormRequest.ts @@ -0,0 +1,24 @@ +import {FormRequest, ValidationRules} from '../../forms' +import {Is, Str} from '../../forms/rules/rules' +import {Singleton} from '../../di' + +export interface BasicLoginCredentials { + username: string, + password: string, +} + +@Singleton() +export class BasicLoginFormRequest extends FormRequest { + protected getRules(): ValidationRules { + return { + username: [ + Is.required, + Str.lengthMin(1), + ], + password: [ + Is.required, + Str.lengthMin(1), + ], + } + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 5cded5e..068ec71 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -19,3 +19,5 @@ export * from './middleware/SessionAuthMiddleware' export * from './Authentication' export * from './config' + +export * from './basic-ui/BasicLoginFormRequest' diff --git a/src/auth/middleware/AuthRequiredMiddleware.ts b/src/auth/middleware/AuthRequiredMiddleware.ts index aed1b67..e0a098e 100644 --- a/src/auth/middleware/AuthRequiredMiddleware.ts +++ b/src/auth/middleware/AuthRequiredMiddleware.ts @@ -5,15 +5,30 @@ import {ResponseObject} from '../../http/routing/Route' import {error} from '../../http/response/ErrorResponseFactory' import {NotAuthorizedError} from '../NotAuthorizedError' import {HTTPStatus} from '../../util' +import {redirect} from '../../http/response/RedirectResponseFactory' +import {Routing} from '../../service/Routing' +import {Session} from '../../http/session/Session' @Injectable() export class AuthRequiredMiddleware extends Middleware { @Inject() protected readonly security!: SecurityContext + @Inject() + protected readonly routing!: Routing + + @Inject() + protected readonly session!: Session + async apply(): Promise { if ( !this.security.hasUser() ) { - return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + this.session.set('auth.intention', this.request.url) + + if ( this.routing.hasNamedRoute('@auth.login') ) { + return redirect(this.routing.getNamedPath('@auth.login').toRemote) + } else { + return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + } } } } diff --git a/src/auth/middleware/GuestRequiredMiddleware.ts b/src/auth/middleware/GuestRequiredMiddleware.ts index e623062..aa8c338 100644 --- a/src/auth/middleware/GuestRequiredMiddleware.ts +++ b/src/auth/middleware/GuestRequiredMiddleware.ts @@ -5,15 +5,24 @@ import {ResponseObject} from '../../http/routing/Route' import {error} from '../../http/response/ErrorResponseFactory' import {NotAuthorizedError} from '../NotAuthorizedError' import {HTTPStatus} from '../../util' +import {Routing} from '../../service/Routing' +import {redirect} from '../../http/response/RedirectResponseFactory' @Injectable() export class GuestRequiredMiddleware extends Middleware { @Inject() protected readonly security!: SecurityContext + @Inject() + protected readonly routing!: Routing + async apply(): Promise { if ( this.security.hasUser() ) { - return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) { + return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote) + } else { + return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) + } } } } diff --git a/src/http/kernel/HTTPKernel.ts b/src/http/kernel/HTTPKernel.ts index 644b728..ed3f017 100644 --- a/src/http/kernel/HTTPKernel.ts +++ b/src/http/kernel/HTTPKernel.ts @@ -5,6 +5,7 @@ import {Logging} from '../../service/Logging' import {AppClass} from '../../lifecycle/AppClass' import {Request} from '../lifecycle/Request' import {error} from '../response/ErrorResponseFactory' +import {HTTPError} from '../HTTPError' /** * Interface for fluently registering kernel modules into the kernel. @@ -105,7 +106,8 @@ export class HTTPKernel extends AppClass { } } catch (e: any) { this.logging.error(e) - await error(e).status(HTTPStatus.INTERNAL_SERVER_ERROR) + const status = (e instanceof HTTPError && e.status) ? e.status : HTTPStatus.INTERNAL_SERVER_ERROR + await error(e).status(status) .write(request) } diff --git a/src/http/kernel/module/ParseIncomingBodyHTTPModule.ts b/src/http/kernel/module/ParseIncomingBodyHTTPModule.ts index 53a6452..c6643c6 100644 --- a/src/http/kernel/module/ParseIncomingBodyHTTPModule.ts +++ b/src/http/kernel/module/ParseIncomingBodyHTTPModule.ts @@ -138,7 +138,6 @@ export class ParseIncomingBodyHTTPModule extends HTTPKernelModule { }) busboy.on('finish', () => { - this.logging.debug(`Parsed body input: ${JSON.stringify(request.parsedInput)}`) res() }) diff --git a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts index 45be108..790cb4b 100644 --- a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts +++ b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts @@ -21,6 +21,7 @@ export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule { public async apply(request: Request): Promise { if ( !this.config.get('server.poweredBy.hide', false) ) { request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo')) + request.response.setHeader('Server', this.config.get('server.poweredBy.header', 'Extollo')) } return request diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index d780517..8b44850 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -229,6 +229,9 @@ export class Response { this.setHeader('Content-Length', String(this.body?.length ?? 0)) } + this.setHeader('Date', (new Date()).toUTCString()) + this.setHeader('Permissions-Policy', 'interest-cohort=()') + await this.write(this.body ?? '') this.end() diff --git a/src/http/response/RedirectResponseFactory.ts b/src/http/response/RedirectResponseFactory.ts new file mode 100644 index 0000000..b4825c3 --- /dev/null +++ b/src/http/response/RedirectResponseFactory.ts @@ -0,0 +1,31 @@ +import {ResponseFactory} from './ResponseFactory' +import {HTTPStatus} from '../../util' +import {Request} from '../lifecycle/Request' + +/** + * Helper function to create a new RedirectResponseFactory to the given destination. + * @param destination + */ +export function redirect(destination: string): RedirectResponseFactory { + return new RedirectResponseFactory(destination) +} + +/** + * Response factory that sends an HTTP redirect to the given destination. + */ +export class RedirectResponseFactory extends ResponseFactory { + protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY + + constructor( + /** THe URL where the client should redirect to. */ + public readonly destination: string, + ) { + super() + } + + public async write(request: Request): Promise { + request = await super.write(request) + request.response.setHeader('Location', this.destination) + return request + } +} diff --git a/src/http/response/RouteResponseFactory.ts b/src/http/response/RouteResponseFactory.ts new file mode 100644 index 0000000..bf4b3c5 --- /dev/null +++ b/src/http/response/RouteResponseFactory.ts @@ -0,0 +1,40 @@ +import {ResponseFactory} from './ResponseFactory' +import {HTTPStatus} from '../../util' +import {Request} from '../lifecycle/Request' +import {Routing} from '../../service/Routing' + +/** + * Helper function to create a new RouteResponseFactory to the given destination. + * @param nameOrPath + */ +export function route(nameOrPath: string): RouteResponseFactory { + return new RouteResponseFactory(nameOrPath) +} + +/** + * Response factory that sends an HTTP redirect to the given destination. + */ +export class RouteResponseFactory extends ResponseFactory { + protected targetStatus: HTTPStatus = HTTPStatus.MOVED_TEMPORARILY + + constructor( + /** The alias or path of the route to redirect to. */ + public readonly nameOrPath: string, + ) { + super() + } + + public async write(request: Request): Promise { + const routing = request.make(Routing) + request = await super.write(request) + + try { + const routePath = routing.getNamedPath(this.nameOrPath) + request.response.setHeader('Location', routePath.toRemote) + } catch (e: unknown) { + request.response.setHeader('Location', routing.getAppUrl().concat(this.nameOrPath).toRemote) + } + + return request + } +} diff --git a/src/http/response/TemporaryRedirectResponseFactory.ts b/src/http/response/TemporaryRedirectResponseFactory.ts index a98885e..e313685 100644 --- a/src/http/response/TemporaryRedirectResponseFactory.ts +++ b/src/http/response/TemporaryRedirectResponseFactory.ts @@ -6,7 +6,7 @@ import {Request} from '../lifecycle/Request' * Helper function to create a new TemporaryRedirectResponseFactory to the given destination. * @param destination */ -export function redirect(destination: string): TemporaryRedirectResponseFactory { +export function temporary(destination: string): TemporaryRedirectResponseFactory { return new TemporaryRedirectResponseFactory(destination) } diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 5d7f2f5..7327f62 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -215,6 +215,9 @@ export class Route extends AppClass { /** Pre-compiled route handler for the main route handler for this route. */ protected compiledPostflight?: ResolvedRouteHandler[] + /** Programmatic aliases of this route. */ + public aliases: string[] = [] + constructor( /** The HTTP method(s) that this route listens on. */ protected method: HTTPMethod | HTTPMethod[], @@ -228,6 +231,15 @@ export class Route extends AppClass { super() } + /** + * Set a programmatic name for this route. + * @param name + */ + public alias(name: string): this { + this.aliases.push(name) + return this + } + /** * Get the string-form of the route. */ diff --git a/src/http/servers/static.ts b/src/http/servers/static.ts index cdca4cb..ac497ff 100644 --- a/src/http/servers/static.ts +++ b/src/http/servers/static.ts @@ -5,7 +5,7 @@ import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util' import {Application} from '../../lifecycle/Application' import {HTTPError} from '../HTTPError' import {view, ViewResponseFactory} from '../response/ViewResponseFactory' -import {redirect} from '../response/TemporaryRedirectResponseFactory' +import {redirect} from '../response/RedirectResponseFactory' import {file} from '../response/FileResponseFactory' import {RouteHandler} from '../routing/Route' @@ -24,6 +24,9 @@ export interface StaticServerOptions { /** If specified, files with these extensions will not be served. */ excludedExtensions?: string[] + + /** If a file with this name exists in a directory, it will be served. */ + indexFile?: string } /** @@ -156,6 +159,13 @@ export function staticServer(options: StaticServerOptions = {}): RouteHandler { // If the resolved path is a directory, send the directory listing response if ( await filePath.isDirectory() ) { + if ( options.indexFile ) { + const indexFile = filePath.concat(options.indexFile) + if ( await indexFile.exists() ) { + return file(indexFile) + } + } + if ( !options.directoryListing ) { throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { basePath: basePath.toString(), diff --git a/src/http/session/MemorySession.ts b/src/http/session/MemorySession.ts index 258a801..b41ed07 100644 --- a/src/http/session/MemorySession.ts +++ b/src/http/session/MemorySession.ts @@ -93,4 +93,12 @@ export class MemorySession extends Session { this.data[key] = value } + + public forget(key: string): void { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + delete this.data[key] + } } diff --git a/src/http/session/Session.ts b/src/http/session/Session.ts index 3364e23..a0385cf 100644 --- a/src/http/session/Session.ts +++ b/src/http/session/Session.ts @@ -57,4 +57,7 @@ export abstract class Session { /** Set a value in the session by key. */ public abstract set(key: string, value: unknown): void + + /** Remove a key from the session data. */ + public abstract forget(key: string): void } diff --git a/src/index.ts b/src/index.ts index 295b4b1..202e9cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,8 +42,10 @@ export * from './http/response/JSONResponseFactory' export * from './http/response/ResponseFactory' export * from './http/response/StringResponseFactory' export * from './http/response/TemporaryRedirectResponseFactory' +export * from './http/response/RedirectResponseFactory' export * from './http/response/ViewResponseFactory' export * from './http/response/FileResponseFactory' +export * from './http/response/RouteResponseFactory' export * from './http/routing/ActivatedRoute' export * from './http/routing/Route' diff --git a/src/orm/support/ORMSession.ts b/src/orm/support/ORMSession.ts index 765e363..61e303d 100644 --- a/src/orm/support/ORMSession.ts +++ b/src/orm/support/ORMSession.ts @@ -68,6 +68,7 @@ export class ORMSession extends Session { if ( !this.data ) { throw new SessionNotLoadedError() } + return this.data[key] ?? fallback } @@ -75,6 +76,15 @@ export class ORMSession extends Session { if ( !this.data ) { throw new SessionNotLoadedError() } + this.data[key] = value } + + public forget(key: string): void { + if ( !this.data ) { + throw new SessionNotLoadedError() + } + + delete this.data[key] + } } diff --git a/src/resources/assets/auth/theme.css b/src/resources/assets/auth/theme.css new file mode 100644 index 0000000..d1e94b1 --- /dev/null +++ b/src/resources/assets/auth/theme.css @@ -0,0 +1,116 @@ +:root { + --input-padding-x: 1.5rem; + --input-padding-y: 0.75rem; +} + +.login, +.image { + min-height: 100vh; +} + +.login-heading { + font-weight: 300; +} + +.btn-login { + font-size: 0.9rem; + letter-spacing: 0.05rem; + padding: 0.75rem 1rem; + border-radius: 2rem; +} + +.form-label-group { + position: relative; + margin-bottom: 1rem; +} + +.form-label-group>input, +.form-label-group>label { + padding: var(--input-padding-y) var(--input-padding-x); + height: auto; + border-radius: 2rem; +} + +.form-label-group>label { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + margin-bottom: 0; + /* Override default `