Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-2200 Move logout to nest #5278

Merged
merged 5 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
Loading