From 506fb55c7437feefa49900698e2c8f9014e20efd Mon Sep 17 00:00:00 2001 From: garrettmills Date: Thu, 20 Jan 2022 00:55:21 -0600 Subject: [PATCH] Start auth provider system --- src/auth/config.ts | 5 +- src/auth/provider/LoginProvider.ts | 61 +++++++++++++++ src/auth/provider/basic/BasicLoginAttempt.ts | 8 ++ src/auth/provider/basic/BasicLoginProvider.ts | 75 +++++++++++++++++++ .../basic/BasicRegistrationAttempt.ts | 19 +++++ src/resources/views/auth/login.pug | 2 - src/service/Queueables.ts | 2 +- src/validation/Validator.ts | 16 +++- 8 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 src/auth/provider/LoginProvider.ts create mode 100644 src/auth/provider/basic/BasicLoginAttempt.ts create mode 100644 src/auth/provider/basic/BasicLoginProvider.ts create mode 100644 src/auth/provider/basic/BasicRegistrationAttempt.ts diff --git a/src/auth/config.ts b/src/auth/config.ts index d911d40..2f35c29 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -1,11 +1,12 @@ import {Instantiable, isInstantiable} from '../di' import {AuthenticatableRepository} from './types' import {hasOwnProperty} from '../util' +import {LoginProviderConfig} from './provider/LoginProvider' export interface AuthenticationConfig { storage: Instantiable, - sources?: { - [key: string]: Instantiable, + providers?: { + [key: string]: LoginProviderConfig }, } diff --git a/src/auth/provider/LoginProvider.ts b/src/auth/provider/LoginProvider.ts new file mode 100644 index 0000000..f58294c --- /dev/null +++ b/src/auth/provider/LoginProvider.ts @@ -0,0 +1,61 @@ +import {Request} from '../../http/lifecycle/Request' +import {ResponseObject, Route} from '../../http/routing/Route' +import {GuestRequiredMiddleware} from '../middleware/GuestRequiredMiddleware' +import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware' +import {Inject, Injectable} from '../../di' +import {SecurityContext} from '../context/SecurityContext' +import {redirect} from '../../http/response/RedirectResponseFactory' + +export interface LoginProviderConfig { + default: boolean, + allow?: { + login?: boolean, + registration?: boolean, + }, +} + +@Injectable() +export abstract class LoginProvider { + @Inject() + protected readonly security!: SecurityContext + + constructor( + protected name: string, + protected config: TConfig, + ) {} + + public routes(): void { + Route.get('login') + .alias(`@auth:${this.name}:login`) + .pipe(line => line.when(this.config.default, route => route.alias('@auth:login'))) + .pre(GuestRequiredMiddleware) + .passingRequest() + .handledBy(this.login.bind(this)) + + Route.any('logout') + .alias(`@auth:${this.name}:logout`) + .pipe(line => line.when(this.config.default, route => route.alias('@auth:logout'))) + .pre(AuthRequiredMiddleware) + .passingRequest() + .handledBy(this.logout.bind(this)) + + Route.get('register') + .alias(`@auth:${this.name}:register`) + .pipe(line => line.when(this.config.default, route => route.alias('@auth:register'))) + .pre(GuestRequiredMiddleware) + .passingRequest() + .handledBy(this.registration.bind(this)) + } + + public abstract login(request: Request): ResponseObject + + public abstract logout(request: Request): ResponseObject + + public registration(request: Request): ResponseObject { + return this.login(request) + } + + protected redirectToIntendedRoute(): ResponseObject { + return redirect('/') // FIXME + } +} diff --git a/src/auth/provider/basic/BasicLoginAttempt.ts b/src/auth/provider/basic/BasicLoginAttempt.ts new file mode 100644 index 0000000..045493d --- /dev/null +++ b/src/auth/provider/basic/BasicLoginAttempt.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export type BasicLoginAttempt = z.infer + +export const BasicLoginAttemptType = z.object({ + username: z.string().nonempty(), + password: z.string().nonempty(), +}) diff --git a/src/auth/provider/basic/BasicLoginProvider.ts b/src/auth/provider/basic/BasicLoginProvider.ts new file mode 100644 index 0000000..5262cbb --- /dev/null +++ b/src/auth/provider/basic/BasicLoginProvider.ts @@ -0,0 +1,75 @@ +import {LoginProvider, LoginProviderConfig} from '../LoginProvider' +import {ResponseObject, Route} from '../../../http/routing/Route' +import {view} from '../../../http/response/ViewResponseFactory' +import {Valid, Validator} from '../../../validation/Validator' +import {BasicLoginAttempt, BasicLoginAttemptType} from './BasicLoginAttempt' +import {BasicRegistrationAttempt, BasicRegistrationAttemptType} from './BasicRegistrationAttempt' + +/** + * LoginProvider implementation that provides basic username/password login. + */ +export class BasicLoginProvider extends LoginProvider { + public routes(): void { + super.routes() + + Route.post('/login') + .alias(`@auth:${this.name}:login.submit`) + .input(Validator.fromSchema(BasicLoginAttemptType)) + .handledBy((...p) => this.attemptLogin(...p)) + + Route.post('/register') + .alias(`@auth:${this.name}:register.submit`) + .input(Validator.fromSchema(BasicRegistrationAttemptType)) + .handledBy((...p) => this.attemptRegistration(...p)) + } + + public login(): ResponseObject { + return view('@extollo:auth:login') + } + + public async logout(): Promise { + await this.security.flush() + return view('@extollo:auth:logout') + } + + public registration(): ResponseObject { + return view('@extollo:auth:register') + } + + /** Attempt to authenticate the user with a username/password. */ + public async attemptLogin(attempt: Valid): Promise { + const user = await this.security.repository.getByIdentifier(attempt.username) + if ( !user ) { + throw new Error('TODO') + } + + if ( !(await user.validateCredential(attempt.password)) ) { + throw new Error('TODO') + } + + await this.security.authenticate(user) + return this.redirectToIntendedRoute() + } + + /** Attempt to register the user with a username/password. */ + public async attemptRegistration(attempt: Valid): Promise { + const existingUser = await this.security.repository.getByIdentifier(attempt.username) + if ( existingUser ) { + throw new Error('TODO') + } + + if ( attempt.password !== attempt.passwordConfirmation ) { + throw new Error('TODO') + } + + const user = await this.security.repository.createFromCredentials(attempt.username, attempt.password) + ;(user as any).firstName = attempt.firstName + ;(user as any).lastName = attempt.lastName + if ( typeof (user as any).save === 'function' ) { + await (user as any).save() + } + + await this.security.authenticate(user) + return this.redirectToIntendedRoute() + } +} diff --git a/src/auth/provider/basic/BasicRegistrationAttempt.ts b/src/auth/provider/basic/BasicRegistrationAttempt.ts new file mode 100644 index 0000000..f6b7592 --- /dev/null +++ b/src/auth/provider/basic/BasicRegistrationAttempt.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +export type BasicRegistrationAttempt = z.infer + +export const BasicRegistrationAttemptType = z.object({ + firstName: z.string().nonempty(), + + lastName: z.string().nonempty(), + + username: z.string().nonempty(), + + password: z.string() + .nonempty() + .min(8), + + passwordConfirmation: z.string() + .nonempty() + .min(8), +}) diff --git a/src/resources/views/auth/login.pug b/src/resources/views/auth/login.pug index 221988d..0c9a54e 100644 --- a/src/resources/views/auth/login.pug +++ b/src/resources/views/auth/login.pug @@ -15,8 +15,6 @@ block form input#inputPassword.form-control(type='password' name='password' required placeholder='Password') label(for='inputPassword') Password - - button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login .text-center diff --git a/src/service/Queueables.ts b/src/service/Queueables.ts index f4ea45a..8d2543a 100644 --- a/src/service/Queueables.ts +++ b/src/service/Queueables.ts @@ -8,7 +8,7 @@ import {Queueable} from '../support/queue/Queue' */ @Singleton() export class Queueables extends CanonicalStatic> { - protected appPath = ['queueables'] + protected appPath = ['jobs'] protected canonicalItem = 'job' diff --git a/src/validation/Validator.ts b/src/validation/Validator.ts index 7d2b4ed..e874495 100644 --- a/src/validation/Validator.ts +++ b/src/validation/Validator.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import {z, ZodType} from 'zod' import {InjectionAware} from '../di' import {ErrorWithContext, TypeTag} from '../util' import {ZodifyRecipient} from './ZodifyRecipient' @@ -8,7 +8,7 @@ import {Logging} from '../service/Logging' /** Type tag for a validated runtime type. */ export type Valid = TypeTag<'@extollo/lib:Valid'> & T -export type ValidatorFactory> = T | (() => T) +export type ValidatorFactory = Validator | (() => Validator) /** * Error thrown if the schema for a validator cannot be located. @@ -45,8 +45,16 @@ export class ValidationError extends ErrorWithContext { * Validates input data against a schema at runtime. */ export class Validator extends InjectionAware implements ZodifyRecipient { + public static fromSchema(type: ZodType): Validator { + const inst = new Validator() + inst.schema = type + return inst + } + __exZodifiedSchemata: number[] = [] + protected schema?: ZodType + /** * Parse the input data against the schema. * @throws ValidationError @@ -90,6 +98,10 @@ export class Validator extends InjectionAware implements ZodifyRecipient { /** Get the Zod schema. */ protected getZod(): z.ZodType { + if ( this.schema ) { + return this.schema + } + // eslint-disable-next-line no-underscore-dangle if ( this.__exZodifiedSchemata.length < 1 ) { throw new InvalidSchemaMappingError()