diff --git a/apps/services/license-api/src/app/modules/license/test/license.service.spec.ts b/apps/services/license-api/src/app/modules/license/test/license.service.spec.ts index 5933d9cb0a92..0e13cdc8e653 100644 --- a/apps/services/license-api/src/app/modules/license/test/license.service.spec.ts +++ b/apps/services/license-api/src/app/modules/license/test/license.service.spec.ts @@ -33,7 +33,6 @@ import { Cache } from 'cache-manager' import * as faker from 'faker' import ShortUniqueId from 'short-unique-id' -import { BARCODE_EXPIRE_TIME_IN_SEC } from '@island.is/services/license' import { VerifyInputData } from '../dto/verifyLicense.input' import { LicenseService } from '../license.service' import { @@ -226,6 +225,7 @@ export class MockUpdateClient extends BaseLicenseUpdateClient { describe('LicenseService', () => { let licenseService: LicenseService let barcodeService: BarcodeService + let config: ConfigType beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -283,6 +283,8 @@ describe('LicenseService', () => { return new BarcodeService( { barcodeSecretKey: 'secret', + barcodeExpireTimeInSec: 60, + barcodeSessionExpireTimeInSec: 3600, } as ConfigType, cacheStore as unknown as Cache, ) @@ -294,6 +296,7 @@ describe('LicenseService', () => { licenseService = moduleRef.get(LicenseService) barcodeService = moduleRef.get(BarcodeService) + config = moduleRef.get(LicenseConfig.KEY) }) describe.each(licenseIds)('given %s license type id', (licenseId) => { @@ -376,7 +379,7 @@ describe('LicenseService', () => { await barcodeService.setCache(code, data) // Let the token expire - jest.advanceTimersByTime(BARCODE_EXPIRE_TIME_IN_SEC * 1000) + jest.advanceTimersByTime(config.barcodeExpireTimeInSec * 1000) // Assert const result = await licenseService.verifyLicense({ diff --git a/libs/api/domains/license-service/src/lib/licenseService.service.ts b/libs/api/domains/license-service/src/lib/licenseService.service.ts index 404777a2cf97..5322dd7af1aa 100644 --- a/libs/api/domains/license-service/src/lib/licenseService.service.ts +++ b/libs/api/domains/license-service/src/lib/licenseService.service.ts @@ -48,6 +48,8 @@ import { TOKEN_EXPIRED_ERROR, } from '@island.is/services/license' import { UserAgent } from '@island.is/nest/core' +import { ProblemError } from '@island.is/nest/problem' +import { ProblemType } from '@island.is/shared/problem' const LOG_CATEGORY = 'license-service' @@ -469,6 +471,34 @@ export class LicenseService { ) } + getBarcodeSessionKey(licenseType: LicenseType, sub: string) { + return `${licenseType}-${sub}` + } + + async checkBarcodeSession( + barcodeSessionKey: string | undefined, + user: User, + licenseType: LicenseType, + ) { + if (barcodeSessionKey) { + const activeBarcodeSession = await this.barcodeService.getSessionCache( + barcodeSessionKey, + ) + + if (activeBarcodeSession && activeBarcodeSession !== user.sid) { + // If the user has an active session for the license type, we should not create a new barcode + this.logger.info('User has an active session for license', { + licenseType, + }) + + throw new ProblemError({ + type: ProblemType.BAD_SESSION, + title: `User has an active session for license type: ${licenseType}`, + }) + } + } + } + async createBarcode( user: User, genericUserLicense: GenericUserLicense, @@ -478,6 +508,12 @@ export class LicenseService { const licenseType = this.mapLicenseType(genericUserLicenseType) const client = await this.getClient(licenseType) + const barcodeSessionKey = user.sub + ? this.getBarcodeSessionKey(licenseType, user.sub) + : undefined + + await this.checkBarcodeSession(barcodeSessionKey, user, licenseType) + if ( genericUserLicense.license.pkpassStatus !== GenericUserLicensePkPassStatus.Available @@ -518,6 +554,9 @@ export class LicenseService { licenseType, extraData, }), + barcodeSessionKey && + user.sid && + this.barcodeService.setSessionCache(barcodeSessionKey, user.sid), ]) return tokenPayload diff --git a/libs/auth-nest-tools/src/lib/auth.ts b/libs/auth-nest-tools/src/lib/auth.ts index 28d3e72b1b59..4cad1545de08 100644 --- a/libs/auth-nest-tools/src/lib/auth.ts +++ b/libs/auth-nest-tools/src/lib/auth.ts @@ -7,6 +7,7 @@ import { export interface Auth { sub?: string + sid?: string nationalId?: string scope: string[] authorization: string diff --git a/libs/auth-nest-tools/src/lib/current-actor.decorator.ts b/libs/auth-nest-tools/src/lib/current-actor.decorator.ts index 9d5e5e763627..e7e11d7ad8fd 100644 --- a/libs/auth-nest-tools/src/lib/current-actor.decorator.ts +++ b/libs/auth-nest-tools/src/lib/current-actor.decorator.ts @@ -25,6 +25,7 @@ export const getCurrentActor = (context: ExecutionContext): User => { : { ...user.actor, sub: user.sub, + sid: user.sid, client: user.client, authorization: user.authorization, ip: user.ip, diff --git a/libs/auth-nest-tools/src/lib/jwt.payload.ts b/libs/auth-nest-tools/src/lib/jwt.payload.ts index a06394aaeb4d..ca545c4c4cd7 100644 --- a/libs/auth-nest-tools/src/lib/jwt.payload.ts +++ b/libs/auth-nest-tools/src/lib/jwt.payload.ts @@ -10,6 +10,7 @@ export interface JwtAct { export interface JwtPayload { sub?: string + sid?: string nationalId?: string scope: string | string[] client_id: string diff --git a/libs/auth-nest-tools/src/lib/jwt.strategy.spec.ts b/libs/auth-nest-tools/src/lib/jwt.strategy.spec.ts index 096e9b09452d..ea8cfe82f1ce 100644 --- a/libs/auth-nest-tools/src/lib/jwt.strategy.spec.ts +++ b/libs/auth-nest-tools/src/lib/jwt.strategy.spec.ts @@ -24,6 +24,8 @@ describe('JwtStrategy#validate', () => { fakePayload = { nationalId: '1234567890', + sub: 'sub', + sid: 'sid', scope: ['test-scope-1'], client_id: 'test-client', delegationType: [AuthDelegationType.Custom], diff --git a/libs/auth-nest-tools/src/lib/jwt.strategy.ts b/libs/auth-nest-tools/src/lib/jwt.strategy.ts index 2e347305e31f..71f23a5504b2 100644 --- a/libs/auth-nest-tools/src/lib/jwt.strategy.ts +++ b/libs/auth-nest-tools/src/lib/jwt.strategy.ts @@ -55,6 +55,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return { sub: payload.sub, + sid: payload.sid, nationalId: payload.nationalId, scope: this.parseScopes(payload.scope), client: payload.client_id, diff --git a/libs/services/license/src/index.ts b/libs/services/license/src/index.ts index 45c92e1194ba..c56b1920d075 100644 --- a/libs/services/license/src/index.ts +++ b/libs/services/license/src/index.ts @@ -4,7 +4,6 @@ export { BarcodeData, LicenseTokenData, TOKEN_EXPIRED_ERROR, - BARCODE_EXPIRE_TIME_IN_SEC, } from './lib/barcode.service' export { LicenseConfig } from './lib/license.config' export { LICENSE_SERVICE_CACHE_MANAGER_PROVIDER } from './lib/licenseCache.provider' diff --git a/libs/services/license/src/lib/barcode.service.ts b/libs/services/license/src/lib/barcode.service.ts index 97b3563a4f88..3fb6f7c9fe45 100644 --- a/libs/services/license/src/lib/barcode.service.ts +++ b/libs/services/license/src/lib/barcode.service.ts @@ -4,13 +4,14 @@ import { } from '@island.is/clients/license-client' import { ConfigType } from '@nestjs/config' import { Inject, Injectable } from '@nestjs/common' -import { Cache as CacheManager } from 'cache-manager' +import { Cache as CacheManager, Milliseconds } from 'cache-manager' import { sign, VerifyOptions, verify } from 'jsonwebtoken' import { LICENSE_SERVICE_CACHE_MANAGER_PROVIDER } from './licenseCache.provider' import { LicenseConfig } from './license.config' -export const BARCODE_EXPIRE_TIME_IN_SEC = 60 export const TOKEN_EXPIRED_ERROR = 'TokenExpiredError' +export const BARCODE_ACTIVE_SESSION_KEY = 'activeSession' + /** * License token data used to generate a license token * The reason for the one letter fields is to keep the token as small as possible, since it will be used to generate barcodes @@ -65,7 +66,8 @@ export class BarcodeService { expiresIn: number }> { // jsonwebtoken uses seconds for expiration time - const exp = Math.floor(Date.now() / 1000) + BARCODE_EXPIRE_TIME_IN_SEC + const exp = + Math.floor(Date.now() / 1000) + this.config.barcodeExpireTimeInSec return new Promise((resolve, reject) => sign( @@ -82,7 +84,7 @@ export class BarcodeService { return resolve({ token: encoded, - expiresIn: BARCODE_EXPIRE_TIME_IN_SEC, + expiresIn: this.config.barcodeExpireTimeInSec, }) }, ), @@ -93,7 +95,23 @@ export class BarcodeService { key: string, value: BarcodeData, ) { - return this.cacheManager.set(key, value, BARCODE_EXPIRE_TIME_IN_SEC * 1000) + return this.cacheManager.set( + key, + value, + this.config.barcodeExpireTimeInSec * 1000, + ) + } + + async setSessionCache(key: string, value: string) { + return this.cacheManager.set( + `${BARCODE_ACTIVE_SESSION_KEY}:${key}`, + value, + this.config.barcodeSessionExpireTimeInSec * 1000, + ) + } + + async getSessionCache(key: string): Promise { + return this.cacheManager.get(`${BARCODE_ACTIVE_SESSION_KEY}:${key}`) } async getCache( diff --git a/libs/services/license/src/lib/license.config.ts b/libs/services/license/src/lib/license.config.ts index 44d3fce72661..cdf839e5a456 100644 --- a/libs/services/license/src/lib/license.config.ts +++ b/libs/services/license/src/lib/license.config.ts @@ -5,6 +5,8 @@ export const DEFAULT_CACHE_TTL = 1 * 1000 // 1 minute const LicenseServiceConfigSchema = z.object({ barcodeSecretKey: z.string(), + barcodeExpireTimeInSec: z.number(), + barcodeSessionExpireTimeInSec: z.number(), redis: z.object({ nodes: z.array(z.string()), ssl: z.boolean(), @@ -17,6 +19,10 @@ export const LicenseConfig = defineConfig({ schema: LicenseServiceConfigSchema, load: (env) => ({ barcodeSecretKey: env.required('LICENSE_SERVICE_BARCODE_SECRET_KEY', ''), + barcodeExpireTimeInSec: + env.optionalJSON('BARCODE_EXPIRE_TIME_IN_SEC') ?? 60, + barcodeSessionExpireTimeInSec: + env.optionalJSON('BARCODE_SESSION_EXPIRE_TIME_IN_SEC') ?? 3600, redis: { nodes: env.requiredJSON('LICENSE_SERVICE_REDIS_NODES', [ 'localhost:7000', diff --git a/libs/shared/problem/src/Problem.ts b/libs/shared/problem/src/Problem.ts index e923030cad99..9a9a0f8f6abd 100644 --- a/libs/shared/problem/src/Problem.ts +++ b/libs/shared/problem/src/Problem.ts @@ -5,6 +5,7 @@ import { BadSubjectProblem, TemplateApiErrorProblem, AttemptFailedProblem, + BadSessionProblem, } from './problems' export type Problem = @@ -14,3 +15,4 @@ export type Problem = | BadSubjectProblem | TemplateApiErrorProblem | AttemptFailedProblem + | BadSessionProblem diff --git a/libs/shared/problem/src/ProblemType.ts b/libs/shared/problem/src/ProblemType.ts index 0e983c2d212f..5e96d9dad498 100644 --- a/libs/shared/problem/src/ProblemType.ts +++ b/libs/shared/problem/src/ProblemType.ts @@ -9,4 +9,5 @@ export enum ProblemType { BAD_SUBJECT = 'https://docs.devland.is/reference/problems/bad-subject', TEMPLATE_API_ERROR = 'https://docs.devland.is/reference/problems/template-api-error', ATTEMPT_FAILED = 'https://docs.devland.is/reference/problems/attempt-failed', + BAD_SESSION = 'https://docs.devland.is/reference/problems/bad-session', } diff --git a/libs/shared/problem/src/problems.ts b/libs/shared/problem/src/problems.ts index 20ea034662f6..fbd51622e57e 100644 --- a/libs/shared/problem/src/problems.ts +++ b/libs/shared/problem/src/problems.ts @@ -31,6 +31,9 @@ export interface BadSubjectProblem extends BaseProblem { type: ProblemType.BAD_SUBJECT alternativeSubjects?: AlternativeSubject[] } +export interface BadSessionProblem extends BaseProblem { + type: ProblemType.BAD_SESSION +} export interface AttemptFailedProblem extends BaseProblem { type: ProblemType.ATTEMPT_FAILED