Rework authentication system
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2021-11-26 14:32:25 -06:00
parent bd7d6a2dbd
commit 5175d64e36
28 changed files with 372 additions and 720 deletions

View File

@ -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<void> {
this.container()
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
}
/**
* Create the canonical namespace resolver for auth middleware.
* @protected
*/
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {
return (key: string) => {
return ({
web: SessionAuthMiddleware,
required: AuthRequiredMiddleware,
guest: GuestRequiredMiddleware,
web: SessionAuthMiddleware,
})[key]
}
}

View File

@ -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<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
],
credential: [
Is.required,
Str.lengthMin(1),
],
}
}
}

View File

@ -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<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
Str.alphaNum,
],
credential: [
Is.required,
Str.lengthMin(8),
Str.confirmed,
],
}
}
}

View File

@ -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<ORMUserRepository>,
}
/**
* 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<AuthenticatableRepository>,
sources?: {
[key: string]: OAuth2LoginConfig,
[key: string]: Instantiable<AuthenticatableRepository>,
},
}
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
}

View File

@ -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<Maybe<Authenticatable>> {
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<Maybe<Authenticatable>> {
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<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
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
}
}
abstract resume(): Awaitable<void>
/**
* Write the current state of the security context to whatever storage
@ -127,12 +88,6 @@ export abstract class SecurityContext {
*/
abstract persist(): Awaitable<void>
/**
* Get the credentials for the current user from whatever storage medium
* the context's host provides.
*/
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
/**
* 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)
}
}

View File

@ -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<void> {
this.session.set(EXTOLLO_AUTH_SESSION_KEY, this.getUser()?.getIdentifier())
}
async resume(): Promise<void> {
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))
}
}
}
}

View File

@ -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<AuthenticatableCredentials> {
return {
identifier: '',
credential: this.session.get('extollo.auth.securityIdentifier'),
}
}
persist(): Awaitable<void> {
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
}
}

View File

@ -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<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

View File

@ -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<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}
export class UserAuthenticatedEvent extends AuthenticationEvent {}

View File

@ -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<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}
export class UserAuthenticationResumedEvent extends AuthenticationEvent {}

View File

@ -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<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}
export class UserFlushedEvent extends AuthenticationEvent {}

View File

@ -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 = <OAuth2LoginController> 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<ResponseObject> {
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
}
}

View File

@ -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<Authenticatable> {
throw new MethodNotSupportedError()
}
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
return this.getAuthenticatableFromBearer(credentials.credential)
}
getByIdentifier(): Awaitable<Maybe<Authenticatable>> {
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<Maybe<OAuth2User>> {
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<Maybe<OAuth2User>> {
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<ResponseObject> {
const state = uuid4()
await this.session.set('extollo.auth.oauth2.state', state)
return temporary(this.getRedirectUrl(state).toRemote)
}
}

View File

@ -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<JSONState> {
return {
isOAuth2User: true,
data: this.data,
displayField: this.displayField,
identifierField: this.identifierField,
}
}
rehydrate(state: JSONState): Awaitable<void> {
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
}
}

View File

@ -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'

View File

@ -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<ResponseObject> {
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)

View File

@ -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()

View File

@ -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<ResponseObject> {
this.logging.debug('Applying session auth middleware...')
this.logging.debug('Applying session auth middleware.')
const context = <SessionSecurityContext> 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<AuthenticatableRepository>(repo ?? ORMUserRepository)
const config: Maybe<AuthenticationConfig> = this.config.get('auth')
if ( !isAuthenticationConfig(config) ) {
throw new TypeError('Invalid authentication config.')
}
const repo: Instantiable<AuthenticatableRepository> = config.storage
return this.make(repo)
}
}

View File

@ -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<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
.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<Maybe<Authenticatable>> {
if ( !credentials.identifier && credentials.credential ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.credential)
.first()
}
if ( credentials.identifier && credentials.credential ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.identifier)
.first()
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

@ -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<ORMUser> 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<ORMUser> implements Authenticatable {
this.passwordHash = await bcrypt.hash(password, 10)
}
validateCredential(credential: string): Awaitable<boolean> {
return this.verifyPassword(credential)
}
async dehydrate(): Promise<JSONState> {
return this.toQueryRow()
}

View File

@ -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<Maybe<Authenticatable>> {
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query<ORMUser>()
.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<Authenticatable> {
if ( await this.getByIdentifier(username) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
username,
})
}
const user = <ORMUser> 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<Authenticatable> {
return this.createFromCredentials(String(user.getUniqueIdentifier()), uuid4())
}
}

View File

@ -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<boolean>
abstract dehydrate(): Promise<JSONState>
@ -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<Maybe<Authenticatable>>
/**
* 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<Maybe<Authenticatable>>
/** Returns true if this repository supports registering users. */
abstract supportsRegistration(): boolean
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
/** Create a user in this repository from an external Authenticatable instance. */
abstract createFromExternal(user: Authenticatable): Awaitable<Authenticatable>
/** Create a user in this repository from basic credentials. */
abstract createFromCredentials(username: string, password: string): Awaitable<Authenticatable>
}

View File

@ -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 <BasicLoginController> request.make(BasicLoginController)
}
Route.group('auth', () => {
Route.get('login', (request: Request) => {
const controller = <BasicLoginController> 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 = <BasicLoginController> 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 = <BasicLoginController> 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 = <BasicLoginController> 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 = <BasicLoginController> 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 = <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)
const data: Valid<BasicLoginFormData> = 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<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)
const data: Valid<BasicRegisterFormData> = 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<ResponseObject> {
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,

View File

@ -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<BasicLoginFormData> {
protected getRules(): ValidationRules {
return {
username: [
Is.required,
Str.lengthMin(1),
],
password: [
Is.required,
Str.lengthMin(1),
],
}
}
}

View File

@ -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<BasicRegisterFormData> {
protected getRules(): ValidationRules {
return {
username: [
Is.required,
Str.lengthMin(1),
],
password: [
Is.required,
Str.lengthMin(1),
Str.confirmed,
],
}
}
}

View File

@ -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.

View File

@ -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

View File

@ -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)