Implement basic login & registration forms
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Garrett Mills 2021-09-21 22:25:51 -05:00
parent 5940b6e2b3
commit a1d04d652e
22 changed files with 294 additions and 64 deletions

View File

@ -47,9 +47,7 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint && rimraf lib",
"build": "tsc",
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js",
"prepare": "pnpm run build",
"docs:build": "typedoc --options typedoc.json",

View File

@ -0,0 +1,5 @@
import {ErrorWithContext} from '../util'
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
}

View File

@ -1,10 +1,11 @@
import {Inject, Injectable} from '../di'
import {EventBus} from '../event/EventBus'
import {Awaitable, Maybe} from '../util'
import {Authenticatable, AuthenticatableRepository} from './types'
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
import {UserFlushedEvent} from './event/UserFlushedEvent'
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
import {Logging} from '../service/Logging'
/**
* Base-class for a context that authenticates users and manages security.
@ -14,6 +15,9 @@ export abstract class SecurityContext {
@Inject()
protected readonly bus!: EventBus
@Inject()
protected readonly logging!: Logging
/** The currently authenticated user, if one exists. */
private authenticatedUser?: Authenticatable
@ -57,7 +61,7 @@ export abstract class SecurityContext {
* unauthenticated implicitly.
* @param credentials
*/
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticateOnce(user)
@ -71,7 +75,7 @@ export abstract class SecurityContext {
* authentication will be persisted.
* @param credentials
*/
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticate(user)
@ -108,6 +112,8 @@ export abstract class SecurityContext {
*/
async resume(): Promise<void> {
const credentials = await this.getCredentials()
this.logging.debug('resume:')
this.logging.debug(credentials)
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
this.authenticatedUser = user
@ -125,7 +131,7 @@ export abstract class SecurityContext {
* Get the credentials for the current user from whatever storage medium
* the context's host provides.
*/
abstract getCredentials(): Awaitable<Record<string, string>>
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
/**
* Get the currently authenticated user, if one exists.
@ -138,6 +144,8 @@ export abstract class SecurityContext {
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
this.logging.debug('hasUser?')
this.logging.debug(this.authenticatedUser)
return Boolean(this.authenticatedUser)
}
}

View File

@ -1,29 +1,145 @@
import {Controller} from '../../http/Controller'
import {Injectable} from '../../di'
import {Route} from '../../http/routing/Route'
import {Inject, Injectable} from '../../di'
import {ResponseObject, Route} from '../../http/routing/Route'
import {Request} from '../../http/lifecycle/Request'
import {view} from '../../http/response/ViewResponseFactory'
import {ResponseFactory} from '../../http/response/ResponseFactory'
import {SecurityContext} from '../SecurityContext'
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
import {Routing} from '../../service/Routing'
import {Valid, ValidationError} from '../../forms'
import {AuthenticatableCredentials} from '../types'
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
import {Session} from '../../http/session/Session'
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
@Injectable()
export class BasicLoginController extends Controller {
public static routes(): void {
public static routes({ enableRegistration = true } = {}): void {
Route.group('auth', () => {
Route.get('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getLogin()
})
.pre('@auth:guest')
.alias('@auth.login')
Route.post('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getLogin()
return controller.attemptLogin()
})
.pre('@auth:guest')
.alias('@auth.login.attempt')
Route.any('logout', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptLogout()
})
.pre('@auth:required')
.alias('@auth.logout')
if ( enableRegistration ) {
Route.get('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getRegistration()
})
.pre('@auth:guest')
.alias('@auth.register')
Route.post('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptRegister()
})
.pre('@auth:guest')
.alias('@auth.register.attempt')
}
}).pre('@auth:web')
}
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
public getLogin(): ResponseFactory {
return this.getLoginView()
}
public getRegistration(): ResponseFactory {
return this.getRegistrationView()
}
public async attemptLogin(): Promise<ResponseObject> {
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.attempt(data)
if ( user ) {
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
}
return this.getLoginView(['Invalid username/password.'])
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getLoginView(e.errors.all())
}
throw e
}
}
public async attemptLogout(): Promise<ResponseObject> {
await this.security.flush()
return this.getMessageView('You have been logged out.')
}
public async attemptRegister(): Promise<ResponseObject> {
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.repository.createByCredentials(data)
await this.security.authenticate(user)
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getRegistrationView(e.errors.all())
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
return this.getRegistrationView(['A user with that username already exists.'])
}
throw e
}
}
protected getLoginView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:login', {
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
errors,
})
}
public getLogin(): ResponseFactory {
return view('@extollo:auth:login')
protected getRegistrationView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:register', {
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
errors,
})
}
protected getMessageView(message: string): ResponseFactory {
return view('@extollo:auth:message', {
message,
})
}
}

View File

@ -1,21 +1,17 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
export interface BasicLoginCredentials {
username: string,
password: string,
}
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
username: [
identifier: [
Is.required,
Str.lengthMin(1),
],
password: [
credential: [
Is.required,
Str.lengthMin(1),
],

View File

@ -0,0 +1,22 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
Str.alphaNum,
],
credential: [
Is.required,
Str.lengthMin(8),
Str.confirmed,
],
}
}
}

View File

@ -2,7 +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'
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
/**
* Security context implementation that uses the session as storage.
@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
super(repository, 'session')
}
getCredentials(): Awaitable<Record<string, string>> {
getCredentials(): Awaitable<AuthenticatableCredentials> {
return {
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
identifier: '',
credential: this.session.get('extollo.auth.securityIdentifier'),
}
}

View File

@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
/** The user's first name. */
@Field(FieldType.varchar, 'first_name')
public firstName!: string
public firstName?: string
/** The user's last name. */
@Field(FieldType.varchar, 'last_name')
public lastName!: string
public lastName?: string
/** The hashed and salted password of the user. */
@Field(FieldType.varchar, 'password_hash')

View File

@ -1,13 +1,22 @@
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
import {
Authenticatable,
AuthenticatableCredentials,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../types'
import {Awaitable, Maybe} from '../../util'
import {ORMUser} from './ORMUser'
import {Injectable} from '../../di'
import {Container, Inject, Injectable} from '../../di'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
@Inject('injector')
protected readonly injector!: Container
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
@ -21,21 +30,36 @@ export class ORMUserRepository extends AuthenticatableRepository {
* If username/password are specified, look up the user and verify the password.
* @param credentials
*/
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
if ( credentials.securityIdentifier ) {
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
if ( !credentials.identifier && credentials.credential ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.securityIdentifier)
.where('username', '=', credentials.credential)
.first()
}
if ( credentials.username && credentials.password ) {
if ( credentials.identifier && credentials.credential ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.username)
.where('username', '=', credentials.identifier)
.first()
if ( user && await user.verifyPassword(credentials.password) ) {
if ( user && await user.verifyPassword(credentials.credential) ) {
return user
}
}
}
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
if ( await this.getByCredentials(credentials) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
identifier: credentials.identifier,
})
}
const user = <ORMUser> this.injector.make(ORMUser)
user.username = credentials.identifier
await user.setPassword(credentials.credential)
await user.save()
return user
}
}

View File

@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
export interface AuthenticatableCredentials {
identifier: string,
credential: string,
}
/**
* Base class for entities that can be authenticated.
*/
@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
* Returns the user if the credentials are valid.
* @param credentials
*/
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
}

View File

@ -1,4 +1,4 @@
import {ValidationResult, ValidatorFunction} from './types'
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
import {isJSON} from '../../util'
/**
@ -221,6 +221,24 @@ function lengthMax(len: number): ValidatorFunction {
}
}
/**
* Validator function that requires the input value to match a `${field}Confirm` field's value.
* @param fieldName
* @param inputValue
* @param params
*/
function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult {
const confirmedFieldName = `${fieldName}Confirm`
if ( inputValue === params.data[confirmedFieldName] ) {
return { valid: true }
}
return {
valid: false,
message: `confirmation does not match`,
}
}
export const Str = {
alpha,
alphaNum,
@ -242,4 +260,5 @@ export const Str = {
length,
lengthMin,
lengthMax,
confirmed,
}

View File

@ -18,11 +18,11 @@ export default class CreateUsersTableMigration extends Migration {
table.column('first_name')
.type(FieldType.varchar)
.required()
.nullable()
table.column('last_name')
.type(FieldType.varchar)
.required()
.nullable()
table.column('password_hash')
.type(FieldType.text)

View File

@ -612,6 +612,8 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
}
const row = this.buildInsertFieldObject()
this.logging.debug('Insert field object:')
this.logging.debug(row)
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
const result = await this.query()
@ -808,10 +810,12 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
private buildInsertFieldObject(): EscapeValueObject {
const ctor = this.constructor as typeof Model
this.logging.debug(`buildInsertFieldObject populateKeyOnInsert? ${ctor.populateKeyOnInsert}; keyName: ${this.keyName()}`)
return getFieldsMeta(this)
.pipe()
.unless(ctor.populateKeyOnInsert, fields => {
return fields.where('modelKey', '!=', this.keyName())
return fields.where('databaseKey', '!=', this.keyName())
})
.get()
.keyMap('databaseKey', inst => (this as any)[inst.modelKey])

View File

@ -164,12 +164,8 @@ export class PostgresSchema extends Schema {
.pluck<string>('column_name')
.each(col => idx.field(col))
})
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => {
idx.primary()
})
.when(groupedIndexes[key]?.[0]?.indisunique, idx => {
idx.unique()
})
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())
.when(groupedIndexes[key]?.[0]?.indisunique, idx => idx.unique())
.get()
.flagAsExistingInSchema()
}

