diff --git a/api/.env.defaults b/api/.env.defaults index ad9987672442b84904a71d8526b7e050267b3924..0320f1402f9ae2e2f1cfa4380a4445073470dece 100644 --- a/api/.env.defaults +++ b/api/.env.defaults @@ -1,3 +1,13 @@ +# ENGINE ENGINE_TYPE=exareme ENGINE_BASE_URL=http://127.0.0.1:8080/services/ + +# 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 \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index 3d4c786544e197a15f2560fff6c55d4c3107e612..8eef2433f6b4fe6ff3de3b3ffa3bfe2939bb1d0a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -15,13 +15,19 @@ "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.0", "@nestjs/graphql": "^10.0.6", + "@nestjs/jwt": "^8.0.0", + "@nestjs/passport": "^8.2.1", "@nestjs/platform-express": "^8.0.0", "@nestjs/typeorm": "^8.0.2", "apollo-server-express": "^3.6.3", "axios": "^0.21.1", + "cookie-parser": "^1.4.6", "graphql": "^15.5.3", "graphql-type-json": "^0.3.2", "jsonata": "^1.8.5", + "passport": "^0.5.2", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -29,6 +35,7 @@ }, "devDependencies": { "@eclass/semantic-release-docker": "^3.0.0", + "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.2.2", @@ -36,9 +43,12 @@ "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/gitlab": "^7.0.4", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.13", "@types/jest": "^27.0.1", "@types/node": "^16.0.0", + "@types/passport-jwt": "^3.0.6", + "@types/passport-local": "^1.0.34", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", @@ -47,6 +57,7 @@ "eslint-plugin-prettier": "^3.4.0", "husky": "^7.0.2", "jest": "^27.0.6", + "jest-mock": "^27.5.1", "prettier": "^2.3.2", "supertest": "^6.1.3", "ts-jest": "^27.0.3", @@ -1064,6 +1075,12 @@ "node": ">=8" } }, + "node_modules/@jest-mock/express": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@jest-mock/express/-/express-1.4.5.tgz", + "integrity": "sha512-bERM1jnutyH7VMahdaOHAKy7lgX47zJ7+RTz2eMz0wlCttd9CkhsKFEyoWmJBSz/ow0nVj3lCuRqLem4QDYFkQ==", + "dev": true + }, "node_modules/@jest/console": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", @@ -1819,6 +1836,18 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-8.0.0.tgz", + "integrity": "sha512-fz2LQgYY2zmuD8S+8UE215anwKyXlnB/1FwJQLVR47clNfMeFMK8WCxmn6xdPhF5JKuV1crO6FVabb1qWzDxqQ==", + "dependencies": { + "@types/jsonwebtoken": "8.5.4", + "jsonwebtoken": "8.5.1" + }, + "peerDependencies": { + "@nestjs/common": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.0.1.tgz", @@ -1838,6 +1867,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.2.1.tgz", + "integrity": "sha512-HXEKMLX1x865+lsJB4srwKHBciDNAhWY1Ha+xbxYRbk7J5leGDoHJAmeqe+Wb3NDn5nkboggLV87t0q2mbYc8w==", + "peerDependencies": { + "@nestjs/common": "^6.0.0 || ^7.0.0 || ^8.0.0", + "passport": "^0.4.0 || ^0.5.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-8.4.0.tgz", @@ -2561,6 +2599,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -2680,6 +2727,14 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.4.tgz", + "integrity": "sha512-4L8msWK31oXwdtC81RmRBAULd0ShnAHjBuKT9MRQpjP0piNrZdXyTRcKY9/UIfhGeKIT4PvF5amOOUbbT/9Wpg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", @@ -2722,6 +2777,47 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/passport": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", + "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", + "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.34.tgz", + "integrity": "sha512-PSc07UdYx+jhadySxxIYWuv6sAnY5e+gesn/5lkPKfBeGuIYn9OPR+AAEDq73VRUh6NBTpvE/iPE62rzZUslog==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -3897,6 +3993,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4542,6 +4643,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -5094,6 +5215,14 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8312,6 +8441,54 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=4", + "npm": ">=1.4.28" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", @@ -8449,23 +8626,41 @@ "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=", "dev": true }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", "dev": true }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", - "dev": true + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -8484,6 +8679,11 @@ "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -11875,6 +12075,50 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.5.2.tgz", + "integrity": "sha512-w9n/Ot5I7orGD4y+7V3EFJCQEznE5RxHamUxcqLT2QoJY0f2JdN8GyHonYFvN0Vz+L6lUJfVhrk2aZz2LbuREw==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz", + "integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==", + "dependencies": { + "jsonwebtoken": "^8.2.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11921,6 +12165,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -15925,6 +16174,12 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true }, + "@jest-mock/express": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@jest-mock/express/-/express-1.4.5.tgz", + "integrity": "sha512-bERM1jnutyH7VMahdaOHAKy7lgX47zJ7+RTz2eMz0wlCttd9CkhsKFEyoWmJBSz/ow0nVj3lCuRqLem4QDYFkQ==", + "dev": true + }, "@jest/console": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", @@ -16463,12 +16718,27 @@ "ws": "8.5.0" } }, + "@nestjs/jwt": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-8.0.0.tgz", + "integrity": "sha512-fz2LQgYY2zmuD8S+8UE215anwKyXlnB/1FwJQLVR47clNfMeFMK8WCxmn6xdPhF5JKuV1crO6FVabb1qWzDxqQ==", + "requires": { + "@types/jsonwebtoken": "8.5.4", + "jsonwebtoken": "8.5.1" + } + }, "@nestjs/mapped-types": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.0.1.tgz", "integrity": "sha512-NFvofzSinp00j5rzUd4tf+xi9od6383iY0JP7o0Bnu1fuItAUkWBgc4EKuIQ3D+c2QI3i9pG1kDWAeY27EMGtg==", "requires": {} }, + "@nestjs/passport": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-8.2.1.tgz", + "integrity": "sha512-HXEKMLX1x865+lsJB4srwKHBciDNAhWY1Ha+xbxYRbk7J5leGDoHJAmeqe+Wb3NDn5nkboggLV87t0q2mbYc8w==", + "requires": {} + }, "@nestjs/platform-express": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-8.4.0.tgz", @@ -17066,6 +17336,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -17185,6 +17464,14 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/jsonwebtoken": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.4.tgz", + "integrity": "sha512-4L8msWK31oXwdtC81RmRBAULd0ShnAHjBuKT9MRQpjP0piNrZdXyTRcKY9/UIfhGeKIT4PvF5amOOUbbT/9Wpg==", + "requires": { + "@types/node": "*" + } + }, "@types/keyv": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", @@ -17227,6 +17514,47 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/passport": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", + "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-jwt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", + "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-local": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.34.tgz", + "integrity": "sha512-PSc07UdYx+jhadySxxIYWuv6sAnY5e+gesn/5lkPKfBeGuIYn9OPR+AAEDq73VRUh6NBTpvE/iPE62rzZUslog==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -18141,6 +18469,11 @@ "ieee754": "^1.1.13" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -18652,6 +18985,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -19095,6 +19444,14 @@ } } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -21513,6 +21870,49 @@ "through": ">=2.2.7 <3" } }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "keyv": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz", @@ -21625,23 +22025,41 @@ "integrity": "sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=", "dev": true }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", "dev": true }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, "lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", - "dev": true + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" }, "lodash.memoize": { "version": "4.1.2", @@ -21660,6 +22078,11 @@ "resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz", "integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=" }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -24084,6 +24507,37 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "passport": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.5.2.tgz", + "integrity": "sha512-w9n/Ot5I7orGD4y+7V3EFJCQEznE5RxHamUxcqLT2QoJY0f2JdN8GyHonYFvN0Vz+L6lUJfVhrk2aZz2LbuREw==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-jwt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz", + "integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==", + "requires": { + "jsonwebtoken": "^8.2.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -24118,6 +24572,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", diff --git a/api/package.json b/api/package.json index 510c73eab121cf232606d6e7c976e44af667b4c5..ac9d8e2879f8235ecbf9d47acfb6cb7e6eea8d12 100644 --- a/api/package.json +++ b/api/package.json @@ -29,13 +29,19 @@ "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.0", "@nestjs/graphql": "^10.0.6", + "@nestjs/jwt": "^8.0.0", + "@nestjs/passport": "^8.2.1", "@nestjs/platform-express": "^8.0.0", "@nestjs/typeorm": "^8.0.2", "apollo-server-express": "^3.6.3", "axios": "^0.21.1", + "cookie-parser": "^1.4.6", "graphql": "^15.5.3", "graphql-type-json": "^0.3.2", "jsonata": "^1.8.5", + "passport": "^0.5.2", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -43,6 +49,7 @@ }, "devDependencies": { "@eclass/semantic-release-docker": "^3.0.0", + "@jest-mock/express": "^1.4.5", "@nestjs/cli": "^8.0.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.2.2", @@ -50,9 +57,12 @@ "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/gitlab": "^7.0.4", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.13", "@types/jest": "^27.0.1", "@types/node": "^16.0.0", + "@types/passport-jwt": "^3.0.6", + "@types/passport-local": "^1.0.34", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", @@ -61,6 +71,7 @@ "eslint-plugin-prettier": "^3.4.0", "husky": "^7.0.2", "jest": "^27.0.6", + "jest-mock": "^27.5.1", "prettier": "^2.3.2", "supertest": "^6.1.3", "ts-jest": "^27.0.3", diff --git a/api/src/auth/auth-constants.ts b/api/src/auth/auth-constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..c904d4554a98dc825e4d2e7d6183fbf7590cebcc --- /dev/null +++ b/api/src/auth/auth-constants.ts @@ -0,0 +1,11 @@ +export const authConstants = { + JWTSecret: 'AUTH_JWT_SECRET', + skipAuth: 'AUTH_SKIP', + expiresIn: 'AUTH_JWT_TOKEN_EXPIRES_IN', + 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 new file mode 100644 index 0000000000000000000000000000000000000000..3a5381e281ff0715c1f08a862b4090f3f133c3f0 --- /dev/null +++ b/api/src/auth/auth.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { authConstants } from './auth-constants'; +import { AuthResolver } from './auth.resolver'; +import { AuthService } from './auth.service'; +import { JwtBearerStrategy } from './strategies/jwt-bearer.strategy'; +import { JwtCookiesStrategy } from './strategies/jwt-cookies.strategy'; +import { LocalStrategy } from './strategies/local.strategy'; + +@Module({ + imports: [ + PassportModule.register({ + session: false, + }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get<string>(authConstants.JWTSecret), + signOptions: { + expiresIn: configService.get<string>(authConstants.expiresIn), + }, + }), + inject: [ConfigService], + }), + ], + providers: [ + AuthService, + LocalStrategy, + JwtBearerStrategy, + JwtCookiesStrategy, + AuthResolver, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/api/src/auth/auth.resolver.spec.ts b/api/src/auth/auth.resolver.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc74383f2ad204a9b0e6a8c4503456e361081193 --- /dev/null +++ b/api/src/auth/auth.resolver.spec.ts @@ -0,0 +1,79 @@ +import { getMockRes } from '@jest-mock/express'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; +import LocalService from '../engine/connectors/local/main.connector'; +import { ENGINE_SERVICE } from '../engine/engine.constants'; +import { authConstants } from './auth-constants'; +import { AuthResolver } from './auth.resolver'; +import { AuthService } from './auth.service'; +import { User } from './models/user.model'; + +const moduleMocker = new ModuleMocker(global); + +describe('AuthResolver', () => { + let resolver: AuthResolver; + const { res } = getMockRes(); + const mockCookie = jest.fn(); + const mockClearCookie = jest.fn(); + + res.cookie = mockCookie; + res.clearCookie = mockClearCookie; + + const user: User = { + id: 'testing', + username: 'testing', + }; + + const authData = { + accessToken: 'DummyToken', + }; + + const credentials = { + username: 'guest', + password: 'guest123', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ENGINE_SERVICE, + useClass: LocalService, + }, + AuthResolver, + ], + }) + .useMocker((token) => { + if (token === AuthService) { + return { + login: jest.fn().mockResolvedValue(authData), + }; + } + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata( + token, + ) as MockFunctionMetadata<any, any>; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }) + .compile(); + + resolver = module.get<AuthResolver>(AuthResolver); + }); + + it('login', async () => { + const data = await resolver.login(res, user, credentials); + + expect(mockCookie.mock.calls[0][0]).toBe(authConstants.cookie.name); + expect(mockCookie.mock.calls[0][1]).toBe(authData.accessToken); + expect(data.accessToken).toBe(authData.accessToken); + expect(data.user).toBe(user); + }); + + it('logout', () => { + resolver.logout(res, user); + + expect(mockClearCookie.mock.calls[0][0]).toBe(authConstants.cookie.name); + }); +}); diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a5f5698a787dea72e3836b3ebc197aef7fea8cf --- /dev/null +++ b/api/src/auth/auth.resolver.ts @@ -0,0 +1,80 @@ +import { + Inject, + InternalServerErrorException, + Logger, + UseGuards, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { Response } from 'express'; +import { parseToBoolean } from '../common/interfaces/utilities.interface'; +import { ENGINE_SERVICE } from '../engine/engine.constants'; +import { IEngineService } from '../engine/engine.interfaces'; +import { authConstants } from './auth-constants'; +import { AuthService } from './auth.service'; +import { GQLResponse } from './decorators/gql-request.decoractor'; +import { CurrentUser } from './decorators/user.decorator'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { LocalAuthGuard } from './guards/local-auth.guard'; +import { AuthenticationInput } from './inputs/authentication.input'; +import { User } from './models/user.model'; +import { AuthenticationOutput } from './outputs/authentication.output'; + +//Custom defined type because Pick<CookieOptions, 'sameSite'> does not work +type SameSiteType = boolean | 'lax' | 'strict' | 'none' | undefined; + +@Resolver() +export class AuthResolver { + private readonly logger = new Logger(AuthResolver.name); + + constructor( + @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + @Mutation(() => AuthenticationOutput) + @UseGuards(LocalAuthGuard) + async login( + @GQLResponse() res: Response, + @CurrentUser() user: User, + @Args('variables') inputs: AuthenticationInput, + ): Promise<AuthenticationOutput> { + this.logger.verbose(`${inputs.username} logged in`); + + 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'), + ), + }); + + return { + user, + accessToken: data.accessToken, + }; + } + + @Mutation(() => Boolean) + @UseGuards(JwtAuthGuard) + logout(@GQLResponse() res: Response, @CurrentUser() user: User): boolean { + this.logger.verbose(`${user.username} logged out`); + + res.clearCookie(authConstants.cookie.name); + this.engineService.logout?.(); + + return true; + } +} diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9122005451083e42a0cd056b161c4d4897f82252 --- /dev/null +++ b/api/src/auth/auth.service.spec.ts @@ -0,0 +1,69 @@ +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; +import LocalService from '../engine/connectors/local/main.connector'; +import { + ENGINE_MODULE_OPTIONS, + ENGINE_SERVICE, +} from '../engine/engine.constants'; +import { AuthService } from './auth.service'; +import { User } from './models/user.model'; + +const moduleMocker = new ModuleMocker(global); + +describe('AuthService', () => { + let service: AuthService; + const user: User = { + id: 'dummy', + username: 'dummy64', + }; + const jwtToken = 'JWTDummyToken'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ENGINE_SERVICE, + useClass: LocalService, + }, + { + provide: ENGINE_MODULE_OPTIONS, + useValue: { + type: 'local', + baseurl: 'test', + }, + }, + AuthService, + ], + }) + .useMocker((token) => { + if (token === JwtService) { + return { + sign: jest.fn().mockReturnValue(jwtToken), + }; + } + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata( + token, + ) as MockFunctionMetadata<any, any>; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }) + .compile(); + + service = module.get<AuthService>(AuthService); + }); + + it('login', () => { + const data = service.login(user); + + expect(data.accessToken).toBe(jwtToken); + }); + + it('validateUser', async () => { + const data = await service.validateUser('guest', 'password123'); + + expect(!!data).toBeTruthy(); + }); +}); diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e992fa760c238d0d475115e87cf52e5e07a0d5f3 --- /dev/null +++ b/api/src/auth/auth.service.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable, NotImplementedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ENGINE_SERVICE } from '../engine/engine.constants'; +import { IEngineService } from '../engine/engine.interfaces'; +import { User } from './models/user.model'; +import { AuthenticationOutput } from './outputs/authentication.output'; + +@Injectable() +export class AuthService { + constructor( + @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, + private jwtService: JwtService, + ) {} + + async validateUser(username: string, password: string): Promise<User> { + if (!this.engineService.login) throw new NotImplementedException(); + return await this.engineService.login?.(username, password); + } + + async login(user: User): Promise<Pick<AuthenticationOutput, 'accessToken'>> { + const payload = { username: user.username, sub: user }; + return Promise.resolve({ + accessToken: this.jwtService.sign(payload), + }); + } +} diff --git a/api/src/auth/decorators/gql-request.decoractor.ts b/api/src/auth/decorators/gql-request.decoractor.ts new file mode 100644 index 0000000000000000000000000000000000000000..4de3409deea7848aa33e7a66d11a842b029ac5a9 --- /dev/null +++ b/api/src/auth/decorators/gql-request.decoractor.ts @@ -0,0 +1,14 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { Response } from 'express'; + +/** + * Access to graphQL request through the context + * @returns request from graphql + */ +export const GQLResponse = createParamDecorator( + (data: unknown, context: ExecutionContext): Response => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().res; + }, +); diff --git a/api/src/auth/decorators/user.decorator.ts b/api/src/auth/decorators/user.decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbb566332c2d214cb3d33ec1b9ff7c458f9992e3 --- /dev/null +++ b/api/src/auth/decorators/user.decorator.ts @@ -0,0 +1,14 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { User } from '../models/user.model'; + +/** + * Retrieve the current user within the graphQL request + * @returns instance of User or undefined + */ +export const CurrentUser = createParamDecorator( + (data: unknown, context: ExecutionContext): User | undefined => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.user; + }, +); diff --git a/api/src/auth/guards/jwt-auth.guard.ts b/api/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..4afaca585a6117aca640b98ef5c641c7cbde9837 --- /dev/null +++ b/api/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,35 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; +import { parseToBoolean } from '../../common/interfaces/utilities.interface'; +import { authConstants } from '../auth-constants'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard(['jwt-cookies', 'jwt-bearer']) { + constructor(private readonly configService: ConfigService) { + super(); + } + + getRequest(context: ExecutionContext) { + const ctx = GqlExecutionContext.create(context); + const gqlReq = ctx.getContext().req; + + return gqlReq ?? ctx.switchToHttp().getRequest(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise<boolean> | Observable<boolean> { + const skipAuth = parseToBoolean( + this.configService.get(authConstants.skipAuth, 'false'), + ); + + if (skipAuth) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/api/src/auth/guards/local-auth.guard.ts b/api/src/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..77782dcac3a3bc9950b5b2a0f8224c940f16c003 --- /dev/null +++ b/api/src/auth/guards/local-auth.guard.ts @@ -0,0 +1,18 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') { + //override to handle graphql specific use case + getRequest(context: ExecutionContext) { + const ctx = GqlExecutionContext.create(context); + const gqlReq = ctx.getContext().req; + if (gqlReq) { + const { variables } = ctx.getArgs(); + gqlReq.body = variables; + return gqlReq; + } + return context.switchToHttp().getRequest(); + } +} diff --git a/api/src/auth/inputs/authentication.input.ts b/api/src/auth/inputs/authentication.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba31ea5e97bfde9fa9bd96bcf98d9921610ab479 --- /dev/null +++ b/api/src/auth/inputs/authentication.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AuthenticationInput { + @Field() + username: string; + + @Field() + password: string; +} diff --git a/api/src/auth/models/user.model.ts b/api/src/auth/models/user.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..2352d59008cc9bf68feefd7b954108cf161730ed --- /dev/null +++ b/api/src/auth/models/user.model.ts @@ -0,0 +1,21 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class User { + @Field() + id: string; + + @Field() + username: string; + + @Field({ nullable: true }) + fullname?: string; + + @Field({ nullable: true }) + email?: string; + + @Field({ nullable: true }) + agreeNDA?: boolean; + + extraFields?: Record<string, any>; +} diff --git a/api/src/auth/outputs/authentication.output.ts b/api/src/auth/outputs/authentication.output.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f4590c2ddbfb3e1a7a4cb69e8127b3cfc07c3c0 --- /dev/null +++ b/api/src/auth/outputs/authentication.output.ts @@ -0,0 +1,11 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { User } from '../models/user.model'; + +@ObjectType() +export class AuthenticationOutput { + @Field(() => User) + user: User; + + @Field() + accessToken: string; +} diff --git a/api/src/auth/strategies/jwt-bearer.strategy.ts b/api/src/auth/strategies/jwt-bearer.strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..9439c87cf54accd4684455c1ce66df564d3af2ac --- /dev/null +++ b/api/src/auth/strategies/jwt-bearer.strategy.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { authConstants } from '../auth-constants'; + +@Injectable() +export class JwtBearerStrategy extends PassportStrategy( + Strategy, + 'jwt-bearer', +) { + constructor(private readonly configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get<string>(authConstants.JWTSecret), + }); + } + + async validate(payload: any) { + return payload.sub; + } +} diff --git a/api/src/auth/strategies/jwt-cookies.strategy.ts b/api/src/auth/strategies/jwt-cookies.strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..29ca031c34660149bb3a90e30ebed97432f7e9ca --- /dev/null +++ b/api/src/auth/strategies/jwt-cookies.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Request } from 'express'; +import { Strategy } from 'passport-jwt'; +import { authConstants } from '../auth-constants'; + +@Injectable() +export class JwtCookiesStrategy extends PassportStrategy( + Strategy, + 'jwt-cookies', +) { + constructor(private readonly configService: ConfigService) { + super({ + jwtFromRequest: JwtCookiesStrategy.extractFromCookie, + ignoreExpiration: false, + secretOrKey: configService.get<string>(authConstants.JWTSecret), + }); + } + + static extractFromCookie = function (req: Request) { + let token = null; + if (req && req.cookies) { + token = req.cookies[authConstants.cookie.name]; + } + return token; + }; + + async validate(payload: any) { + return payload.sub; + } +} diff --git a/api/src/auth/strategies/local.strategy.ts b/api/src/auth/strategies/local.strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..166e9efb0d75dcb31a56ab1c4e09ceb9c8dc846a --- /dev/null +++ b/api/src/auth/strategies/local.strategy.ts @@ -0,0 +1,29 @@ +import { Strategy } from 'passport-local'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; +import { User } from '../models/user.model'; +import { ContextIdFactory, ModuleRef } from '@nestjs/core'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy, 'local') { + constructor(private moduleRef: ModuleRef) { + super({ + passReqToCallback: true, + }); + } + + async validate( + request: Request, + username: string, + password: string, + ): Promise<User> { + const contextId = ContextIdFactory.getByRequest(request); + const authService = await this.moduleRef.resolve(AuthService, contextId); + const user = await authService.validateUser(username, password); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} diff --git a/api/src/common/interfaces/utilities.interface.ts b/api/src/common/interfaces/utilities.interface.ts index db02ef99e3f4c67f65f320f1d0bfe1a2a6b832f5..762534ac111d76f505e55c787d771ff6eb164d13 100644 --- a/api/src/common/interfaces/utilities.interface.ts +++ b/api/src/common/interfaces/utilities.interface.ts @@ -16,3 +16,16 @@ export enum MIME_TYPES { HTML = 'text/html', TEXT = 'text/plain', } + +/** + * Utility method to convert string value to boolean + * @param value string value to be converted + * @returns true if string value equals to 'true', false otherwise + */ +export const parseToBoolean = (value: string): boolean => { + try { + return value.toLowerCase() == 'true'; + } catch { + return false; + } +}; diff --git a/api/src/common/utilities.ts b/api/src/common/utilities.ts new file mode 100644 index 0000000000000000000000000000000000000000..c109df5e5b8c5506844a4e43a7b1d7612440a135 --- /dev/null +++ b/api/src/common/utilities.ts @@ -0,0 +1,21 @@ +import { + HttpException, + InternalServerErrorException, + NotFoundException, + RequestTimeoutException, + UnauthorizedException, +} from '@nestjs/common'; +import axios from 'axios'; + +export const errorAxiosHandler = (e: any) => { + if (!axios.isAxiosError(e)) throw new InternalServerErrorException(e); + + if (e.response.status === 401) throw new UnauthorizedException(); + if (e.response.status === 404) throw new NotFoundException(); + if (e.response.status === 408) throw new RequestTimeoutException(); + if (e.response.status === 500) throw new InternalServerErrorException(); + + if (e.response) throw new HttpException(e.response.data, e.response.status); + + throw new InternalServerErrorException('Unknown error'); +}; diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index 81f7bf6bb6ecba86cba15b2350d460e57dadf933..2e4f3be2ebce9f21319426a07cda073005fcd4ec 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -1,9 +1,11 @@ import { HttpService } from '@nestjs/axios'; -import { Inject, Logger } from '@nestjs/common'; +import { Inject, Logger, NotImplementedException } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; -import { firstValueFrom, Observable } from 'rxjs'; +import { catchError, firstValueFrom, Observable } from 'rxjs'; +import { User } from 'src/auth/models/user.model'; import { MIME_TYPES } from 'src/common/interfaces/utilities.interface'; +import { errorAxiosHandler } from 'src/common/utilities'; import { ENGINE_MODULE_OPTIONS } from 'src/engine/engine.constants'; import { IConfiguration, @@ -43,22 +45,50 @@ export default class DataShieldService implements IEngineService { return {}; } - logout(): void { - throw new Error('Method not implemented.'); + async login(username: string, password: string): Promise<User> { + const loginPath = this.options.baseurl + 'login'; + + const user: User = { + id: username, + username, + extraFields: { + sid: '', + }, + }; + + const loginData = await firstValueFrom( + this.httpService + .get(loginPath, { + auth: { username, password }, + }) + .pipe(catchError((e) => errorAxiosHandler(e))), + ); + + const cookies = (loginData.headers['set-cookie'] as string[]) ?? []; + if (loginData.headers && loginData.headers['set-cookie']) { + cookies.forEach((cookie) => { + const [key, value] = cookie.split(/={1}/); + if (key === 'sid') { + user.extraFields.sid = value; + } + }); + } + + return user; } getAlgorithms(): Algorithm[] | Promise<Algorithm[]> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } - async getHistogram(variable: string): Promise<RawResult> { + async getHistogram(variable: string, cookie?: string): Promise<RawResult> { const path = this.options.baseurl + `histogram?var=${variable}&type=combine`; const response = await firstValueFrom( this.httpService.get(path, { headers: { - cookie: this.req['req'].headers['cookie'], + cookie, }, }), ); @@ -87,13 +117,16 @@ export default class DataShieldService implements IEngineService { }; } - async getDescriptiveStats(variable: string): Promise<TableResult> { + async getDescriptiveStats( + variable: string, + cookie?: string, + ): Promise<TableResult> { const path = this.options.baseurl + `quantiles?var=${variable}&type=split`; const response = await firstValueFrom( this.httpService.get(path, { headers: { - cookie: this.req['req'].headers['cookie'], + cookie, }, }), ); @@ -111,6 +144,10 @@ export default class DataShieldService implements IEngineService { data: ExperimentCreateInput, isTransient: boolean, ): Promise<Experiment> { + const user = this.req.user as User; + const cookie = [`sid=${user.extraFields['sid']}`, `user=${user.id}`].join( + ';', + ); const expResult: Experiment = { id: `${data.algorithm.id}-${Date.now()}`, variables: data.variables, @@ -126,7 +163,7 @@ export default class DataShieldService implements IEngineService { case 'MULTIPLE_HISTOGRAMS': { expResult.results = await Promise.all<RawResult>( data.variables.map( - async (variable) => await this.getHistogram(variable), + async (variable) => await this.getHistogram(variable, cookie), ), ); break; @@ -134,7 +171,8 @@ export default class DataShieldService implements IEngineService { case 'DESCRIPTIVE_STATS': { expResult.results = await Promise.all<TableResult>( [...data.variables, ...data.coVariables].map( - async (variable) => await this.getDescriptiveStats(variable), + async (variable) => + await this.getDescriptiveStats(variable, cookie), ), ); break; @@ -157,40 +195,23 @@ export default class DataShieldService implements IEngineService { } getExperiment(id: string): Experiment | Promise<Experiment> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } removeExperiment(id: string): PartialExperiment | Promise<PartialExperiment> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } editExperient( id: string, expriment: ExperimentEditInput, ): Experiment | Promise<Experiment> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } async getDomains(): Promise<Domain[]> { - const loginPath = this.options.baseurl + 'login'; - - const loginData = await firstValueFrom( - this.httpService.get(loginPath, { - auth: { username: 'guest', password: 'guest123' }, - }), - ); - - const cookies = (loginData.headers['set-cookie'] as string[]) ?? []; - if (loginData.headers && loginData.headers['set-cookie']) { - cookies.forEach((cookie) => { - const [key, value] = cookie.split(/={1}/); - this.req.res.cookie(key, value, { - httpOnly: true, - //sameSite: 'none', - }); - }); - } - + const user = this.req.user as User; + const cookies = [`sid=${user.extraFields['sid']}`, `user=${user.id}`]; const path = this.options.baseurl + 'getvars'; const response = await firstValueFrom( @@ -216,27 +237,27 @@ export default class DataShieldService implements IEngineService { } editActiveUser(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } getExperimentREST(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } deleteExperiment(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } editExperimentREST(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } startExperimentTransient(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } startExperiment(): Observable<string> { - throw new Error('Method not implemented.'); + throw new NotImplementedException(); } getExperiments(): string { diff --git a/api/src/engine/connectors/local/main.connector.ts b/api/src/engine/connectors/local/main.connector.ts index 003b3c8cc2927a84dda52f8efe97b7d44edb8ab8..af5cd6b66443af0f60c8d0db97e596f3b2810fa5 100644 --- a/api/src/engine/connectors/local/main.connector.ts +++ b/api/src/engine/connectors/local/main.connector.ts @@ -9,10 +9,14 @@ import { import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; +import { User } from 'src/auth/models/user.model'; export default class LocalService implements IEngineService { - logout(): void { - throw new Error('Method not implemented.'); + login(): User | Promise<User> { + return { + id: '1', + username: 'LocalServiceUser', + }; } getAlgorithms(): Algorithm[] | Promise<Algorithm[]> { diff --git a/api/src/engine/engine.controller.ts b/api/src/engine/engine.controller.ts index 6338a6b073b79e1a11c64ce895944b6268c50548..ffd77ead399601950d98d82433a5967deec7f71a 100644 --- a/api/src/engine/engine.controller.ts +++ b/api/src/engine/engine.controller.ts @@ -1,18 +1,5 @@ -import { - Controller, - Get, - Inject, - NotFoundException, - Param, - Post, - Req, - Res, - UseInterceptors, -} from '@nestjs/common'; -import { Request, Response } from 'express'; -import { join } from 'path/posix'; +import { Controller, Get, Inject, Post, UseInterceptors } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { AssetsService } from './assets.service'; import { ENGINE_SERVICE } from './engine.constants'; import { IEngineService } from './engine.interfaces'; import { ErrorsInterceptor } from './interceptors/errors.interceptor'; @@ -22,35 +9,8 @@ import { ErrorsInterceptor } from './interceptors/errors.interceptor'; export class EngineController { constructor( @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, - private readonly assetsService: AssetsService, ) {} - @Get('assets/:name') - getFile( - @Req() request: Request, - @Res() response: Response, - @Param('name') filename: string, - ) { - if (filename.endsWith('.md')) { - const baseurl = - request.protocol + - '://' + - join(request.get('host'), process.env.BASE_URL_CONTEXT ?? '', 'assets'); // not full url, should consider "/services" - const text = this.assetsService.getMarkdown(filename, baseurl); - response.setHeader('Content-Type', 'text/markdown'); - return response.send(text); - } - - const filepath = this.assetsService.getAssetFile(filename); - - // Test if the file exist, if not send 404 - if (filepath) { - return response.sendFile(filepath); - } else { - throw new NotFoundException(); - } - } - @Get('/algorithms') getAlgorithms(): Observable<string> | string { return this.engineService.getAlgorithmsREST(); diff --git a/api/src/engine/engine.interfaces.ts b/api/src/engine/engine.interfaces.ts index 69da9f65a819f323731ac0a75f59a39811c58be3..281ae1f3f5e51ba1c42ec28b91ede0f6637c668a 100644 --- a/api/src/engine/engine.interfaces.ts +++ b/api/src/engine/engine.interfaces.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { User } from 'src/auth/models/user.model'; import { Configuration } from './models/configuration.model'; import { Domain } from './models/domain.model'; import { Algorithm } from './models/experiment/algorithm.model'; @@ -69,7 +70,18 @@ export interface IEngineService { editActiveUser(): Observable<string> | string; - logout(): void; + logout?(): void; + + /** + * Method that login a user with username and password + * @param username + * @param password + * @returns User object or empty if user not found + */ + login?( + username: string, + password: string, + ): Promise<User | undefined> | User | undefined; getPassthrough?(suffix: string): Observable<string> | string; } diff --git a/api/src/engine/engine.module.ts b/api/src/engine/engine.module.ts index 8831eb2aeb9f62e9469de310502d7d2740e6926d..3f5cf47b16136f4485649ad6ef81b96ed2bc32e5 100644 --- a/api/src/engine/engine.module.ts +++ b/api/src/engine/engine.module.ts @@ -1,11 +1,8 @@ -import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { HttpModule, HttpService } from '@nestjs/axios'; import { DynamicModule, Global, Logger, Module } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { GraphQLModule } from '@nestjs/graphql'; import { Request } from 'express'; -import { join } from 'path'; -import { AssetsService } from './assets.service'; +import { IncomingMessage } from 'http'; import { ENGINE_MODULE_OPTIONS, ENGINE_SERVICE } from './engine.constants'; import { EngineController } from './engine.controller'; import { IEngineOptions, IEngineService } from './engine.interfaces'; @@ -16,64 +13,52 @@ import { EngineResolver } from './engine.resolver'; export class EngineModule { private static readonly logger = new Logger(EngineModule.name); - static async forRootAsync(options: IEngineOptions): Promise<DynamicModule> { + static forRoot(options?: Partial<IEngineOptions>): DynamicModule { const optionsProvider = { provide: ENGINE_MODULE_OPTIONS, - useValue: options, + useValue: { + type: process.env.ENGINE_TYPE, + baseurl: process.env.ENGINE_BASE_URL, + ...(options ?? {}), + }, }; const engineProvider = { provide: ENGINE_SERVICE, useFactory: async (httpService: HttpService, req: Request) => { - return await this.createEngineConnection(options, httpService, req); + return await this.createEngineConnection( + optionsProvider.useValue, + httpService, + req, + ); }, inject: [HttpService, REQUEST], }; return { module: EngineModule, - imports: [ - HttpModule, - GraphQLModule.forRoot<ApolloDriverConfig>({ - driver: ApolloDriver, - autoSchemaFile: join(process.cwd(), 'src/schema.gql'), - context: ({ req, res }) => ({ req, res }), - cors: { - credentials: true, - origin: [ - /http:\/\/localhost($|:\d*)/, - /http:\/\/127.0.0.1($|:\d*)/, - ], - }, - }), - ], - providers: [ - optionsProvider, - engineProvider, - EngineResolver, - AssetsService, - ], + imports: [HttpModule], + providers: [optionsProvider, engineProvider, EngineResolver], controllers: [EngineController], exports: [optionsProvider, engineProvider], }; } private static async createEngineConnection( - options: IEngineOptions, + opt: IEngineOptions, httpService: HttpService, req: Request, ): Promise<IEngineService> { try { - const service = await import( - `./connectors/${options.type}/main.connector` - ); - const engine = new service.default(options, httpService, req); + const service = await import(`./connectors/${opt.type}/main.connector`); + const gqlRequest = req && req['req']; // graphql headers exception + const request = + gqlRequest && gqlRequest instanceof IncomingMessage ? gqlRequest : req; + const engine = new service.default(opt, httpService, request); return engine; } catch (e) { - this.logger.error( - `There is a problem with the connector '${options.type}'`, - ); + this.logger.error(`There is a problem with the connector '${opt.type}'`); this.logger.verbose(e); } } diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index a67967c8d1db1c13b9cfcf2aa64ae12a19661d96..ac8845c7e04e77ddb05f56cebf1b1e02ad316fdd 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -1,8 +1,9 @@ -import { Inject, UseInterceptors } from '@nestjs/common'; +import { Inject, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { Md5 } from 'ts-md5'; import { ENGINE_MODULE_OPTIONS, ENGINE_SERVICE } from './engine.constants'; import { IEngineOptions, IEngineService } from './engine.interfaces'; -import { ErrorsInterceptor } from './interceptors/errors.interceptor'; import { Configuration } from './models/configuration.model'; import { Domain } from './models/domain.model'; import { Algorithm } from './models/experiment/algorithm.model'; @@ -13,9 +14,8 @@ import { import { ExperimentCreateInput } from './models/experiment/input/experiment-create.input'; import { ExperimentEditInput } from './models/experiment/input/experiment-edit.input'; import { ListExperiments } from './models/experiment/list-experiments.model'; -import { Md5 } from 'ts-md5'; -@UseInterceptors(ErrorsInterceptor) +@UseGuards(JwtAuthGuard) @Resolver() export class EngineResolver { constructor( diff --git a/api/src/engine/interceptors/errors.interceptor.ts b/api/src/engine/interceptors/errors.interceptor.ts index 528e62ae1b525d3f8618070a20c924cc0f140e7d..47e61fc3aab815fa820b1180b655a65529723dc0 100644 --- a/api/src/engine/interceptors/errors.interceptor.ts +++ b/api/src/engine/interceptors/errors.interceptor.ts @@ -1,4 +1,3 @@ -import { HttpService } from '@nestjs/axios'; import { CallHandler, HttpException, @@ -17,7 +16,6 @@ export class ErrorsInterceptor implements NestInterceptor { private readonly logger: Logger; constructor( - private httpService: HttpService, @Inject(ENGINE_MODULE_OPTIONS) private readonly options: IEngineOptions, ) { // Logger name is the engine name diff --git a/api/src/files/files.controller.spec.ts b/api/src/files/files.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ff16a6ce0d67ecfb2e16e7b3ded0df966e7878e --- /dev/null +++ b/api/src/files/files.controller.spec.ts @@ -0,0 +1,42 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ENGINE_MODULE_OPTIONS } from '../engine/engine.constants'; +import { FilesController } from './files.controller'; +import { FilesService } from './files.service'; + +describe('FilesController', () => { + let service: FilesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FilesController], + providers: [ + { + provide: ENGINE_MODULE_OPTIONS, + useValue: { + type: 'test', + baseurl: 'test', + }, + }, + FilesService, + ], + }).compile(); + + service = module.get<FilesService>(FilesService); + }); + + it('getAssetFile', () => { + const filePathEmpty = service.getAssetFile('FILE_THAT_DOES_NOT_EXIST.txt'); + const filePath = service.getAssetFile('tos.md'); + const fileWithLFI = service.getAssetFile('../../../.env'); + + expect(filePathEmpty).toBeUndefined(); + expect(fileWithLFI).toBeUndefined(); + expect(filePath).toEqual(expect.anything()); + }); + + it('markdown', () => { + const fileContent = service.getMarkdown('login.md', 'http://localtest'); + expect(!!fileContent).toBeTruthy(); + expect(fileContent.includes('http://localtest')).toBeTruthy(); + }); +}); diff --git a/api/src/files/files.controller.ts b/api/src/files/files.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..91f3f8549eaf05822ed51d456b29dc03e49a5a99 --- /dev/null +++ b/api/src/files/files.controller.ts @@ -0,0 +1,42 @@ +import { + Controller, + Get, + NotFoundException, + Param, + Req, + Res, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { join } from 'path/posix'; +import { FilesService } from './files.service'; + +@Controller() +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get('assets/:name') + getFile( + @Req() request: Request, + @Res() response: Response, + @Param('name') filename: string, + ) { + if (filename.endsWith('.md')) { + const baseurl = + request.protocol + + '://' + + join(request.get('host'), process.env.BASE_URL_CONTEXT ?? '', 'assets'); // not full url, should consider "/services" + const text = this.filesService.getMarkdown(filename, baseurl); + response.setHeader('Content-Type', 'text/markdown'); + return response.send(text); + } + + const filepath = this.filesService.getAssetFile(filename); + + // Test if the file exist, if not send 404 + if (filepath) { + return response.sendFile(filepath); + } else { + throw new NotFoundException(); + } + } +} diff --git a/api/src/files/files.module.ts b/api/src/files/files.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcfb777b965a099ff7337d7b716797a5b8fe368c --- /dev/null +++ b/api/src/files/files.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { FilesController } from './files.controller'; +import { FilesService } from './files.service'; + +@Module({ + controllers: [FilesController], + providers: [FilesService], +}) +export class FilesModule {} diff --git a/api/src/engine/assets.service.ts b/api/src/files/files.service.ts similarity index 90% rename from api/src/engine/assets.service.ts rename to api/src/files/files.service.ts index 4abde93c41659838fd369e8cdd4bbf195970b220..685ab86da64b8f1ecf1ead4f11a4e8fe5701ff5d 100644 --- a/api/src/engine/assets.service.ts +++ b/api/src/files/files.service.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import * as fs from 'fs'; import { join } from 'path/posix'; -import { ENGINE_MODULE_OPTIONS } from './engine.constants'; -import { IEngineOptions } from './engine.interfaces'; +import { ENGINE_MODULE_OPTIONS } from '../engine/engine.constants'; +import { IEngineOptions } from '../engine/engine.interfaces'; @Injectable() -export class AssetsService { +export class FilesService { constructor( @Inject(ENGINE_MODULE_OPTIONS) private readonly engineOptions: IEngineOptions, diff --git a/api/src/main.ts b/api/src/main.ts index 97c880c6c04e645b925fe1262997de18282174fa..e33633323795e658f72a120ce67e44dec64a1e1b 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,7 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; -import { join } from 'path/posix'; import { AppModule } from './main/app.module'; +import * as cookieParser from 'cookie-parser'; const CORS_URL = process.env.CORS_URL ?? process.env.ENGINE_BASE_URL; @@ -17,6 +17,8 @@ async function bootstrap() { }, }); + app.use(cookieParser()); + await app.listen(process.env.GATEWAY_PORT); } bootstrap(); diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index 7638ce2f6f28ce28f57f57974833ba8207669bba..dc204f2c50b100bccff9430051c040538c50da4e 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -1,6 +1,11 @@ +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { GraphQLModule } from '@nestjs/graphql'; +import { join } from 'path'; +import { AuthModule } from 'src/auth/auth.module'; import { EngineModule } from 'src/engine/engine.module'; +import { FilesModule } from 'src/files/files.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -10,10 +15,21 @@ import { AppService } from './app.service'; isGlobal: true, envFilePath: ['.env', '.env.defaults'], }), - EngineModule.forRootAsync({ + GraphQLModule.forRoot<ApolloDriverConfig>({ + driver: ApolloDriver, + autoSchemaFile: join(process.cwd(), 'src/schema.gql'), + context: ({ req, res }) => ({ req, res }), + cors: { + credentials: true, + origin: [/http:\/\/localhost($|:\d*)/, /http:\/\/127.0.0.1($|:\d*)/], + }, + }), + EngineModule.forRoot({ type: process.env.ENGINE_TYPE, baseurl: process.env.ENGINE_BASE_URL, }), + AuthModule, + FilesModule, ], controllers: [AppController], providers: [AppService], diff --git a/api/src/schema.gql b/api/src/schema.gql index 9fb8aadc4ad75433fbaa7215ecde5afe71110e58..0804f393996f71a257f74595bc84f138262bc2f4 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -2,6 +2,19 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type User { + id: String! + username: String! + fullname: String + email: String + agreeNDA: Boolean +} + +type AuthenticationOutput { + user: User! + accessToken: String! +} + type Configuration { connectorId: String! galaxy: Boolean @@ -224,6 +237,8 @@ type Mutation { createExperiment(data: ExperimentCreateInput!, isTransient: Boolean = false): Experiment! editExperiment(id: String!, data: ExperimentEditInput!): Experiment! removeExperiment(id: String!): PartialExperiment! + login(variables: AuthenticationInput!): AuthenticationOutput! + logout: Boolean! } input ExperimentCreateInput { @@ -265,3 +280,8 @@ input ExperimentEditInput { shared: Boolean viewed: Boolean } + +input AuthenticationInput { + username: String! + password: String! +}