diff --git a/package-lock.json b/package-lock.json index b0edd72b..3bf3ee7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.1", "license": "BUSL-1.1", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", diff --git a/package.json b/package.json index 56e58396..75c1af36 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solidxai/core", - "version": "0.1.11", + "version": "0.1.12-beta.1", "description": "This module is a NestJS module containing all the required core providers required by a Solid application", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/constants/error-messages.ts b/src/constants/error-messages.ts index 8f888d0e..43a309f8 100644 --- a/src/constants/error-messages.ts +++ b/src/constants/error-messages.ts @@ -56,6 +56,7 @@ export const ERROR_MESSAGES = { // general errors FORBIDDEN: 'Forbidden', + RESOURCE_NOT_FOUND: 'The requested resource was not found.', // database errors diff --git a/src/controllers/chatter-message.controller.ts b/src/controllers/chatter-message.controller.ts index 361b1feb..7b4951fa 100644 --- a/src/controllers/chatter-message.controller.ts +++ b/src/controllers/chatter-message.controller.ts @@ -20,12 +20,12 @@ enum ShowSoftDeleted { export class ChatterMessageController { constructor(private readonly service: ChatterMessageService) {} - @ApiBearerAuth("jwt") - @Post() - @UseInterceptors(AnyFilesInterceptor()) - create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array) { - return this.service.create(createDto, files); - } + // @ApiBearerAuth("jwt") + // @Post() + // @UseInterceptors(AnyFilesInterceptor()) + // create(@Body() createDto: CreateChatterMessageDto, @UploadedFiles() files: Array) { + // return this.service.create(createDto, files); + // } // @ApiBearerAuth("jwt") // @Post('/bulk') @@ -61,7 +61,7 @@ export class ChatterMessageController { // return this.service.recover(id); // } - @Public() + @ApiBearerAuth("jwt") @Get('/getChatterMessages/:entityId/:entityName') @ApiQuery({ name: 'showSoftDeleted', required: false, enum: ShowSoftDeleted }) @ApiQuery({ name: 'limit', required: false, type: Number }) diff --git a/src/guards/access-token.guard.ts b/src/guards/access-token.guard.ts index 9e7949a1..b478a60a 100755 --- a/src/guards/access-token.guard.ts +++ b/src/guards/access-token.guard.ts @@ -2,23 +2,26 @@ import type { SolidCoreSetting } from "src/services/settings/default-settings-pr import { CanActivate, ExecutionContext, - Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { ERROR_MESSAGES } from "src/constants/error-messages"; import { ActiveUserData } from '../interfaces/active-user-data.interface'; import { REQUEST_USER_KEY } from "../constants"; import { PermissionMetadataService } from '../services/permission-metadata.service'; import { ClsService } from 'nestjs-cls'; +import { ActiveSessionStorageService } from "../services/active-session-storage.service"; import { SettingService } from '../services/setting.service'; +import { createHash } from "crypto"; @Injectable() export class AccessTokenGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly permissionsService: PermissionMetadataService, + private readonly activeSessionStorage: ActiveSessionStorageService, private readonly settingService: SettingService, private readonly cls: ClsService ) { } @@ -44,6 +47,9 @@ export class AccessTokenGuard implements CanActivate { jwtConfiguration ); + // Prevent Concurrent Login Feature + await this.validateConcurrentLoginSession(payload, token); + // Load permissions given the user. const permissions = await this.permissionsService.findAllUsingRoles(payload.roles); payload.permissions = permissions.map((permission) => permission.name); @@ -52,8 +58,10 @@ export class AccessTokenGuard implements CanActivate { this.cls.set(REQUEST_USER_KEY, payload); // console.log(`About to set payload in the request user key:`); // console.log(payload); - } catch { - throw new UnauthorizedException(); + } catch (err) { + throw err instanceof UnauthorizedException + ? err + : new UnauthorizedException(); } return true; } @@ -64,4 +72,36 @@ export class AccessTokenGuard implements CanActivate { const [_, token] = request.headers.authorization?.split(' ') ?? []; return token; } + + private resolveSessionId(payload: ActiveUserData, token: string): string { + if (payload.sessionId) { + return payload.sessionId; + } + + // Legacy tokens (issued before preventConcurrentLogins was enabled) + // have no sessionId claim, so derive a stable fallback from the token. + return createHash('sha256').update(token).digest('hex'); + } + + private async validateConcurrentLoginSession(payload: ActiveUserData, token: string,): Promise { + if (!this.settingService.getConfigValue("preventConcurrentLogins",)) { + return; + } + + const activeSessionId = await this.activeSessionStorage.getActiveSession(payload.sub); + const currentSessionId = this.resolveSessionId(payload, token); + + if (!activeSessionId) { + // No active session recorded yet for this user — this is the first + // request we've seen since the feature was enabled (or storage was + // cleared). Adopt this request's session as the active one so this + // browser stays logged in going forward. + await this.activeSessionStorage.setActiveSession(payload.sub, currentSessionId,); + return; + } + + if (currentSessionId !== activeSessionId) { + throw new UnauthorizedException(ERROR_MESSAGES.SESSION_INVALID); + } + } } diff --git a/src/guards/authentication.guard.ts b/src/guards/authentication.guard.ts index d88c1fed..20d11b3d 100755 --- a/src/guards/authentication.guard.ts +++ b/src/guards/authentication.guard.ts @@ -32,6 +32,22 @@ export class AuthenticationGuard implements CanActivate { private readonly cls: ClsService, ) { } + private isGenericUnauthorizedError(error: unknown): boolean { + if (!(error instanceof UnauthorizedException)) { + return false; + } + + const response = error.getResponse(); + const message = + typeof response === 'string' + ? response + : Array.isArray((response as any)?.message) + ? (response as any).message[0] + : (response as any)?.message; + + return !message || message === 'Unauthorized'; + } + async canActivate(context: ExecutionContext): Promise { // If method marked as public, then we return with true, else go ahead and apply the access token guard. const contextLog = context.getHandler(); @@ -72,7 +88,12 @@ export class AuthenticationGuard implements CanActivate { const canActivate = await Promise.resolve( instance.canActivate(context), ).catch((err) => { - error = err; + if ( + this.isGenericUnauthorizedError(error) || + !this.isGenericUnauthorizedError(err) + ) { + error = err; + } }); if (canActivate) { diff --git a/src/helpers/bootstrap.helper.ts b/src/helpers/bootstrap.helper.ts index cd06ba7e..3b78b568 100644 --- a/src/helpers/bootstrap.helper.ts +++ b/src/helpers/bootstrap.helper.ts @@ -1,5 +1,5 @@ -import { ValidationPipe } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; +import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; import { WsAdapter } from '@nestjs/platform-ws'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NextFunction, Request, Response } from 'express'; @@ -169,7 +169,13 @@ export async function bootstrapSolidApp( } // Global interceptor - app.useGlobalInterceptors(new WrapResponseInterceptor()); + app.useGlobalInterceptors( + new ClassSerializerInterceptor(app.get(Reflector), { + enableImplicitConversion: true, // optional but handy + // groups: ['client'], // if you use groups + }), + new WrapResponseInterceptor() + ); // CORS app.enableCors(buildDefaultCorsOptions()); diff --git a/src/helpers/solid-core-error-codes-provider.service.ts b/src/helpers/solid-core-error-codes-provider.service.ts index 84f6a24a..18fc14c3 100644 --- a/src/helpers/solid-core-error-codes-provider.service.ts +++ b/src/helpers/solid-core-error-codes-provider.service.ts @@ -1,5 +1,6 @@ // src/common/errors/providers/solidcore-error-code.provider.ts import { Injectable } from '@nestjs/common'; +import { ERROR_MESSAGES } from 'src/constants/error-messages'; import { ErrorCodeProvider } from 'src/decorators/error-codes-provider.decorator'; import { ErrorMeta, ErrorRule, IErrorCodeProvider } from 'src/interfaces'; @@ -13,6 +14,24 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { rules(): ReadonlyArray { return [ + { + code: 'solidx-session-invalid', + priority: 110, + match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_INVALID.toLowerCase()), + meta: { + message: ERROR_MESSAGES.SESSION_INVALID, + httpStatus: 401, + }, + }, + { + code: 'solidx-session-expired', + priority: 110, + match: (txt) => txt.includes(ERROR_MESSAGES.SESSION_EXPIRED.toLowerCase()), + meta: { + message: ERROR_MESSAGES.SESSION_EXPIRED, + httpStatus: 401, + }, + }, { code: 'solidx-mcp-server-unavailable', priority: 100, @@ -24,6 +43,15 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { httpStatus: 503, }, }, + { + code: 'solidx-resource-not-found', + priority: 95, + match: (txt) => txt.includes('enoent') && txt.includes('no such file or directory'), + meta: { + message: ERROR_MESSAGES.RESOURCE_NOT_FOUND, + httpStatus: 404, + }, + }, { code: 'solidx-db-duplicate-key', priority: 90, @@ -60,4 +88,4 @@ export class SolidCoreErrorCodesProvider implements IErrorCodeProvider { const rule = this.rules().find((r) => r.code === code); return rule?.meta; } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index ca2d4d36..9de23ac7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from './commands/run-tests.command' export * from './commands/test.command' export * from './config/cache.options' +export * from './services/active-session-storage.service' export * from './decorators/active-user.decorator' export * from './decorators/solid-request-context.decorator' diff --git a/src/interfaces/active-user-data.interface.ts b/src/interfaces/active-user-data.interface.ts index 7882f980..34bb8d1f 100755 --- a/src/interfaces/active-user-data.interface.ts +++ b/src/interfaces/active-user-data.interface.ts @@ -16,6 +16,12 @@ export interface ActiveUserData { */ email: string; + /** + * Logical login session identifier used to invalidate older sessions when + * concurrent logins are disabled. + */ + sessionId?: string; + /** * The subject's (user) roles. * These are part of the JWT token, we simply decode them. diff --git a/src/seeders/module-metadata-seeder.service.ts b/src/seeders/module-metadata-seeder.service.ts index 669ba9df..3fe1aa96 100755 --- a/src/seeders/module-metadata-seeder.service.ts +++ b/src/seeders/module-metadata-seeder.service.ts @@ -1536,8 +1536,9 @@ export class ModuleMetadataSeederService { const { fields: fieldsMetadata, ...modelMetaDataWithoutFields } = modelMetadata; // Load and set the parent model if it exists. + let parentModel: ModelMetadata | null = null; if (modelMetadata.isChild && modelMetadata.parentModelUserKey) { - const parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, { + parentModel = await this.getModelByUserKeyCached(modelMetadata.parentModelUserKey, { moduleName: moduleMetadata.name, component: 'module-model-fields', details: `parentModel=${modelMetadata.parentModelUserKey}`, @@ -1605,7 +1606,27 @@ export class ModuleMetadataSeederService { } } - // Now that we have created fields & model update the model to stamp the userKeyField. + // If userKeyField wasn't found among this model's own fields (e.g. STI "extension" models + // whose userKeyField is a column inherited from the parent entity), look it up on the + // parent model by the same name. + if (!userKeyField && modelMetadata.isChild && parentModel && userKeyFieldName) { + const inheritedUserKeyField = await this.timeOperation('field-find-inherited-user-key', () => fieldMetadataRepo.findOne({ + where: { + model: { id: parentModel.id }, + name: userKeyFieldName, + }, + }), { + moduleName: moduleMetadata.name, + component: 'module-model-fields', + serviceCall: 'fieldMetadataRepo.findOne', + details: `model=${modelMetadata.singularName} parentModel=${modelMetadata.parentModelUserKey} field=${userKeyFieldName}`, + }); + if (inheritedUserKeyField) { + userKeyField = inheritedUserKeyField; + } + } + + // Now that we have created fields & model update the model to stamp the userKeyField. if (userKeyField) { modelMetaDataWithoutFields['userKeyField'] = userKeyField; await this.timeOperation('model-user-key-field-upsert', () => this.modelMetadataService.upsert(modelMetaDataWithoutFields), { diff --git a/src/seeders/seed-data/solid-core-metadata.json b/src/seeders/seed-data/solid-core-metadata.json index eea5f383..02fd75e2 100755 --- a/src/seeders/seed-data/solid-core-metadata.json +++ b/src/seeders/seed-data/solid-core-metadata.json @@ -5304,7 +5304,6 @@ "SavedFiltersController.update", "SavedFiltersController.insertMany", "SavedFiltersController.create", - "ChatterMessageController.create", "ChatterMessageController.getChatterMessages", "ChatterMessageController.postMessage", "ChatterMessageController.findMany", diff --git a/src/services/active-session-storage.service.ts b/src/services/active-session-storage.service.ts new file mode 100644 index 00000000..929e8e11 --- /dev/null +++ b/src/services/active-session-storage.service.ts @@ -0,0 +1,24 @@ +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable } from "@nestjs/common"; +import { Cache } from "cache-manager"; + +@Injectable() +export class ActiveSessionStorageService { + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + + async setActiveSession(userId: number, sessionId: string): Promise { + await this.cacheManager.set(this.getKey(userId), sessionId); + } + + async getActiveSession(userId: number): Promise { + return this.cacheManager.get(this.getKey(userId)); + } + + async clearActiveSession(userId: number): Promise { + await this.cacheManager.del(this.getKey(userId)); + } + + private getKey(userId: number): string { + return `active-session-${userId}`; + } +} diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts index 3785e240..67438d05 100755 --- a/src/services/authentication.service.ts +++ b/src/services/authentication.service.ts @@ -39,6 +39,7 @@ import { SignUpDto } from "../dtos/sign-up.dto"; import { User } from "../entities/user.entity"; import { EventDetails, EventType } from "../interfaces"; import { ActiveUserData } from "../interfaces/active-user-data.interface"; +import { ActiveSessionStorageService } from "./active-session-storage.service"; import { HashingService } from "./hashing.service"; import { InvalidatedRefreshTokenError, @@ -74,6 +75,7 @@ export class AuthenticationService { private readonly userRepository: UserRepository, private readonly hashingService: HashingService, private readonly jwtService: JwtService, + private readonly activeSessionStorage: ActiveSessionStorageService, private readonly refreshTokenIdsStorage: RefreshTokenIdsStorageService, private readonly httpService: HttpService, // private readonly mailService: SMTPEMailService, @@ -1711,8 +1713,16 @@ export class AuthenticationService { } async generateTokens(user: User) { + const sessionId = this.shouldPreventConcurrentLogins() + ? randomUUID() + : undefined; + if (sessionId) { + await this.activeSessionStorage.setActiveSession(user.id, sessionId); + } else { + await this.activeSessionStorage.clearActiveSession(user.id); + } const [accessToken, refreshToken] = await Promise.all([ - await this.generateAccessToken(user), + await this.generateAccessToken(user, sessionId), await this.generateRefreshToken(user), ]); @@ -1722,16 +1732,24 @@ export class AuthenticationService { }; } - async generateAccessToken(user: User) { + async generateAccessToken(user: User, sessionId?: string) { // const userRoleNames = user.roles.map((role) => role.name).join(';') const userRoleNames = user.roles.map((role) => role.name); + const resolvedSessionId = this.shouldPreventConcurrentLogins() + ? sessionId ?? (await this.activeSessionStorage.getActiveSession(user.id)) + : undefined; const accessTokenTtl = this.settingService.getConfigValue("accessTokenTtl"); const accessToken = await this.signToken>( user.id, accessTokenTtl, - { username: user.username, email: user.email, roles: userRoleNames }, + { + username: user.username, + email: user.email, + roles: userRoleNames, + ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), + }, ); return accessToken; @@ -2150,6 +2168,14 @@ export class AuthenticationService { ); } + private shouldPreventConcurrentLogins(): boolean { + return ( + this.settingService.getConfigValue( + "preventConcurrentLogins", + ) === true + ); + } + private checkAccountBlocked(user: User): void { const maxFailedAttempts = this.settingService.getConfigValue( @@ -2206,6 +2232,7 @@ export class AuthenticationService { const userId = payload.sub; await this.refreshTokenIdsStorage.invalidate(userId); + await this.activeSessionStorage.clearActiveSession(userId); const user = await this.userRepository.findOne({ where: { id: userId, diff --git a/src/services/chatter-message.service.ts b/src/services/chatter-message.service.ts index 790c0184..34cc597e 100644 --- a/src/services/chatter-message.service.ts +++ b/src/services/chatter-message.service.ts @@ -23,6 +23,7 @@ import { ChatterMessage } from '../entities/chatter-message.entity'; import { getMediaStorageProvider } from './mediaStorageProviders'; import { RequestContextService } from './request-context.service'; import { Logger } from '@nestjs/common'; +import { SolidIntrospectService } from './solid-introspect.service'; @Injectable() export class ChatterMessageService extends CRUDService { @@ -50,6 +51,26 @@ export class ChatterMessageService extends CRUDService { super(entityManager, repo, 'chatterMessage', 'solid-core', moduleRef); } + private getCoModelService(coModelName: string): CRUDService { + const introspectService = this.moduleRef.get(SolidIntrospectService, { strict: false }); + const modelSingularName = lowerFirst(coModelName); + const coModelService = introspectService?.getCRUDService(modelSingularName); + if (!coModelService) { + throw new BadRequestException(ERROR_MESSAGES.MODEL_SERVICE_NOT_FOUND(modelSingularName)); + } + return coModelService; + } + + private async assertRecordAccess(coModelName: string, coModelEntityId: number) { + const activeUser = this.requestContextService.getActiveUser(); + if (!activeUser) { + throw new ForbiddenException(ERROR_MESSAGES.FORBIDDEN); + } + + const coModelService = this.getCoModelService(coModelName); + await coModelService.findOne(coModelEntityId, {}, { activeUser }); + } + private resolveMessageUserId(userId?: number | null): number | null { if (userId) { return userId; @@ -96,6 +117,8 @@ export class ChatterMessageService extends CRUDService { throw new NotFoundException(`Entity [solid-core.chatterMessage] with id ${id} not found`); } + await this.assertRecordAccess(message.coModelName, message.coModelEntityId); + message.status = CHATTER_MESSAGE_STATUS.COMPLETED; return this.repo.save(message); } @@ -111,6 +134,8 @@ export class ChatterMessageService extends CRUDService { throw new NotFoundException(`Entity [solid-core.chatterMessage] with id ${id} not found`); } + await this.assertRecordAccess(message.coModelName, message.coModelEntityId); + if (!this.isEditableCustomNoteMessage(message)) { throw new BadRequestException('Only custom note messages can be edited.'); } @@ -193,17 +218,20 @@ export class ChatterMessageService extends CRUDService { } async postMessage(postDto: PostChatterMessageDto, files: Express.Multer.File[] = []) { + const coModelName = lowerFirst(postDto.coModelName); + await this.assertRecordAccess(coModelName, postDto.coModelEntityId); + const chatterMessage = new ChatterMessage(); chatterMessage.messageType = CHATTER_MESSAGE_TYPE.CUSTOM; chatterMessage.messageSubType = postDto.messageSubType || CHATTER_MESSAGE_SUBTYPE.CUSTOM; chatterMessage.status = postDto.status ?? CHATTER_MESSAGE_STATUS.PENDING; chatterMessage.messageBody = postDto.messageBody; chatterMessage.coModelEntityId = postDto.coModelEntityId; - chatterMessage.coModelName = postDto.coModelName; + chatterMessage.coModelName = coModelName; chatterMessage.modelUserKey = postDto.modelUserKey ?? null; const model = await this.modelMetadataRepo.findOne({ - where: { singularName: lowerFirst(postDto.coModelName) }, + where: { singularName: coModelName }, relations: { userKeyField: true } }); chatterMessage.modelDisplayName = model?.displayName ?? null; @@ -683,6 +711,8 @@ export class ChatterMessageService extends CRUDService { const { limit = 25, offset = 0, populate = [], populateMedia = [], filters } = query; this.logHeapUsed('getChatterMessages-start'); + await this.assertRecordAccess(lowerFirst(entityName), entityId); + const model = await this.modelMetadataRepo.findOne({ where: { singularName: entityName diff --git a/src/services/crud.service.ts b/src/services/crud.service.ts index 33d2cf84..92a2cec6 100755 --- a/src/services/crud.service.ts +++ b/src/services/crud.service.ts @@ -232,6 +232,7 @@ export class CRUDService { // Add two generic value i.e // 5. Save the entity // For media, we need to use a storage provider and save the media, then save the associated uri against the entity or media table const mergedEntity = this.repo.merge(entity, updateDto); + delete mergedEntity.updatedAt; const savedEntity = await this.repo.save(mergedEntity) as T; //FIXME: Skip the many-to-many, and instead fire differential updates and avoid loading the entire association graph for the ids diff --git a/src/services/refresh-token-ids-storage.service.ts b/src/services/refresh-token-ids-storage.service.ts index 141244d6..95b5b601 100755 --- a/src/services/refresh-token-ids-storage.service.ts +++ b/src/services/refresh-token-ids-storage.service.ts @@ -6,6 +6,11 @@ import { AuthenticationService } from './authentication.service'; // TODO: Ideally this should be in a separate file - putting this here for brevity export class InvalidatedRefreshTokenError extends Error { } +type RefreshTokenState = { + currentRefreshToken: string; + previousRefreshToken: string; +}; + @Injectable() // export class RefreshTokenIdsStorageService implements OnApplicationBootstrap, OnApplicationShutdown { export class RefreshTokenIdsStorageService { @@ -29,10 +34,7 @@ export class RefreshTokenIdsStorageService { ) { } async insert(userId: number, refreshToken: string, previousRefreshToken?: string): Promise { - // TODO: save a refresh token object with this shape {"currentRefreshToken": "", "previousRefreshToken": ""} - // Save a refresh token object with the shape: { currentRefreshToken: string, previousRefreshToken: string } - const existing = (await this.cacheManager.get(this.getKey(userId))) as { currentRefreshToken?: string, previousRefreshToken?: string } | undefined; - const refreshTokenState = { + const refreshTokenState: RefreshTokenState = { currentRefreshToken: refreshToken, previousRefreshToken: previousRefreshToken ?? "", }; @@ -58,8 +60,7 @@ export class RefreshTokenIdsStorageService { // TODO: Assume you get this shape out of the cache {"currentRefreshToken": "", "previousRefreshToken": ""} // Then you will compare against the currentRefreshToken. - const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)); - console.log("refreshTokenState", refreshTokenState); + const refreshTokenState = await this.cacheManager.get(this.getKey(user.id)) as RefreshTokenState | undefined; // Use the authentication service to generate a new refresh token, set it in the currentRefreshToken in scenario 1 and return. @@ -108,7 +109,7 @@ export class RefreshTokenIdsStorageService { valid = true; // Do not modify cache // Generate new refresh token based on currentRefreshToken - const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as { currentRefreshToken?: string, previousRefreshToken?: string } | undefined; + const existingRefreshTokenState = (await this.cacheManager.get(this.getKey(user.id))) as RefreshTokenState | undefined; newRefreshToken = existingRefreshTokenState?.currentRefreshToken; } } @@ -126,7 +127,7 @@ export class RefreshTokenIdsStorageService { return `user-${userId}`; } - getCurrentRefreshTokenState(userId: number): Promise { + getCurrentRefreshTokenState(userId: number): Promise { return this.cacheManager.get(this.getKey(userId)); } } diff --git a/src/services/settings/default-settings-provider.service.ts b/src/services/settings/default-settings-provider.service.ts index 9fd6e466..6db0fc41 100644 --- a/src/services/settings/default-settings-provider.service.ts +++ b/src/services/settings/default-settings-provider.service.ts @@ -1201,7 +1201,16 @@ const getSolidCoreSettings = (isProd: boolean) => value: parseInt(process.env.IAM_JWT_REFRESH_TOKEN_TTL ?? "604800", 10), level: SettingLevel.SystemEnv, }, - + { + moduleName: "solid-core", + key: "preventConcurrentLogins", + value: false, + level: SettingLevel.SystemAdminEditable, + label: "Prevent Concurrent Logins", + group: "authentication-settings", + sortOrder: 200, + controlType: "boolean", + }, // queues-settings-provider.service.ts { moduleName: "solid-core", diff --git a/src/solid-core.module.ts b/src/solid-core.module.ts index f89b3bf8..861b0883 100644 --- a/src/solid-core.module.ts +++ b/src/solid-core.module.ts @@ -153,6 +153,7 @@ import { TwilioSmsQueueSubscriberRedis } from "./jobs/redis/twilio-sms-subscribe import { UserRegistrationListener } from "./listeners/user-registration.listener"; import { GoogleOauthStrategy } from "./passport-strategies/google-oauth.strategy"; import { ApiKeyService } from "./services/api-key.service"; +import { ActiveSessionStorageService } from "./services/active-session-storage.service"; import { AuthenticationService } from "./services/authentication.service"; import { BcryptService } from "./services/bcrypt.service"; import { UuidExternalIdEntityComputedFieldProvider } from "./services/computed-fields/entity/uuid-externalid-entity-computed-field-provider.service"; @@ -647,6 +648,7 @@ import { PostgresDatasourceIntrospectionProviderService } from "./services/datas AccessTokenGuard, ApiKeyGuard, ApiKeyService, + ActiveSessionStorageService, AuthenticationService, GoogleAuthenticationController, RefreshTokenIdsStorageService,