View File

@ -6,9 +6,11 @@ import {FieldType} from '../types'
* Model used to fetch & store sessions from the ORMSession driver.
*/
export class SessionModel extends Model<SessionModel> {
protected static table = 'sessions'; // FIXME allow configuring
protected static table = 'sessions' // FIXME allow configuring
protected static key = 'session_uuid';
protected static key = 'session_uuid'
protected static populateKeyOnInsert = true
@Field(FieldType.varchar, 'session_uuid')
public uuid!: string;

View File

@ -8,5 +8,9 @@ block content
each error in errors
p.form-error-message #{error}
form(method='post' enctype='multipart/form-data')
block form
if formAction
form(method='post' enctype='multipart/form-data' action=formAction)
block form
else
form(method='post' enctype='multipart/form-data')
block form

View File

@ -8,11 +8,11 @@ block heading
block form
.form-label-group
input#inputUsername.form-control(type='text' name='username' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
label(for='inputPassword') Password
@ -21,4 +21,4 @@ block form
.text-center
span.small Need an account?&nbsp;
a(href='./register') Register here.
a(href=named('@auth.register')) Register here.

View File

@ -0,0 +1,26 @@
extends ./form
block head
title Register | #{config('app.name', 'Extollo')}
block heading
| Register to continue
block form
.form-label-group
input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='credential' required placeholder='Password')
label(for='inputPassword') Password
.form-label-group
input#inputPasswordConfirm.form-control(type='password' name='credentialConfirm' required placeholder='Confirm Password')
label(for='inputPassword') Confirm 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
span.small Have an account?&nbsp;
a(href=named('@auth.login')) Login here.

