Skip to content

Commit

Permalink
Spsh 59 (#37)
Browse files Browse the repository at this point in the history
* created controllers, services for /api/login endpoint for user authentication on keycloak

* include dummy values for Schulportal in config-files

* put config for schulportal realm and client-id in existing kc-config

* adjust realm names: realm and client-id for admin renamed

* adjust base64-encoded string because kc-admin-secret variable was renamed
  • Loading branch information
DPDS93CT authored Oct 16, 2023
1 parent ea8bfd6 commit 2d94222
Show file tree
Hide file tree
Showing 24 changed files with 619 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/reusable_job_nest_test_sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
fileName: 'secrets.json'
fileDir: './config/'
# These are placeholder secrets without any significance
encodedString: ewogICAgIkRCIjogewogICAgICAgICJTRUNSRVQiOiAiVmVyeSBoaWRkZW4gc2VjcmV0IgogICAgfSwKICAgICJLRVlDTE9BSyI6IHsKICAgICAgICAiU0VDUkVUIjogIkNsaWVudCBTZWNyZXQiCiAgICB9Cn0=
encodedString: ewogICAgIkRCIjogewogICAgICAgICJTRUNSRVQiOiAiVmVyeSBoaWRkZW4gc2VjcmV0IgogICAgfSwKICAgICJLRVlDTE9BSyI6IHsKICAgICAgICAiQURNSU5fU0VDUkVUIjogIkNsaWVudCBTZWNyZXQiCiAgICB9Cn0=
- name: Setup node
uses: actions/setup-node@v3
with:
Expand Down
7 changes: 4 additions & 3 deletions config/config.dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
},
"KEYCLOAK": {
"BASE_URL": "http://127.0.0.1:8080",
"REALM_NAME": "master",
"CLIENT_ID": "admin-cli",
"SECRET": "topsecret"
"ADMIN_REALM_NAME": "master",
"ADMIN_CLIENT_ID": "admin-cli",
"REALM_NAME": "schulportal",
"CLIENT_ID": "schulportal"
}
}
6 changes: 4 additions & 2 deletions config/config.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
},
"KEYCLOAK": {
"BASE_URL": "http://127.0.0.1:8080",
"REALM_NAME": "master",
"CLIENT_ID": "admin-cli"
"ADMIN_REALM_NAME": "master",
"ADMIN_CLIENT_ID": "admin-cli",
"REALM_NAME": "schulportal",
"CLIENT_ID": "schulportal"
}
}
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@
"axios": "^1.5.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"express": "^4.18.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nest-commander": "^3.11.0",
"nest-keycloak-connect": "^1.9.2",
"openid-client": "^5.6.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0"
},
Expand Down
8 changes: 5 additions & 3 deletions src/modules/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ describe('HealthController', () => {
let entityManager: SqlEntityManager;
let httpHealthIndicator: DeepMocked<HttpHealthIndicator>;
const keycloakConfig: KeycloakConfig = {
CLIENT_ID: '',
SECRET: '',
REALM_NAME: '',
ADMIN_CLIENT_ID: '',
ADMIN_SECRET: '',
ADMIN_REALM_NAME: '',
BASE_URL: 'http://keycloak.test',
REALM_NAME: '',
CLIENT_ID: '',
};
let configService: DeepMocked<ConfigService>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class KeycloakAdministrationService {

this.kcAdminClient.setConfig({
baseUrl: this.kcConfig.BASE_URL,
realmName: this.kcConfig.REALM_NAME,
realmName: this.kcConfig.ADMIN_REALM_NAME,
});
}

Expand All @@ -45,8 +45,8 @@ export class KeycloakAdministrationService {
try {
const credentials: Credentials = {
grantType: 'client_credentials',
clientId: this.kcConfig.CLIENT_ID,
clientSecret: this.kcConfig.SECRET,
clientId: this.kcConfig.ADMIN_CLIENT_ID,
clientSecret: this.kcConfig.ADMIN_SECRET,
};

await this.kcAdminClient.auth(credentials);
Expand Down
6 changes: 6 additions & 0 deletions src/modules/ui-backend/api/keycloak-exception-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Catch } from '@nestjs/common';
import { KeycloakClientError } from '../../../shared/error/index.js';
import { UiBackendExceptionFilter } from './ui-backend-exception-filter.js';

@Catch(KeycloakClientError)
export class KeyCloakExceptionFilter extends UiBackendExceptionFilter<KeycloakClientError> {}
116 changes: 116 additions & 0 deletions src/modules/ui-backend/api/login.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoginController } from './login.controller.js';
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { LoginService } from '../domain/login.service.js';
import { faker } from '@faker-js/faker';
import { TokenSet } from 'openid-client';
import { UserParams } from './user.params.js';
import { KeycloakClientError, UserAuthenticationFailedError } from '../../../shared/error/index.js';
import { NewLoginService } from '../domain/new-login.service.js';

describe('LoginController', () => {
let module: TestingModule;
let loginController: LoginController;
let loginServiceMock: DeepMocked<LoginService>;
let someServiceMock: DeepMocked<NewLoginService>;
let tokenSet: DeepMocked<TokenSet>;

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [],
providers: [
LoginController,
{
provide: LoginService,
useValue: createMock<LoginService>(),
},
{
provide: NewLoginService,
useValue: createMock<NewLoginService>(),
},
{
provide: TokenSet,
useValue: createMock<TokenSet>(),
},
],
}).compile();
loginController = module.get(LoginController);
loginServiceMock = module.get(LoginService);
someServiceMock = module.get(NewLoginService);
tokenSet = module.get(TokenSet);
});

