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 {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]
|
||||
}
|
||||
}
|
||||
|
@ -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 {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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
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 {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 {}
|
||||
|
@ -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 {}
|
||||
|
@ -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 {}
|
||||
|
@ -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 './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'
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 {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()
|
||||
}
|
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. */
|
||||
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>
|
||||
}
|
||||
|
@ -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,
|
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
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