View File

@ -82,7 +82,8 @@ export class HTTPServer extends Unit {
}
public get handler(): RequestListener {
const timeout = this.config.get('server.timeout', 10000)
// const timeout = this.config.get('server.timeout', 10000)
const timeout = 0 // temporarily disable this because it is causing problems
return async (request: IncomingMessage, response: ServerResponse) => {
const extolloReq = new Request(request, response)

View File

@ -8,7 +8,7 @@ export type PipeOperator<T, T2> = (subject: T) => T2
/**
* A closure that maps a given pipe item to an item of the same type.
*/
export type ReflexivePipeOperator<T> = (subject: T) => T|void
export type ReflexivePipeOperator<T> = (subject: T) => T
/**
* A condition or condition-resolving function for pipe methods.
@ -97,7 +97,7 @@ export class Pipe<T> {
*/
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
if ( (typeof check === 'function' && check(this.subject)) || check ) {
Pipe.wrap(op(this.subject))
return Pipe.wrap(op(this.subject))
}
return this
@ -115,8 +115,7 @@ export class Pipe<T> {
return this
}
Pipe.wrap(op(this.subject))
return this
return Pipe.wrap(op(this.subject))
}
/**

View File

@ -52,12 +52,14 @@ export function withTimeout<T>(timeout: number, promise: Promise<T>): TimeoutSub
run: async () => {
let expired = false
let resolved = false
setTimeout(() => {
expired = true
if ( !resolved ) {
timeoutHandler()
}
}, timeout)
if ( timeout ) {
setTimeout(() => {
expired = true
if ( !resolved ) {
timeoutHandler()
}
}, timeout)
}
const result: T = await promise
resolved = true

View File

@ -41,7 +41,7 @@ export class PugViewEngine extends ViewEngine {
return {
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
debug: this.debug,
compileDebug: this.debug,
// compileDebug: this.debug,
globals: [],
}
}