diff --git a/changelog.md b/changelog.md index 7fce514..1cd018f 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,10 @@ - Stores Spark metrics and runtime info on cluster node records - Extends jump onboarding with per-node Spark telemetry credentials +### Fixes + +- invalidate expired dashboard sessions and return admins to login + ### Maintenance - refresh release tooling dependencies diff --git a/ts/manager.auth/classes.authmanager.ts b/ts/manager.auth/classes.authmanager.ts index da71c6b..ed32fd2 100644 --- a/ts/manager.auth/classes.authmanager.ts +++ b/ts/manager.auth/classes.authmanager.ts @@ -11,6 +11,17 @@ export interface IJwtData { expiresAt: number; } +interface IReq_AdminValidateIdentity { + method: 'adminValidateIdentity'; + request: { + identity: plugins.servezoneInterfaces.data.IIdentity; + }; + response: { + valid: boolean; + reason?: string; + }; +} + export class CloudlyAuthManager { cloudlyRef: Cloudly; public get db() { @@ -82,6 +93,16 @@ export class CloudlyAuthManager { }, ), ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('adminValidateIdentity', async (dataArg) => { + const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false); + return { + valid, + reason: valid ? undefined : 'identity is not valid', + }; + }), + ); } private async bootstrapInitialAdmin() { @@ -126,28 +147,22 @@ export class CloudlyAuthManager { identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { - const jwt = dataArg.identity.jwt; - const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); - const expired = jwtData.expiresAt < Date.now(); - plugins.smartexpect - .expect(jwtData.status) - .setFailMessage('user not logged in') - .toEqual('loggedIn'); - plugins.smartexpect.expect(expired).setFailMessage(`jwt expired`).toBeFalse(); - plugins.smartexpect - .expect(dataArg.identity.expiresAt) - .setFailMessage( - `expiresAt >>identity valid until:${dataArg.identity.expiresAt}, but jwt says: ${jwtData.expiresAt}<< has been tampered with`, - ) - .toEqual(jwtData.expiresAt); - plugins.smartexpect - .expect(dataArg.identity.userId) - .setFailMessage('userId has been tampered with') - .toEqual(jwtData.userId); - if (expired) { - throw new Error('identity is expired'); + try { + const jwt = dataArg.identity?.jwt; + if (!jwt) { + return false; + } + const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); + const expired = jwtData.expiresAt < Date.now(); + return ( + jwtData.status === 'loggedIn' && + !expired && + dataArg.identity.expiresAt === jwtData.expiresAt && + dataArg.identity.userId === jwtData.userId + ); + } catch { + return false; } - return true; }, { failedHint: 'identity is not valid.', @@ -159,16 +174,17 @@ export class CloudlyAuthManager { identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { - await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]); + const validIdentity = await this.validIdentityGuard.exec(dataArg); + if (!validIdentity) { + return false; + } const jwt = dataArg.identity.jwt; const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const user = await this.CUser.getInstance({ id: jwtData.userId }); - const isAdminBool = user.data.role === 'admin'; - console.log(`user is admin: ${isAdminBool}`); - return isAdminBool; + return user?.data.role === 'admin'; }, { - failedHint: 'user is not admin.', + failedHint: 'identity is not valid or user is not admin.', name: 'adminIdentityGuard', }, ); @@ -177,14 +193,17 @@ export class CloudlyAuthManager { identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { - await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]); + const validIdentity = await this.validIdentityGuard.exec(dataArg); + if (!validIdentity) { + return false; + } const jwt = dataArg.identity.jwt; const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const user = await this.CUser.getInstance({ id: jwtData.userId }); - return user.data.role === 'admin' || user.data.role === 'cluster'; + return user?.data.role === 'admin' || user?.data.role === 'cluster'; }, { - failedHint: 'user is not admin or cluster.', + failedHint: 'identity is not valid or user is not admin or cluster.', name: 'adminOrClusterIdentityGuard', }, ); diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 5b30578..2ece1be 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -16,6 +16,55 @@ export interface IUiState { activeSubview: string | null; } +export interface IDataState { + secretGroups?: plugins.interfaces.data.ISecretGroup[]; + secretBundles?: plugins.interfaces.data.ISecretBundle[]; + clusters?: plugins.interfaces.data.ICluster[]; + externalRegistries?: plugins.interfaces.data.IExternalRegistry[]; + images?: any[]; + services?: plugins.interfaces.data.IService[]; + deployments?: plugins.interfaces.data.IDeployment[]; + domains?: plugins.interfaces.data.IDomain[]; + dnsEntries?: plugins.interfaces.data.IDnsEntry[]; + tasks?: any[]; + taskExecutions?: plugins.interfaces.data.ITaskExecution[]; + mails?: any[]; + logs?: any[]; + s3?: any[]; + dbs?: any[]; + backups?: any[]; +} + +const emptyDataState: IDataState = { + secretGroups: [], + secretBundles: [], + clusters: [], + externalRegistries: [], + images: [], + services: [], + deployments: [], + domains: [], + dnsEntries: [], + tasks: [], + taskExecutions: [], + mails: [], + logs: [], + s3: [], + dbs: [], + backups: [], +}; + +interface IReq_AdminValidateIdentity { + method: 'adminValidateIdentity'; + request: { + identity: plugins.interfaces.data.IIdentity; + }; + response: { + valid: boolean; + reason?: string; + }; +} + const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs']; @@ -49,7 +98,7 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw } const newState = { ...currentState, - ...(identity ? { identity } : {}), + identity, }; try { // Keep shared API client in sync and establish WS for modules using sockets @@ -67,50 +116,19 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw export const logoutAction = loginStatePart.createAction(async (statePartArg) => { const currentState = statePartArg.getState() || { identity: null }; + try { + apiClient.identity = null; + dataState.setState({ ...emptyDataState }); + } catch {} return { ...currentState, identity: null, }; }); -export interface IDataState { - secretGroups?: plugins.interfaces.data.ISecretGroup[]; - secretBundles?: plugins.interfaces.data.ISecretBundle[]; - clusters?: plugins.interfaces.data.ICluster[]; - externalRegistries?: plugins.interfaces.data.IExternalRegistry[]; - images?: any[]; - services?: plugins.interfaces.data.IService[]; - deployments?: plugins.interfaces.data.IDeployment[]; - domains?: plugins.interfaces.data.IDomain[]; - dnsEntries?: plugins.interfaces.data.IDnsEntry[]; - tasks?: any[]; - taskExecutions?: plugins.interfaces.data.ITaskExecution[]; - mails?: any[]; - logs?: any[]; - s3?: any[]; - dbs?: any[]; - backups?: any[]; -} export const dataState = await appstate.getStatePart( 'data', - { - secretGroups: [], - secretBundles: [], - clusters: [], - externalRegistries: [], - images: [], - services: [], - deployments: [], - domains: [], - dnsEntries: [], - tasks: [], - taskExecutions: [], - mails: [], - logs: [], - s3: [], - dbs: [], - backups: [], - }, + { ...emptyDataState }, 'soft' ); @@ -124,6 +142,115 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({ cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined, }) as TCloudlyApiClientWithNullableIdentity; +let identityExpiryTimer: number | undefined; +let identityInvalidationRunning = false; + +const getErrorText = (errorArg: unknown): string => { + if (!errorArg) return ''; + if (typeof errorArg === 'string') return errorArg; + const errorLike = errorArg as { errorText?: string; message?: string; text?: string }; + return errorLike.errorText || errorLike.message || errorLike.text || ''; +}; + +const isAuthRejectionText = (errorTextArg: string): boolean => { + const errorText = errorTextArg.toLowerCase(); + return [ + 'identity is not valid', + 'jwt expired', + 'identity is expired', + 'user not logged in', + 'has been tampered with', + 'invalid jwt', + 'invalid signature', + ].some((textPart) => errorText.includes(textPart)); +}; + +export const isIdentityExpired = (identityArg: plugins.interfaces.data.IIdentity | null | undefined): boolean => { + return typeof identityArg?.expiresAt === 'number' && identityArg.expiresAt <= Date.now(); +}; + +export const invalidateIdentity = async (reasonArg = 'identity is not valid'): Promise => { + if (identityInvalidationRunning) return; + identityInvalidationRunning = true; + try { + const currentLoginState = loginStatePart.getState() || { identity: null }; + if (currentLoginState.identity) { + console.warn(`Cloudly session invalidated: ${reasonArg}`); + } + apiClient.identity = null; + try { await apiClient.typedsocketClient?.setTag('identity', null); } catch {} + loginStatePart.setState({ + ...currentLoginState, + identity: null, + }); + dataState.setState({ ...emptyDataState }); + } finally { + identityInvalidationRunning = false; + } +}; + +export const validateStoredIdentity = async (): Promise => { + const identity = loginStatePart.getState()?.identity ?? null; + if (!identity) return false; + + if (isIdentityExpired(identity)) { + await invalidateIdentity('identity expired'); + return false; + } + + const validateIdentityRequest = new plugins.typedrequest.TypedRequest( + '/typedrequest', + 'adminValidateIdentity', + ); + + try { + const response = await validateIdentityRequest.fire({ identity }); + if (!response?.valid) { + await invalidateIdentity(response?.reason || 'identity rejected by server'); + return false; + } + } catch (error) { + const errorText = getErrorText(error); + if (isAuthRejectionText(errorText)) { + await invalidateIdentity(errorText); + return false; + } + console.warn('Could not validate stored identity:', error); + } + + return !!loginStatePart.getState()?.identity; +}; + +const scheduleIdentityExpiryTimer = () => { + if (identityExpiryTimer) { + window.clearTimeout(identityExpiryTimer); + identityExpiryTimer = undefined; + } + const identity = loginStatePart.getState()?.identity ?? null; + if (!identity?.expiresAt) return; + const msUntilExpiry = identity.expiresAt - Date.now(); + if (msUntilExpiry <= 0) { + void invalidateIdentity('identity expired'); + return; + } + identityExpiryTimer = window.setTimeout(() => { + void invalidateIdentity('identity expired'); + }, Math.min(msUntilExpiry, 2147483647)); +}; + +plugins.typedrequest.TypedRouter.setGlobalHooks({ + onIncomingResponse: (entryArg) => { + if (entryArg.error && isAuthRejectionText(entryArg.error)) { + void invalidateIdentity(entryArg.error); + } + }, +}); + +loginStatePart.select((stateArg) => stateArg?.identity ?? null).subscribe(() => { + scheduleIdentityExpiryTimer(); +}); +scheduleIdentityExpiryTimer(); + // Getting data export const getAllDataAction = dataState.createAction(async (statePartArg) => { let currentState = statePartArg.getState() || {}; diff --git a/ts_web/elements/cloudly-dashboard.ts b/ts_web/elements/cloudly-dashboard.ts index 7fcc896..8372f19 100644 --- a/ts_web/elements/cloudly-dashboard.ts +++ b/ts_web/elements/cloudly-dashboard.ts @@ -169,6 +169,17 @@ export class CloudlyDashboard extends DeesElement { this.syncAppdashView(uiState.activeView, uiState.activeSubview); }); this.rxSubscriptions.push(uiSubscription); + + const loginSubscription = appstate.loginStatePart + .select((stateArg) => stateArg?.identity ?? null) + .subscribe((identityArg) => { + const hadIdentity = !!this.identity; + this.identity = identityArg ?? null; + if (!identityArg && hadIdentity) { + void this.switchToLoginContent('Session expired. Please sign in again.'); + } + }); + this.rxSubscriptions.push(loginSubscription); } private syncAppdashView(viewSlug: string, subviewSlug: string | null): void { @@ -267,9 +278,16 @@ export class CloudlyDashboard extends DeesElement { const domtools = await this.domtoolsPromise; const loginState = appstate.loginStatePart.getState(); if (loginState?.identity) { - this.identity = loginState.identity; + const identityValid = await appstate.validateStoredIdentity(); + const currentIdentity = appstate.loginStatePart.getState()?.identity ?? null; + if (!identityValid || !currentIdentity) { + await this.switchToLoginContent('Session expired. Please sign in again.'); + return; + } + + this.identity = currentIdentity; try { - appstate.apiClient.identity = loginState.identity; + appstate.apiClient.identity = currentIdentity; if (!appstate.apiClient['typedsocketClient']) { await appstate.apiClient.start(); } @@ -301,5 +319,34 @@ export class CloudlyDashboard extends DeesElement { } } - private async logout() {} + private async switchToLoginContent(statusMessageArg?: string) { + const simpleLogin = this.shadowRoot?.querySelector('dees-simple-login') as any; + if (!simpleLogin?.shadowRoot) return; + + const loginDiv = simpleLogin.shadowRoot.querySelector('.login') as HTMLDivElement | null; + const loginContainerDiv = simpleLogin.shadowRoot.querySelector('.loginContainer') as HTMLDivElement | null; + const slotContainerDiv = simpleLogin.shadowRoot.querySelector('.slotContainer') as HTMLDivElement | null; + const form = simpleLogin.shadowRoot.querySelector('dees-form') as any; + + if (loginDiv) { + loginDiv.style.opacity = '1'; + loginDiv.style.transform = 'translateY(0px)'; + } + if (loginContainerDiv) { + loginContainerDiv.style.pointerEvents = 'all'; + } + if (slotContainerDiv) { + slotContainerDiv.style.opacity = '0'; + slotContainerDiv.style.transform = 'translateY(20px)'; + slotContainerDiv.style.pointerEvents = 'none'; + } + if (form && statusMessageArg) { + form.setStatus('error', statusMessageArg); + } + } + + private async logout() { + await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); + await this.switchToLoginContent(); + } } diff --git a/ts_web/plugins.ts b/ts_web/plugins.ts index 94a6816..bfc8234 100644 --- a/ts_web/plugins.ts +++ b/ts_web/plugins.ts @@ -4,6 +4,11 @@ export { interfaces } +// @api.global scope +import * as typedrequest from '@api.global/typedrequest'; + +export { typedrequest }; + // @design.estate scope import * as deesDomtools from '@design.estate/dees-domtools'; import * as deesElement from '@design.estate/dees-element'; @@ -11,11 +16,6 @@ import * as deesCatalog from '@design.estate/dees-catalog'; export { deesDomtools, deesElement, deesCatalog }; -// @api.global scope -import * as typedrequest from '@api.global/typedrequest'; - -export { typedrequest }; - // @push.rocks scope import * as webjwt from '@push.rocks/webjwt'; import * as smartstate from '@push.rocks/smartstate';