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; + } }