diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b46ce431e434d2e7eeae29aadcb863c690735639..1e83e380a7eb1a7d01198c8a939fff70fe4c436a 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -1,3 +1,46 @@ +## [1.2.2-beta.6](https://gitlab.com/sibmip/gateway/compare/1.2.2-beta.5...1.2.2-beta.6) (2022-10-19) + + +### Bug Fixes + +* Issue with Promise activeUser ([ba6e871](https://gitlab.com/sibmip/gateway/commit/ba6e8719106b7a283d898a76105eddfaa7fc1d98)) + +## [1.2.2-beta.5](https://gitlab.com/sibmip/gateway/compare/1.2.2-beta.4...1.2.2-beta.5) (2022-10-19) + + +### Bug Fixes + +* **exareme:** Redirect issue with activeUser and domains ([11f0a61](https://gitlab.com/sibmip/gateway/commit/11f0a61164d4926f01aa9d5e9ac16ef16470240b)) + +## [1.2.2-beta.4](https://gitlab.com/sibmip/gateway/compare/1.2.2-beta.3...1.2.2-beta.4) (2022-10-19) + + +### Bug Fixes + +* **exareme:** Issue with logout response status from portalbackend ([c49a30d](https://gitlab.com/sibmip/gateway/commit/c49a30d921b7cc3a1bcebc0cdbf773c818957645)) +* **exareme:** Limit redirection from portalbackend ([e3a11f4](https://gitlab.com/sibmip/gateway/commit/e3a11f42d3fad693a9672f58e4096d4c11343c99)) + +## [1.2.2-beta.3](https://gitlab.com/sibmip/gateway/compare/1.2.2-beta.2...1.2.2-beta.3) (2022-10-19) + + +### Bug Fixes + +* Issue when logging out ([110b31a](https://gitlab.com/sibmip/gateway/commit/110b31ac56675dfbbdc25b02d2d2db4b669ac0d5)) + +## [1.2.2-beta.2](https://gitlab.com/sibmip/gateway/compare/1.2.2-beta.1...1.2.2-beta.2) (2022-10-18) + + +### Bug Fixes + +* **Datashield:** issue with jwt ([e5114ef](https://gitlab.com/sibmip/gateway/commit/e5114ef9ae9447fbda7f37c9ccaa2b8d043f44e8)) + +## [1.2.2-beta.1](https://gitlab.com/sibmip/gateway/compare/1.2.1...1.2.2-beta.1) (2022-10-11) + + +### Bug Fixes + +* Logout was always creating a DB record ([873ea93](https://gitlab.com/sibmip/gateway/commit/873ea9380a01ca7710db876d064492113b6d5587)) + ## [1.2.1](https://gitlab.com/sibmip/gateway/compare/1.2.0...1.2.1) (2022-09-28) diff --git a/api/package-lock.json b/api/package-lock.json index 4c750496a82382234cf6980dc09b522f64221157..6194b54160a06ed249e41c5a69544825fe797a91 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "gateway", - "version": "1.2.1", + "version": "1.2.2-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gateway", - "version": "1.2.1", + "version": "1.2.2-beta.6", "license": "Apache-2.0", "dependencies": { "@nestjs/apollo": "^10.0.22", diff --git a/api/package.json b/api/package.json index a8ecaa441346ef816a4160fe9a70e42950eaf931..ffe866503bb2b15ed95b54ed9e28882bef78578a 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "gateway", - "version": "1.2.1", + "version": "1.2.2-beta.6", "description": "", "author": "", "private": true, diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts index 27c2694fc1d1a07873d3e18a5927ce2bc5359f0e..bca4f7f9921f5cb30241f8ee5c2042fcd043ad7d 100644 --- a/api/src/auth/auth.resolver.ts +++ b/api/src/auth/auth.resolver.ts @@ -92,9 +92,9 @@ export class AuthResolver { if (user) { this.logger.verbose(`${user.username} logged out`); try { - if (this.engineService.has('logout')) { + if (this.engineService.has('logout')) await this.engineService.logout(req); - } + this.authService.logout(user); } catch (e) { this.logger.warn( diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index fd2110714baf44e5f64f12dfd059039b2f6dfa15..303b4978c8e48578e1f2d65ba77b80e1d8c7f5bf 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -55,7 +55,7 @@ export class AuthService { const hashRefresh = await this.getHash(refreshToken); - this.usersService.update(user.id, { + this.usersService.save(user.id, { refreshToken: hashRefresh, }); @@ -71,8 +71,8 @@ export class AuthService { */ async logout(user: User): Promise<void> { try { - if (user.id) - await this.usersService.update(user.id, { refreshToken: null }); + if (!user || !user.id) return; + await this.usersService.update(user.id, { refreshToken: null }); } catch (err) { //user not found or others errors AuthService.logger.debug('Error while logging out user', err); @@ -87,6 +87,16 @@ export class AuthService { refreshToken, this.getRefreshTokenOptions(), ); + + //check if user is connected + const isConnected = await this.engineService.isSessionValid( + payload.context, + ); + + if (!isConnected) { + throw new UnauthorizedException('User need to reconnect'); + } + const user = await this.usersService.findOne(payload.context.id); const isMatchingTokens = user.refreshToken === (await this.getHash(refreshToken)); @@ -97,7 +107,8 @@ export class AuthService { } return this.login(payload.context); } catch (error) { - throw new UnauthorizedException('Invalid refresh token'); + const msg = error.message ?? 'Invalid refresh token'; + throw new UnauthorizedException(msg); } } diff --git a/api/src/auth/strategies/engine.strategy.ts b/api/src/auth/strategies/engine.strategy.ts index 7bf68c2df41d842645f11ce8bb36891213ec6f84..d1a160c363e0f64175def445c4819c6554f50dcc 100644 --- a/api/src/auth/strategies/engine.strategy.ts +++ b/api/src/auth/strategies/engine.strategy.ts @@ -12,7 +12,7 @@ export class EngineStrategy extends PassportStrategy(Strategy, 'engine') { async validate(req: Request) { if (!this.engineService.has('getActiveUser')) return false; - const user = this.engineService.getActiveUser(req); + const user = await this.engineService.getActiveUser(req); return user ?? false; } diff --git a/api/src/engine/connectors/datashield/datashield.connector.ts b/api/src/engine/connectors/datashield/datashield.connector.ts index 0bbc8deb2e1ac039ae76f26b33ff79d982513a1a..2bbfa3e6aba6d486b33105142de72e6824064404 100644 --- a/api/src/engine/connectors/datashield/datashield.connector.ts +++ b/api/src/engine/connectors/datashield/datashield.connector.ts @@ -272,14 +272,10 @@ export default class DataShieldConnector implements Connector { ) { const path = new URL('/runAlgorithm', this.options.baseurl); - // Covariable and variable are inversed in Datashield API - const variable = - experiment.variables.length > 0 ? experiment.variables[0] : undefined; - const expToInput = { algorithm: { id: experiment.algorithm.name, - variable, + variables: experiment.variables, covariables: experiment.coVariables, }, datasets: experiment.datasets, @@ -344,6 +340,33 @@ export default class DataShieldConnector implements Connector { return [dsDomain]; } + async isSessionValid(user: User): Promise<boolean> { + const sid = user && user.extraFields && user.extraFields['sid']; + + if (!sid) return false; + + try { + const cookies = [`sid=${user.extraFields['sid']}`, `user=${user.id}`]; + const path = this.options.baseurl + 'getvars'; + + await firstValueFrom( + this.httpService.get(path, { + headers: { + cookie: cookies.join(';'), + }, + }), + ); + + return true; + } catch (err) { + DataShieldConnector.logger.verbose( + `User ${user.id} is not connected to Datashield`, + ); + DataShieldConnector.logger.debug(err); + return false; + } + } + async getActiveUser(req: Request): Promise<User> { const user = req.user as User; diff --git a/api/src/engine/connectors/exareme/exareme.connector.ts b/api/src/engine/connectors/exareme/exareme.connector.ts index fc473ea95c09d124d97ea2f6f8c8423651578a49..1c1002f5581de7fa33e7b96cbdec73a88d6d82a4 100644 --- a/api/src/engine/connectors/exareme/exareme.connector.ts +++ b/api/src/engine/connectors/exareme/exareme.connector.ts @@ -77,7 +77,12 @@ export default class ExaremeConnector implements Connector { async logout(request: Request) { const path = `${this.options.baseurl}logout`; - await firstValueFrom(this.get(request, path)); + await firstValueFrom( + this.get(request, path, { + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status <= 302, + }), + ); } async createExperiment( @@ -124,7 +129,11 @@ export default class ExaremeConnector implements Connector { async getAlgorithms(request: Request): Promise<Algorithm[]> { const path = this.options.baseurl + 'algorithms'; - const resultAPI = await firstValueFrom(this.get<string>(request, path)); + const resultAPI = await firstValueFrom( + this.get<string>(request, path, { + maxRedirects: 0, + }), + ); return transformToAlgorithms.evaluate(resultAPI.data); } @@ -177,7 +186,11 @@ export default class ExaremeConnector implements Connector { const path = this.options.baseurl + 'pathologies'; try { - const data = await firstValueFrom(this.get<Pathology[]>(request, path)); + const data = await firstValueFrom( + this.get<Pathology[]>(request, path, { + maxRedirects: 0, + }), + ); return ( data?.data.map((d): Domain => { @@ -206,7 +219,11 @@ export default class ExaremeConnector implements Connector { async getActiveUser(request: Request): Promise<User> { const path = this.options.baseurl + 'activeUser'; - const response = await firstValueFrom(this.get<string>(request, path)); + const response = await firstValueFrom( + this.get<string>(request, path, { + maxRedirects: 0, + }), + ); try { return transformToUser.evaluate(response.data); diff --git a/api/src/engine/engine.service.ts b/api/src/engine/engine.service.ts index fc07746b38fb38c1f6d64f843271330fdbd493b1..b7344332a30ce1e1b3dd9b58f0c04f6b471a3d62 100644 --- a/api/src/engine/engine.service.ts +++ b/api/src/engine/engine.service.ts @@ -5,6 +5,7 @@ import { Injectable, InternalServerErrorException, NotImplementedException, + UnauthorizedException, } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { Cache } from 'cache-manager'; @@ -79,6 +80,11 @@ export default class EngineService implements Connector { return this.connector.getConfiguration?.() ?? {}; } + isSessionValid(user: User): Promise<boolean> { + if (!this.connector.isSessionValid) throw new NotImplementedException(); + return this.connector.isSessionValid(user); + } + /** * "If the cache is enabled, try to get the value from the cache, otherwise call the function and cache * the result." @@ -162,15 +168,22 @@ export default class EngineService implements Connector { req?: Request, ): Promise<RunResult> { if (!this.connector.runExperiment) throw new NotImplementedException(); - return this.connector.runExperiment(data, req).catch((err) => ({ - results: [ - { - level: AlertLevel.ERROR, - message: `Error while running experiment, details '${err}'`, - }, - ], - status: ExperimentStatus.ERROR, - })); + return this.connector.runExperiment(data, req).catch((err) => { + if (err.status === 401 || err.response?.status === 401) { + throw new UnauthorizedException( + 'Experiment cannot be run because of a bad authentication', + ); + } + return { + results: [ + { + level: AlertLevel.ERROR, + message: `Error while running experiment, details '${err}'`, + }, + ], + status: ExperimentStatus.ERROR, + }; + }); } async listExperiments?( @@ -233,7 +246,7 @@ export default class EngineService implements Connector { async logout(req: Request): Promise<void> { await this.clearCache(req); - if (this.connector.logout) this.connector.logout(req); + if (this.connector.logout) return this.connector.logout(req); } /** diff --git a/api/src/engine/interfaces/connector.interface.ts b/api/src/engine/interfaces/connector.interface.ts index d74d50a9828833b0b371a5b73cb946895b830138..22cc537e3c104a1414929443031fe7b9cdd0d2a0 100644 --- a/api/src/engine/interfaces/connector.interface.ts +++ b/api/src/engine/interfaces/connector.interface.ts @@ -22,6 +22,12 @@ export default interface Connector { */ getConfiguration?(): ConnectorConfiguration; + /** + * Tell if the session is still valid + * @param user User to check + */ + isSessionValid?(user: User): Promise<boolean>; + /** * Get the list of domains along with a list of variables * @param req - Request - this is the request object from the HTTP request. diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index f63ec51cd94f57739ab9f13610ac7a6ae2be7a89..82a5dbb88164f51d26753aee422722ad67603b87 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -55,8 +55,8 @@ import { AppService } from './app.service'; useFactory: (config: ConfigService) => ({ ...config.get('database'), migrations: ['dist/migrations/*{.ts,.js}'], - migrationsRun: process.env.NODE_ENV !== 'dev', - synchronize: process.env.NODE_ENV === 'dev', + migrationsRun: process.env.NODE_ENV !== 'development', + synchronize: process.env.NODE_ENV === 'development', autoLoadEntities: true, }), }), diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index 47fa24b93094f81116aa6d5657b322dc16b15db2..b4227b2ee91f346552b29bbf08d130f8fc54f283 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -65,12 +65,12 @@ describe('UsersService', () => { }); }); - describe('updateUser', () => { - it('should return an updated user', async () => { + describe('saveUser', () => { + it('should return an user', async () => { const expectedUser = { ...user, ...updateData }; usersRepository.save.mockResolvedValue(expectedUser); - const result = await service.update('idThatExist', updateData); + const result = await service.save('idThatExist', updateData); expect(result).toStrictEqual(expectedUser); }); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 773a77019e755044f1de87f4daba20d6939d4b22..271725157aa9ada6aae49d318d3100954d0e1d16 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, UpdateResult } from 'typeorm'; import { UpdateUserInput } from './inputs/update-user.input'; import { User } from './models/user.model'; @@ -37,7 +37,18 @@ export class UsersService { * @param {UserDataUpdate} data - update params * @returns The updated user. */ - async update(id: string, data: UserDataUpdate): Promise<InternalUser> { + async update(id: string, data: UserDataUpdate): Promise<UpdateResult> { + return this.userRepository.update({ id }, data); + } + + /** + * Saves user in the database. + * If user does not exist in the database then inserts, otherwise updates. + * @param {string} id - The id of the user to update. + * @param {UserDataUpdate} data - update params + * @returns The updated user. + */ + async save(id: string, data: UserDataUpdate): Promise<InternalUser> { const updateData = { id, ...data, diff --git a/docs/for-developers/configuration/frontend.md b/docs/for-developers/configuration/frontend.md new file mode 100644 index 0000000000000000000000000000000000000000..15284e4c88878c4d77d40bbfd2355da91919414f --- /dev/null +++ b/docs/for-developers/configuration/frontend.md @@ -0,0 +1,45 @@ +--- +description: >- + This page description all the possible configuration that can be made in the + Gateway. +--- + +# Frontend + +### :toolbox: Options + +#### General + +| name | type | default | description | +| -------------------------- | ------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| VERSION | string | "DEV" | Portal frontend's version | +| INSTANCE\_NAME | string | "HBP MIP" | Instance name of the MIP (visible in the header) | +| ONTOLOGY\_URL | string \| undefined | undefined | Ontology's URL | +| DATACATALOGUE\_SERVER | string \| undefined | undefined | Datacatalogue's URL | +| CONTACT\_LINK | string | http://ebrains.eu/support | Contact URL (support) | +| EXPERIMENTS\_LIST\_REFRESH | string | "300000" | Time to wait before refresh experiments list in `milliseconds` | +| GATEWAY\_SERVER | string | none | Indicate the Gateway's endpoint. Used when the Gateway is behind the Frontend (reverse proxy) | +| EXTERNAL\_MIP\_PROTOCOL | string | https | Indicate the protocol should be forwarded to the Gateway. | +| ERROR\_LOG\_LEVEL | string | WARN | Level is the minimum level to emit, and is inclusive. Possible levels: DEBUG, INFO, WARN, ERROR, PANIC, and FATAL. See [Caddy logs level](https://caddyserver.com/docs/json/logging/logs/level/). | + +#### Matomo + +Matomo is an open source alternative to Google Analytics. + +| name | type | default | description | +| ---------------- | ------------------- | --------- | --------------------------------------------------------------------------------------------------- | +| MATOMO\_ENABLED | boolean | false | Enable or disable Matomo | +| MATOMO\_URL | string \| undefined | undefined | Base url for matomo scripts and data reporting. This parameter is `required` if Matomo is `enabled` | +| MATOMO\_SITE\_ID | string \| undefined | undefined | Matomo Website ID. This parameter is required if `Matomo` is `enabled`. | + +#### Caddyfile configuration + +The frontend container has a simple implementation of Caddyfile, located in `/etc/caddy/Caddyfile`. If you are using Docker compose you can easily overwrite by specifying a volume : + +{% code title="docker-compose.yml" %} +``` +... + volumes: + - ./path/to/your/Caddyfile:/etc/caddy/Caddyfile +``` +{% endcode %} diff --git a/docs/for-developers/configuration/gateway.md b/docs/for-developers/configuration/gateway.md index 7f879ca3369f383b0f2b26fbacc31d35d889258b..2b16dacfcbdfbc415f9091c3086a000032787d21 100644 --- a/docs/for-developers/configuration/gateway.md +++ b/docs/for-developers/configuration/gateway.md @@ -18,7 +18,6 @@ description: >- | GATEWAY\_PORT | number | 8081 | Indicate the port that should be used by the gateway | | NODE\_ENV | string | dev | Value can be `prod` or `dev` | | BASE\_URL\_CONTEXT | string | null | Define context of the gateway. E.g. `api` if the api is under `http://127.0.0.1/api/` | -| ONTOLOGY\_URL | string | null | Define ontology's url | #### Authentication diff --git a/docs/for-developers/gateway/authentication.md b/docs/for-developers/gateway/authentication.md index 6c8a63fa9d5e7bf3a606cca9069b135d6301f6f9..7e6f9bbc908a578da5b9966ae2f8a406cd49add5 100644 --- a/docs/for-developers/gateway/authentication.md +++ b/docs/for-developers/gateway/authentication.md @@ -48,9 +48,9 @@ This field can be used by the connector to store information related to the user The real login system is delegated to the connector by using the `login` method in the interface. -{% code title="engine.interface.ts" %} +{% code title="connector.interface.ts" %} ```typescript -export interface IEngineService { +export interface Connector { // ... /** @@ -77,9 +77,9 @@ When the login is performed, this function should return a `User` object and can The same mechanism is applied to the logout system using the method logout from the engine. -{% code title="engine.interface.ts" %} +{% code title="connector.interface.ts" %} ```typescript -export interface IEngineService { +export interface Connector { // ... logout?(req: Request @@ -90,3 +90,37 @@ export interface IEngineService { ``` {% endcode %} +#### Session validation + +Whenever a Frontend required a refreshToken, the gateway should tell if the user is still connected to the engine. For this, your connector should implements the function **isSessionValid**.  + +{% code title="connector.interface.ts" %} +```typescript +export interface Connector { + // ... + + isSessionValid?(user: User): Promise<boolean>; + + // ... +} +``` +{% endcode %} + +This function should ensure that the user can still access the engine with the current token. + +#### How to get the user  + +Whether you use the local login or a 3rd party system, there is a unique way to access the user inside the Gateway. This method through the request :  + +```typescript +request.user +``` + +This request's attribute is feed by strategy policies defined in the Gateway. Currently the following strategies are applied  + +1. JWT cookies +2. JWT bearer +3. Engine (use the connector to retrieve the user) + +Even if the `AUTH_SKIP` is defined you should be able to retrieve the user through the request. +