diff --git a/apps/server/src/imports-from-feathers.ts b/apps/server/src/imports-from-feathers.ts index 0804901a53b..57cf4ad7ea8 100644 --- a/apps/server/src/imports-from-feathers.ts +++ b/apps/server/src/imports-from-feathers.ts @@ -4,5 +4,13 @@ export { addTokenToWhitelist, createRedisIdentifierFromJwtData, ensureTokenIsWhitelisted, + getRedisData, } from '../../../src/services/authentication/logic/whitelist.js'; export * as feathersRedis from '../../../src/utils/redis.js'; +export type JwtRedisData = { + IP: string; + Browser: string; + Device: string; + privateDevice: boolean; + expirationInSeconds: number; +}; diff --git a/apps/server/src/modules/authentication/authentication-api.module.ts b/apps/server/src/modules/authentication/authentication-api.module.ts index 6780e5fe11c..07780ddbe4c 100644 --- a/apps/server/src/modules/authentication/authentication-api.module.ts +++ b/apps/server/src/modules/authentication/authentication-api.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthenticationModule } from './authentication.module'; -import { LoginController } from './controllers/login.controller'; -import { LoginUc } from './uc/login.uc'; +import { LoginController, LogoutController } from './controllers'; +import { LoginUc, LogoutUc } from './uc'; @Module({ imports: [AuthenticationModule], - providers: [LoginUc], - controllers: [LoginController], - exports: [], + providers: [LoginUc, LogoutUc], + controllers: [LoginController, LogoutController], }) export class AuthenticationApiModule {} diff --git a/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts new file mode 100644 index 00000000000..0ea7fd6bd2b --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts @@ -0,0 +1,72 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Cache } from 'cache-manager'; +import { Response } from 'supertest'; + +describe('Logout Controller (api)', () => { + const baseRouteName = '/logout'; + + let app: INestApplication; + let em: EntityManager; + let cacheManager: Cache; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + cacheManager = app.get(CACHE_MANAGER); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('logout', () => { + describe('when a valid jwt is provided', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { + loggedInClient, + studentAccount, + }; + }; + + it('should log out the user', async () => { + const { loggedInClient, studentAccount } = await setup(); + + const response: Response = await loggedInClient.post(''); + + expect(response.status).toEqual(HttpStatus.OK); + expect(await cacheManager.store.keys(`jwt:${studentAccount.id}:*`)).toHaveLength(0); + }); + }); + + describe('when the user is not logged in', () => { + it('should return unauthorized', async () => { + const response: Response = await testApiClient.post(''); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/controllers/index.ts b/apps/server/src/modules/authentication/controllers/index.ts new file mode 100644 index 00000000000..94b9a1dc3aa --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/index.ts @@ -0,0 +1,2 @@ +export { LogoutController } from './logout.controller'; +export { LoginController } from './login.controller'; diff --git a/apps/server/src/modules/authentication/controllers/logout.controller.ts b/apps/server/src/modules/authentication/controllers/logout.controller.ts new file mode 100644 index 00000000000..a483a9a1bec --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/logout.controller.ts @@ -0,0 +1,20 @@ +import { JWT, JwtAuthentication } from '@infra/auth-guard'; +import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse } from '@nestjs/swagger'; +import { LogoutUc } from '../uc'; + +@ApiTags('Authentication') +@Controller('logout') +export class LogoutController { + constructor(private readonly logoutUc: LogoutUc) {} + + @JwtAuthentication() + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Logs out a user.' }) + @ApiOkResponse({ description: 'Logout was successful.' }) + @ApiUnauthorizedResponse({ description: 'There has been an error while logging out.' }) + async logout(@JWT() jwt: string): Promise { + await this.logoutUc.logout(jwt); + } +} diff --git a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts index 2d2662e1dde..471584ea96c 100644 --- a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts @@ -1,47 +1,30 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { JwtValidationAdapter } from '@infra/auth-guard/'; -import { CacheService } from '@infra/cache'; -import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test, TestingModule } from '@nestjs/testing'; -import { feathersRedis } from '@src/imports-from-feathers'; import { Cache } from 'cache-manager'; import { JwtWhitelistAdapter } from './jwt-whitelist.adapter'; -import RedisMock = require('../../../../../../test/utils/redis/redisMock'); describe('jwt strategy', () => { let module: TestingModule; let jwtWhitelistAdapter: JwtWhitelistAdapter; - let jwtValidationAdapter: JwtValidationAdapter; let cacheManager: DeepMocked; - let cacheService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ - JwtValidationAdapter, JwtWhitelistAdapter, { provide: CACHE_MANAGER, useValue: createMock(), }, - { - provide: CacheService, - useValue: createMock(), - }, ], }).compile(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - const redisClientMock = new RedisMock(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - feathersRedis.setRedisClient(redisClientMock); cacheManager = module.get(CACHE_MANAGER); - cacheService = module.get(CacheService); jwtWhitelistAdapter = module.get(JwtWhitelistAdapter); - jwtValidationAdapter = module.get(JwtValidationAdapter); }); afterAll(async () => { @@ -52,41 +35,58 @@ describe('jwt strategy', () => { jest.resetAllMocks(); }); - describe('when authenticate a user with jwt', () => { - it('should fail without whitelisted jwt', async () => { - const accountId = new ObjectId().toHexString(); - const jti = new ObjectId().toHexString(); - await expect(jwtValidationAdapter.isWhitelisted(accountId, jti)).rejects.toThrow( - 'Session was expired due to inactivity - autologout.' - ); - }); - it('should pass when jwt has been whitelisted', async () => { - const accountId = new ObjectId().toHexString(); - const jti = new ObjectId().toHexString(); - await jwtWhitelistAdapter.addToWhitelist(accountId, jti); - // might fail when we would wait more than JWT_TIMEOUT_SECONDS - await jwtValidationAdapter.isWhitelisted(accountId, jti); - }); - }); + describe('addToWhitelist', () => { + describe('when adding jwt to the whitelist', () => { + const setup = () => { + const accountId = new ObjectId().toHexString(); + const jti = new ObjectId().toHexString(); + const expirationInSeconds = Configuration.get('JWT_TIMEOUT_SECONDS') as number; - describe('removeFromWhitelist is called', () => { - describe('when redis is used as cache store', () => { - it('should call the cache manager to delete the entry from the cache', async () => { - cacheService.getStoreType.mockReturnValue(CacheStoreType.REDIS); + return { + accountId, + jti, + expirationInSeconds, + }; + }; - await jwtWhitelistAdapter.removeFromWhitelist('accountId', 'jti'); + it('should call the cache manager to set the jwt from the cache', async () => { + const { accountId, jti, expirationInSeconds } = setup(); - expect(cacheManager.del).toHaveBeenCalledWith('jwt:accountId:jti'); + await jwtWhitelistAdapter.addToWhitelist(accountId, jti); + + expect(cacheManager.set).toHaveBeenCalledWith( + `jwt:${accountId}:${jti}`, + { + IP: 'NONE', + Browser: 'NONE', + Device: 'NONE', + privateDevice: false, + expirationInSeconds, + }, + expirationInSeconds * 1000 + ); }); }); + }); + + describe('removeFromWhitelist', () => { + describe('when removing a token from the whitelist', () => { + const setup = () => { + const accountId = new ObjectId().toHexString(); + const jti = new ObjectId().toHexString(); + + return { + accountId, + jti, + }; + }; - describe('when a memory store is used', () => { - it('should do nothing', async () => { - cacheService.getStoreType.mockReturnValue(CacheStoreType.MEMORY); + it('should call the cache manager to jwt the entry from the cache', async () => { + const { accountId, jti } = setup(); - await jwtWhitelistAdapter.removeFromWhitelist('accountId', 'jti'); + await jwtWhitelistAdapter.removeFromWhitelist(accountId, jti); - expect(cacheManager.del).not.toHaveBeenCalled(); + expect(cacheManager.del).toHaveBeenCalledWith(`jwt:${accountId}:${jti}`); }); }); }); diff --git a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts index 9a7b9d81f26..c7568545906 100644 --- a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts @@ -1,27 +1,23 @@ -import { CacheService } from '@infra/cache'; -import { CacheStoreType } from '@infra/cache/interface/cache-store-type.enum'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable } from '@nestjs/common'; -import { addTokenToWhitelist, createRedisIdentifierFromJwtData } from '@src/imports-from-feathers'; +import { createRedisIdentifierFromJwtData, getRedisData, JwtRedisData } from '@src/imports-from-feathers'; import { Cache } from 'cache-manager'; @Injectable() export class JwtWhitelistAdapter { - constructor( - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, - private readonly cacheService: CacheService - ) {} + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} async addToWhitelist(accountId: string, jti: string): Promise { - const redisIdentifier = createRedisIdentifierFromJwtData(accountId, jti); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await addTokenToWhitelist(redisIdentifier); + const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti); + const redisData: JwtRedisData = getRedisData({}); + const expirationInMilliseconds: number = redisData.expirationInSeconds * 1000; + + await this.cacheManager.set(redisIdentifier, redisData, expirationInMilliseconds); } async removeFromWhitelist(accountId: string, jti: string): Promise { - if (this.cacheService.getStoreType() === CacheStoreType.REDIS) { - const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti); - await this.cacheManager.del(redisIdentifier); - } + const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti); + + await this.cacheManager.del(redisIdentifier); } } diff --git a/apps/server/src/modules/authentication/uc/index.ts b/apps/server/src/modules/authentication/uc/index.ts index 8616d4505f9..bd541002fd4 100644 --- a/apps/server/src/modules/authentication/uc/index.ts +++ b/apps/server/src/modules/authentication/uc/index.ts @@ -1,2 +1,3 @@ export { LoginDto } from './dto'; export { LoginUc } from './login.uc'; +export { LogoutUc } from './logout.uc'; diff --git a/apps/server/src/modules/authentication/uc/logout.uc.spec.ts b/apps/server/src/modules/authentication/uc/logout.uc.spec.ts new file mode 100644 index 00000000000..fc06ce13cf6 --- /dev/null +++ b/apps/server/src/modules/authentication/uc/logout.uc.spec.ts @@ -0,0 +1,47 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtTestFactory } from '@shared/testing'; +import { AuthenticationService } from '../services'; +import { LogoutUc } from './logout.uc'; + +describe(LogoutUc.name, () => { + let module: TestingModule; + let logoutUc: LogoutUc; + + let authenticationService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LogoutUc, + { + provide: AuthenticationService, + useValue: createMock(), + }, + ], + }).compile(); + + logoutUc = await module.get(LogoutUc); + authenticationService = await module.get(AuthenticationService); + }); + + describe('logout', () => { + describe('when a jwt is given', () => { + const setup = () => { + const jwt = JwtTestFactory.createJwt(); + + return { + jwt, + }; + }; + + it('should remove the user from the whitelist', async () => { + const { jwt } = setup(); + + await logoutUc.logout(jwt); + + expect(authenticationService.removeJwtFromWhitelist).toHaveBeenCalledWith(jwt); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/uc/logout.uc.ts b/apps/server/src/modules/authentication/uc/logout.uc.ts new file mode 100644 index 00000000000..63c792626cb --- /dev/null +++ b/apps/server/src/modules/authentication/uc/logout.uc.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { AuthenticationService } from '../services'; + +@Injectable() +export class LogoutUc { + constructor(private readonly authenticationService: AuthenticationService) {} + + async logout(jwt: string): Promise { + await this.authenticationService.removeJwtFromWhitelist(jwt); + } +} diff --git a/apps/server/src/shared/testing/factory/jwt.test.factory.ts b/apps/server/src/shared/testing/factory/jwt.test.factory.ts index 6d63fada5c2..54658c5abeb 100644 --- a/apps/server/src/shared/testing/factory/jwt.test.factory.ts +++ b/apps/server/src/shared/testing/factory/jwt.test.factory.ts @@ -1,5 +1,5 @@ -import jwt from 'jsonwebtoken'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; +import jwt from 'jsonwebtoken'; const keyPair: KeyPairKeyObjectResult = crypto.generateKeyPairSync('rsa', { modulusLength: 4096 }); const publicKey: string | Buffer = keyPair.publicKey.export({ type: 'pkcs1', format: 'pem' }); @@ -36,6 +36,7 @@ export class JwtTestFactory { algorithm: 'RS256', } ); + return validJwt; } }