diff --git a/src/auth/Authentication.ts b/src/auth/Authentication.ts index 4f76950..825ac04 100644 --- a/src/auth/Authentication.ts +++ b/src/auth/Authentication.ts @@ -1,16 +1,13 @@ -import {Inject, Injectable, Instantiable, StaticClass} from '../di' import {Unit} from '../lifecycle/Unit' +import {Injectable, Inject, StaticInstantiable} from '../di' import {Logging} from '../service/Logging' +import {Middlewares} from '../service/Middlewares' 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' +import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware' -/** - * Unit class that bootstraps the authentication framework. - */ @Injectable() export class Authentication extends Unit { @Inject() @@ -20,20 +17,15 @@ export class Authentication extends Unit { protected readonly middleware!: Middlewares async up(): Promise { - this.container() this.middleware.registerNamespace('@auth', this.getMiddlewareResolver()) } - /** - * Create the canonical namespace resolver for auth middleware. - * @protected - */ - protected getMiddlewareResolver(): CanonicalResolver>> { + protected getMiddlewareResolver(): CanonicalResolver> { return (key: string) => { return ({ - web: SessionAuthMiddleware, required: AuthRequiredMiddleware, guest: GuestRequiredMiddleware, + web: SessionAuthMiddleware, })[key] } } diff --git a/src/auth/basic-ui/BasicLoginFormRequest.ts b/src/auth/basic-ui/BasicLoginFormRequest.ts deleted file mode 100644 index b2d80d5..0000000 --- a/src/auth/basic-ui/BasicLoginFormRequest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {FormRequest, ValidationRules} from '../../forms' -import {Is, Str} from '../../forms/rules/rules' -import {Singleton} from '../../di' -import {AuthenticatableCredentials} from '../types' - -@Singleton() -export class BasicLoginFormRequest extends FormRequest { - protected getRules(): ValidationRules { - return { - identifier: [ - Is.required, - Str.lengthMin(1), - ], - credential: [ - Is.required, - Str.lengthMin(1), - ], - } - } -} diff --git a/src/auth/basic-ui/BasicRegisterFormRequest.ts b/src/auth/basic-ui/BasicRegisterFormRequest.ts deleted file mode 100644 index 699f212..0000000 --- a/src/auth/basic-ui/BasicRegisterFormRequest.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 { - protected getRules(): ValidationRules { - return { - identifier: [ - Is.required, - Str.lengthMin(1), - Str.alphaNum, - ], - credential: [ - Is.required, - Str.lengthMin(8), - Str.confirmed, - ], - } - } -} diff --git a/src/auth/config.ts b/src/auth/config.ts index 8c082ad..d911d40 100644 --- a/src/auth/config.ts +++ b/src/auth/config.ts @@ -1,29 +1,41 @@ -import {Instantiable} from '../di' -import {ORMUserRepository} from './orm/ORMUserRepository' -import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController' +import {Instantiable, isInstantiable} from '../di' +import {AuthenticatableRepository} from './types' +import {hasOwnProperty} from '../util' -/** - * 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, - }, +export interface AuthenticationConfig { + storage: Instantiable, sources?: { - [key: string]: OAuth2LoginConfig, + [key: string]: Instantiable, }, } + +export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig { + if ( typeof what !== 'object' || !what ) { + return false + } + + if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'sources') ) { + return false + } + + if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) { + return false + } + + if ( typeof what.sources !== 'object' ) { + return false + } + + for ( const key in what.sources ) { + if ( !hasOwnProperty(what.sources, key) ) { + continue + } + + const source = what.sources[key] + if ( !isInstantiable(source) || !(source.prototype instanceof AuthenticatableRepository) ) { + return false + } + } + + return true +} diff --git a/src/auth/SecurityContext.ts b/src/auth/context/SecurityContext.ts similarity index 53% rename from src/auth/SecurityContext.ts rename to src/auth/context/SecurityContext.ts index 54c1482..8c2ab35 100644 --- a/src/auth/SecurityContext.ts +++ b/src/auth/context/SecurityContext.ts @@ -1,11 +1,10 @@ -import {Inject, Injectable} from '../di' -import {EventBus} from '../event/EventBus' -import {Awaitable, Maybe} from '../util' -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' +import {Inject, Injectable} from '../../di' +import {EventBus} from '../../event/EventBus' +import {Awaitable, Maybe} from '../../util' +import {Authenticatable, AuthenticatableRepository} from '../types' +import {Logging} from '../../service/Logging' +import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent' +import {UserFlushedEvent} from '../event/UserFlushedEvent' /** * Base-class for a context that authenticates users and manages security. @@ -19,10 +18,10 @@ export abstract class SecurityContext { protected readonly logging!: Logging /** The currently authenticated user, if one exists. */ - private authenticatedUser?: Authenticatable + protected authenticatedUser?: Authenticatable constructor( - /** The repository from which to draw users. */ + /** The repository where users are persisted. */ public readonly repository: AuthenticatableRepository, /** The name of this context. */ @@ -54,35 +53,6 @@ export abstract class SecurityContext { await this.bus.dispatch(new UserAuthenticatedEvent(user, this)) } - /** - * Attempt to authenticate a user based on their credentials. - * If the credentials are valid, the user will be authenticated, but the authentication - * will not be persisted. That is, when the lifecycle ends, the user will be - * unauthenticated implicitly. - * @param credentials - */ - async attemptOnce(credentials: AuthenticatableCredentials): Promise> { - const user = await this.repository.getByCredentials(credentials) - if ( user ) { - await this.authenticateOnce(user) - return user - } - } - - /** - * Attempt to authenticate a user based on their credentials. - * If the credentials are valid, the user will be authenticated and the - * authentication will be persisted. - * @param credentials - */ - async attempt(credentials: AuthenticatableCredentials): Promise> { - const user = await this.repository.getByCredentials(credentials) - if ( user ) { - await this.authenticate(user) - return user - } - } - /** * Unauthenticate the current user, if one exists, but do not persist the change. */ @@ -110,16 +80,7 @@ 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() - this.logging.debug('resume:') - this.logging.debug(credentials) - const user = await this.repository.getByCredentials(credentials) - if ( user ) { - this.authenticatedUser = user - await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this)) - } - } + abstract resume(): Awaitable /** * Write the current state of the security context to whatever storage @@ -127,12 +88,6 @@ export abstract class SecurityContext { */ abstract persist(): Awaitable - /** - * Get the credentials for the current user from whatever storage medium - * the context's host provides. - */ - abstract getCredentials(): Awaitable - /** * Get the currently authenticated user, if one exists. */ @@ -144,8 +99,6 @@ 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) } } diff --git a/src/auth/context/SessionSecurityContext.ts b/src/auth/context/SessionSecurityContext.ts new file mode 100644 index 0000000..4a17d09 --- /dev/null +++ b/src/auth/context/SessionSecurityContext.ts @@ -0,0 +1,39 @@ +import {SecurityContext} from './SecurityContext' +import {Inject, Injectable} from '../../di' +import {Session} from '../../http/session/Session' +import {Awaitable} from '../../util' +import {AuthenticatableRepository} from '../types' +import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent' + +export const EXTOLLO_AUTH_SESSION_KEY = '@extollo:auth.securityIdentifier' + +/** + * Security context implementation that uses the session as storage. + */ +@Injectable() +export class SessionSecurityContext extends SecurityContext { + @Inject() + protected readonly session!: Session + + constructor( + /** The repository from which to draw users. */ + public readonly repository: AuthenticatableRepository, + ) { + super(repository, 'session') + } + + persist(): Awaitable { + this.session.set(EXTOLLO_AUTH_SESSION_KEY, this.getUser()?.getIdentifier()) + } + + async resume(): Promise { + const identifier = this.session.get(EXTOLLO_AUTH_SESSION_KEY) + if ( identifier ) { + const user = await this.repository.getByIdentifier(identifier) + if ( user ) { + this.authenticatedUser = user + await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this)) + } + } + } +} diff --git a/src/auth/contexts/SessionSecurityContext.ts b/src/auth/contexts/SessionSecurityContext.ts deleted file mode 100644 index 83d859f..0000000 --- a/src/auth/contexts/SessionSecurityContext.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {SecurityContext} from '../SecurityContext' -import {Inject, Injectable} from '../../di' -import {Session} from '../../http/session/Session' -import {Awaitable} from '../../util' -import {AuthenticatableCredentials, AuthenticatableRepository} from '../types' - -/** - * Security context implementation that uses the session as storage. - */ -@Injectable() -export class SessionSecurityContext extends SecurityContext { - @Inject() - protected readonly session!: Session - - constructor( - /** The repository from which to draw users. */ - public readonly repository: AuthenticatableRepository, - ) { - super(repository, 'session') - } - - getCredentials(): Awaitable { - return { - identifier: '', - credential: this.session.get('extollo.auth.securityIdentifier'), - } - } - - persist(): Awaitable { - this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier()) - } -} diff --git a/src/auth/event/AuthenticationEvent.ts b/src/auth/event/AuthenticationEvent.ts new file mode 100644 index 0000000..960a84d --- /dev/null +++ b/src/auth/event/AuthenticationEvent.ts @@ -0,0 +1,27 @@ +import {Event} from '../../event/Event' +import {SecurityContext} from '../context/SecurityContext' +import {Awaitable, JSONState} from '../../util' +import {Authenticatable} from '../types' + +/** + * Event fired when a user is authenticated. + */ +export class AuthenticationEvent 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/event/UserAuthenticatedEvent.ts b/src/auth/event/UserAuthenticatedEvent.ts index 95a1571..f999f91 100644 --- a/src/auth/event/UserAuthenticatedEvent.ts +++ b/src/auth/event/UserAuthenticatedEvent.ts @@ -1,27 +1,6 @@ -import {Event} from '../../event/Event' -import {SecurityContext} from '../SecurityContext' -import {Awaitable, JSONState} from '../../util' -import {Authenticatable} from '../types' +import {AuthenticationEvent} from './AuthenticationEvent' /** * Event fired when a user is authenticated. */ -export class UserAuthenticatedEvent 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 - } -} +export class UserAuthenticatedEvent extends AuthenticationEvent {} diff --git a/src/auth/event/UserAuthenticationResumedEvent.ts b/src/auth/event/UserAuthenticationResumedEvent.ts index 3750965..180cc41 100644 --- a/src/auth/event/UserAuthenticationResumedEvent.ts +++ b/src/auth/event/UserAuthenticationResumedEvent.ts @@ -1,27 +1,6 @@ -import {Event} from '../../event/Event' -import {SecurityContext} from '../SecurityContext' -import {Awaitable, JSONState} from '../../util' -import {Authenticatable} from '../types' +import {AuthenticationEvent} from './AuthenticationEvent' /** - * Event fired when a security context for a given user is resumed. + * Event raised when a user is re-authenticated to a security context */ -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 - } -} +export class UserAuthenticationResumedEvent extends AuthenticationEvent {} diff --git a/src/auth/event/UserFlushedEvent.ts b/src/auth/event/UserFlushedEvent.ts index d1c97a2..601d3dc 100644 --- a/src/auth/event/UserFlushedEvent.ts +++ b/src/auth/event/UserFlushedEvent.ts @@ -1,27 +1,6 @@ -import {Event} from '../../event/Event' -import {SecurityContext} from '../SecurityContext' -import {Awaitable, JSONState} from '../../util' -import {Authenticatable} from '../types' +import {AuthenticationEvent} from './AuthenticationEvent' /** * Event fired when a user is unauthenticated. */ -export class UserFlushedEvent 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 - } -} +export class UserFlushedEvent extends AuthenticationEvent {} diff --git a/src/auth/external/oauth2/OAuth2LoginController.ts b/src/auth/external/oauth2/OAuth2LoginController.ts deleted file mode 100644 index e8ac319..0000000 --- a/src/auth/external/oauth2/OAuth2LoginController.ts +++ /dev/null @@ -1,95 +0,0 @@ -import {Controller} from '../../../http/Controller' -import {Inject, Injectable} from '../../../di' -import {Config} from '../../../service/Config' -import {Request} from '../../../http/lifecycle/Request' -import {ResponseObject, Route} from '../../../http/routing/Route' -import {ErrorWithContext} from '../../../util' -import {OAuth2Repository} from './OAuth2Repository' -import {json} from '../../../http/response/JSONResponseFactory' - -export interface OAuth2LoginConfig { - name: string, - clientId: string, - clientSecret: string, - redirectUrl: string, - authorizationCodeField: string, - tokenEndpoint: string, - tokenEndpointMapping?: { - clientId?: string, - clientSecret?: string, - grantType?: string, - codeKey?: string, - }, - tokenEndpointResponseMapping?: { - token?: string, - expiresIn?: string, - expiresAt?: string, - }, - userEndpoint: string, - userEndpointResponseMapping?: { - identifier?: string, - display?: string, - }, -} - -export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig { - return ( - Boolean(what) - && typeof (what as any).name === 'string' - && typeof (what as any).clientId === 'string' - && typeof (what as any).clientSecret === 'string' - && typeof (what as any).redirectUrl === 'string' - && typeof (what as any).authorizationCodeField === 'string' - && typeof (what as any).tokenEndpoint === 'string' - && typeof (what as any).userEndpoint === 'string' - ) -} - -@Injectable() -export class OAuth2LoginController extends Controller { - public static routes(configName: string): void { - Route.group(`/auth/${configName}`, () => { - Route.get('login', (request: Request) => { - const controller = request.make(OAuth2LoginController, configName) - return controller.getLogin() - }).pre('@auth:guest') - }).pre('@auth:web') - } - - @Inject() - protected readonly config!: Config - - constructor( - protected readonly request: Request, - protected readonly configName: string, - ) { - super(request) - } - - public async getLogin(): Promise { - const repo = this.getRepository() - if ( repo.shouldRedirect() ) { - return repo.redirect() - } - - // We were redirected from the auth source - const user = await repo.redeem() - return json(user) - } - - protected getRepository(): OAuth2Repository { - return this.request.make(OAuth2Repository, this.getConfig()) - } - - protected getConfig(): OAuth2LoginConfig { - const config = this.config.get(`auth.sources.${this.configName}`) - if ( !isOAuth2LoginConfig(config) ) { - throw new ErrorWithContext('Invalid OAuth2 source config.', { - configName: this.configName, - config, - }) - } - - return config - } -} diff --git a/src/auth/external/oauth2/OAuth2Repository.ts b/src/auth/external/oauth2/OAuth2Repository.ts deleted file mode 100644 index e4572b5..0000000 --- a/src/auth/external/oauth2/OAuth2Repository.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - Authenticatable, - AuthenticatableCredentials, - AuthenticatableRepository, -} from '../../types' -import {Inject, Injectable} from '../../../di' -import { - Awaitable, - dataGetUnsafe, - fetch, - Maybe, - MethodNotSupportedError, - UniversalPath, - universalPath, - uuid4, -} from '../../../util' -import {OAuth2LoginConfig} from './OAuth2LoginController' -import {Session} from '../../../http/session/Session' -import {ResponseObject} from '../../../http/routing/Route' -import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory' -import {Request} from '../../../http/lifecycle/Request' -import {Logging} from '../../../service/Logging' -import {OAuth2User} from './OAuth2User' - -@Injectable() -export class OAuth2Repository implements AuthenticatableRepository { - @Inject() - protected readonly session!: Session - - @Inject() - protected readonly request!: Request - - @Inject() - protected readonly logging!: Logging - - constructor( - protected readonly config: OAuth2LoginConfig, - ) { } - - public createByCredentials(): Awaitable { - throw new MethodNotSupportedError() - } - - getByCredentials(credentials: AuthenticatableCredentials): Awaitable> { - return this.getAuthenticatableFromBearer(credentials.credential) - } - - getByIdentifier(): Awaitable> { - return undefined - } - - public getRedirectUrl(state?: string): UniversalPath { - const url = universalPath(this.config.redirectUrl) - if ( state ) { - url.query.append('state', state) - } - - return url - } - - public getTokenEndpoint(): UniversalPath { - return universalPath(this.config.tokenEndpoint) - } - - public getUserEndpoint(): UniversalPath { - return universalPath(this.config.userEndpoint) - } - - public async redeem(): Promise> { - if ( !this.stateIsValid() ) { - return // FIXME throw - } - - const body = new URLSearchParams() - - if ( this.config.tokenEndpointMapping ) { - if ( this.config.tokenEndpointMapping.clientId ) { - body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId) - } - - if ( this.config.tokenEndpointMapping.clientSecret ) { - body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret) - } - - if ( this.config.tokenEndpointMapping.codeKey ) { - body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField))) - } - - if ( this.config.tokenEndpointMapping.grantType ) { - body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code') - } - } - - this.logging.debug(`Redeeming auth code: ${body.toString()}`) - - const response = await fetch(this.getTokenEndpoint().toRemote, { - method: 'post', - body: body, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - }) - - const data = await response.json() - if ( typeof data !== 'object' || data === null ) { - throw new Error() - } - - this.logging.debug(data) - const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer')) - - this.logging.debug(bearer) - if ( !bearer || typeof bearer !== 'string' ) { - throw new Error() - } - - return this.getAuthenticatableFromBearer(bearer) - } - - public async getAuthenticatableFromBearer(bearer: string): Promise> { - const response = await fetch(this.getUserEndpoint().toRemote, { - method: 'get', - headers: { - 'Accept': 'application/json', - 'Authorization': `Bearer ${bearer}`, - }, - }) - - const data = await response.json() - if ( typeof data !== 'object' || data === null ) { - throw new Error() - } - - return new OAuth2User(data, this.config) - } - - public stateIsValid(): boolean { - const correctState = this.session.get('extollo.auth.oauth2.state', '') - const inputState = this.request.input('state') || '' - return correctState === inputState - } - - public shouldRedirect(): boolean { - const codeField = this.config.authorizationCodeField - const code = this.request.input(codeField) - return !code - } - - public async redirect(): Promise { - const state = uuid4() - await this.session.set('extollo.auth.oauth2.state', state) - return temporary(this.getRedirectUrl(state).toRemote) - } -} diff --git a/src/auth/external/oauth2/OAuth2User.ts b/src/auth/external/oauth2/OAuth2User.ts deleted file mode 100644 index bbe901c..0000000 --- a/src/auth/external/oauth2/OAuth2User.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {Authenticatable, AuthenticatableIdentifier} from '../../types' -import {OAuth2LoginConfig} from './OAuth2LoginController' -import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util' - -export class OAuth2User implements Authenticatable { - protected displayField: string - - protected identifierField: string - - constructor( - protected data: {[key: string]: any}, - config: OAuth2LoginConfig, - ) { - this.displayField = config.userEndpointResponseMapping?.display || 'name' - this.identifierField = config.userEndpointResponseMapping?.identifier || 'id' - } - - getDisplayIdentifier(): string { - return String(dataGetUnsafe(this.data, this.displayField || 'name', '')) - } - - getIdentifier(): AuthenticatableIdentifier { - return String(dataGetUnsafe(this.data, this.identifierField || 'id', '')) - } - - async dehydrate(): Promise { - return { - isOAuth2User: true, - data: this.data, - displayField: this.displayField, - identifierField: this.identifierField, - } - } - - rehydrate(state: JSONState): Awaitable { - if ( - !state.isOAuth2User - || typeof state.data !== 'object' - || state.data === null - || typeof state.displayField !== 'string' - || typeof state.identifierField !== 'string' - ) { - throw new InvalidJSONStateError('OAuth2User state is invalid', { state }) - } - - this.data = state.data - this.identifierField = state.identifierField - this.displayField = state.identifierField - } -} diff --git a/src/auth/index.ts b/src/auth/index.ts index 4340aca..6db3b87 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,26 +1,21 @@ export * from './types' +export * from './AuthenticatableAlreadyExistsError' 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 './context/SecurityContext' +export * from './context/SessionSecurityContext' + +export * from './event/AuthenticationEvent' +export * from './event/UserAuthenticatedEvent' +export * from './event/UserAuthenticationResumedEvent' +export * from './event/UserFlushedEvent' + +export * from './repository/orm/ORMUser' +export * from './repository/orm/ORMUserRepository' + +export * from './ui/basic/BasicRegisterFormRequest' +export * from './ui/basic/BasicLoginFormRequest' +export * from './ui/basic/BasicLoginController' + export * from './config' - -export * from './basic-ui/BasicLoginFormRequest' -export * from './basic-ui/BasicLoginController' - -export * from './external/oauth2/OAuth2LoginController' diff --git a/src/auth/middleware/AuthRequiredMiddleware.ts b/src/auth/middleware/AuthRequiredMiddleware.ts index e0a098e..cc1fd27 100644 --- a/src/auth/middleware/AuthRequiredMiddleware.ts +++ b/src/auth/middleware/AuthRequiredMiddleware.ts @@ -1,6 +1,6 @@ import {Middleware} from '../../http/routing/Middleware' import {Inject, Injectable} from '../../di' -import {SecurityContext} from '../SecurityContext' +import {SecurityContext} from '../context/SecurityContext' import {ResponseObject} from '../../http/routing/Route' import {error} from '../../http/response/ErrorResponseFactory' import {NotAuthorizedError} from '../NotAuthorizedError' @@ -9,6 +9,8 @@ import {redirect} from '../../http/response/RedirectResponseFactory' import {Routing} from '../../service/Routing' import {Session} from '../../http/session/Session' +// TODO handle JSON and non-web + @Injectable() export class AuthRequiredMiddleware extends Middleware { @Inject() @@ -22,7 +24,7 @@ export class AuthRequiredMiddleware extends Middleware { async apply(): Promise { if ( !this.security.hasUser() ) { - this.session.set('auth.intention', this.request.url) + this.session.set('@extollo:auth.intention', this.request.url) if ( this.routing.hasNamedRoute('@auth.login') ) { return redirect(this.routing.getNamedPath('@auth.login').toRemote) diff --git a/src/auth/middleware/GuestRequiredMiddleware.ts b/src/auth/middleware/GuestRequiredMiddleware.ts index aa8c338..2732845 100644 --- a/src/auth/middleware/GuestRequiredMiddleware.ts +++ b/src/auth/middleware/GuestRequiredMiddleware.ts @@ -1,6 +1,6 @@ import {Middleware} from '../../http/routing/Middleware' import {Inject, Injectable} from '../../di' -import {SecurityContext} from '../SecurityContext' +import {SecurityContext} from '../context/SecurityContext' import {ResponseObject} from '../../http/routing/Route' import {error} from '../../http/response/ErrorResponseFactory' import {NotAuthorizedError} from '../NotAuthorizedError' @@ -8,6 +8,8 @@ import {HTTPStatus} from '../../util' import {Routing} from '../../service/Routing' import {redirect} from '../../http/response/RedirectResponseFactory' +// TODO handle JSON and non-web + @Injectable() export class GuestRequiredMiddleware extends Middleware { @Inject() diff --git a/src/auth/middleware/SessionAuthMiddleware.ts b/src/auth/middleware/SessionAuthMiddleware.ts index 2f43449..52aad9c 100644 --- a/src/auth/middleware/SessionAuthMiddleware.ts +++ b/src/auth/middleware/SessionAuthMiddleware.ts @@ -1,13 +1,13 @@ import {Middleware} from '../../http/routing/Middleware' -import {Inject, Injectable} from '../../di' -import {ResponseObject} from '../../http/routing/Route' +import {Inject, Injectable, Instantiable} from '../../di' 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, AuthenticatableRepositories} from '../config' import {Logging} from '../../service/Logging' +import {AuthenticatableRepository} from '../types' +import {Maybe} from '../../util' +import {AuthenticationConfig, isAuthenticationConfig} from '../config' +import {ResponseObject} from '../../http/routing/Route' +import {SessionSecurityContext} from '../context/SessionSecurityContext' +import {SecurityContext} from '../context/SecurityContext' /** * Injects a SessionSecurityContext into the request and attempts to @@ -22,7 +22,7 @@ export class SessionAuthMiddleware extends Middleware { protected readonly logging!: Logging async apply(): Promise { - this.logging.debug('Applying session auth middleware...') + this.logging.debug('Applying session auth middleware.') const context = this.make(SessionSecurityContext, this.getRepository()) this.request.registerSingletonInstance(SecurityContext, context) await context.resume() @@ -33,8 +33,12 @@ export class SessionAuthMiddleware extends Middleware { * @protected */ protected getRepository(): AuthenticatableRepository { - const config: AuthConfig | undefined = this.config.get('auth') - const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm'] - return this.make(repo ?? ORMUserRepository) + const config: Maybe = this.config.get('auth') + if ( !isAuthenticationConfig(config) ) { + throw new TypeError('Invalid authentication config.') + } + + const repo: Instantiable = config.storage + return this.make(repo) } } diff --git a/src/auth/orm/ORMUserRepository.ts b/src/auth/orm/ORMUserRepository.ts deleted file mode 100644 index 1ba409a..0000000 --- a/src/auth/orm/ORMUserRepository.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - Authenticatable, - AuthenticatableCredentials, - AuthenticatableIdentifier, - AuthenticatableRepository, -} from '../types' -import {Awaitable, Maybe} from '../../util' -import {ORMUser} from './ORMUser' -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> { - return ORMUser.query() - .where('username', '=', id) - .first() - } - - /** - * Try to look up a user by the credentials provided. - * If a securityIdentifier is specified, look up the user by username. - * If username/password are specified, look up the user and verify the password. - * @param credentials - */ - async getByCredentials(credentials: AuthenticatableCredentials): Promise> { - if ( !credentials.identifier && credentials.credential ) { - return ORMUser.query() - .where('username', '=', credentials.credential) - .first() - } - - if ( credentials.identifier && credentials.credential ) { - const user = await ORMUser.query() - .where('username', '=', credentials.identifier) - .first() - - if ( user && await user.verifyPassword(credentials.credential) ) { - return user - } - } - } - - async createByCredentials(credentials: AuthenticatableCredentials): Promise { - if ( await this.getByCredentials(credentials) ) { - throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, { - identifier: credentials.identifier, - }) - } - - const user = this.injector.make(ORMUser) - user.username = credentials.identifier - await user.setPassword(credentials.credential) - await user.save() - - return user - } -} diff --git a/src/auth/orm/ORMUser.ts b/src/auth/repository/orm/ORMUser.ts similarity index 70% rename from src/auth/orm/ORMUser.ts rename to src/auth/repository/orm/ORMUser.ts index 3356450..bbec6e9 100644 --- a/src/auth/orm/ORMUser.ts +++ b/src/auth/repository/orm/ORMUser.ts @@ -1,8 +1,8 @@ -import {Field, FieldType, Model} from '../../orm' -import {Authenticatable, AuthenticatableIdentifier} from '../types' -import {Injectable} from '../../di' import * as bcrypt from 'bcrypt' -import {Awaitable, JSONState} from '../../util' +import {Field, FieldType, Model} from '../../../orm' +import {Authenticatable, AuthenticatableIdentifier} from '../../types' +import {Injectable} from '../../../di' +import {Awaitable, JSONState} from '../../../util' /** * A basic ORM-driven user class. @@ -35,8 +35,17 @@ export class ORMUser extends Model implements Authenticatable { public passwordHash!: string /** Human-readable display name of the user. */ - getDisplayIdentifier(): string { - return `${this.firstName} ${this.lastName}` + getDisplay(): string { + if ( this.firstName || this.lastName ) { + return `${this.firstName} ${this.lastName}` + } + + return this.username + } + + /** Globally-unique identifier of the user. */ + getUniqueIdentifier(): AuthenticatableIdentifier { + return `user-${this.userId}` } /** Unique identifier of the user. */ @@ -54,6 +63,10 @@ export class ORMUser extends Model implements Authenticatable { this.passwordHash = await bcrypt.hash(password, 10) } + validateCredential(credential: string): Awaitable { + return this.verifyPassword(credential) + } + async dehydrate(): Promise { return this.toQueryRow() } diff --git a/src/auth/repository/orm/ORMUserRepository.ts b/src/auth/repository/orm/ORMUserRepository.ts new file mode 100644 index 0000000..bdb7952 --- /dev/null +++ b/src/auth/repository/orm/ORMUserRepository.ts @@ -0,0 +1,51 @@ +import { + Authenticatable, + AuthenticatableIdentifier, + AuthenticatableRepository, +} from '../../types' +import {Awaitable, Maybe, uuid4} from '../../../util' +import {ORMUser} from './ORMUser' +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> { + return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query() + .where('username', '=', id) + .first() + } + + /** Returns true if this repository supports registering users. */ + supportsRegistration(): boolean { + return true + } + + /** Create a user in this repository from basic credentials. */ + async createFromCredentials(username: string, password: string): Promise { + if ( await this.getByIdentifier(username) ) { + throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, { + username, + }) + } + + const user = this.injector.makeByStaticOverride(ORMUser) + user.username = username + await user.setPassword(password) + await user.save() + + return user + } + + /** Create a user in this repository from an external Authenticatable instance. */ + async createFromExternal(user: Authenticatable): Promise { + return this.createFromCredentials(String(user.getUniqueIdentifier()), uuid4()) + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts index 8fe29b3..689116c 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -3,21 +3,22 @@ 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. */ export abstract class Authenticatable implements Rehydratable { - /** Get the unique identifier of the user. */ + /** Get the globally-unique identifier of the user. */ + abstract getUniqueIdentifier(): AuthenticatableIdentifier + + /** Get the repository-unique identifier of the user. */ abstract getIdentifier(): AuthenticatableIdentifier /** Get the human-readable identifier of the user. */ - abstract getDisplayIdentifier(): string + abstract getDisplay(): string + + /** Attempt to validate a credential of the user. */ + abstract validateCredential(credential: string): Awaitable abstract dehydrate(): Promise @@ -28,16 +29,15 @@ export abstract class Authenticatable implements Rehydratable { * Base class for a repository that stores and recalls users. */ export abstract class AuthenticatableRepository { - /** Look up the user by their unique identifier. */ abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable> - /** - * Attempt to look up and verify a user by their credentials. - * Returns the user if the credentials are valid. - * @param credentials - */ - abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable> + /** Returns true if this repository supports registering users. */ + abstract supportsRegistration(): boolean - abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable + /** Create a user in this repository from an external Authenticatable instance. */ + abstract createFromExternal(user: Authenticatable): Awaitable + + /** Create a user in this repository from basic credentials. */ + abstract createFromCredentials(username: string, password: string): Awaitable } diff --git a/src/auth/basic-ui/BasicLoginController.ts b/src/auth/ui/basic/BasicLoginController.ts similarity index 52% rename from src/auth/basic-ui/BasicLoginController.ts rename to src/auth/ui/basic/BasicLoginController.ts index 6dcde06..a9dca90 100644 --- a/src/auth/basic-ui/BasicLoginController.ts +++ b/src/auth/ui/basic/BasicLoginController.ts @@ -1,56 +1,48 @@ -import {Controller} from '../../http/Controller' -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' +import {Controller} from '../../../http/Controller' +import {Inject, Injectable} from '../../../di' +import {SecurityContext} from '../../context/SecurityContext' +import {Session} from '../../../http/session/Session' +import {ResponseFactory} from '../../../http/response/ResponseFactory' +import {redirectToGet, view} from '../../../http/response/ViewResponseFactory' +import {Routing} from '../../../service/Routing' +import {ResponseObject, Route} from '../../../http/routing/Route' +import {BasicLoginFormData, BasicLoginFormRequest} from './BasicLoginFormRequest' +import {Valid, ValidationError} from '../../../forms' +import {BasicRegisterFormData, BasicRegisterFormRequest} from './BasicRegisterFormRequest' +import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError' +import {Request} from '../../../http/lifecycle/Request' @Injectable() export class BasicLoginController extends Controller { public static routes({ enableRegistration = true } = {}): void { + const controller = (request: Request) => { + return request.make(BasicLoginController) + } + Route.group('auth', () => { - Route.get('login', (request: Request) => { - const controller = request.make(BasicLoginController) - return controller.getLogin() - }) + Route.get('login', (request: Request) => + controller(request).getLogin()) .pre('@auth:guest') .alias('@auth.login') - Route.post('login', (request: Request) => { - const controller = request.make(BasicLoginController) - return controller.attemptLogin() - }) + Route.post('login', (request: Request) => + controller(request).attemptLogin()) .pre('@auth:guest') .alias('@auth.login.attempt') - Route.any('logout', (request: Request) => { - const controller = request.make(BasicLoginController) - return controller.attemptLogout() - }) + Route.any('logout', (request: Request) => + controller(request).attemptLogout()) .pre('@auth:required') .alias('@auth.logout') if ( enableRegistration ) { - Route.get('register', (request: Request) => { - const controller = request.make(BasicLoginController) - return controller.getRegistration() - }) + Route.get('register', (request: Request) => + controller(request).getRegistration()) .pre('@auth:guest') .alias('@auth.register') - Route.post('register', (request: Request) => { - const controller = request.make(BasicLoginController) - return controller.attemptRegister() - }) + Route.post('register', (request: Request) => + controller(request).attemptRegister()) .pre('@auth:guest') .alias('@auth.register.attempt') } @@ -61,16 +53,20 @@ export class BasicLoginController extends Controller { protected readonly security!: SecurityContext @Inject() - protected readonly routing!: Routing + protected readonly session!: Session @Inject() - protected readonly session!: Session + protected readonly routing!: Routing public getLogin(): ResponseFactory { return this.getLoginView() } public getRegistration(): ResponseFactory { + if ( !this.security.repository.supportsRegistration() ) { + return redirectToGet('/') + } + return this.getRegistrationView() } @@ -78,12 +74,14 @@ export class BasicLoginController extends Controller { const form = this.request.make(BasicLoginFormRequest) try { - const data: Valid = 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) + const data: Valid = await form.get() + const user = await this.security.repository.getByIdentifier(data.username) + if ( user && (await user.validateCredential(data.password)) ) { + await this.security.authenticate(user) + + const intention = this.session.get('@extollo:auth.intention', '/') + this.session.forget('@extollo:auth.intention') + return redirectToGet(intention) } return this.getLoginView(['Invalid username/password.']) @@ -96,22 +94,17 @@ export class BasicLoginController extends Controller { } } - public async attemptLogout(): Promise { - await this.security.flush() - return this.getMessageView('You have been logged out.') - } - public async attemptRegister(): Promise { const form = this.request.make(BasicRegisterFormRequest) try { - const data: Valid = await form.get() - const user = await this.security.repository.createByCredentials(data) + const data: Valid = await form.get() + const user = await this.security.repository.createFromCredentials(data.username, data.password) await this.security.authenticate(user) - const intention = this.session.get('auth.intention', '/') - this.session.forget('auth.intention') - return temporary(intention) + const intention = this.session.get('@extollo:auth.intention', '/') + this.session.forget('@extollo:auth.intention') + return redirectToGet(intention) } catch (e: unknown) { if ( e instanceof ValidationError ) { return this.getRegistrationView(e.errors.all()) @@ -123,11 +116,9 @@ export class BasicLoginController extends Controller { } } - protected getLoginView(errors?: string[]): ResponseFactory { - return view('@extollo:auth:login', { - formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote, - errors, - }) + public async attemptLogout(): Promise { + await this.security.flush() + return this.getMessageView('You have been logged out.') } protected getRegistrationView(errors?: string[]): ResponseFactory { @@ -137,6 +128,13 @@ export class BasicLoginController extends Controller { }) } + protected getLoginView(errors?: string[]): ResponseFactory { + return view('@extollo:auth:login', { + formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote, + errors, + }) + } + protected getMessageView(message: string): ResponseFactory { return view('@extollo:auth:message', { message, diff --git a/src/auth/ui/basic/BasicLoginFormRequest.ts b/src/auth/ui/basic/BasicLoginFormRequest.ts new file mode 100644 index 0000000..b7ce175 --- /dev/null +++ b/src/auth/ui/basic/BasicLoginFormRequest.ts @@ -0,0 +1,24 @@ +import {FormRequest, ValidationRules} from '../../../forms' +import {Is, Str} from '../../../forms/rules/rules' +import {Singleton} from '../../../di' + +export interface BasicLoginFormData { + 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/ui/basic/BasicRegisterFormRequest.ts b/src/auth/ui/basic/BasicRegisterFormRequest.ts new file mode 100644 index 0000000..b1a1499 --- /dev/null +++ b/src/auth/ui/basic/BasicRegisterFormRequest.ts @@ -0,0 +1,25 @@ +import {FormRequest, ValidationRules} from '../../../forms' +import {Is, Str} from '../../../forms/rules/rules' +import {Singleton} from '../../../di' + +export interface BasicRegisterFormData { + username: string, + password: string, +} + +@Singleton() +export class BasicRegisterFormRequest extends FormRequest { + protected getRules(): ValidationRules { + return { + username: [ + Is.required, + Str.lengthMin(1), + ], + password: [ + Is.required, + Str.lengthMin(1), + Str.confirmed, + ], + } + } +} diff --git a/src/http/response/ViewResponseFactory.ts b/src/http/response/ViewResponseFactory.ts index 52bde3c..8048a73 100644 --- a/src/http/response/ViewResponseFactory.ts +++ b/src/http/response/ViewResponseFactory.ts @@ -13,6 +13,15 @@ export function view(name: string, data?: {[key: string]: any}): ViewResponseFac return new ViewResponseFactory(name, data) } +/** + * Helper function that creates a new ViewResponseFactory that redirects the user to + * the given URL. + * @param url + */ +export function redirectToGet(url: string): ViewResponseFactory { + return view('@extollo:redirect', { redirectUrl: url }) +} + /** * HTTP response factory that uses the ViewEngine service to render a view * and send it as HTML. diff --git a/src/resources/views/auth/login.pug b/src/resources/views/auth/login.pug index 0c340c1..221988d 100644 --- a/src/resources/views/auth/login.pug +++ b/src/resources/views/auth/login.pug @@ -8,11 +8,11 @@ block heading block form .form-label-group - input#inputUsername.form-control(type='text' name='identifier' value=(formData ? formData.username : '') required placeholder='Username' autofocus) + input#inputUsername.form-control(type='text' name='username' 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') + input#inputPassword.form-control(type='password' name='password' required placeholder='Password') label(for='inputPassword') Password diff --git a/src/resources/views/redirect.pug b/src/resources/views/redirect.pug new file mode 100644 index 0000000..62a4a6b --- /dev/null +++ b/src/resources/views/redirect.pug @@ -0,0 +1,8 @@ +html + head + meta(http-equiv='Refresh' content='0; url="' + redirectUrl) + title Redirecting... + body + script. + var url = `${redirectUrl}` + window.location.assign(url)