Skip to content

Commit

Permalink
N21-2200 Move logout to nest (#5278)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarvinOehlerkingCap authored Oct 10, 2024
1 parent ac4f35d commit 691acab
Show file tree
Hide file tree
Showing 11 changed files with 222 additions and 65 deletions.
8 changes: 8 additions & 0 deletions apps/server/src/imports-from-feathers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
2 changes: 2 additions & 0 deletions apps/server/src/modules/authentication/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { LogoutController } from './logout.controller';
export { LoginController } from './login.controller';
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.logoutUc.logout(jwt);
}
}
Original file line number Diff line number Diff line change
@@ -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<Cache>;
let cacheService: DeepMocked<CacheService>;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [
JwtValidationAdapter,
JwtWhitelistAdapter,
{
provide: CACHE_MANAGER,
useValue: createMock<Cache>(),
},
{
provide: CacheService,
useValue: createMock<CacheService>(),
},
],
}).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 () => {
Expand All @@ -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}`);
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/authentication/uc/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { LoginDto } from './dto';
export { LoginUc } from './login.uc';
export { LogoutUc } from './logout.uc';
47 changes: 47 additions & 0 deletions apps/server/src/modules/authentication/uc/logout.uc.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AuthenticationService>;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [
LogoutUc,
{
provide: AuthenticationService,
useValue: createMock<AuthenticationService>(),
},
],
}).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);
});
});
});
});
11 changes: 11 additions & 0 deletions apps/server/src/modules/authentication/uc/logout.uc.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.authenticationService.removeJwtFromWhitelist(jwt);
}
}
3 changes: 2 additions & 1 deletion apps/server/src/shared/testing/factory/jwt.test.factory.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand Down Expand Up @@ -36,6 +36,7 @@ export class JwtTestFactory {
algorithm: 'RS256',
}
);

return validJwt;
}
}

0 comments on commit 691acab

Please sign in to comment.