Rework authentication system
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
bd7d6a2dbd
commit
5175d64e36
@ -1,16 +1,13 @@
|
|||||||
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
|
|
||||||
import {Unit} from '../lifecycle/Unit'
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {Injectable, Inject, StaticInstantiable} from '../di'
|
||||||
import {Logging} from '../service/Logging'
|
import {Logging} from '../service/Logging'
|
||||||
|
import {Middlewares} from '../service/Middlewares'
|
||||||
import {CanonicalResolver} from '../service/Canonical'
|
import {CanonicalResolver} from '../service/Canonical'
|
||||||
import {Middleware} from '../http/routing/Middleware'
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
|
||||||
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||||
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||||
import {Middlewares} from '../service/Middlewares'
|
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit class that bootstraps the authentication framework.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class Authentication extends Unit {
|
export class Authentication extends Unit {
|
||||||
@Inject()
|
@Inject()
|
||||||
@ -20,20 +17,15 @@ export class Authentication extends Unit {
|
|||||||
protected readonly middleware!: Middlewares
|
protected readonly middleware!: Middlewares
|
||||||
|
|
||||||
async up(): Promise<void> {
|
async up(): Promise<void> {
|
||||||
this.container()
|
|
||||||
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {
|
||||||
* Create the canonical namespace resolver for auth middleware.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
|
|
||||||
return (key: string) => {
|
return (key: string) => {
|
||||||
return ({
|
return ({
|
||||||
web: SessionAuthMiddleware,
|
|
||||||
required: AuthRequiredMiddleware,
|
required: AuthRequiredMiddleware,
|
||||||
guest: GuestRequiredMiddleware,
|
guest: GuestRequiredMiddleware,
|
||||||
|
web: SessionAuthMiddleware,
|
||||||
})[key]
|
})[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +1,41 @@
|
|||||||
import {Instantiable} from '../di'
|
import {Instantiable, isInstantiable} from '../di'
|
||||||
import {ORMUserRepository} from './orm/ORMUserRepository'
|
import {AuthenticatableRepository} from './types'
|
||||||
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController'
|
import {hasOwnProperty} from '../util'
|
||||||
|
|
||||||
/**
|
export interface AuthenticationConfig {
|
||||||
* Inferface for type-checking the AuthenticatableRepositories values.
|
storage: Instantiable<AuthenticatableRepository>,
|
||||||
*/
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
sources?: {
|
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
|
||||||
|
}
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import {Inject, Injectable} from '../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {EventBus} from '../event/EventBus'
|
import {EventBus} from '../../event/EventBus'
|
||||||
import {Awaitable, Maybe} from '../util'
|
import {Awaitable, Maybe} from '../../util'
|
||||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
import {Authenticatable, AuthenticatableRepository} from '../types'
|
||||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
import {Logging} from '../../service/Logging'
|
||||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
||||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
||||||
import {Logging} from '../service/Logging'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base-class for a context that authenticates users and manages security.
|
* Base-class for a context that authenticates users and manages security.
|
||||||
@ -19,10 +18,10 @@ export abstract class SecurityContext {
|
|||||||
protected readonly logging!: Logging
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
/** The currently authenticated user, if one exists. */
|
/** The currently authenticated user, if one exists. */
|
||||||
private authenticatedUser?: Authenticatable
|
protected authenticatedUser?: Authenticatable
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
/** The repository from which to draw users. */
|
/** The repository where users are persisted. */
|
||||||
public readonly repository: AuthenticatableRepository,
|
public readonly repository: AuthenticatableRepository,
|
||||||
|
|
||||||
/** The name of this context. */
|
/** The name of this context. */
|
||||||
@ -54,35 +53,6 @@ export abstract class SecurityContext {
|
|||||||
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
|
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.
|
* 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,
|
* Assuming a user is still authenticated in the context,
|
||||||
* try to look up and fill in the user.
|
* try to look up and fill in the user.
|
||||||
*/
|
*/
|
||||||
async resume(): Promise<void> {
|
abstract resume(): Awaitable<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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the current state of the security context to whatever storage
|
* Write the current state of the security context to whatever storage
|
||||||
@ -127,12 +88,6 @@ export abstract class SecurityContext {
|
|||||||
*/
|
*/
|
||||||
abstract persist(): Awaitable<void>
|
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.
|
* 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.
|
* Returns true if there is a currently authenticated user.
|
||||||
*/
|
*/
|
||||||
hasUser(): boolean {
|
hasUser(): boolean {
|
||||||
this.logging.debug('hasUser?')
|
|
||||||
this.logging.debug(this.authenticatedUser)
|
|
||||||
return Boolean(this.authenticatedUser)
|
return Boolean(this.authenticatedUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
39
src/auth/context/SessionSecurityContext.ts
Normal file
39
src/auth/context/SessionSecurityContext.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
27
src/auth/event/AuthenticationEvent.ts
Normal file
27
src/auth/event/AuthenticationEvent.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +1,6 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a user is authenticated.
|
* Event fired when a user is authenticated.
|
||||||
*/
|
*/
|
||||||
export class UserAuthenticatedEvent extends Event {
|
export class UserAuthenticatedEvent extends AuthenticationEvent {}
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,27 +1,6 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
export class UserAuthenticationResumedEvent extends AuthenticationEvent {}
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,27 +1,6 @@
|
|||||||
import {Event} from '../../event/Event'
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a user is unauthenticated.
|
* Event fired when a user is unauthenticated.
|
||||||
*/
|
*/
|
||||||
export class UserFlushedEvent extends Event {
|
export class UserFlushedEvent extends AuthenticationEvent {}
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
155
src/auth/external/oauth2/OAuth2Repository.ts
vendored
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
50
src/auth/external/oauth2/OAuth2User.ts
vendored
50
src/auth/external/oauth2/OAuth2User.ts
vendored
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +1,21 @@
|
|||||||
export * from './types'
|
export * from './types'
|
||||||
|
export * from './AuthenticatableAlreadyExistsError'
|
||||||
export * from './NotAuthorizedError'
|
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 './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 './config'
|
||||||
|
|
||||||
export * from './basic-ui/BasicLoginFormRequest'
|
|
||||||
export * from './basic-ui/BasicLoginController'
|
|
||||||
|
|
||||||
export * from './external/oauth2/OAuth2LoginController'
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
import {error} from '../../http/response/ErrorResponseFactory'
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
@ -9,6 +9,8 @@ import {redirect} from '../../http/response/RedirectResponseFactory'
|
|||||||
import {Routing} from '../../service/Routing'
|
import {Routing} from '../../service/Routing'
|
||||||
import {Session} from '../../http/session/Session'
|
import {Session} from '../../http/session/Session'
|
||||||
|
|
||||||
|
// TODO handle JSON and non-web
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthRequiredMiddleware extends Middleware {
|
export class AuthRequiredMiddleware extends Middleware {
|
||||||
@Inject()
|
@Inject()
|
||||||
@ -22,7 +24,7 @@ export class AuthRequiredMiddleware extends Middleware {
|
|||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
async apply(): Promise<ResponseObject> {
|
||||||
if ( !this.security.hasUser() ) {
|
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') ) {
|
if ( this.routing.hasNamedRoute('@auth.login') ) {
|
||||||
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
return redirect(this.routing.getNamedPath('@auth.login').toRemote)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../di'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
import {error} from '../../http/response/ErrorResponseFactory'
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
import {NotAuthorizedError} from '../NotAuthorizedError'
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
@ -8,6 +8,8 @@ import {HTTPStatus} from '../../util'
|
|||||||
import {Routing} from '../../service/Routing'
|
import {Routing} from '../../service/Routing'
|
||||||
import {redirect} from '../../http/response/RedirectResponseFactory'
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
|
||||||
|
// TODO handle JSON and non-web
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GuestRequiredMiddleware extends Middleware {
|
export class GuestRequiredMiddleware extends Middleware {
|
||||||
@Inject()
|
@Inject()
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import {Middleware} from '../../http/routing/Middleware'
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable, Instantiable} from '../../di'
|
||||||
import {ResponseObject} from '../../http/routing/Route'
|
|
||||||
import {Config} from '../../service/Config'
|
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 {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
|
* Injects a SessionSecurityContext into the request and attempts to
|
||||||
@ -22,7 +22,7 @@ export class SessionAuthMiddleware extends Middleware {
|
|||||||
protected readonly logging!: Logging
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
async apply(): Promise<ResponseObject> {
|
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())
|
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
|
||||||
this.request.registerSingletonInstance(SecurityContext, context)
|
this.request.registerSingletonInstance(SecurityContext, context)
|
||||||
await context.resume()
|
await context.resume()
|
||||||
@ -33,8 +33,12 @@ export class SessionAuthMiddleware extends Middleware {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
protected getRepository(): AuthenticatableRepository {
|
protected getRepository(): AuthenticatableRepository {
|
||||||
const config: AuthConfig | undefined = this.config.get('auth')
|
const config: Maybe<AuthenticationConfig> = this.config.get('auth')
|
||||||
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
|
if ( !isAuthenticationConfig(config) ) {
|
||||||
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
|
throw new TypeError('Invalid authentication config.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo: Instantiable<AuthenticatableRepository> = config.storage
|
||||||
|
return this.make(repo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 * 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.
|
* A basic ORM-driven user class.
|
||||||
@ -35,8 +35,17 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
|||||||
public passwordHash!: string
|
public passwordHash!: string
|
||||||
|
|
||||||
/** Human-readable display name of the user. */
|
/** Human-readable display name of the user. */
|
||||||
getDisplayIdentifier(): string {
|
getDisplay(): string {
|
||||||
return `${this.firstName} ${this.lastName}`
|
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. */
|
/** Unique identifier of the user. */
|
||||||
@ -54,6 +63,10 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
|||||||
this.passwordHash = await bcrypt.hash(password, 10)
|
this.passwordHash = await bcrypt.hash(password, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateCredential(credential: string): Awaitable<boolean> {
|
||||||
|
return this.verifyPassword(credential)
|
||||||
|
}
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
async dehydrate(): Promise<JSONState> {
|
||||||
return this.toQueryRow()
|
return this.toQueryRow()
|
||||||
}
|
}
|
51
src/auth/repository/orm/ORMUserRepository.ts
Normal file
51
src/auth/repository/orm/ORMUserRepository.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
@ -3,21 +3,22 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
|||||||
/** Value that can be used to uniquely identify a user. */
|
/** Value that can be used to uniquely identify a user. */
|
||||||
export type AuthenticatableIdentifier = string | number
|
export type AuthenticatableIdentifier = string | number
|
||||||
|
|
||||||
export interface AuthenticatableCredentials {
|
|
||||||
identifier: string,
|
|
||||||
credential: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for entities that can be authenticated.
|
* Base class for entities that can be authenticated.
|
||||||
*/
|
*/
|
||||||
export abstract class Authenticatable implements Rehydratable {
|
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
|
abstract getIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
/** Get the human-readable identifier of the user. */
|
/** 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>
|
abstract dehydrate(): Promise<JSONState>
|
||||||
|
|
||||||
@ -28,16 +29,15 @@ export abstract class Authenticatable implements Rehydratable {
|
|||||||
* Base class for a repository that stores and recalls users.
|
* Base class for a repository that stores and recalls users.
|
||||||
*/
|
*/
|
||||||
export abstract class AuthenticatableRepository {
|
export abstract class AuthenticatableRepository {
|
||||||
|
|
||||||
/** Look up the user by their unique identifier. */
|
/** Look up the user by their unique identifier. */
|
||||||
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
/**
|
/** Returns true if this repository supports registering users. */
|
||||||
* Attempt to look up and verify a user by their credentials.
|
abstract supportsRegistration(): boolean
|
||||||
* Returns the user if the credentials are valid.
|
|
||||||
* @param credentials
|
|
||||||
*/
|
|
||||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
|
||||||
|
|
||||||
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>
|
||||||
}
|
}
|
||||||
|
@ -1,56 +1,48 @@
|
|||||||
import {Controller} from '../../http/Controller'
|
import {Controller} from '../../../http/Controller'
|
||||||
import {Inject, Injectable} from '../../di'
|
import {Inject, Injectable} from '../../../di'
|
||||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
import {SecurityContext} from '../../context/SecurityContext'
|
||||||
import {Request} from '../../http/lifecycle/Request'
|
import {Session} from '../../../http/session/Session'
|
||||||
import {view} from '../../http/response/ViewResponseFactory'
|
import {ResponseFactory} from '../../../http/response/ResponseFactory'
|
||||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
import {redirectToGet, view} from '../../../http/response/ViewResponseFactory'
|
||||||
import {SecurityContext} from '../SecurityContext'
|
import {Routing} from '../../../service/Routing'
|
||||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
import {ResponseObject, Route} from '../../../http/routing/Route'
|
||||||
import {Routing} from '../../service/Routing'
|
import {BasicLoginFormData, BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||||
import {Valid, ValidationError} from '../../forms'
|
import {Valid, ValidationError} from '../../../forms'
|
||||||
import {AuthenticatableCredentials} from '../types'
|
import {BasicRegisterFormData, BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError'
|
||||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
import {Request} from '../../../http/lifecycle/Request'
|
||||||
import {Session} from '../../http/session/Session'
|
|
||||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BasicLoginController extends Controller {
|
export class BasicLoginController extends Controller {
|
||||||
public static routes({ enableRegistration = true } = {}): void {
|
public static routes({ enableRegistration = true } = {}): void {
|
||||||
|
const controller = (request: Request) => {
|
||||||
|
return <BasicLoginController> request.make(BasicLoginController)
|
||||||
|
}
|
||||||
|
|
||||||
Route.group('auth', () => {
|
Route.group('auth', () => {
|
||||||
Route.get('login', (request: Request) => {
|
Route.get('login', (request: Request) =>
|
||||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
controller(request).getLogin())
|
||||||
return controller.getLogin()
|
|
||||||
})
|
|
||||||
.pre('@auth:guest')
|
.pre('@auth:guest')
|
||||||
.alias('@auth.login')
|
.alias('@auth.login')
|
||||||
|
|
||||||
Route.post('login', (request: Request) => {
|
Route.post('login', (request: Request) =>
|
||||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
controller(request).attemptLogin())
|
||||||
return controller.attemptLogin()
|
|
||||||
})
|
|
||||||
.pre('@auth:guest')
|
.pre('@auth:guest')
|
||||||
.alias('@auth.login.attempt')
|
.alias('@auth.login.attempt')
|
||||||
|
|
||||||
Route.any('logout', (request: Request) => {
|
Route.any('logout', (request: Request) =>
|
||||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
controller(request).attemptLogout())
|
||||||
return controller.attemptLogout()
|
|
||||||
})
|
|
||||||
.pre('@auth:required')
|
.pre('@auth:required')
|
||||||
.alias('@auth.logout')
|
.alias('@auth.logout')
|
||||||
|
|
||||||
if ( enableRegistration ) {
|
if ( enableRegistration ) {
|
||||||
Route.get('register', (request: Request) => {
|
Route.get('register', (request: Request) =>
|
||||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
controller(request).getRegistration())
|
||||||
return controller.getRegistration()
|
|
||||||
})
|
|
||||||
.pre('@auth:guest')
|
.pre('@auth:guest')
|
||||||
.alias('@auth.register')
|
.alias('@auth.register')
|
||||||
|
|
||||||
Route.post('register', (request: Request) => {
|
Route.post('register', (request: Request) =>
|
||||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
controller(request).attemptRegister())
|
||||||
return controller.attemptRegister()
|
|
||||||
})
|
|
||||||
.pre('@auth:guest')
|
.pre('@auth:guest')
|
||||||
.alias('@auth.register.attempt')
|
.alias('@auth.register.attempt')
|
||||||
}
|
}
|
||||||
@ -61,16 +53,20 @@ export class BasicLoginController extends Controller {
|
|||||||
protected readonly security!: SecurityContext
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly routing!: Routing
|
protected readonly session!: Session
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
protected readonly session!: Session
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
public getLogin(): ResponseFactory {
|
public getLogin(): ResponseFactory {
|
||||||
return this.getLoginView()
|
return this.getLoginView()
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRegistration(): ResponseFactory {
|
public getRegistration(): ResponseFactory {
|
||||||
|
if ( !this.security.repository.supportsRegistration() ) {
|
||||||
|
return redirectToGet('/')
|
||||||
|
}
|
||||||
|
|
||||||
return this.getRegistrationView()
|
return this.getRegistrationView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,12 +74,14 @@ export class BasicLoginController extends Controller {
|
|||||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
const data: Valid<BasicLoginFormData> = await form.get()
|
||||||
const user = await this.security.attempt(data)
|
const user = await this.security.repository.getByIdentifier(data.username)
|
||||||
if ( user ) {
|
if ( user && (await user.validateCredential(data.password)) ) {
|
||||||
const intention = this.session.get('auth.intention', '/')
|
await this.security.authenticate(user)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getLoginView(['Invalid username/password.'])
|
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> {
|
public async attemptRegister(): Promise<ResponseObject> {
|
||||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
const data: Valid<BasicRegisterFormData> = await form.get()
|
||||||
const user = await this.security.repository.createByCredentials(data)
|
const user = await this.security.repository.createFromCredentials(data.username, data.password)
|
||||||
await this.security.authenticate(user)
|
await this.security.authenticate(user)
|
||||||
|
|
||||||
const intention = this.session.get('auth.intention', '/')
|
const intention = this.session.get('@extollo:auth.intention', '/')
|
||||||
this.session.forget('auth.intention')
|
this.session.forget('@extollo:auth.intention')
|
||||||
return temporary(intention)
|
return redirectToGet(intention)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if ( e instanceof ValidationError ) {
|
if ( e instanceof ValidationError ) {
|
||||||
return this.getRegistrationView(e.errors.all())
|
return this.getRegistrationView(e.errors.all())
|
||||||
@ -123,11 +116,9 @@ export class BasicLoginController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
public async attemptLogout(): Promise<ResponseObject> {
|
||||||
return view('@extollo:auth:login', {
|
await this.security.flush()
|
||||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
return this.getMessageView('You have been logged out.')
|
||||||
errors,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
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 {
|
protected getMessageView(message: string): ResponseFactory {
|
||||||
return view('@extollo:auth:message', {
|
return view('@extollo:auth:message', {
|
||||||
message,
|
message,
|
24
src/auth/ui/basic/BasicLoginFormRequest.ts
Normal file
24
src/auth/ui/basic/BasicLoginFormRequest.ts
Normal 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),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
src/auth/ui/basic/BasicRegisterFormRequest.ts
Normal file
25
src/auth/ui/basic/BasicRegisterFormRequest.ts
Normal 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,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,15 @@ export function view(name: string, data?: {[key: string]: any}): ViewResponseFac
|
|||||||
return new ViewResponseFactory(name, data)
|
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
|
* HTTP response factory that uses the ViewEngine service to render a view
|
||||||
* and send it as HTML.
|
* and send it as HTML.
|
||||||
|
@ -8,11 +8,11 @@ block heading
|
|||||||
|
|
||||||
block form
|
block form
|
||||||
.form-label-group
|
.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
|
label(for='inputUsername') Username
|
||||||
|
|
||||||
.form-label-group
|
.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
|
label(for='inputPassword') Password
|
||||||
|
|
||||||
|
|
||||||
|
8
src/resources/views/redirect.pug
Normal file
8
src/resources/views/redirect.pug
Normal 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)
|
Loading…
Reference in New Issue
Block a user