diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx
index c733509971..96c813919c 100644
--- a/frontend/src/component/App.tsx
+++ b/frontend/src/component/App.tsx
@@ -22,6 +22,7 @@ import { ExternalBanners } from './banners/externalBanners/ExternalBanners';
import { LicenseBanner } from './banners/internalBanners/LicenseBanner';
import { Demo } from './demo/Demo';
import { LoginRedirect } from './common/LoginRedirect/LoginRedirect';
+import { SecurityBanner } from './banners/internalBanners/SecurityBanner';
const StyledContainer = styled('div')(() => ({
'& ul': {
@@ -66,6 +67,7 @@ export const App = () => {
show={}
/>
+
diff --git a/frontend/src/component/banners/internalBanners/SecurityBanner.tsx b/frontend/src/component/banners/internalBanners/SecurityBanner.tsx
new file mode 100644
index 0000000000..4d29cdbc73
--- /dev/null
+++ b/frontend/src/component/banners/internalBanners/SecurityBanner.tsx
@@ -0,0 +1,32 @@
+import { useContext } from 'react';
+import { Banner } from 'component/banners/Banner/Banner';
+import AccessContext from 'contexts/AccessContext';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import { useUiFlag } from 'hooks/useUiFlag';
+import type { BannerVariant } from 'interfaces/banner';
+
+export const SecurityBanner = () => {
+ const { uiConfig } = useUiConfig();
+ const showUserDeviceCount = useUiFlag('showUserDeviceCount');
+ const { isAdmin } = useContext(AccessContext);
+
+ if (
+ !isAdmin ||
+ !showUserDeviceCount ||
+ !uiConfig.maxSessionsCount ||
+ uiConfig.maxSessionsCount < 5
+ ) {
+ return null;
+ }
+
+ const banner = {
+ message: `Potential security issue: there are ${uiConfig.maxSessionsCount} parallel sessions for a single user account.`,
+ variant: 'warning' as BannerVariant,
+ sticky: false,
+ link: '/admin/users',
+ plausibleEvent: 'showUserDeviceCount',
+ linkText: 'Review user accounts',
+ };
+
+ return ;
+};
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 965a667e34..cca7e5055e 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -34,6 +34,7 @@ export interface IUiConfig {
oidcConfiguredThroughEnv?: boolean;
samlConfiguredThroughEnv?: boolean;
unleashAIAvailable?: boolean;
+ maxSessionsCount?: number;
}
export interface IProclamationToast {
diff --git a/frontend/src/openapi/models/uiConfigSchema.ts b/frontend/src/openapi/models/uiConfigSchema.ts
index 3cedb7a0e6..31eeb7254e 100644
--- a/frontend/src/openapi/models/uiConfigSchema.ts
+++ b/frontend/src/openapi/models/uiConfigSchema.ts
@@ -36,6 +36,8 @@ export interface UiConfigSchema {
links?: UiConfigSchemaLinksItem[];
/** Whether maintenance mode is currently active or not. */
maintenanceMode?: boolean;
+ /** The maximum number of sessions that a user has. */
+ maxSessionsCount?: number;
/** The name of this Unleash instance. Used to build the text in the footer. */
name?: string;
/** Whether to enable the Unleash network view or not. */
diff --git a/frontend/src/openapi/models/userSchema.ts b/frontend/src/openapi/models/userSchema.ts
index 17f2a79c3f..897ff0e1af 100644
--- a/frontend/src/openapi/models/userSchema.ts
+++ b/frontend/src/openapi/models/userSchema.ts
@@ -10,8 +10,15 @@
export interface UserSchema {
/** A user is either an actual User or a Service Account */
accountType?: string;
+ /**
+ * Count of active browser sessions for this user
+ * @nullable
+ */
+ activeSessions?: number | null;
/** The user was created at this time */
createdAt?: string;
+ /** Experimental. The number of deleted browser sessions after last login */
+ deletedSessions?: number;
/** Email of the user */
email?: string;
/** Is the welcome email sent to the user or not */
@@ -59,6 +66,4 @@ export interface UserSchema {
* @nullable
*/
username?: string | null;
- deletedSessions?: number;
- activeSessions?: number;
}
diff --git a/src/lib/db/session-store.ts b/src/lib/db/session-store.ts
index acee76eec1..6817aad7f4 100644
--- a/src/lib/db/session-store.ts
+++ b/src/lib/db/session-store.ts
@@ -120,6 +120,17 @@ export default class SessionStore implements ISessionStore {
count: Number(row.count),
}));
}
+
+ async getMaxSessionsCount(): Promise {
+ const result = await this.db(TABLE)
+ .select(this.db.raw("sess->'user'->>'id' AS user_id"))
+ .count('* as count')
+ .groupBy('user_id')
+ .orderBy('count', 'desc')
+ .first();
+
+ return result ? Number(result.count) : 0;
+ }
}
module.exports = SessionStore;
diff --git a/src/lib/openapi/spec/ui-config-schema.ts b/src/lib/openapi/spec/ui-config-schema.ts
index 184580284b..c9a8af7719 100644
--- a/src/lib/openapi/spec/ui-config-schema.ts
+++ b/src/lib/openapi/spec/ui-config-schema.ts
@@ -191,6 +191,11 @@ export const uiConfigSchema = {
description: 'Whether Unleash AI is available.',
example: false,
},
+ maxSessionsCount: {
+ type: 'number',
+ description: 'The maximum number of sessions that a user has.',
+ example: 10,
+ },
},
components: {
schemas: {
diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts
index 8c9cad80b6..36c28be817 100644
--- a/src/lib/routes/admin-api/config.ts
+++ b/src/lib/routes/admin-api/config.ts
@@ -23,9 +23,10 @@ import type { IAuthRequest } from '../unleash-types';
import NotFoundError from '../../error/notfound-error';
import type { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
-import type { FrontendApiService } from '../../services';
+import type { FrontendApiService, SessionService } from '../../services';
import type MaintenanceService from '../../features/maintenance/maintenance-service';
import type ClientInstanceService from '../../features/metrics/instance/instance-service';
+import type { IFlagResolver } from '../../types';
class ConfigController extends Controller {
private versionService: VersionService;
@@ -38,8 +39,12 @@ class ConfigController extends Controller {
private clientInstanceService: ClientInstanceService;
+ private sessionService: SessionService;
+
private maintenanceService: MaintenanceService;
+ private flagResolver: IFlagResolver;
+
private readonly openApiService: OpenApiService;
constructor(
@@ -52,6 +57,7 @@ class ConfigController extends Controller {
frontendApiService,
maintenanceService,
clientInstanceService,
+ sessionService,
}: Pick<
IUnleashServices,
| 'versionService'
@@ -61,6 +67,7 @@ class ConfigController extends Controller {
| 'frontendApiService'
| 'maintenanceService'
| 'clientInstanceService'
+ | 'sessionService'
>,
) {
super(config);
@@ -71,6 +78,8 @@ class ConfigController extends Controller {
this.frontendApiService = frontendApiService;
this.maintenanceService = maintenanceService;
this.clientInstanceService = clientInstanceService;
+ this.sessionService = sessionService;
+ this.flagResolver = config.flagResolver;
this.route({
method: 'get',
path: '',
@@ -113,14 +122,24 @@ class ConfigController extends Controller {
req: AuthedRequest,
res: Response,
): Promise {
- const [frontendSettings, simpleAuthSettings, maintenanceMode] =
- await Promise.all([
- this.frontendApiService.getFrontendSettings(false),
- this.settingService.get(
- simpleAuthSettingsKey,
- ),
- this.maintenanceService.isMaintenanceMode(),
- ]);
+ const getMaxSessionsCount = async () => {
+ if (this.flagResolver.isEnabled('showUserDeviceCount')) {
+ return this.sessionService.getMaxSessionsCount();
+ }
+ return 0;
+ };
+
+ const [
+ frontendSettings,
+ simpleAuthSettings,
+ maintenanceMode,
+ maxSessionsCount,
+ ] = await Promise.all([
+ this.frontendApiService.getFrontendSettings(false),
+ this.settingService.get(simpleAuthSettingsKey),
+ this.maintenanceService.isMaintenanceMode(),
+ getMaxSessionsCount(),
+ ]);
const disablePasswordAuth =
simpleAuthSettings?.disabled ||
@@ -153,6 +172,7 @@ class ConfigController extends Controller {
maintenanceMode,
feedbackUriPath: this.config.feedbackUriPath,
unleashAIAvailable: this.config.openAIAPIKey !== undefined,
+ maxSessionsCount,
};
this.openApiService.respondWithValidation(
diff --git a/src/lib/services/session-service.ts b/src/lib/services/session-service.ts
index 283796f60a..162b7c1899 100644
--- a/src/lib/services/session-service.ts
+++ b/src/lib/services/session-service.ts
@@ -2,12 +2,14 @@ import type { IUnleashStores } from '../types/stores';
import type { IUnleashConfig } from '../types/option';
import type { Logger } from '../logger';
import type { ISession, ISessionStore } from '../types/stores/session-store';
-import { compareDesc } from 'date-fns';
+import { compareDesc, minutesToMilliseconds } from 'date-fns';
+import memoizee from 'memoizee';
export default class SessionService {
private logger: Logger;
private sessionStore: ISessionStore;
+ private resolveMaxSessions: () => Promise;
constructor(
{ sessionStore }: Pick,
@@ -15,6 +17,14 @@ export default class SessionService {
) {
this.logger = getLogger('lib/services/session-service.ts');
this.sessionStore = sessionStore;
+
+ this.resolveMaxSessions = memoizee(
+ async () => await this.sessionStore.getMaxSessionsCount(),
+ {
+ promise: true,
+ maxAge: minutesToMilliseconds(1),
+ },
+ );
}
async getActiveSessions(): Promise {
@@ -69,6 +79,10 @@ export default class SessionService {
),
);
}
+
+ async getMaxSessionsCount() {
+ return this.resolveMaxSessions();
+ }
}
module.exports = SessionService;
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index 2d4910e9ac..4ae809d839 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -282,6 +282,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_ENTERPRISE_PAYG,
false,
),
+ showUserDeviceCount: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT,
+ false,
+ ),
simplifyProjectOverview: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW,
false,
diff --git a/src/lib/types/stores/session-store.ts b/src/lib/types/stores/session-store.ts
index 307e93b3fd..f912c212ff 100644
--- a/src/lib/types/stores/session-store.ts
+++ b/src/lib/types/stores/session-store.ts
@@ -13,4 +13,5 @@ export interface ISessionStore extends Store {
deleteSessionsForUser(userId: number): Promise;
insertSession(data: Omit): Promise;
getSessionsCount(): Promise<{ userId: number; count: number }[]>;
+ getMaxSessionsCount(): Promise;
}
diff --git a/src/test/e2e/api/admin/config.e2e.test.ts b/src/test/e2e/api/admin/config.e2e.test.ts
index 9d4f6f3680..c5ed318d50 100644
--- a/src/test/e2e/api/admin/config.e2e.test.ts
+++ b/src/test/e2e/api/admin/config.e2e.test.ts
@@ -5,13 +5,14 @@ import {
} from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
import { simpleAuthSettingsKey } from '../../../../lib/types/settings/simple-auth-settings';
-import { TEST_AUDIT_USER } from '../../../../lib/types';
+import { RoleName, TEST_AUDIT_USER } from '../../../../lib/types';
+import { addDays, minutesToMilliseconds } from 'date-fns';
let db: ITestDb;
let app: IUnleashTest;
-
beforeAll(async () => {
db = await dbInit('config_api_serial', getLogger);
+
app = await setupAppWithCustomConfig(
db.stores,
{
@@ -98,3 +99,72 @@ test('sets ui config with frontendSettings', async () => {
expect(res.body.frontendApiOrigins).toEqual(frontendApiOrigins),
);
});
+
+describe('maxSessionsCount', () => {
+ beforeEach(async () => {
+ // prevent memoization of session count
+ await app?.destroy();
+ app = await setupAppWithCustomConfig(
+ db.stores,
+ {
+ experimental: {
+ flags: {
+ strictSchemaValidation: true,
+ showUserDeviceCount: true,
+ },
+ },
+ },
+ db.rawDatabase,
+ );
+ });
+
+ test('should return max sessions count', async () => {
+ const { body: noLoggedInUsers } = await app.request
+ .get(`/api/admin/ui-config`)
+ .expect('Content-Type', /json/)
+ .expect(200);
+
+ expect(noLoggedInUsers.maxSessionsCount).toEqual(0);
+ });
+
+ test('should count number of session per user', async () => {
+ const email = 'user@getunleash.io';
+
+ const adminRole = (await db.stores.roleStore.getRootRoles()).find(
+ (r) => r.name === RoleName.ADMIN,
+ )!;
+ const user = await app.services.userService.createUser(
+ {
+ email,
+ password: 'test password',
+ rootRole: adminRole.id,
+ },
+ TEST_AUDIT_USER,
+ );
+
+ const userSession = (index: number) => ({
+ sid: `sid${index}`,
+ sess: {
+ cookie: {
+ originalMaxAge: minutesToMilliseconds(48),
+ expires: addDays(Date.now(), 1).toDateString(),
+ secure: false,
+ httpOnly: true,
+ path: '/',
+ },
+ user,
+ },
+ });
+
+ for (let i = 0; i < 5; i++) {
+ await app.services.sessionService.insertSession(userSession(i));
+ }
+
+ const { body: withSessions } = await app.request
+ .get(`/api/admin/ui-config`)
+ .expect('Content-Type', /json/)
+ .expect(200);
+
+ expect(withSessions.maxSessionsCount).toEqual(5);
+ });
+});
diff --git a/src/test/fixtures/fake-session-store.ts b/src/test/fixtures/fake-session-store.ts
index 6882c17335..f28bac3c6e 100644
--- a/src/test/fixtures/fake-session-store.ts
+++ b/src/test/fixtures/fake-session-store.ts
@@ -56,4 +56,8 @@ export default class FakeSessionStore implements ISessionStore {
async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
return [];
}
+
+ async getMaxSessionsCount(): Promise {
+ return 0;
+ }
}