fix(cloudly): invalidate expired dashboard sessions
This commit is contained in:
+164
-37
@@ -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<IDataState>(
|
||||
'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<void> => {
|
||||
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<boolean> => {
|
||||
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<IReq_AdminValidateIdentity>(
|
||||
'/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() || {};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user