diff --git a/CHANGELOG.md b/CHANGELOG.md index 046785b..245f2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### 1.8.3 (August 27th, 2024) + +- bump versions +- fix oauth +- remove tech users and api keys + ### 1.5.1 (Februrary 6th, 2024) - bump minor dep versions diff --git a/cfg/config.json b/cfg/config.json index bd7677b..7b067e3 100644 --- a/cfg/config.json +++ b/cfg/config.json @@ -407,7 +407,7 @@ } ], "oauth": { - "redirect_uri_base": "http://localhost:5000/oauth2/", + "redirect_uri_base": "http://127.0.0.1:5000/oauth2/", "services": { "google": { "client_id": "", diff --git a/data/seed_data/seed-accounts.json b/data/seed_data/seed-accounts.json index 616369d..6aa4fa1 100644 --- a/data/seed_data/seed-accounts.json +++ b/data/seed_data/seed-accounts.json @@ -17,16 +17,6 @@ "timezone_id": "europe-berlin", "meta": { "owners": [ - { - "id": "urn:restorecommerce:acs:names:ownerIndicatoryEntity", - "value": "urn:restorecommerce:acs:model:organization.Organization", - "attributes": [ - { - "id": "urn:restorecommerce:acs:names:ownerInstance", - "value": "system" - } - ] - }, { "id": "urn:restorecommerce:acs:names:ownerIndicatoryEntity", "value": "urn:restorecommerce:acs:model:user.User", diff --git a/data/seed_data/seed-roles.json b/data/seed_data/seed-roles.json index 14e2560..01cc4ed 100644 --- a/data/seed_data/seed-roles.json +++ b/data/seed_data/seed-roles.json @@ -7,18 +7,7 @@ "superadministrator-r-id" ], "meta": { - "owners": [ - { - "id": "urn:restorecommerce:acs:names:ownerIndicatoryEntity", - "value": "urn:restorecommerce:acs:model:organization.Organization", - "attributes": [ - { - "id": "urn:restorecommerce:acs:names:ownerInstance", - "value": "system" - } - ] - } - ] + "owners": [] } } ] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 09318a8..6897a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "1.5.0", "license": "MIT", "dependencies": { - "@restorecommerce/acs-client": "^2.0.0", + "@restorecommerce/acs-client": "^2.0.3", "@restorecommerce/chassis-srv": "^1.6.2", "@restorecommerce/grpc-client": "^2.2.4", - "@restorecommerce/kafka-client": "^1.2.10", + "@restorecommerce/kafka-client": "^1.2.13", "@restorecommerce/logger": "^1.3.1", - "@restorecommerce/rc-grpc-clients": "^5.1.32", + "@restorecommerce/rc-grpc-clients": "^5.1.35", "@restorecommerce/resource-base-interface": "^1.6.2", - "@restorecommerce/scs-jobs": "^0.1.34", + "@restorecommerce/scs-jobs": "^0.1.37", "@restorecommerce/service-config": "^1.0.15", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -1981,14 +1981,15 @@ } }, "node_modules/@restorecommerce/acs-client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@restorecommerce/acs-client/-/acs-client-2.0.0.tgz", - "integrity": "sha512-y4McvRbVkyCa1Y4y+yCh8HKHrGz3XIGm/D4wvX61y7X0F5NAKC26CzlHy7u6qjCwplBVrp10ogtKpIW5ogaOgQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@restorecommerce/acs-client/-/acs-client-2.0.3.tgz", + "integrity": "sha512-QjlHfzZ/BKKquaIpX1HRU7hf9WL9L8xQhSD4KT3Ash8ShRDC32pbE0lSOWiBfWiCiMU9EUOtIu0BOn9feRn+LQ==", + "license": "MIT", "dependencies": { "@restorecommerce/grpc-client": "^2.2.4", - "@restorecommerce/kafka-client": "^1.2.10", + "@restorecommerce/kafka-client": "^1.2.13", "@restorecommerce/logger": "^1.3.1", - "@restorecommerce/rc-grpc-clients": "^5.1.32", + "@restorecommerce/rc-grpc-clients": "^5.1.35", "@restorecommerce/service-config": "^1.0.15", "deepdash": "^5.3.9", "lodash": "^4.17.21", @@ -2078,12 +2079,13 @@ } }, "node_modules/@restorecommerce/kafka-client": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@restorecommerce/kafka-client/-/kafka-client-1.2.10.tgz", - "integrity": "sha512-PC58Yr7Hx5AVNo3rfRZ7nWrT/2xWN155t9I3WjgqZK3/IBByvLjl5rMhGBlbElRb0fsCU+Y0ujhrYdQtIiX+mA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@restorecommerce/kafka-client/-/kafka-client-1.2.13.tgz", + "integrity": "sha512-z5aT2eyqbuBb228fK36tJZMNEGs+TJJ+Dei4eVb1C5XtahY92awJY9svcQuNLyASsDJ0l0+9fxz1jeLsU6oKeA==", + "license": "MIT", "dependencies": { "@restorecommerce/logger": "^1.3.1", - "@restorecommerce/rc-grpc-clients": "^5.1.32", + "@restorecommerce/rc-grpc-clients": "^5.1.35", "async": "^3.2.5", "cls-rtracer": "^2.6.3", "events": "^3.3.0", @@ -2120,9 +2122,10 @@ "license": "MIT" }, "node_modules/@restorecommerce/rc-grpc-clients": { - "version": "5.1.32", - "resolved": "https://registry.npmjs.org/@restorecommerce/rc-grpc-clients/-/rc-grpc-clients-5.1.32.tgz", - "integrity": "sha512-Q2wl28Jy20wjO3xSwholPdwtoL5OFfWOzAkO3Ff60gQHk+HFrjPirbQQASw5BKIp2RaNFyTLjmeLElm4kgaR7Q==", + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/@restorecommerce/rc-grpc-clients/-/rc-grpc-clients-5.1.35.tgz", + "integrity": "sha512-+wPBwUdhC8N+C4iMRYhfLe8QJl4+WARLKvnRBcrhSWroWolYLHHwxR4XMgpnJrnDLSnpXPSn14NHsv3fAjLhWg==", + "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.9.11", "@restorecommerce/grpc-client": "^2.2.4", @@ -2157,14 +2160,15 @@ } }, "node_modules/@restorecommerce/scs-jobs": { - "version": "0.1.34", - "resolved": "https://registry.npmjs.org/@restorecommerce/scs-jobs/-/scs-jobs-0.1.34.tgz", - "integrity": "sha512-thLEnNb1VEeQ7E2yNFtOd70jQBW5odIbD1dYG4npnz9+w3p5hyOmCbbXMEcYJ6iqUZ4alUgIReYzLL3tLsQfeQ==", + "version": "0.1.37", + "resolved": "https://registry.npmjs.org/@restorecommerce/scs-jobs/-/scs-jobs-0.1.37.tgz", + "integrity": "sha512-XxpXei8FOAbVde8fiwNdlc8+u3TS0x33TuYEEk/IV4usc0ftepCnqctF6LAbPJRTeu/AJgHtOFF79Yi6yj916w==", + "license": "MIT", "dependencies": { "@restorecommerce/grpc-client": "^2.2.4", - "@restorecommerce/kafka-client": "^1.2.10", + "@restorecommerce/kafka-client": "^1.2.13", "@restorecommerce/logger": "^1.3.1", - "@restorecommerce/rc-grpc-clients": "^5.1.32", + "@restorecommerce/rc-grpc-clients": "^5.1.35", "bullmq": "^5.7.15", "lodash": "^4.17.21", "redis": "^4.6.8", diff --git a/package.json b/package.json index f4cf8b7..87a12ac 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,14 @@ "srv" ], "dependencies": { - "@restorecommerce/acs-client": "^2.0.0", + "@restorecommerce/acs-client": "^2.0.3", "@restorecommerce/chassis-srv": "^1.6.2", "@restorecommerce/grpc-client": "^2.2.4", - "@restorecommerce/kafka-client": "^1.2.10", + "@restorecommerce/kafka-client": "^1.2.13", "@restorecommerce/logger": "^1.3.1", - "@restorecommerce/rc-grpc-clients": "^5.1.32", + "@restorecommerce/rc-grpc-clients": "^5.1.35", "@restorecommerce/resource-base-interface": "^1.6.2", - "@restorecommerce/scs-jobs": "^0.1.34", + "@restorecommerce/scs-jobs": "^0.1.37", "@restorecommerce/service-config": "^1.0.15", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -83,7 +83,7 @@ "start": "node lib/start.cjs", "dev": "cross-env NODE_ENV=development node --watch lib/start.cjs", "test": "npm run lint && c8 --reporter=text npm run mocha", - "lint": "eslint src --ext .ts", + "lint": "eslint src --fix --ext .ts", "mocha": "cross-env NODE_ENV=test mocha --full-trace --exit --timeout 30000", "test-debug": "npm run mocha -- --inspect-brk", "lcov-report": "c8 report --reporter=lcov", diff --git a/src/oauth_service.ts b/src/oauth_service.ts index 10f9476..968ca34 100644 --- a/src/oauth_service.ts +++ b/src/oauth_service.ts @@ -1,17 +1,18 @@ import { Logger } from 'winston'; import { OAuth2 } from 'oauth'; -import { checkAccessRequest, createMetadata } from './utils.js'; import { UserService } from './service.js'; -import { AuthZAction, Operation } from '@restorecommerce/acs-client'; import * as _ from 'lodash-es'; import * as uuid from 'uuid'; import * as jose from 'jose'; import { DeepPartial, + ExchangeCodeRequest, ExchangeCodeResponse, GenerateLinksResponse, OAuthServiceImplementation, - ServicesResponse + ServicesResponse, + GetTokenResponse, + GetTokenRequest, } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/oauth.js'; import { Empty } from '@restorecommerce/rc-grpc-clients/dist/generated/google/protobuf/empty.js'; import { WithRequestID } from '@restorecommerce/chassis-srv/lib/microservice/transport/provider/grpc/middlewares.js'; @@ -20,41 +21,24 @@ import { Filter_Operation, ReadRequest } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/resource_base.js'; -import { - Response_Decision -} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/access_control.js'; import fetch from 'node-fetch'; export const accountResolvers: { [key: string]: (access_token: string) => Promise } = { google: async access_token => { - const response = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', { - headers: { - Authorization: 'Bearer ' + access_token + const response: any = await fetch( + 'https://www.googleapis.com/oauth2/v1/userinfo', + { + headers: { + Authorization: 'Bearer ' + access_token + } } - }).then(response => response.json()); - return response['email']; + ).then( + response => response.json() + ); + return response.email; } }; -interface ExchangeCodeRequest { - service: string; - code: string; - state: string; -} - -interface GetTokenRequest { - subject: any; - service: string; -} - -interface GetTokenResponse { - status?: { - code: number; - message: string; - }; - token?: string; -} - export class OAuthService implements OAuthServiceImplementation { logger: Logger; @@ -96,110 +80,118 @@ export class OAuthService implements OAuthServiceImplementation { async generateLinks(request: Empty, context: any): Promise> { const nonce = 'nonce'; // TODO Generate, store and compare unique nonce return { - links: Object.entries(this.clients).reduce((result, entry) => { - result[entry[0]] = entry[1].getAuthorizeUrl({ - redirect_uri: this.cfg.get('oauth:redirect_uri_base') + entry[0], - scope: this.cfg.get('oauth:services:' + entry[0] + ':scope'), - response_type: 'code', - state: nonce, - prompt: 'consent', - access_type: 'offline' - }); - return result; - }, {}) + links: Object.assign({}, + ...Object.entries(this.clients).map(([key, value]) => ({ + key: value.getAuthorizeUrl({ + redirect_uri: this.cfg.get('oauth:redirect_uri_base') + key, + scope: this.cfg.get('oauth:services:' + key + ':scope'), + response_type: 'code', + state: nonce, + prompt: 'consent', + access_type: 'offline' + }) + })) + ) }; } async exchangeCode(request: ExchangeCodeRequest, context: any): Promise> { - const oauthService = request.service; - if (!(oauthService in this.clients)) { - throw new Error('Unknown service: ' + oauthService); - } - - const data: any = await new Promise((resolve, reject) => this.clients[oauthService].getOAuthAccessToken(request.code, { - grant_type: 'authorization_code', - redirect_uri: this.cfg.get('oauth:redirect_uri_base') + oauthService, - }, (err, access_token, refresh_token, result) => { - if (err) { - this.logger.error('Oauth failed:', { err }); - reject(err); - return; + try { + const oauthService = request.service; + if (!(oauthService in this.clients)) { + throw new Error('Unknown service: ' + oauthService); } - resolve({ - access_token, - refresh_token, - result - }); - })); - - const email = await accountResolvers[oauthService](data['access_token']); - - const users = await this.userService.superRead(ReadRequest.fromPartial({ - filters: [ - { - filters: [ - { - field: 'email', - operation: Filter_Operation.eq, - value: email + const data: any = await new Promise( + (resolve, reject) => this.clients[oauthService].getOAuthAccessToken( + request.code, + { + grant_type: 'authorization_code', + redirect_uri: this.cfg.get('oauth:redirect_uri_base') + oauthService, + }, + (err, access_token, refresh_token, result) => { + if (err) { + this.logger.error('Oauth failed:', { err }); + reject(err); + return; } - ] - } - ] - }), context); - if (users.total_count === 0) { - return { email }; - } - - const user = users.items[0].payload; - const resultTokens = (user.tokens || []).filter(t => { - return t.name === oauthService + '-access_token' || t.name === oauthService + '-refresh_token'; - }); - - const userCopy = { - ...user - }; - - delete userCopy['tokens']; - delete userCopy['password_hash']; - delete userCopy['data']; - - const token = new jose.UnsecuredJWT({ - user: userCopy - }).setIssuedAt() - .setExpirationTime((Date.now() / 1000) + (60 * 60 * 24 * 30 * 6)) - .encode(); - - const authToken: any = { - name: uuid.v4().replace(/-/g, ''), - expires_in: Date.now() + (1000 * 60 * 60 * 24 * 30 * 6), // 6 months - token, - type: 'access_token', - interactive: true, - last_login: Date.now() - }; - - const accessToken = { - name: oauthService + '-access_token', - expires_in: Date.now() + (data['result']['expires_in'] * 1000), - token: data['access_token'], - type: 'access_token', - interactive: true, - last_login: Date.now() - }; + resolve({ + access_token, + refresh_token, + result + }); + } + ) + ); + + const email = await accountResolvers[oauthService](data.access_token); + const user = await this.userService.superRead(ReadRequest.fromPartial({ + filters: [ + { + filters: [ + { + field: 'email', + operation: Filter_Operation.eq, + value: email + } + ] + } + ], + limit: 1 + }), context).then( + response => response?.items?.[0]?.payload + ); + + if (!user) { + throw { + code: 404, + message: `No user found for ${email}`, + }; + } - const refreshToken = { - name: oauthService + '-refresh_token', - expires_in: Date.now() + (1000 * 60 * 60 * 24 * 30 * 6), // 6 months - token: data['refresh_token'], - type: 'refresh_token', - interactive: true, - last_login: Date.now() - }; + const resultTokens = (user.tokens || []).filter( + t => t.name === oauthService + '-access_token' + || t.name === oauthService + '-refresh_token' + ); + + delete user.tokens; + delete user.password_hash; + delete user.data; + + const token = new jose.UnsecuredJWT({ + user + }).setIssuedAt() + .setExpirationTime((Date.now() / 1000) + (60 * 60 * 24 * 30 * 6)) + .encode(); + + const authToken: any = { + name: uuid.v4().replace(/-/g, ''), + expires_in: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 6), // 6 months + token, + type: 'access_token', + interactive: true, + last_login: new Date() + }; + + const accessToken = { + name: oauthService + '-access_token', + expires_in: new Date(Date.now() + data.result.expires_in * 1000), + token: data.access_token, + type: 'access_token', + interactive: true, + last_login: new Date() + }; + + const refreshToken = { + name: oauthService + '-refresh_token', + expires_in: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 6), // 6 months + token: data.refresh_token, + type: 'refresh_token', + interactive: true, + last_login: new Date() + }; - try { // append access token on user entity // remove expired tokens await this.userService.updateUserTokens(user.id, accessToken, resultTokens.filter(t => { @@ -213,102 +205,150 @@ export class OAuthService implements OAuthServiceImplementation { // append auth token on user entity await this.userService.updateUserTokens(user.id, authToken); this.logger.info('Token updated successfully on user entity', { id: user.id }); + + authToken.expires_in = new Date(authToken.expires_in); + return { + email, + user: { + payload: user, + status: { + code: 200, + message: 'success' + } + }, + token: authToken + }; } catch (err: any) { - this.logger.error('Error Updating Token', err); - return { user: { status: { code: err.code, message: err.message } } }; + this.logger.error('Error on token exchange', err); + return { + user: { + status: { + code: err.code, + message: err.message + } + } + }; } - - // convert expires_in to date, as updateUserTokens is done using custom AQL query and not via resource base interface - authToken.expires_in = new Date(authToken.expires_in); - return { email, user: { payload: user, status: { code: 200, message: 'success' } }, token: authToken }; } async getToken(request: GetTokenRequest, context: any): Promise> { - const oauthService = request.service; - if (!(oauthService in this.clients)) { - throw new Error('Unknown service: ' + oauthService); - } - - const user = await this.userService.findByToken(FindByTokenRequest.fromPartial({token: request.subject.token}), context); - if (!user || !user.payload || !user.payload.tokens) { - return {status: {code: 404, message: 'user not found'}}; - } - - const tokens = user?.payload?.tokens?.filter((t: any) => t?.name?.startsWith(oauthService + '-')); - if (tokens.length < 2) { - if (tokens.length > 0) { - return {token: tokens[0].token}; - } else { - return {status: {code: 404, message: 'user has no token for this service'}}; + try { + const oauthService = request.service; + if (!(oauthService in this.clients)) { + throw new Error('Unknown service: ' + oauthService); } - } - - const toRemove = []; - const accessTokens: any[] = tokens.filter(t => t.name.endsWith('access_token')); - for (let accessToken of accessTokens) { - if (accessToken.expires_in.getTime() > Date.now()) { - return {token: accessToken.token}; + const user = await this.userService.findByToken( + FindByTokenRequest.fromPartial( + { + token: request.subject.token + } + ), + context + ); + + if (!user?.payload?.tokens) { + return { + status: { + code: 404, + message: 'user not found' + } + }; } - toRemove.push(accessToken); - } - - const refreshTokens: any[] = tokens.filter(t => t.name.endsWith('refresh_token')); - - let data; - for (const refreshToken of refreshTokens) { - if (refreshToken.expires_in.getTime() < Date.now()) { - toRemove.push(refreshToken); - continue; + const tokens = user?.payload?.tokens?.filter((t: any) => t?.name?.startsWith(oauthService + '-')); + if (tokens.length < 2) { + if (tokens.length > 0) { + return { + token: tokens[0].token + }; + } else { + return { + status: { + code: 404, + message: 'user has no token for this service' + } + }; + } } - data = await new Promise((resolve) => this.clients[oauthService].getOAuthAccessToken(refreshToken.token, { - grant_type: 'refresh_token' - }, (err, access_token, refresh_token, result) => { - if (err) { - this.logger.error('Error Refreshing Token', err); - resolve(undefined); - return; - } + const toRemove = tokens.filter( + t => t.expires_in.getTime() < Date.now() + ); + + await this.userService.removeToken( + user.payload.id, + toRemove + ).catch( + err => this.logger.warn( + 'Failed to remove expired tokens', + { err, toRemove } + ) + ); + + const validAccessToken = tokens.find( + t => t.name.endsWith('access_token') + && t.expires_in.getTime() >= Date.now() + ); + + if (validAccessToken) { + return { + token: validAccessToken.token + }; + } - resolve({ - access_token, - refresh_token, - result - }); - })); + const validRefreshToken = tokens.find( + t => t.name.endsWith('refresh_token') + && t.expires_in.getTime() >= Date.now() + ); + + const data: any = validRefreshToken && await new Promise( + (resolve, reject) => this.clients[oauthService].getOAuthAccessToken( + validRefreshToken.token, + { + grant_type: 'refresh_token' + }, + (err, access_token, refresh_token, result) => { + if (err) { + this.logger.error('Error Refreshing Token', err); + reject(err); + return; + } - if (data) { - break; - } else { - toRemove.push(refreshToken); + resolve({ + access_token, + refresh_token, + result + }); + } + ) + ); + + if (!data) { + return {status: {code: 400, message: 'refresh token has expired'}}; } - } - if (!data) { - return {status: {code: 400, message: 'refresh token has expired'}}; - } + const newAccessToken = { + name: oauthService + '-access_token', + expires_in: new Date(Date.now() + data.result.expires_in * 1000), + token: data.access_token, + type: 'access_token', + interactive: true, + last_login: new Date() + }; - const newAccessToken = { - name: oauthService + '-access_token', - expires_in: Date.now() + (data['result']['expires_in'] * 1000), - token: data['access_token'], - type: 'access_token', - interactive: true, - last_login: Date.now() - }; - - try { // append access token on user entity - await this.userService.updateUserTokens(user.payload.id, newAccessToken, toRemove); + await this.userService.updateUserTokens(user.payload.id, newAccessToken); this.logger.info('Token updated successfully on user entity', {id: user.payload.id}); + return { token: newAccessToken.token }; } catch (err: any) { this.logger.error('Error Updating Token', err); - return {status: {code: err.code, message: err.message}}; + return { + status: { + code: err.code, + message: err.message + } + }; } - - return { token: newAccessToken.token }; } - } diff --git a/src/service.ts b/src/service.ts index 405343a..07fd98c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -30,7 +30,6 @@ import { import { createClient, RedisClientType } from 'redis'; import { query } from '@restorecommerce/chassis-srv/lib/database/provider/arango/common.js'; import { validateAllChar, validateEmail, validateFirstChar, validateStrLen, validateSymbolRepeat } from './validation.js'; -import { TokenService } from './token_service.js'; import { Arango } from '@restorecommerce/chassis-srv/lib/database/provider/arango/base.js'; import { ActivateRequest, @@ -120,7 +119,6 @@ export class UserService extends ServiceBase impleme authZ: ACSAuthZ; redisClient: RedisClientType; authZCheck: boolean; - tokenService: TokenService; tokenRedisClient: RedisClientType; uniqueEmailConstraint: boolean; @@ -170,7 +168,6 @@ export class UserService extends ServiceBase impleme this.tokenRedisClient.on('error', (err) => logger.error('Redis client error in token cache store', err)); this.tokenRedisClient.connect().then((val) => logger.info('Redis client connection successful for token cache store')).catch(err => logger.error('Redis connection error', err)); - this.tokenService = new TokenService(cfg, logger, this); this.emailEnabled = this.cfg.get('service:enableEmail'); const isConfigSet = this.cfg.get('service:uniqueEmailConstraint'); if (isConfigSet === undefined || isConfigSet) { @@ -293,16 +290,20 @@ export class UserService extends ServiceBase impleme */ async updateTokenLastLogin(id: string, token: string) { // update last_login - const aql_last_login = `FOR u IN users - FILTER u.id == @docID - UPDATE u WITH { - tokens: ( - FOR tokenObj in u.tokens - RETURN tokenObj.token == @token - ? MERGE( tokenObj, {last_login: @last_login }) - : tokenObj - ) - } IN users`; + const aql_last_login = ` + FOR u IN users + FILTER u._key == @docID OR u.id == @docID + LIMIT 1 + UPDATE u WITH { + tokens: ( + FOR tokenObj in u.tokens + RETURN tokenObj.token == @token + ? MERGE( tokenObj, {last_login: @last_login }) + : tokenObj + ) + } IN users + RETURN NEW + `; const bindVars_last_login = Object.assign({ docID: id, token, @@ -313,12 +314,29 @@ export class UserService extends ServiceBase impleme } async updateUserTokens(id: string, token: Tokens, expiredTokens?: Tokens[]) { + // since AQL is used to remove object - convert DateObject to time in ms + token = { + ...token, + expires_in: token?.expires_in?.getTime(), + last_login: token?.last_login?.getTime(), + } as any; + expiredTokens = expiredTokens?.map((token): any => ({ + ...token, + expires_in: token.expires_in?.getTime(), + last_login: token.last_login?.getTime() + })); + // temporary hack to update tokens on user(to fix issue when same user login multiple times simultaneously) // tokens get overwritten with update operation on simultaneours req if (token && token.interactive) { // insert token to tokens array - const aql_token = `FOR doc in users FILTER doc.id == @docID UPDATE doc WITH - { tokens: PUSH(doc.tokens, @token)} IN users return doc`; + const aql_token = ` + FOR doc IN users + FILTER doc._key == @docID OR doc.id == @docID + LIMIT 1 + UPDATE doc WITH { tokens: PUSH(doc.tokens, @token)} IN users + RETURN doc + `; const bindVars = Object.assign({ docID: id, token @@ -326,8 +344,13 @@ export class UserService extends ServiceBase impleme const res = await query(this.db.db, 'users', aql_token, bindVars); await res.all(); // update last_access - const aql_last_accesss = `FOR doc in users FILTER doc.id == @docID UPDATE doc WITH - { last_access: @last_access} IN users return doc`; + const aql_last_accesss = ` + FOR doc IN users + FILTER doc._key == @docID OR doc.id == @docID + LIMIT 1 + UPDATE doc WITH { last_access: @last_access} IN users + RETURN NEW + `; const bindVars_last_access = Object.assign({ docID: id, last_access: new Date().getTime() @@ -337,8 +360,13 @@ export class UserService extends ServiceBase impleme this.logger.debug('Tokens updated successuflly for subject', { id }); // check for expired tokens if they exist and remove them if (expiredTokens?.length > 0) { - const token_remove = `FOR doc in users FILTER doc.id == @docID UPDATE doc WITH - { tokens: REMOVE_VALUES(doc.tokens, @expiredTokens)} IN users return doc`; + const token_remove = ` + FOR doc IN users + FILTER doc._key == @docID OR doc.id == @docID + LIMIT 1 + UPDATE doc WITH { tokens: REMOVE_VALUES(doc.tokens, @expiredTokens)} IN users + RETURN NEW + `; const bindTokenVars = Object.assign({ docID: id, expiredTokens @@ -353,8 +381,13 @@ export class UserService extends ServiceBase impleme async removeToken(id: string, tokenObj: Tokens[]) { // Remove token using AQL query if (tokenObj?.length > 0) { - const token_remove = `FOR doc in users FILTER doc.id == @docID UPDATE doc WITH - { tokens: REMOVE_VALUES(doc.tokens, @tokenObj)} IN users return doc`; + const token_remove = ` + FOR doc in users + FILTER doc._key == @docID OR doc.id == @docID + LIMIT 1 + UPDATE doc WITH { tokens: REMOVE_VALUES(doc.tokens, @tokenObj)} IN users + RETURN NEW + `; const bindTokenVars = Object.assign({ docID: id, tokenObj @@ -389,8 +422,8 @@ export class UserService extends ServiceBase impleme logger.error('Token missing in User Data!', { userData }); return { status: { code: 500, message: 'Token missing in User Data!' } }; } - else if (!redisToken.expires_in - || redisToken?.expires_in?.getTime() === 0 + else if ( + new Date(redisToken?.expires_in ?? 0).getTime() === 0 || new Date(redisToken?.expires_in) >= new Date() ) { return { payload: userData, status: returnCodeMessage(200, 'success') }; @@ -403,22 +436,30 @@ export class UserService extends ServiceBase impleme } else { // when not set in redis // regex filter search field for token array - const filters = [{ + const query = ReadRequest.fromPartial({ filters: [{ - field: 'tokens[*].token', - operation: Filter_Operation.in, - value: token - }] - }]; - const users = await super.read(ReadRequest.fromPartial({ filters }), context); - const user = users.items?.[0]?.payload; + filters: [{ + field: 'tokens[*].token', + operation: Filter_Operation.in, + value: token + }], + }], + limit: 2 // limit 2 for checking invalids! + }); + const user = await super.read(query, context).then( + response => { + if (response?.items?.length > 1) { + logger.error('multiple user found for request', { request }); + throw { code: 400, message: 'Multiple users found for token' }; + } + else { + return response.items?.[0]?.payload; + } + } + ); - if (users?.items?.length > 1) { - logger.error('multiple user found for request', { request }); - return { status: { code: 400, message: 'Multiple users found for token' } }; - } - else if (user) { - logger.debug('found user from token', users); + if (user) { + logger.debug('found user from token', user); // validate token expiry and delete if expired const dbToken = user?.tokens?.find(t => t.token === token); // if expires_in does not exist or if its set to value 0 - token valid without time frame @@ -426,18 +467,25 @@ export class UserService extends ServiceBase impleme logger.error('Token missing in User Data!', { user }); return { status: { code: 500, message: 'Token missing in User Data!' } }; } - else if (!dbToken.expires_in - || dbToken.expires_in.getTime() === 0 - || dbToken?.expires_in >= new Date() + else if ( + new Date(dbToken.expires_in ?? 0).getTime() === 0 + || new Date(dbToken.expires_in) >= new Date() ) { // update token last_login await this.updateTokenLastLogin(user.id, token); - const response = await super.read(ReadRequest.fromPartial({ filters }), context); - const updatedUser = response.items?.[0]?.payload; - if (response?.items?.length > 1) { - return { status: { code: 400, message: 'Multiple users found for token' } }; - } - else if (updatedUser) { + const updatedUser = await super.read(query, context).then( + response => { + if (response?.items?.length > 1) { + logger.error('multiple user found for request', { request }); + throw { code: 400, message: 'Multiple users found for token' }; + } + else { + return response.items?.[0]?.payload; + } + } + ); + + if (updatedUser) { logger.debug('update of user token last login successfully', { updatedUser }); await this.tokenRedisClient.set(token, JSON.stringify(updatedUser)); logger.debug('Stored user data to redis cache successfully'); @@ -467,7 +515,7 @@ export class UserService extends ServiceBase impleme this.logger.error('Fatal error', { error: e.stack, code: e.code, message: e.message }); return { status: { - code: 500, + code: e.code ?? 500, message: e.message ?? e.details ?? e.toString() ?? e, } }; diff --git a/src/token_service.ts b/src/token_service.ts index 73d81a1..e4758e8 100644 --- a/src/token_service.ts +++ b/src/token_service.ts @@ -61,36 +61,31 @@ export class TokenService implements TokenServiceImplementation { field: 'id', operation: Filter_Operation.eq, value: payload?.accountId - }] + }], + limit: 1 }]; - const userData = await this.userService.superRead(ReadRequest.fromPartial({ filters }), {}); - if (userData?.items?.length > 0) { - let user = userData.items[0].payload; - let expiredTokenList = new Array(); - if (user?.tokens?.length > 0) { - // remove expired tokens - expiredTokenList = (user.tokens).filter(obj => { - if (obj?.expires_in && (obj.expires_in.getTime() < new Date().getTime())) { - // since AQL is used to remove object - convert DateObject to time in ms - (obj as any).expires_in = obj.expires_in ? obj.expires_in.getTime() : undefined; - (obj as any).last_login = obj.last_login ? obj.last_login.getTime() : undefined; - return obj; - } - }); - } + const user = await this.userService.superRead( + ReadRequest.fromPartial({ filters }), {} + ).then( + response => response.items?.[0]?.payload + ); + if (user) { + const expiredTokenList = user?.tokens?.length > 0 && user.tokens.filter( + obj => obj?.expires_in && (obj.expires_in.getTime() < new Date().getTime()) + ); let token_name; - if (payload.claims && payload.claims.token_name) { + if (payload.claims?.token_name) { token_name = payload.claims.token_name; } else { token_name = uuid.v4().replace(/-/g, ''); } const token = { name: token_name, - expires_in: tokenData?.expires_in?.getTime(), // since AQL is used to store to DB + expires_in: tokenData?.expires_in, token: payload.jti, type, interactive: true, - last_login: new Date().getTime(), + last_login: new Date(), client_id: payload?.clientId }; try { diff --git a/test/cfg/config.json b/test/cfg/config.json index 8afc2ed..811f603 100644 --- a/test/cfg/config.json +++ b/test/cfg/config.json @@ -150,6 +150,9 @@ "role": { "address": "localhost:50051" }, + "token": { + "address": "localhost:50051" + }, "reflection": { "address": "localhost:50051" }, diff --git a/test/service.spec.ts b/test/service.spec.ts index 7ce9c12..e967b18 100644 --- a/test/service.spec.ts +++ b/test/service.spec.ts @@ -22,6 +22,10 @@ import { createClient as RedisCreateClient, RedisClientType } from 'redis'; import { Rule, Effect } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/rule'; import { Meta } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/meta'; import { PolicySetRQ } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/policy_set'; +import { + TokenServiceClient, + TokenServiceDefinition +} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/token'; /* * Note: To run this test, a running ArangoDB and Kafka instance is required. @@ -46,36 +50,44 @@ async function start(): Promise { await worker.start(); } -async function connect(clientCfg: string, resourceName: string): Promise { // returns a gRPC service +async function connect(clientCfg: string, resourceName: string): Promise { // returns a gRPC service logger = worker.logger; if (events) { await events.stop(); } - events = new Events({ - ...cfg.get('events:kafka'), - groupId: 'restore-identity-srv-test-runner', - kafka: { - ...cfg.get('events:kafka:kafka'), - } - }, logger); - await (events.start()); - let topicLable = `${resourceName}.resource`; - topic = await events.topic(cfg.get(`events:kafka:topics:${topicLable}:topic`)); + const topicLable = cfg.get(`events:kafka:topics:${resourceName}.resource:topic`); + if (topicLable) { + events = new Events({ + ...cfg.get('events:kafka'), + groupId: 'restore-identity-srv-test-runner', + kafka: { + ...cfg.get('events:kafka:kafka'), + } + }, logger); + await (events.start()); + topic = await events.topic(topicLable); + } const channel = createChannel(cfg.get(clientCfg).address); if (resourceName.startsWith('user')) { return createClient({ ...cfg.get(clientCfg), logger - }, UserServiceDefinition, channel) as any; + }, UserServiceDefinition, channel) as T; } else if (resourceName.startsWith('role')) { return createClient({ ...cfg.get(clientCfg), logger - }, RoleServiceDefinition, channel) as any; + }, RoleServiceDefinition, channel) as T; + } else if (resourceName.startsWith('token')) { + return createClient({ + ...cfg.get(clientCfg), + logger + }, TokenServiceDefinition, channel) as T; } + throw new Error('Given client config not supported!'); } let meta: Meta = { @@ -341,7 +353,7 @@ describe('testing identity-srv', () => { describe('testing User service with email constraint (default)', () => { describe('with test client with email constraint (default)', () => { let userService: UserServiceClient; - let testUserID, upserUserID, user, testUserName; + let testUserID, upsertUserID, user, testUserName; before(async function connectUserService(): Promise { userService = await connect('client:user', 'user'); user = { @@ -1476,7 +1488,7 @@ describe('testing identity-srv', () => { last_name: 'upsert' }] }); - upserUserID = result!.items![0]!.payload!.id; + upsertUserID = result!.items![0]!.payload!.id; should.exist(result); should.exist(result!.items); result!.items![0]!.payload!.email!.should.equal('upsert@restorecommerce.io'); @@ -1491,7 +1503,7 @@ describe('testing identity-srv', () => { // sampleuser1 already exists in DB, so changing the `upseruser` name to `sampleuser1` should fail let result = await userService.update({ items: [{ - id: upserUserID, + id: upsertUserID, name: 'sampleuser1', email: 'upsert@restorecommerce.io', password: 'RNZzHwG&jpv5RS4Ev', @@ -1530,7 +1542,7 @@ describe('testing identity-srv', () => { it('should upsert (update) user and delete user collection', async function upsert(): Promise { let result = await userService.upsert({ items: [{ - id: upserUserID, + id: upsertUserID, name: 'upsertuser', email: 'upsert2@restorecommerce.io', password: 'RNZzHwG&jpv5RS4Ev2', @@ -1916,6 +1928,29 @@ describe('testing identity-srv', () => { await userService.delete({ ids: ['example-unauthenticated-user', 'foo-unauthenticated-user'] }); }); }); + + describe('testing user Token service', async function testTokenService() { + let tokenService: TokenServiceClient; + + before(async () => { + tokenService = await connect('client:token', 'token'); + }); + + it('should upsert token to User', async () => { + await tokenService.upsert({ + id: upsertUserID, + expires_in: new Date(new Date().getTime() + 100000), + payload: { + value: Buffer.from( + JSON.stringify({ + jti: 'TESTTOKEN', + accountId: upsertUserID, + }) + ) + } + }); + }); + }); }); }); });