afterAll(async () => {
await module.close();
});

beforeEach(() => {
jest.resetAllMocks();
});

it('should be defined', () => {
expect(loginController).toBeDefined();
});

describe('when getting result from service', () => {
it('should not throw', async () => {
const userParams: UserParams = {
username: faker.string.alpha(),
password: faker.string.alpha(),
};
loginServiceMock.getTokenForUser.mockResolvedValue(tokenSet);
await expect(loginController.loginUser(userParams)).resolves.not.toThrow();
expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1);
});
});

describe('when getting KeyCloak-error from service', () => {
it('should throw', async () => {
const errorMsg: string = 'keycloak not available';
const userParams: UserParams = {
username: faker.string.alpha(),
password: faker.string.alpha(),
};
loginServiceMock.getTokenForUser.mockImplementation(() => {
throw new KeycloakClientError(errorMsg);
});
await expect(loginController.loginUser(userParams)).rejects.toThrow(errorMsg);
expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1);
});
});

describe('when getting User-authentication-failed-error from service', () => {
it('should throw', async () => {
const errorMsg: string = 'user could not be authenticated';
const userParams: UserParams = {
username: faker.string.alpha(),
password: faker.string.alpha(),
};
loginServiceMock.getTokenForUser.mockImplementation(() => {
throw new UserAuthenticationFailedError(errorMsg);
});
await expect(loginController.loginUser(userParams)).rejects.toThrow(errorMsg);
expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1);
});
});

describe('when getting User-authentication-failed-error from service', () => {
it('should throw', async () => {
const userParams: UserParams = {
username: faker.string.alpha(),
password: faker.string.alpha(),
};
someServiceMock.auth.mockResolvedValueOnce({
ok: false,
error: new UserAuthenticationFailedError('User could not be authenticated successfully.'),
});
await expect(loginController.loginUserResult(userParams)).resolves.toStrictEqual<
Result<UserAuthenticationFailedError>
>({
ok: false,
error: new UserAuthenticationFailedError('User could not be authenticated successfully.'),
});
expect(someServiceMock.auth).toHaveBeenCalledTimes(1);
});
});
});
50 changes: 50 additions & 0 deletions src/modules/ui-backend/api/login.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Body, Controller, HttpStatus, Post, UseFilters } from '@nestjs/common';
import {
ApiInternalServerErrorResponse,
ApiNotFoundResponse,
ApiServiceUnavailableResponse,
ApiTags,
} from '@nestjs/swagger';
import { UserParams } from './user.params.js';
import { LoginService } from '../domain/login.service.js';
import { TokenSet } from 'openid-client';
import { KeyCloakExceptionFilter } from './keycloak-exception-filter.js';
import { UserAuthenticationFailedExceptionFilter } from './user-authentication-failed-exception-filter.js';
import { NewLoginService } from '../domain/new-login.service.js';
import { DomainError } from '../../../shared/error/index.js';
import { Public } from 'nest-keycloak-connect';

@ApiTags('api/login')
@Controller({ path: 'login' })
export class LoginController {
public constructor(private loginService: LoginService, private someService: NewLoginService) {}

@Post()
@UseFilters(
new KeyCloakExceptionFilter(HttpStatus.SERVICE_UNAVAILABLE),
new UserAuthenticationFailedExceptionFilter(HttpStatus.NOT_FOUND),
)
@ApiNotFoundResponse({
description: 'USER_AUTHENTICATION_FAILED_ERROR: User could not be authenticated successfully.',
})
@ApiInternalServerErrorResponse({ description: 'Internal server error while retrieving token.' })
@ApiServiceUnavailableResponse({ description: 'KEYCLOAK_CLIENT_ERROR: KeyCloak service did not respond.' })
@Public()
public async loginUser(@Body() params: UserParams): Promise<TokenSet> {
return this.loginService.getTokenForUser(params.username, params.password);
}

@Post('result')
@UseFilters(
new KeyCloakExceptionFilter(HttpStatus.SERVICE_UNAVAILABLE),
new UserAuthenticationFailedExceptionFilter(HttpStatus.NOT_FOUND),
)
@ApiNotFoundResponse({
description: 'USER_AUTHENTICATION_FAILED_ERROR: User could not be authenticated successfully.',
})
@ApiInternalServerErrorResponse({ description: 'Internal server error while retrieving token.' })
@Public()
public async loginUserResult(@Body() params: UserParams): Promise<Result<string, DomainError>> {
return this.someService.auth(params.username, params.password);
}
}
Loading

0 comments on commit 2d94222

Please sign in to comment.