diff --git a/api/.env.defaults b/api/.env.defaults index c6b1dbd100c3a53dd9aec8f09c0db9704b962bb9..f11df677fee167b13856039b06d92a627a815bc4 100644 --- a/api/.env.defaults +++ b/api/.env.defaults @@ -6,16 +6,4 @@ ENGINE_BASE_URL=http://127.0.0.1:8080/services/ TOS_SKIP=false # SERVER -GATEWAY_PORT=8081 - -# AUTHENTICATION -AUTH_SKIP=false -AUTH_JWT_SECRET=SecretForDevPurposeOnly -AUTH_JWT_TOKEN_EXPIRES_IN=2d -AUTH_COOKIE_SAME_SITE=strict -AUTH_COOKIE_SECURE=true - -# Cache -CACHE_ENABLED=true -CACHE_TTL=1800 # 1800 == 30 minutes -CACHE_MAX_ITEMS=100 \ No newline at end of file +GATEWAY_PORT=8081 \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index 74d865159a05949fc6f0951cf37a5f60a21ee877..389d9d380f6ee55562b32fce2f7c0e092a8a135e 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/passport": "^8.2.1", "@nestjs/platform-express": "^8.0.0", "@nestjs/typeorm": "^8.0.3", + "@types/object-hash": "^2.2.1", "apollo-server-express": "^3.6.3", "axios": "^0.21.1", "cache-manager": "^4.0.1", @@ -26,6 +27,7 @@ "graphql": "^15.5.3", "graphql-type-json": "^0.3.2", "jsonata": "^1.8.5", + "object-hash": "^3.0.0", "passport": "^0.5.2", "passport-custom": "^1.1.1", "passport-jwt": "^4.0.0", @@ -2773,6 +2775,11 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/object-hash": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.2.1.tgz", + "integrity": "sha512-i/rtaJFCsPljrZvP/akBqEwUP2y5cZLOmvO+JaYnz01aPknrQ+hB5MRcO7iqCUsFaYfTG8kGfKUyboA07xeDHQ==" + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -17552,6 +17559,11 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "@types/object-hash": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.2.1.tgz", + "integrity": "sha512-i/rtaJFCsPljrZvP/akBqEwUP2y5cZLOmvO+JaYnz01aPknrQ+hB5MRcO7iqCUsFaYfTG8kGfKUyboA07xeDHQ==" + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", diff --git a/api/package.json b/api/package.json index 41ac9d65ba74232143c5a2d9db79e93ef7ba5e2d..78c2d6d8c55a1a4977f77394de8becf0408c70c7 100644 --- a/api/package.json +++ b/api/package.json @@ -36,6 +36,7 @@ "@nestjs/passport": "^8.2.1", "@nestjs/platform-express": "^8.0.0", "@nestjs/typeorm": "^8.0.3", + "@types/object-hash": "^2.2.1", "apollo-server-express": "^3.6.3", "axios": "^0.21.1", "cache-manager": "^4.0.1", @@ -43,6 +44,7 @@ "graphql": "^15.5.3", "graphql-type-json": "^0.3.2", "jsonata": "^1.8.5", + "object-hash": "^3.0.0", "passport": "^0.5.2", "passport-custom": "^1.1.1", "passport-jwt": "^4.0.0", diff --git a/api/src/auth/auth-constants.ts b/api/src/auth/auth-constants.ts deleted file mode 100644 index 5e4748c1e52bca63b765b555568f6da6d6bb9d85..0000000000000000000000000000000000000000 --- a/api/src/auth/auth-constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const authConstants = { - JWTSecret: 'AUTH_JWT_SECRET', - skipAuth: 'AUTH_SKIP', - expiresIn: 'AUTH_JWT_TOKEN_EXPIRES_IN', - enableSSO: 'AUTH_ENABLE_SSO', - cookie: { - name: 'jwt-gateway', - sameSite: 'AUTH_COOKIE_SAME_SITE', - secure: 'AUTH_COOKIE_SECURE', - httpOnly: 'AUTH_COOKIE_HTTPONLY', - }, -}; diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 936e09cab00920b1f9d4494200b10cce62fb8b06..b84f68ffff7c94834b11d1f12d57ec8b1237c1b7 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ConfigModule, ConfigService, ConfigType } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { authConstants } from './auth-constants'; +import authConfig from 'src/config/auth.config'; +import { UsersModule } from 'src/users/users.module'; import { AuthResolver } from './auth.resolver'; import { AuthService } from './auth.service'; import { EngineStrategy } from './strategies/engine.strategy'; @@ -12,17 +13,22 @@ import { LocalStrategy } from './strategies/local.strategy'; @Module({ imports: [ + UsersModule, PassportModule.register({ session: false, }), JwtModule.registerAsync({ imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - secret: configService.get(authConstants.JWTSecret), - signOptions: { - expiresIn: configService.get(authConstants.expiresIn, '2d'), - }, - }), + useFactory: async (configService: ConfigService) => { + const authConf = + configService.get<ConfigType<typeof authConfig>>('auth'); + return { + secret: authConf.JWTSecret, + signOptions: { + expiresIn: authConf.expiresIn, + }, + }; + }, inject: [ConfigService], }), ], diff --git a/api/src/auth/auth.resolver.spec.ts b/api/src/auth/auth.resolver.spec.ts index 7890a9e5bc8f52774599a6a66a14a9a54ee75a04..1c660e25244b2580cb40a51c5344ca1617dafb65 100644 --- a/api/src/auth/auth.resolver.spec.ts +++ b/api/src/auth/auth.resolver.spec.ts @@ -1,11 +1,12 @@ -import { getMockRes } from '@jest-mock/express'; +import { getMockReq, getMockRes } from '@jest-mock/express'; +import { ConfigType } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; -import EngineService from '../engine/engine.service'; -import LocalService from '../engine/connectors/local/local.connector'; +import authConfig from '../config/auth.config'; import { ENGINE_MODULE_OPTIONS } from '../engine/engine.constants'; +import EngineService from '../engine/engine.service'; import { User } from '../users/models/user.model'; -import { authConstants } from './auth-constants'; import { AuthResolver } from './auth.resolver'; import { AuthService } from './auth.service'; @@ -14,8 +15,10 @@ const moduleMocker = new ModuleMocker(global); describe('AuthResolver', () => { let resolver: AuthResolver; const { res } = getMockRes(); + const req: Request = getMockReq(); const mockCookie = jest.fn(); const mockClearCookie = jest.fn(); + let config: ConfigType<typeof authConfig>; res.cookie = mockCookie; res.clearCookie = mockClearCookie; @@ -37,9 +40,15 @@ describe('AuthResolver', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ + { + provide: authConfig.KEY, + useValue: authConfig(), + }, { provide: EngineService, - useClass: LocalService, + useValue: { + clearCache: jest.fn(), + }, }, { provide: ENGINE_MODULE_OPTIONS, @@ -67,12 +76,13 @@ describe('AuthResolver', () => { .compile(); resolver = module.get<AuthResolver>(AuthResolver); + config = module.get<ConfigType<typeof authConfig>>(authConfig.KEY); }); it('login', async () => { - const data = await resolver.login(res, user, credentials); + const data = await resolver.login(res, req, user, credentials); - expect(mockCookie.mock.calls[0][0]).toBe(authConstants.cookie.name); + expect(mockCookie.mock.calls[0][0]).toBe(config.cookie.name); expect(mockCookie.mock.calls[0][1]).toBe(authData.accessToken); expect(data.accessToken).toBe(authData.accessToken); }); @@ -81,6 +91,6 @@ describe('AuthResolver', () => { const request: any = jest.fn(); await resolver.logout(request, res, user); - expect(mockClearCookie.mock.calls[0][0]).toBe(authConstants.cookie.name); + expect(mockClearCookie.mock.calls[0][0]).toBe(config.cookie.name); }); }); diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts index cf3208a99b76554b383a8dd3c181805f38d9ce06..27c2694fc1d1a07873d3e18a5927ce2bc5359f0e 100644 --- a/api/src/auth/auth.resolver.ts +++ b/api/src/auth/auth.resolver.ts @@ -4,23 +4,23 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { ConfigType } from '@nestjs/config'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; -import { Response, Request } from 'express'; -import { CurrentUser } from '../common/decorators/user.decorator'; +import { Request, Response } from 'express'; import { GQLRequest } from '../common/decorators/gql-request.decoractor'; import { GQLResponse } from '../common/decorators/gql-response.decoractor'; +import { CurrentUser } from '../common/decorators/user.decorator'; +import { parseToBoolean } from '../common/utils/shared.utils'; +import authConfig from '../config/auth.config'; import { ENGINE_MODULE_OPTIONS } from '../engine/engine.constants'; +import EngineService from '../engine/engine.service'; +import EngineOptions from '../engine/interfaces/engine-options.interface'; import { User } from '../users/models/user.model'; -import { authConstants } from './auth-constants'; import { AuthService } from './auth.service'; import { GlobalAuthGuard } from './guards/global-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; import { AuthenticationInput } from './inputs/authentication.input'; import { AuthenticationOutput } from './outputs/authentication.output'; -import { parseToBoolean } from '../common/utils/shared.utils'; -import EngineOptions from '../engine/interfaces/engine-options.interface'; -import EngineService from '../engine/engine.service'; //Custom defined type because Pick<CookieOptions, 'sameSite'> does not work type SameSiteType = boolean | 'lax' | 'strict' | 'none' | undefined; @@ -34,40 +34,52 @@ export class AuthResolver { @Inject(ENGINE_MODULE_OPTIONS) private readonly engineOptions: EngineOptions, private readonly authService: AuthService, - private readonly configService: ConfigService, + @Inject(authConfig.KEY) private authConf: ConfigType<typeof authConfig>, ) {} @Mutation(() => AuthenticationOutput) @UseGuards(LocalAuthGuard) async login( @GQLResponse() res: Response, + @GQLRequest() req: Request, @CurrentUser() user: User, @Args('variables') inputs: AuthenticationInput, ): Promise<AuthenticationOutput> { this.logger.verbose(`${inputs.username} logged in`); + this.engineService.clearCache(req); + const data = await this.authService.login(user); if (!data) throw new InternalServerErrorException( `Error during the authentication process`, ); - res.cookie(authConstants.cookie.name, data.accessToken, { - httpOnly: parseToBoolean( - this.configService.get(authConstants.cookie.httpOnly, 'true'), - ), - sameSite: this.configService.get<SameSiteType>( - authConstants.cookie.sameSite, - 'strict', - ), - secure: parseToBoolean( - this.configService.get(authConstants.cookie.secure, 'true'), - ), + res.cookie(this.authConf.cookie.name, data.accessToken, { + httpOnly: parseToBoolean(this.authConf.cookie.httpOnly), + sameSite: this.authConf.cookie.sameSite as SameSiteType, + secure: parseToBoolean(this.authConf.cookie.secure), + }); + + return data; + } + + @Mutation(() => AuthenticationOutput) + async refresh( + @GQLResponse() res: Response, + @Args('refreshToken', { type: () => String }) refreshToken: string, + ): Promise<AuthenticationOutput> { + const data = await this.authService.createTokensWithRefreshToken( + refreshToken, + ); + + res.cookie(this.authConf.cookie.name, data.accessToken, { + httpOnly: parseToBoolean(this.authConf.cookie.httpOnly), + sameSite: this.authConf.cookie.sameSite as SameSiteType, + secure: parseToBoolean(this.authConf.cookie.secure), }); - return { - accessToken: data.accessToken, - }; + return data; } @Mutation(() => Boolean) @@ -83,14 +95,16 @@ export class AuthResolver { if (this.engineService.has('logout')) { await this.engineService.logout(req); } + this.authService.logout(user); } catch (e) { - this.logger.debug( + this.logger.warn( `Service ${this.engineOptions.type} produce an error when logging out ${user.username}`, ); + this.logger.debug(e); } } - res.clearCookie(authConstants.cookie.name); + res.clearCookie(this.authConf.cookie.name); return true; } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index ea689bdf469a81b7e8bdc4d9429a87598406d05c..0e92ef5b9e4892fddc2474ab690f6c3d7e5e7ca5 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -5,6 +5,7 @@ import { ENGINE_MODULE_OPTIONS } from '../engine/engine.constants'; import { AuthService } from './auth.service'; import { User } from '../users/models/user.model'; import EngineService from '../engine/engine.service'; +import authConfig from '../config/auth.config'; const moduleMocker = new ModuleMocker(global); @@ -27,6 +28,10 @@ describe('AuthService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ + { + provide: authConfig.KEY, + useValue: authConfig(), + }, { provide: EngineService, useValue: createEngineService(), @@ -45,6 +50,7 @@ describe('AuthService', () => { if (token === JwtService) { return { sign: jest.fn().mockReturnValue(jwtToken), + signAsync: jest.fn().mockResolvedValue(jwtToken), }; } if (typeof token === 'function') { diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 8300b9a1649ce7683d0559c2814dbde94fec16bc..fd2110714baf44e5f64f12dfd059039b2f6dfa15 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -1,14 +1,32 @@ -import { Injectable, NotImplementedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; +import { + Inject, + Injectable, + Logger, + NotImplementedException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import authConfig from '../config/auth.config'; import EngineService from '../engine/engine.service'; import { User } from '../users/models/user.model'; import { AuthenticationOutput } from './outputs/authentication.output'; +import * as hashing from 'object-hash'; + +type TokenPayload = { + context: User; +}; @Injectable() export class AuthService { + private static readonly logger = new Logger(AuthService.name); + constructor( + @Inject(authConfig.KEY) private authConf: ConfigType<typeof authConfig>, private readonly engineService: EngineService, - private jwtService: JwtService, + private readonly usersService: UsersService, + private readonly jwtService: JwtService, ) {} async validateUser(username: string, password: string): Promise<User> { @@ -17,14 +35,86 @@ export class AuthService { } /** - * It takes a user and returns an access token + * It takes a user and returns an access and refresh tokens * @param {User} user - The user object that is being authenticated. * @returns An object with an accessToken property. */ - async login(user: User): Promise<Pick<AuthenticationOutput, 'accessToken'>> { - const payload = { username: user.username, sub: user }; - return Promise.resolve({ - accessToken: this.jwtService.sign(payload), + async login(user: User): Promise<AuthenticationOutput> { + const payload: TokenPayload = { + context: { + ...user, + refreshToken: undefined, + agreeNDA: undefined, + }, + }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(payload), + this.jwtService.signAsync(payload, this.getRefreshTokenOptions()), + ]); + + const hashRefresh = await this.getHash(refreshToken); + + this.usersService.update(user.id, { + refreshToken: hashRefresh, }); + + return { + accessToken, + refreshToken, + }; + } + + /** + * Logout the user by deleting the refresh token from the database. + * @param {User} user - User object that is being logged out. + */ + async logout(user: User): Promise<void> { + try { + if (user.id) + 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); + } + } + + async createTokensWithRefreshToken( + refreshToken: string, + ): Promise<AuthenticationOutput> { + try { + const payload = this.jwtService.verify<TokenPayload>( + refreshToken, + this.getRefreshTokenOptions(), + ); + const user = await this.usersService.findOne(payload.context.id); + const isMatchingTokens = + user.refreshToken === (await this.getHash(refreshToken)); + + if (!isMatchingTokens) { + this.logout(payload.context); + throw new UnauthorizedException(); + } + return this.login(payload.context); + } catch (error) { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + /** + * Make a hash out of an object. This function is not cryptographically secure + * and should only be used for hashing purposes. + * @param {any} obj - The object to be hashed. + * @returns A promise that resolves to a string. + */ + private async getHash(obj: any): Promise<string> { + return hashing(obj); + } + + private getRefreshTokenOptions(): JwtSignOptions { + return { + expiresIn: this.authConf.refreshExperiesIn, + secret: this.authConf.JWTResfreshSecret, + }; } } diff --git a/api/src/auth/guards/global-auth.guard.ts b/api/src/auth/guards/global-auth.guard.ts index ecfd65e01ac3ae1a09b452ca74ff86b62cc27b7d..7a210b3dff67b09effdd6119e804f5c001347634 100644 --- a/api/src/auth/guards/global-auth.guard.ts +++ b/api/src/auth/guards/global-auth.guard.ts @@ -1,4 +1,8 @@ -import { ExecutionContext, Injectable } from '@nestjs/common'; +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; @@ -18,6 +22,14 @@ export class GlobalAuthGuard extends AuthGuard([ super(); } + handleRequest<TUser = any>(err: any, user: any): TUser { + if (err || !user) { + throw new UnauthorizedException(); + } + + return user; + } + getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); const gqlReq = ctx.getContext().req; diff --git a/api/src/auth/outputs/authentication.output.ts b/api/src/auth/outputs/authentication.output.ts index f6a3dabfb1fcb672ae57129854fc6f8c26d3b9c8..bcefaee22cabf051a35b55c7f036c10f19773541 100644 --- a/api/src/auth/outputs/authentication.output.ts +++ b/api/src/auth/outputs/authentication.output.ts @@ -4,4 +4,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; export class AuthenticationOutput { @Field() accessToken: string; + + @Field() + refreshToken: string; } diff --git a/api/src/auth/strategies/jwt-bearer.strategy.ts b/api/src/auth/strategies/jwt-bearer.strategy.ts index 9439c87cf54accd4684455c1ce66df564d3af2ac..4a036ec1094258c2558b333d56b0a0956ce36b43 100644 --- a/api/src/auth/strategies/jwt-bearer.strategy.ts +++ b/api/src/auth/strategies/jwt-bearer.strategy.ts @@ -1,23 +1,25 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { authConstants } from '../auth-constants'; +import authConfig from 'src/config/auth.config'; @Injectable() export class JwtBearerStrategy extends PassportStrategy( Strategy, 'jwt-bearer', ) { - constructor(private readonly configService: ConfigService) { + constructor( + @Inject(authConfig.KEY) private authConf: ConfigType<typeof authConfig>, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get<string>(authConstants.JWTSecret), + secretOrKey: authConf.JWTSecret, }); } async validate(payload: any) { - return payload.sub; + return payload.context; } } diff --git a/api/src/auth/strategies/jwt-cookies.strategy.ts b/api/src/auth/strategies/jwt-cookies.strategy.ts index 29ca031c34660149bb3a90e30ebed97432f7e9ca..1fb31ca54985d3d4eb48ae00b494f6dc248dbabc 100644 --- a/api/src/auth/strategies/jwt-cookies.strategy.ts +++ b/api/src/auth/strategies/jwt-cookies.strategy.ts @@ -1,32 +1,34 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { Request } from 'express'; import { Strategy } from 'passport-jwt'; -import { authConstants } from '../auth-constants'; +import authConfig from 'src/config/auth.config'; @Injectable() export class JwtCookiesStrategy extends PassportStrategy( Strategy, 'jwt-cookies', ) { - constructor(private readonly configService: ConfigService) { + constructor( + @Inject(authConfig.KEY) private authConf: ConfigType<typeof authConfig>, + ) { super({ jwtFromRequest: JwtCookiesStrategy.extractFromCookie, ignoreExpiration: false, - secretOrKey: configService.get<string>(authConstants.JWTSecret), + secretOrKey: authConf.JWTSecret, }); } static extractFromCookie = function (req: Request) { let token = null; if (req && req.cookies) { - token = req.cookies[authConstants.cookie.name]; + token = req.cookies[this.authConf.cookie.name]; } return token; }; async validate(payload: any) { - return payload.sub; + return payload.context; } } diff --git a/api/src/config/auth.config.ts b/api/src/config/auth.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..85bf65d0956383b1eb011b5d2a9f0f456ada4ed8 --- /dev/null +++ b/api/src/config/auth.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('auth', () => ({ + JWTSecret: process.env.AUTH_JWT_SECRET || 'access-secret', + JWTResfreshSecret: process.env.AUTH_JWT_REFRESH_SECRET || 'refresh-secret', + skipAuth: process.env.AUTH_SKIP || 'false', + expiresIn: process.env.AUTH_JWT_TOKEN_EXPIRES_IN || '1h', + refreshExperiesIn: process.env.AUTH_JWT_REFRESH_TOKEN_EXPIRES_IN || '2d', + enableSSO: process.env.AUTH_ENABLE_SSO || 'false', + cookie: { + name: process.env.AUTH_COOKIE_NAME || 'jwt-gateway', + sameSite: process.env.AUTH_COOKIE_SAME_SITE || 'strict', + secure: process.env.AUTH_COOKIE_SECURE || 'false', + httpOnly: process.env.AUTH_COOKIE_HTTPONLY || 'true', + }, +})); diff --git a/api/src/config/cache.config.ts b/api/src/config/cache.config.ts index 763d7d5b3bcc73dbdfeabdebdba94f4837b1f8ed..c99534d7c4168bf3e2bb2bf4a9c3b17933a2e83e 100644 --- a/api/src/config/cache.config.ts +++ b/api/src/config/cache.config.ts @@ -5,8 +5,8 @@ export default registerAs('cache', () => { const max = process.env.CACHE_MAX_ITEMS; const ttl = process.env.CACHE_TTL; return { - enabled: parseToBoolean(process.env.CACHE_ENABLED, false), - ttl: ttl ? parseInt(ttl) : undefined, - max: max ? parseInt(max) : undefined, + enabled: parseToBoolean(process.env.CACHE_ENABLED, true), + ttl: ttl ? parseInt(ttl) : 1800, + max: max ? parseInt(max) : 100, }; }); diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index cb0ffac9dc36e5e358959e18a412ac980a7cc7ac..97b39c736ff064772fc7df701a404965dae187db 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -1,12 +1,12 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { ConfigService, ConfigType } from '@nestjs/config'; import { Query, Resolver } from '@nestjs/graphql'; import { Request } from 'express'; import { Public } from 'src/auth/decorators/public.decorator'; import { GlobalAuthGuard } from 'src/auth/guards/global-auth.guard'; import { parseToBoolean } from 'src/common/utils/shared.utils'; +import authConfig from 'src/config/auth.config'; import { Md5 } from 'ts-md5'; -import { authConstants } from '../auth/auth-constants'; import { GQLRequest } from '../common/decorators/gql-request.decoractor'; import { ENGINE_CONTACT_LINK, @@ -37,20 +37,17 @@ export class EngineResolver { @Query(() => Configuration) @Public() configuration(): Configuration { - const config = this.engineService.getConfiguration(); + const engineConf = this.engineService.getConfiguration(); const matomo = this.configSerivce.get('matomo'); + const authConf: ConfigType<typeof authConfig> = + this.configSerivce.get('auth'); const data = { - ...(config ?? {}), + ...(engineConf ?? {}), connectorId: this.engineOptions.type, skipTos: parseToBoolean(this.configSerivce.get(ENGINE_SKIP_TOS)), - enableSSO: parseToBoolean( - this.configSerivce.get(authConstants.enableSSO), - ), - skipAuth: parseToBoolean( - this.configSerivce.get(authConstants.skipAuth), - true, - ), + enableSSO: parseToBoolean(authConf.enableSSO), + skipAuth: parseToBoolean(authConf.skipAuth, true), matomo, ontologyUrl: this.configSerivce.get(ENGINE_ONTOLOGY_URL), contactLink: this.configSerivce.get(ENGINE_CONTACT_LINK), diff --git a/api/src/engine/engine.service.ts b/api/src/engine/engine.service.ts index 70a7180033d724421c44589f206cca935130f9c4..35b3cc39db577982f63605f6457ec3929d9b7f90 100644 --- a/api/src/engine/engine.service.ts +++ b/api/src/engine/engine.service.ts @@ -221,11 +221,10 @@ export default class EngineService implements Connector { return this.connector.getFilterConfiguration(req); } - async logout?(req: Request): Promise<void> { + async logout(req: Request): Promise<void> { await this.clearCache(req); - if (!this.connector.logout) throw new NotImplementedException(); - return this.connector.logout(req); + if (this.connector.logout) this.connector.logout(req); } /** diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index 5c113466f7233d9ff30419b48c9c495e60ae9f66..6b2415689fa0cfb23f66596e388e8ee3d4307e0d 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { GraphQLError } from 'graphql'; import { join } from 'path'; import { AuthModule } from 'src/auth/auth.module'; +import authConfig from 'src/config/auth.config'; import cacheConfig from 'src/config/cache.config'; import dbConfig from 'src/config/db.config'; import matomoConfig from 'src/config/matomo.config'; @@ -21,7 +22,7 @@ import { AppService } from './app.service'; ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env', '.env.defaults'], - load: [dbConfig, matomoConfig, cacheConfig], + load: [dbConfig, matomoConfig, cacheConfig, authConfig], }), GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, @@ -59,8 +60,8 @@ import { AppService } from './app.service'; autoLoadEntities: true, }), }), - AuthModule, UsersModule, + AuthModule, ExperimentsModule, FilesModule, ], diff --git a/api/src/migrations/1659096310828-UsersRefreshToken.ts b/api/src/migrations/1659096310828-UsersRefreshToken.ts new file mode 100644 index 0000000000000000000000000000000000000000..90bb3dbb95145f17421d33640c0f90a99dcaa670 --- /dev/null +++ b/api/src/migrations/1659096310828-UsersRefreshToken.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UsersRefreshToken1659096310828 implements MigrationInterface { + name = 'UsersRefreshToken1659096310828'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "user" ADD "refreshToken" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "refreshToken"`); + } +} diff --git a/api/src/schema.gql b/api/src/schema.gql index 3720e78944a93b077e3e7458d088bf4c562dda53..913fd6dca93e03650a13765141ebf554c3fbd318 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -23,6 +23,7 @@ type User { type AuthenticationOutput { accessToken: String! + refreshToken: String! } type Matomo { @@ -385,23 +386,24 @@ type Query { } type Mutation { + updateUser(updateUserInput: UpdateUserInput!): User! login(variables: AuthenticationInput!): AuthenticationOutput! + refresh(refreshToken: String!): AuthenticationOutput! logout: Boolean! - updateUser(updateUserInput: UpdateUserInput!): User! createExperiment(data: ExperimentCreateInput!, isTransient: Boolean = false): Experiment! editExperiment(id: String!, data: ExperimentEditInput!): Experiment! removeExperiment(id: String!): PartialExperiment! } +input UpdateUserInput { + agreeNDA: Boolean! +} + input AuthenticationInput { username: String! password: String! } -input UpdateUserInput { - agreeNDA: Boolean! -} - input ExperimentCreateInput { datasets: [String!]! filter: String diff --git a/api/src/users/decorators/extend-user.decorator.ts b/api/src/users/decorators/extend-user.decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..1912708faa011ab3939fa15099ffcf199394bc07 --- /dev/null +++ b/api/src/users/decorators/extend-user.decorator.ts @@ -0,0 +1,2 @@ +import { SetMetadata } from '@nestjs/common'; +export const ExtendUser = () => SetMetadata('extendUser', true); diff --git a/api/src/users/interceptors/users.interceptor.ts b/api/src/users/interceptors/users.interceptor.ts index d0fd2785ebd82d8823a3163ad7e5f10a936ea00a..b1f6d8a8c03cc2c7e34f985bc6199226809b4d88 100644 --- a/api/src/users/interceptors/users.interceptor.ts +++ b/api/src/users/interceptors/users.interceptor.ts @@ -4,6 +4,7 @@ import { Injectable, NestInterceptor, } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; import { Observable } from 'rxjs'; import { User } from '../models/user.model'; @@ -11,7 +12,10 @@ import { UsersService } from '../users.service'; @Injectable() export class UsersInterceptor implements NestInterceptor { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly reflector: Reflector, + ) {} async intercept( context: ExecutionContext, @@ -19,8 +23,12 @@ export class UsersInterceptor implements NestInterceptor { ): Promise<Observable<any>> { const ctx = GqlExecutionContext.create(context); const req = ctx.getContext().req ?? ctx.switchToHttp().getRequest(); + const extendUser = this.reflector.getAllAndOverride<boolean>('extendUser', [ + context.getHandler(), + context.getClass(), + ]); - if (req.userExtended) return next.handle(); // user already extended + if (req.userExtended || !extendUser) return next.handle(); // user already extended req.userExtended = true; const user: User = req.user; diff --git a/api/src/users/models/user.model.ts b/api/src/users/models/user.model.ts index 980d7c1af5d7ca2a15cf997e316fd6ca51c29480..f611151c02a8f4d21f050e334d30656cf0b7886f 100644 --- a/api/src/users/models/user.model.ts +++ b/api/src/users/models/user.model.ts @@ -22,4 +22,7 @@ export class User { agreeNDA?: boolean; extraFields?: Record<string, any>; + + @Column({ nullable: true }) + refreshToken?: string; } diff --git a/api/src/users/users.module.ts b/api/src/users/users.module.ts index b5374872ed6f2336185bc5d10c5f9c680b473507..e052012a2c8312633da8d90c84b9705c5b243a8f 100644 --- a/api/src/users/users.module.ts +++ b/api/src/users/users.module.ts @@ -16,5 +16,6 @@ import { UsersService } from './users.service'; useClass: UsersInterceptor, }, ], + exports: [UsersService], }) export class UsersModule {} diff --git a/api/src/users/users.resolver.ts b/api/src/users/users.resolver.ts index d13ae670e9499d07b768cd3706ff1173f3de4135..7e8461d6a621fcb62a53191bd311c601de58c8bb 100644 --- a/api/src/users/users.resolver.ts +++ b/api/src/users/users.resolver.ts @@ -9,10 +9,12 @@ import { GlobalAuthGuard } from '../auth/guards/global-auth.guard'; import { GQLRequest } from '../common/decorators/gql-request.decoractor'; import { CurrentUser } from '../common/decorators/user.decorator'; import EngineService from '../engine/engine.service'; +import { ExtendUser } from './decorators/extend-user.decorator'; import { UpdateUserInput } from './inputs/update-user.input'; import { User } from './models/user.model'; import { UsersService } from './users.service'; +@ExtendUser() @UseGuards(GlobalAuthGuard) @Resolver() export class UsersResolver { diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 3138f9a5e748bb329b154d9ac3e8f9319213066f..04b951092bef81489ddc08e28f8bb04f4e4d7abe 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -4,7 +4,10 @@ import { Repository } from 'typeorm'; import { UpdateUserInput } from './inputs/update-user.input'; import { User } from './models/user.model'; -export type InternalUser = Pick<User, 'id' | 'agreeNDA'>; +export type InternalUser = Pick<User, 'id' | 'agreeNDA' | 'refreshToken'>; +export type UserDataUpdate = Partial< + Pick<User, 'agreeNDA' | 'refreshToken'> | UpdateUserInput +>; @Injectable() export class UsersService { @@ -31,10 +34,10 @@ export class UsersService { /** * Update a user * @param {string} id - The id of the user to update. - * @param {UpdateUserInput} data - update params + * @param {UserDataUpdate} data - update params * @returns The updated user. */ - async update(id: string, data: UpdateUserInput): Promise<InternalUser> { + async update(id: string, data: UserDataUpdate): Promise<InternalUser> { const updateData = { id, ...data,