diff --git a/package.json b/package.json index 1621b61..a3f4549 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/context/SecurityContext.ts b/src/auth/context/SecurityContext.ts index 32765a2..30eb45c 100644 --- a/src/auth/context/SecurityContext.ts +++ b/src/auth/context/SecurityContext.ts @@ -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 diff --git a/src/auth/context/SessionSecurityContext.ts b/src/auth/context/SessionSecurityContext.ts index 137cfcc..d5a67b6 100644 --- a/src/auth/context/SessionSecurityContext.ts +++ b/src/auth/context/SessionSecurityContext.ts @@ -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 } } diff --git a/src/auth/context/TokenSecurityContext.ts b/src/auth/context/TokenSecurityContext.ts index 4dbd74a..7434aea 100644 --- a/src/auth/context/TokenSecurityContext.ts +++ b/src/auth/context/TokenSecurityContext.ts @@ -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 } } diff --git a/src/auth/event/AuthCheckFailed.ts b/src/auth/event/AuthCheckFailed.ts new file mode 100644 index 0000000..4f6f1d5 --- /dev/null +++ b/src/auth/event/AuthCheckFailed.ts @@ -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 { + protected decodeSerial(): Awaitable { + 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 + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index e666ade..ab94f7a 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -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' diff --git a/src/auth/webSocketAuthCheck.ts b/src/auth/webSocketAuthCheck.ts new file mode 100644 index 0000000..830aff4 --- /dev/null +++ b/src/auth/webSocketAuthCheck.ts @@ -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 { + const request = Container.getContainer() + .make(RequestLocalStorage) + .get() + + const logging = request.make(Logging) + + try { + // Try to re-load the session in case we're using the SessionSecurityContext + await request.make(Session).load() + } catch (e: unknown) { + logging.error(e) + } + + const security = request.make(SecurityContext) + await security.resume() + + if ( !security.hasUser() ) { + await request.make(Bus).push(new AuthCheckFailed()) + } +} diff --git a/src/http/lifecycle/WebSocketHealthCheckEvent.ts b/src/http/lifecycle/WebSocketHealthCheckEvent.ts new file mode 100644 index 0000000..4c3c597 --- /dev/null +++ b/src/http/lifecycle/WebSocketHealthCheckEvent.ts @@ -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 +} diff --git a/src/index.ts b/src/index.ts index 4231820..7543292 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/support/bus/WebSocketBus.ts b/src/support/bus/WebSocketBus.ts index 22e6849..f487dc1 100644 --- a/src/support/bus/WebSocketBus.ts +++ b/src/support/bus/WebSocketBus.ts @@ -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 { 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 { + 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 }