From c184bc996903acd12f5e2f735fe90297b644c969 Mon Sep 17 00:00:00 2001 From: "Marvin Rode (Cap)" <127723478+marode-cap@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:34:49 +0100 Subject: [PATCH] Make changes needed for frontend login (#66) --- config/config.dev.json | 6 +- dev-realm-spsh.json | 1 + .../frontend/api/frontend.controller.spec.ts | 23 ++++++-- .../frontend/api/frontend.controller.ts | 24 ++++++-- .../frontend/auth/authenticated.guard.spec.ts | 8 +-- .../frontend/auth/authenticated.guard.ts | 10 +++- src/modules/frontend/frontend-api.module.ts | 10 +++- .../outbound/provider.service.spec.ts | 58 +++++++++++++++++++ .../frontend/outbound/provider.service.ts | 20 +++++++ 9 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 src/modules/frontend/outbound/provider.service.spec.ts create mode 100644 src/modules/frontend/outbound/provider.service.ts diff --git a/config/config.dev.json b/config/config.dev.json index 69d2636f5..9b4badc39 100644 --- a/config/config.dev.json +++ b/config/config.dev.json @@ -8,9 +8,9 @@ "SESSION_SECRET": "SessionSecretForDevelopment", "SESSION_TTL_MS": 3600000, "BACKEND_ADDRESS": "http://127.0.0.1:9090", - "OIDC_CALLBACK_URL": "http://localhost:9091/api/frontend/login", - "DEFAULT_LOGIN_REDIRECT": "/", - "LOGOUT_REDIRECT": "/" + "OIDC_CALLBACK_URL": "http://localhost:8099/api/frontend/login", + "DEFAULT_LOGIN_REDIRECT": "http://localhost:8099/", + "LOGOUT_REDIRECT": "http://localhost:8099/" }, "DB": { "CLIENT_URL": "postgres://admin:password@127.0.0.1:5432", diff --git a/dev-realm-spsh.json b/dev-realm-spsh.json index a3364637b..f5df18333 100644 --- a/dev-realm-spsh.json +++ b/dev-realm-spsh.json @@ -829,6 +829,7 @@ "secret": "YDp6fYkbUcj4ZkyAOnbAHGQ9O72htc5M", "redirectUris": [ "http://localhost:9091/*", + "http://localhost:8099/*", "/*" ], "webOrigins": [ diff --git a/src/modules/frontend/api/frontend.controller.spec.ts b/src/modules/frontend/api/frontend.controller.spec.ts index f4797962f..c0f2ddb67 100644 --- a/src/modules/frontend/api/frontend.controller.spec.ts +++ b/src/modules/frontend/api/frontend.controller.spec.ts @@ -10,28 +10,30 @@ import { ConfigTestModule } from '../../../../test/utils/config-test.module.js'; import { FrontendConfig } from '../../../shared/config/frontend.config.js'; import { OIDC_CLIENT } from '../auth/oidc-client.service.js'; import { User } from '../auth/user.decorator.js'; +import { ProviderService } from '../outbound/provider.service.js'; import { FrontendController } from './frontend.controller.js'; +import { GetServiceProviderInfoDo } from '../../rolle/domain/get-service-provider-info.do.js'; describe('FrontendController', () => { let module: TestingModule; let frontendController: FrontendController; let oidcClient: DeepMocked; let frontendConfig: FrontendConfig; + let providerService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ imports: [ConfigTestModule], providers: [ FrontendController, - { - provide: OIDC_CLIENT, - useValue: createMock(), - }, + { provide: ProviderService, useValue: createMock() }, + { provide: OIDC_CLIENT, useValue: createMock() }, ], }).compile(); frontendController = module.get(FrontendController); oidcClient = module.get(OIDC_CLIENT); + providerService = module.get(ProviderService); frontendConfig = module.get(ConfigService).getOrThrow('FRONTEND'); }); @@ -179,4 +181,17 @@ describe('FrontendController', () => { expect(result).toBe(user.userinfo); }); }); + + describe('provider', () => { + it('should return providers', async () => { + const providers: GetServiceProviderInfoDo[] = [ + { id: faker.string.uuid(), name: faker.hacker.noun(), url: faker.internet.url() }, + ]; + providerService.listProviders.mockResolvedValueOnce(providers); + + const result: GetServiceProviderInfoDo[] = await frontendController.provider(createMock()); + + expect(result).toEqual(providers); + }); + }); }); diff --git a/src/modules/frontend/api/frontend.controller.ts b/src/modules/frontend/api/frontend.controller.ts index 2907da2ba..a9fe54902 100644 --- a/src/modules/frontend/api/frontend.controller.ts +++ b/src/modules/frontend/api/frontend.controller.ts @@ -1,20 +1,22 @@ -import { Controller, Get, Inject, Logger, Post, Req, Res, Session, UseGuards } from '@nestjs/common'; +import { Controller, Get, Inject, Logger, Req, Res, Session, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { - ApiForbiddenResponse, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, ApiQuery, ApiResponse, ApiTags, + ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { SessionData } from 'express-session'; import { Client, UserinfoResponse } from 'openid-client'; import { FrontendConfig, ServerConfig } from '../../../shared/config/index.js'; +import { GetServiceProviderInfoDo } from '../../rolle/domain/get-service-provider-info.do.js'; import { AuthenticatedGuard, CurrentUser, LoginGuard, OIDC_CLIENT, User } from '../auth/index.js'; +import { ProviderService } from '../outbound/provider.service.js'; import { RedirectQueryParams } from './redirect.query.params.js'; @ApiTags('frontend') @@ -26,7 +28,11 @@ export class FrontendController { private readonly logoutRedirect: string; - public constructor(configService: ConfigService, @Inject(OIDC_CLIENT) private client: Client) { + public constructor( + configService: ConfigService, + @Inject(OIDC_CLIENT) private client: Client, + private providerService: ProviderService, + ) { const frontendConfig: FrontendConfig = configService.getOrThrow('FRONTEND'); this.defaultLoginRedirect = frontendConfig.DEFAULT_LOGIN_REDIRECT; this.logoutRedirect = frontendConfig.LOGOUT_REDIRECT; @@ -43,7 +49,7 @@ export class FrontendController { res.redirect(target); } - @Post('logout') + @Get('logout') @ApiOperation({ summary: 'Used to log out the current user.' }) @ApiResponse({ status: 302, description: 'Redirect to logout.' }) @ApiInternalServerErrorResponse({ description: 'Internal server error while trying to log out.' }) @@ -81,9 +87,17 @@ export class FrontendController { @Get('logininfo') @UseGuards(AuthenticatedGuard) @ApiOperation({ summary: 'Info about logged in user.' }) - @ApiForbiddenResponse({ description: 'User is not logged in.' }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) @ApiOkResponse({ description: 'Returns info about the logged in user.' }) public info(@CurrentUser() user: User): UserinfoResponse { return user.userinfo; } + + @Get('provider') + @UseGuards(AuthenticatedGuard) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + @ApiOkResponse({ description: 'Returns the providers for the current user.' }) + public provider(@CurrentUser() user: User): Promise { + return this.providerService.listProviders(user); + } } diff --git a/src/modules/frontend/auth/authenticated.guard.spec.ts b/src/modules/frontend/auth/authenticated.guard.spec.ts index 5d8d5e77e..57067fffd 100644 --- a/src/modules/frontend/auth/authenticated.guard.spec.ts +++ b/src/modules/frontend/auth/authenticated.guard.spec.ts @@ -1,5 +1,5 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { ExecutionContext } from '@nestjs/common'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; @@ -35,13 +35,11 @@ describe('AuthenticatedGuard', () => { expect(result).toBe(true); }); - it('should return false if user is not authenticated', () => { + it('should throw UnauthorizedException if user is not authenticated', () => { const contextMock: DeepMocked = createMock(); contextMock.switchToHttp().getRequest>().isAuthenticated.mockReturnValueOnce(false); - const result: boolean = sut.canActivate(contextMock); - - expect(result).toBe(false); + expect(() => sut.canActivate(contextMock)).toThrow(UnauthorizedException); }); }); }); diff --git a/src/modules/frontend/auth/authenticated.guard.ts b/src/modules/frontend/auth/authenticated.guard.ts index c1e8e86ff..ee5fc2132 100644 --- a/src/modules/frontend/auth/authenticated.guard.ts +++ b/src/modules/frontend/auth/authenticated.guard.ts @@ -1,10 +1,16 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Request } from 'express'; @Injectable() export class AuthenticatedGuard implements CanActivate { public canActivate(context: ExecutionContext): boolean { const request: Request = context.switchToHttp().getRequest(); - return request.isAuthenticated(); + const isAuthenticated: boolean = request.isAuthenticated(); + + if (!isAuthenticated) { + throw new UnauthorizedException(); + } + + return isAuthenticated; } } diff --git a/src/modules/frontend/frontend-api.module.ts b/src/modules/frontend/frontend-api.module.ts index 9709a50b1..4da3d1a7d 100644 --- a/src/modules/frontend/frontend-api.module.ts +++ b/src/modules/frontend/frontend-api.module.ts @@ -5,9 +5,17 @@ import { PassportModule } from '@nestjs/passport'; import { FrontendController } from './api/frontend.controller.js'; import { AuthenticatedGuard, OIDCClientProvider, OpenIdConnectStrategy, SessionSerializer } from './auth/index.js'; import { BackendHttpService } from './outbound/backend-http.service.js'; +import { ProviderService } from './outbound/provider.service.js'; @Module({ imports: [HttpModule, PassportModule.register({ session: true, defaultStrategy: 'oidc', keepSessionInfo: true })], - providers: [AuthenticatedGuard, BackendHttpService, OpenIdConnectStrategy, SessionSerializer, OIDCClientProvider], + providers: [ + AuthenticatedGuard, + BackendHttpService, + ProviderService, + OpenIdConnectStrategy, + SessionSerializer, + OIDCClientProvider, + ], controllers: [FrontendController], }) export class FrontendApiModule {} diff --git a/src/modules/frontend/outbound/provider.service.spec.ts b/src/modules/frontend/outbound/provider.service.spec.ts new file mode 100644 index 000000000..9ebd3d50a --- /dev/null +++ b/src/modules/frontend/outbound/provider.service.spec.ts @@ -0,0 +1,58 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; +import { of } from 'rxjs'; + +import { GetServiceProviderInfoDo } from '../../rolle/domain/get-service-provider-info.do.js'; +import { User } from '../auth/user.decorator.js'; +import { BackendHttpService } from './backend-http.service.js'; +import { ProviderService } from './provider.service.js'; + +describe('ProviderService', () => { + let module: TestingModule; + let sut: ProviderService; + let httpServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ProviderService, + { + provide: BackendHttpService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(ProviderService); + httpServiceMock = module.get(BackendHttpService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('listProviders', () => { + it('should call HttpService.get with params', async () => { + httpServiceMock.get.mockReturnValueOnce(of({ data: [] } as AxiosResponse)); + const userMock: User = createMock(); + + await sut.listProviders(userMock); + + expect(httpServiceMock.get).toHaveBeenCalledWith('/api/provider', userMock); + }); + + it('should return response from service', async () => { + const axiosResponse: AxiosResponse = { data: [] } as AxiosResponse; + httpServiceMock.get.mockReturnValueOnce(of(axiosResponse)); + + const result: GetServiceProviderInfoDo[] = await sut.listProviders(createMock()); + + expect(result).toBe(axiosResponse.data); + }); + }); +}); diff --git a/src/modules/frontend/outbound/provider.service.ts b/src/modules/frontend/outbound/provider.service.ts new file mode 100644 index 000000000..80136eaf4 --- /dev/null +++ b/src/modules/frontend/outbound/provider.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosResponse } from 'axios'; +import { firstValueFrom, map } from 'rxjs'; + +import { GetServiceProviderInfoDo } from '../../rolle/domain/get-service-provider-info.do.js'; +import { User } from '../auth/user.decorator.js'; +import { BackendHttpService } from './backend-http.service.js'; + +@Injectable() +export class ProviderService { + public constructor(private httpService: BackendHttpService) {} + + public async listProviders(user: User): Promise { + return firstValueFrom( + this.httpService + .get('/api/provider', user) + .pipe(map((res: AxiosResponse) => res.data)), + ); + } +}