Support periodic auth checks in SecurityContext on web socket connections

This commit is contained in:
Garrett Mills 2022-08-09 23:49:25 -05:00
parent efb9726470
commit e339ec718d
10 changed files with 137 additions and 2 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.13.6",
"version": "0.13.7",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",

View File

@ -80,6 +80,9 @@ export abstract class SecurityContext {
/**
* Assuming a user is still authenticated in the context,
* try to look up and fill in the user.
*
* If there is NO USER to be resumed, then the method should flush
* the user from this context.
*/
abstract resume(): Awaitable<void>

View File

@ -33,7 +33,10 @@ export class SessionSecurityContext extends SecurityContext {
if ( user ) {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
return
}
}
this.authenticatedUser = undefined
}
}

View File

@ -36,6 +36,9 @@ export class TokenSecurityContext extends SecurityContext {
if ( user ) {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
return
}
this.authenticatedUser = undefined
}
}

View File

@ -0,0 +1,27 @@
import {BaseEvent, BaseSerializer, ObjectSerializer} from '../../support/bus'
import {Awaitable} from '../../util'
/** An event raised when a required auth check has failed. */
export class AuthCheckFailed extends BaseEvent {
eventName = '@extollo/lib:AuthCheckFailed'
}
/** Serializes AuthCheckFailed events. */
@ObjectSerializer()
export class AuthCheckFailedSerializer extends BaseSerializer<AuthCheckFailed, { authCheckFailed: true }> {
protected decodeSerial(): Awaitable<AuthCheckFailed> {
return new AuthCheckFailed()
}
protected encodeActual(): Awaitable<{ authCheckFailed: true }> {
return { authCheckFailed: true }
}
protected getName(): string {
return '@extollo/lib:AuthCheckFailedSerializer'
}
matchActual(some: AuthCheckFailed): boolean {
return some instanceof AuthCheckFailed
}
}

View File

@ -12,6 +12,7 @@ export * from './event/AuthenticationEvent'
export * from './event/UserAuthenticatedEvent'
export * from './event/UserAuthenticationResumedEvent'
export * from './event/UserFlushedEvent'
export * from './event/AuthCheckFailed'
export * from './middleware/AuthRequiredMiddleware'
export * from './middleware/GuestRequiredMiddleware'
@ -33,6 +34,8 @@ export * from './repository/orm/ORMUserRepository'
export * from './config'
export * from './webSocketAuthCheck'
export * from './server/types'
export * from './server/models/OAuth2TokenModel'
export * from './server/repositories/ConfigClientRepository'

View File

@ -0,0 +1,36 @@
import {Container} from '../di'
import {RequestLocalStorage} from '../http/RequestLocalStorage'
import {Session} from '../http/session/Session'
import {Logging} from '../service/Logging'
import {SecurityContext} from './context/SecurityContext'
import {Bus} from '../support/bus'
import {AuthCheckFailed} from './event/AuthCheckFailed'
/**
* Check if the security context for the current request's web socket is still valid.
* If not, raise an `AuthCheckFailed` event. This is meant to be used as a subscriber
* to `WebSocketHealthCheckEvent` on the request.
*
* @see AuthCheckFailed
*/
export async function webSocketAuthCheck(): Promise<void> {
const request = Container.getContainer()
.make<RequestLocalStorage>(RequestLocalStorage)
.get()
const logging = request.make<Logging>(Logging)
try {
// Try to re-load the session in case we're using the SessionSecurityContext
await request.make<Session>(Session).load()
} catch (e: unknown) {
logging.error(e)
}
const security = request.make<SecurityContext>(SecurityContext)
await security.resume()
if ( !security.hasUser() ) {
await request.make<Bus>(Bus).push(new AuthCheckFailed())
}
}

View File

@ -0,0 +1,11 @@
import {Event} from '../../support/bus'
import {uuid4} from '../../util'
/** Event used to tell the server to close the websocket connection. */
export class WebSocketHealthCheckEvent implements Event {
eventName = '@extollo/lib:WebSocketHealthCheckEvent'
eventUuid = uuid4()
shouldBroadcast = false
}

View File

@ -36,6 +36,7 @@ export * from './http/kernel/HTTPCookieJar'
export * from './http/lifecycle/Request'
export * from './http/lifecycle/Response'
export * from './http/lifecycle/WebSocketCloseEvent'
export * from './http/lifecycle/WebSocketHealthCheckEvent'
export * from './http/RequestLocalStorage'
export * from './make'

View File

@ -17,6 +17,8 @@ import {WebSocketCloseEvent} from '../../http/lifecycle/WebSocketCloseEvent'
import {apiEvent, error} from '../../http/response/api'
import {AsyncResource, executionAsyncId} from 'async_hooks'
import {Session} from '../../http/session/Session'
import {Config} from '../../service/Config'
import {WebSocketHealthCheckEvent} from '../../http/lifecycle/WebSocketHealthCheckEvent'
@Injectable()
export class WebSocketBus implements EventBus, AwareOfContainerLifecycle {
@ -50,6 +52,9 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle {
@Inject()
protected readonly session!: Session
@Inject()
protected readonly config!: Config
public readonly uuid = uuid4()
private connected = false
@ -125,7 +130,7 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle {
}
}
up(): void {
async up(): Promise<void> {
const resource = new AsyncResource('WebSocketBus', {
triggerAsyncId: executionAsyncId(),
requireManualDestroy: false,
@ -147,10 +152,53 @@ export class WebSocketBus implements EventBus, AwareOfContainerLifecycle {
})
})
this.ws.on('close', () => this.bus.push(new WebSocketCloseEvent()))
this.ws.on('error', () => this.bus.push(new WebSocketCloseEvent()))
await this.registerHealthCheck()
this.connected = true
}
/**
* Set up an interval that fires a `WebSocketHealthCheckEvent` on the request bus.
* This can be used, e.g., to check if the user is still logged in.
* You can control the interval by setting the `server.socket.healthCheckIntervalSeconds`
* config option. Set the option to 0 to disable the interval.
* @protected
*/
protected async registerHealthCheck(): Promise<void> {
const interval = this.config.safe('server.socket.healthCheckIntervalSeconds')
.or(60)
.integer()
if ( interval === 0 ) {
return
}
const handle = setInterval(() => {
if ( !this.isConnected() ) {
return
}
return this.bus.push(new WebSocketHealthCheckEvent())
}, interval * 1000)
await this.bus.subscribe(WebSocketCloseEvent, () => {
clearInterval(handle)
})
}
down(): void {
if ( this.connected ) {
try {
this.ws.close()
} catch (e) {
this.logging.error('Error closing socket:')
this.logging.error(e)
}
}
this.connected = false
}