diff --git a/.env.example b/.env.example index 6406c304..51acb58f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ APPROVED_TOKENS=abc,def DEBUG=false +JWT_SECRET=test-secret STARGATE_BASEURL=http://localhost:8082 STARGATE_BASE_API_PATH=/v2/namespaces diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 061f06ba..4459eb6b 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -40,3 +40,8 @@ spec: secretKeyRef: name: approved-tokens key: APPROVED_TOKENS + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: jwt-secret + key: JWT_SECRET diff --git a/package-lock.json b/package-lock.json index cc5cb559..6cffb3ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,11 @@ { "name": "api", - "version": "0.6.10", + "version": "0.6.11", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "api", - "version": "0.6.10", + "version": "0.6.11", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,6 +14,7 @@ "@nestjs/common": "^7.6.13", "@nestjs/config": "^0.6.3", "@nestjs/core": "^7.6.13", + "@nestjs/jwt": "^8.0.0", "@nestjs/mapped-types": "^0.3.0", "@nestjs/passport": "^7.1.5", "@nestjs/platform-express": "^7.6.13", @@ -24,11 +24,13 @@ "date-fns": "^2.21.1", "helmet": "^4.5.0", "passport": "^0.4.1", - "passport-unique-token": "^2.0.0", + "passport-jwt": "^4.0.0", + "passport-unique-token": "^3.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^6.6.6", - "swagger-ui-express": "^4.1.6" + "swagger-ui-express": "^4.1.6", + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^12.1.1", @@ -40,7 +42,9 @@ "@types/express": "4.17.11", "@types/jest": "26.0.20", "@types/node": "14.14.31", + "@types/passport-jwt": "^3.0.6", "@types/supertest": "2.0.10", + "@types/uuid": "^8.3.1", "@typescript-eslint/eslint-plugin": "^4.6.1", "@typescript-eslint/parser": "^4.6.1", "chai": "^4.3.0", @@ -1680,6 +1684,18 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, + "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": "0.3.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-0.3.0.tgz", @@ -2066,6 +2082,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/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -2095,6 +2119,36 @@ "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-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.2.1", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.1.tgz", @@ -2186,6 +2240,12 @@ "node": ">=0.10.0" } }, + "node_modules/@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.1.3.tgz", @@ -3306,6 +3366,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.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -4447,6 +4512,14 @@ "safer-buffer": "^2.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", @@ -7526,6 +7599,32 @@ "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -7550,6 +7649,25 @@ "node >=0.6.0" ] }, + "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/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -7646,6 +7764,41 @@ "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=" }, + "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.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=" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "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.set": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", @@ -8671,6 +8824,15 @@ "node": ">= 0.4.0" } }, + "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-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8680,14 +8842,14 @@ } }, "node_modules/passport-unique-token": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-2.0.0.tgz", - "integrity": "sha512-SVsNT7wZ6QboP1Lm94BWjbwghV3Qoo71w0SZ075e4kGp+ibnFmIywZf1DgoFa5l+eSDzgAjW3h4zWFQbTDEv2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-3.0.0.tgz", + "integrity": "sha512-BkSWODzwS1i8Z5ImmPQOWZ05dw9oS09VMBIZOogubKACrm3UO3wlJnwT/fCMQh5iTtFLYA+X4yWmtsqufftgXw==", "dependencies": { "passport-strategy": "^1.0.0" }, "engines": { - "node": ">= 10.22.x" + "node": ">= 12" } }, "node_modules/path-exists": { @@ -9651,7 +9813,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true, "bin": { "semver": "bin/semver" } @@ -13758,6 +13919,15 @@ } } }, + "@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": "0.3.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-0.3.0.tgz", @@ -14125,6 +14295,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/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -14154,6 +14332,36 @@ "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-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.2.1", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.1.tgz", @@ -14244,6 +14452,12 @@ } } }, + "@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "@types/validator": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.1.3.tgz", @@ -15206,6 +15420,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.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -16138,6 +16357,14 @@ "safer-buffer": "^2.1.0" } }, + "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", @@ -18601,6 +18828,30 @@ "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": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -18621,6 +18872,25 @@ } } }, + "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" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -18699,6 +18969,41 @@ "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=" }, + "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.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=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "lodash.set": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", @@ -19524,15 +19829,24 @@ "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-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, "passport-unique-token": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-2.0.0.tgz", - "integrity": "sha512-SVsNT7wZ6QboP1Lm94BWjbwghV3Qoo71w0SZ075e4kGp+ibnFmIywZf1DgoFa5l+eSDzgAjW3h4zWFQbTDEv2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-unique-token/-/passport-unique-token-3.0.0.tgz", + "integrity": "sha512-BkSWODzwS1i8Z5ImmPQOWZ05dw9oS09VMBIZOogubKACrm3UO3wlJnwT/fCMQh5iTtFLYA+X4yWmtsqufftgXw==", "requires": { "passport-strategy": "^1.0.0" } @@ -20304,8 +20618,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "send": { "version": "0.17.1", diff --git a/package.json b/package.json index 97a27710..8654e372 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nestjs/common": "^7.6.13", "@nestjs/config": "^0.6.3", "@nestjs/core": "^7.6.13", + "@nestjs/jwt": "^8.0.0", "@nestjs/mapped-types": "^0.3.0", "@nestjs/passport": "^7.1.5", "@nestjs/platform-express": "^7.6.13", @@ -39,11 +40,13 @@ "date-fns": "^2.21.1", "helmet": "^4.5.0", "passport": "^0.4.1", - "passport-unique-token": "^2.0.0", + "passport-jwt": "^4.0.0", + "passport-unique-token": "^3.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^6.6.6", - "swagger-ui-express": "^4.1.6" + "swagger-ui-express": "^4.1.6", + "uuid": "^8.3.2" }, "devDependencies": { "@commitlint/cli": "^12.1.1", @@ -55,7 +58,9 @@ "@types/express": "4.17.11", "@types/jest": "26.0.20", "@types/node": "14.14.31", + "@types/passport-jwt": "^3.0.6", "@types/supertest": "2.0.10", + "@types/uuid": "^8.3.1", "@typescript-eslint/eslint-plugin": "^4.6.1", "@typescript-eslint/parser": "^4.6.1", "chai": "^4.3.0", @@ -91,4 +96,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/src/app.module.ts b/src/app.module.ts index 1da1e2b2..a642911c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { AstraConfigService } from './astra/astra-config.service'; @Module({ imports: [ + AuthModule, AstraModule.forRootAsync({ useClass: AstraConfigService, }), @@ -22,7 +23,6 @@ import { AstraConfigService } from './astra/astra-config.service'; ConfigModule.forRoot({ isGlobal: true, }), - AuthModule, CalendarModule, ], controllers: [AppController], diff --git a/src/astra/astra-api.module.ts b/src/astra/astra-api.module.ts new file mode 100644 index 00000000..4e50b03e --- /dev/null +++ b/src/astra/astra-api.module.ts @@ -0,0 +1,9 @@ +import { HttpModule, Module } from '@nestjs/common'; +import { KeyspaceService } from './keyspace.service'; + +@Module({ + imports: [HttpModule], + providers: [KeyspaceService], + exports: [KeyspaceService], +}) +export class AstraApiModule {} diff --git a/src/astra/astra.service.ts b/src/astra/astra.service.ts new file mode 100644 index 00000000..f3526f5c --- /dev/null +++ b/src/astra/astra.service.ts @@ -0,0 +1,161 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { from, Observable } from 'rxjs'; +import { + deleteItem, + documentId, + findResult, +} from '@cahllagerfeld/nestjs-astra'; +const DATASTAX_CLIENT = 'DATASTAX_CLIENT'; + +@Injectable() +export class AstraService { + private collection: any; + constructor(@Inject(DATASTAX_CLIENT) private readonly client: any) {} + + private setupClient(namespace: string, collection: string) { + this.collection = this.client.namespace(namespace).collection(collection); + } + + /** + * Gets a document from a collection by its ID. + * @param id ID of the document, that should be retrieved + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @returns + */ + public get( + id: string, + namespace: string, + collection: string, + ): Observable { + this.setupClient(namespace, collection); + const response: Promise = this.collection.get(id); + return from(response); + } + + /** + * Creates a new Document + * @param document The document that should be created + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @param id The desired ID * + * @returns document ID of created document + */ + public create( + document: T, + namespace: string, + collection: string, + id?: string, + ): Observable { + this.setupClient(namespace, collection); + let promise: Promise; + if (!id) { + promise = this.collection.create(document); + return from(promise); + } + promise = this.collection.create(id, document); + return from(promise); + } + + /** + * Search a collection + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @param query The query for searching the collection + * @param options Possible searchoptions + * @returns + */ + public find( + namespace: string, + collection: string, + query?: any, + options?: any, + ): Observable | null> { + this.setupClient(namespace, collection); + const promise: Promise | null> = this.collection.find( + query, + options, + ); + return from(promise); + } + + /** + * Find a single document + * @param query The query for searching the collection + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @param options Possible searchoptions + * @returns + */ + public findOne( + query: any, + namespace: string, + collection: string, + options?: any, + ): Observable { + this.setupClient(namespace, collection); + const promise: Promise = this.collection.findOne(query, options); + return from(promise); + } + + /** + * Partially update a existing document + * @param path Path to document, may also be path to a subdocument + * @param document Document with which the existing should be updated + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @returns + */ + public update( + path: string, + document: T, + namespace: string, + collection: string, + ): Observable { + this.setupClient(namespace, collection); + const promise: Promise = this.collection.update( + path, + document, + ); + return from(promise); + } + + /** + * + * @param path Path to document, that should be replaced + * @param document Document with which the specified docuent should be updated + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @returns + */ + public replace( + path: string, + document: T, + namespace: string, + collection: string, + ): Observable { + this.setupClient(namespace, collection); + const promise: Promise = this.collection.replace( + path, + document, + ); + return from(promise); + } + + /** + * + * @param path Path to document, that should be deleted + * @param namespace Namespace in Database + * @param collection Collection-Name in Database + * @returns + */ + public delete( + path: string, + namespace: string, + collection: string, + ): Observable { + this.setupClient(namespace, collection); + const promise: Promise = this.collection.delete(path); + return from(promise); + } +} diff --git a/src/astra/keyspace.interceptor.spec.ts b/src/astra/keyspace.interceptor.spec.ts new file mode 100644 index 00000000..bab47247 --- /dev/null +++ b/src/astra/keyspace.interceptor.spec.ts @@ -0,0 +1,13 @@ +import { HttpService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { KeyspaceInterceptor } from './keyspace.interceptor'; +import { KeyspaceService } from './keyspace.service'; + +describe('KeyspaceInterceptor', () => { + const http = new HttpService(); + const config = new ConfigService(); + const keyspaceService = new KeyspaceService(http, config); + it('should be defined', () => { + expect(new KeyspaceInterceptor(keyspaceService)).toBeDefined(); + }); +}); diff --git a/src/astra/keyspace.interceptor.ts b/src/astra/keyspace.interceptor.ts new file mode 100644 index 00000000..df209ec0 --- /dev/null +++ b/src/astra/keyspace.interceptor.ts @@ -0,0 +1,24 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Request } from 'express'; +import { TokenPayload } from '../auth/interfaces/token-payload.interface'; +import { KeyspaceService } from './keyspace.service'; + +@Injectable() +export class KeyspaceInterceptor implements NestInterceptor { + private existingKeyspaces = []; + constructor(private readonly keyspaceService: KeyspaceService) {} + async intercept(context: ExecutionContext, next: CallHandler) { + const request: Request = context.switchToHttp().getRequest(); + const user: TokenPayload = request.user as TokenPayload; + if (this.existingKeyspaces.includes(user.keyspace)) return next.handle(); + + await this.keyspaceService.createKeyspace(user.keyspace); + this.existingKeyspaces = [...this.existingKeyspaces, user.keyspace]; + return next.handle(); + } +} diff --git a/src/astra/keyspace.service.ts b/src/astra/keyspace.service.ts new file mode 100644 index 00000000..5889b402 --- /dev/null +++ b/src/astra/keyspace.service.ts @@ -0,0 +1,58 @@ +import { HttpService, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class KeyspaceService { + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + ) {} + + private getUrl(): string { + const databaseId = this.config.get('ASTRA_DATABASE_ID'); + const databaseRegion = this.config.get('ASTRA_DATABASE_REGION'); + const baseUrl = this.config.get('STARGATE_BASEURL'); + let url: string = null; + + if (databaseId && databaseRegion) + url = `https://${databaseId}-${databaseRegion}.apps.astra.datastax.com/api/rest`; + if (baseUrl) url = baseUrl; + + if (!url) throw new Error('could not return Url'); + return url; + } + + private async getAuthToken(): Promise { + const astraToken = this.config.get('ASTRA_APPLICATION_TOKEN'); + const stargateToken = this.config.get('STARGATE_AUTH_TOKEN'); + const authUrl = this.config.get('STARGATE_AUTH_URL'); + + if (astraToken) return astraToken; + if (stargateToken) return stargateToken; + if (authUrl) { + const response = await this.http + .post(authUrl, { + username: this.config.get('STARGATE_USERNAME'), + password: this.config.get('STARGATE_PASSWORD'), + }) + .toPromise(); + return response.data.authToken; + } + throw new Error('Could not return AuthToken'); + } + + public async createKeyspace(serverId: string): Promise { + const authToken: string = await this.getAuthToken(); + const url = `${this.getUrl()}/v2/schemas/keyspaces`; + const postBody = { name: serverId }; + + await this.http + .post(url, postBody, { + headers: { + 'X-Cassandra-Token': authToken, + 'Content-Type': 'application/json', + }, + }) + .toPromise(); + } +} diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..ecce3338 --- /dev/null +++ b/src/auth/auth.controller.spec.ts @@ -0,0 +1,22 @@ +import { JwtModule } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule.register({ secret: 'test-secret' })], + controllers: [AuthController], + providers: [AuthService], + }).compile(); + + controller = module.get(AuthController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 00000000..3ddc7a20 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiQuery, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { AuthService } from './auth.service'; +import { AuthDTO } from './dto/auth.dto'; +import { TokenGuard } from './token.strategy'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Get() + @UseGuards(TokenGuard) + @ApiSecurity('token') + @ApiQuery({ name: 'keyspace', required: true }) + getTokens(@Query('keyspace') keyspace: string) { + return this.authService.getClientIds(keyspace); + } + + @Post() + @UseGuards(TokenGuard) + @ApiSecurity('token') + register(@Body() body: AuthDTO) { + return this.authService.register(body); + } + + @Delete() + @UseGuards(TokenGuard) + @ApiSecurity('token') + @ApiQuery({ + name: 'token', + description: 'Token to delete', + required: true, + }) + @HttpCode(204) + deleteClient(@Query() query) { + return this.authService.removeClient(query.token); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d354ed0f..01666423 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,8 +1,24 @@ import { Module } from '@nestjs/common'; import { TokenStrategy } from './token.strategy'; import { ValidationService } from './header-validation.service'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtStrategy } from './jwt.strategy'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + @Module({ - providers: [TokenStrategy, ValidationService], + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + }), + inject: [ConfigService], + }), + ], + providers: [TokenStrategy, ValidationService, AuthService, JwtStrategy], exports: [ValidationService], + controllers: [AuthController], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..3c9890c8 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,20 @@ +import { JwtModule } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule.register({ secret: 'test-secret' })], + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 00000000..5c5d6b62 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,71 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { TokenPayload } from './interfaces/token-payload.interface'; +import { JwtService } from '@nestjs/jwt'; +import { v4 as uuidv4 } from 'uuid'; +import { AuthDTO } from './dto/auth.dto'; + +@Injectable() +export class AuthService { + //TODO move configCollection to database => own ConfigDataModule + private configCollection: { [id: string]: { knownClients: string[] } } = {}; + constructor(private readonly jwtService: JwtService) {} + + public getClientIds(keyspace: string) { + if (!this.configCollection[keyspace]) return {}; + return { tokens: this.configCollection[keyspace]?.knownClients }; + } + + public register(body: AuthDTO) { + const tokenType = 'bearer'; + const clientId = uuidv4(); + const { serverId, scopes } = body; + + const payload: TokenPayload = { + clientId, + keyspace: serverId, + scopes, + }; + if (!this.configCollection[serverId]) { + this.configCollection[serverId] = { knownClients: [] }; + } + this.configCollection[serverId].knownClients = [ + ...this.configCollection[serverId].knownClients, + clientId, + ]; + //TODO token-expiry + const signedToken = this.jwtService.sign(payload, { expiresIn: '1y' }); + const decoded: any = this.jwtService.decode(signedToken); + const expiresIn: number = decoded.exp - Math.round(Date.now() / 1000); + return { ...payload, accessToken: signedToken, expiresIn, tokenType }; + } + + public validateClient(payload: TokenPayload): boolean { + const { keyspace, clientId } = payload; + if ( + this.configCollection[keyspace] && + this.configCollection[keyspace].knownClients.includes(clientId) + ) { + return true; + } + return false; + } + + public removeClient(token: string) { + if (!token) + throw new HttpException('Please provide token', HttpStatus.BAD_REQUEST); + + const decoded = this.jwtService.decode(token) as TokenPayload; + + try { + this.configCollection[ + decoded.keyspace + ].knownClients = this.configCollection[ + decoded.keyspace + ].knownClients.filter((client) => client !== decoded.clientId); + console.log(this.configCollection); + return; + } catch (e) { + throw new HttpException('Invalid client id', HttpStatus.BAD_REQUEST); + } + } +} diff --git a/src/auth/decorators/scopes.decorator.ts b/src/auth/decorators/scopes.decorator.ts new file mode 100644 index 00000000..437bffc6 --- /dev/null +++ b/src/auth/decorators/scopes.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const Scopes = (...scopes: string[]) => SetMetadata('scopes', scopes); diff --git a/src/auth/decorators/user.decorator.ts b/src/auth/decorators/user.decorator.ts new file mode 100644 index 00000000..674ad93b --- /dev/null +++ b/src/auth/decorators/user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; + +export const User = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request: Request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts new file mode 100644 index 00000000..41f98373 --- /dev/null +++ b/src/auth/dto/auth.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsIn, IsNotEmpty, IsString } from 'class-validator'; + +const scopes = ['Data.Read', 'Data.Write']; + +export class AuthDTO { + @IsArray() + @IsNotEmpty() + @IsString({ each: true }) + @IsIn(scopes, { each: true }) + @ApiProperty({ + required: true, + enum: scopes, + isArray: true, + }) + scopes: string[]; + + @IsString() + @IsNotEmpty() + @ApiProperty({ required: true }) + serverId: string; +} diff --git a/src/auth/guards/scopes.guard.ts b/src/auth/guards/scopes.guard.ts new file mode 100644 index 00000000..ccf3afa6 --- /dev/null +++ b/src/auth/guards/scopes.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TokenPayload } from '../interfaces/token-payload.interface'; + +@Injectable() +export class ScopesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + canActivate(context: ExecutionContext): boolean { + const scopes = this.reflector.get('scopes', context.getHandler()); + if (!scopes) { + return true; + } + const request = context.switchToHttp().getRequest(); + const user = request.user as TokenPayload; + return this.matchScopes(scopes, user); + } + private matchScopes(scopes: string[], user: any) { + if (scopes.every((scope: string) => user.scopes.includes(scope))) + return true; + throw new ForbiddenException(); + } +} diff --git a/src/auth/interfaces/token-payload.interface.ts b/src/auth/interfaces/token-payload.interface.ts new file mode 100644 index 00000000..dc0b2114 --- /dev/null +++ b/src/auth/interfaces/token-payload.interface.ts @@ -0,0 +1,10 @@ +export interface TokenPayload { + clientId: string; + keyspace: string; + scopes: string[]; +} + +export enum ScopesDictionary { + READ = 'Data.Read', + WRITE = 'Data.Write', +} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts new file mode 100644 index 00000000..70e1d2d2 --- /dev/null +++ b/src/auth/jwt.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard, PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; +import { AuthService } from './auth.service'; +import { TokenPayload } from './interfaces/token-payload.interface'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET, + }); + } + validate(payload: TokenPayload): TokenPayload { + if (!this.authService.validateClient(payload)) + throw new UnauthorizedException(); + return payload; + } +} + +export const JWTGuard = AuthGuard('jwt'); + +// export class MyAuthGuard extends AuthGuard(['jwt', 'discordGithub-strategy']) { +// constructor() { +// super(); +// } +// canActivate(ctx: ExecutionContext) { +// return super.canActivate(ctx); +// } +// } diff --git a/src/auth/token.strategy.ts b/src/auth/token.strategy.ts index 0a08173a..1ce4b8af 100644 --- a/src/auth/token.strategy.ts +++ b/src/auth/token.strategy.ts @@ -1,6 +1,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuthGuard, PassportStrategy } from '@nestjs/passport'; +import express from 'express'; import { UniqueTokenStrategy } from 'passport-unique-token'; @Injectable() @@ -9,17 +10,26 @@ export class TokenStrategy extends PassportStrategy( 'discordGithub-strategy', ) { constructor(private config: ConfigService) { - super({ tokenHeader: 'Client-Token' }); + super({ + tokenHeader: 'Client-Token', + tokenQuery: 'Client-Token', + passReqToCallback: true, + }); } - async validate(token: string, done) { + validate(req: express.Request, token: string, done: any) { + let userObject; const approvedTokens: Array = this.config .get('APPROVED_TOKENS') .split(','); if (!approvedTokens.includes(token)) { throw new UnauthorizedException(); } - return done(null, token); + + if (req.headers.keyspace) { + userObject = { keyspace: req.headers.keyspace }; + } + return done(null, userObject ? userObject : token); } } export const TokenGuard = AuthGuard('discordGithub-strategy'); diff --git a/src/calendar/calendar.controller.ts b/src/calendar/calendar.controller.ts index 98c11b46..4331f90e 100644 --- a/src/calendar/calendar.controller.ts +++ b/src/calendar/calendar.controller.ts @@ -9,9 +9,16 @@ import { Put, UseGuards, } from '@nestjs/common'; -import { ApiHeader, ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { TokenGuard } from '../auth/token.strategy'; +import { ApiBearerAuth, ApiHeader, ApiTags } from '@nestjs/swagger'; import { Author, AuthorObject } from '../auth/author-headers'; +import { Scopes } from '../auth/decorators/scopes.decorator'; +import { User } from '../auth/decorators/user.decorator'; +import { ScopesGuard } from '../auth/guards/scopes.guard'; +import { + ScopesDictionary, + TokenPayload, +} from '../auth/interfaces/token-payload.interface'; +import { JWTGuard } from '../auth/jwt.strategy'; import { CalendarService } from './calendar.service'; import { CalendarEventDTO } from './dto/calendar.dto'; @@ -21,42 +28,56 @@ export class CalendarController { constructor(private readonly service: CalendarService) {} @Post() - @UseGuards(TokenGuard) - @ApiSecurity('token') - create(@Body() calendarEvent: CalendarEventDTO) { - return this.service.createCalendarEvent(calendarEvent); + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.WRITE) + create(@Body() calendarEvent: CalendarEventDTO, @User() user: TokenPayload) { + return this.service.createCalendarEvent(calendarEvent, user.keyspace); } @Get() - findAll() { - return this.service.findAllEvents(); + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.READ) + findAll(@User() user: TokenPayload) { + return this.service.findAllEvents(user.keyspace); } @Get(':id') - findOne(@Param('id') id: string) { - return this.service.findOne(id); + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.READ) + findOne(@Param('id') id: string, @User() user: TokenPayload) { + return this.service.findOne(id, user.keyspace); } @Put(':id') - @UseGuards(TokenGuard) - @ApiSecurity('token') + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.WRITE) @ApiHeader({ name: 'User-Uid', required: true }) @ApiHeader({ name: 'Platform', required: true }) updateOne( @Param('id') id: string, @Body() calendarEvent: CalendarEventDTO, @AuthorObject() author: Author, + @User() user: TokenPayload, ) { - return this.service.updateOne(id, calendarEvent, author); + return this.service.updateOne(id, calendarEvent, author, user.keyspace); } @Delete(':id') - @UseGuards(TokenGuard) + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.WRITE) @HttpCode(204) - @ApiSecurity('token') @ApiHeader({ name: 'User-Uid', required: true }) @ApiHeader({ name: 'Platform', required: true }) - remove(@Param('id') id: string, @AuthorObject() author: Author) { - return this.service.remove(id, author); + remove( + @Param('id') id: string, + @AuthorObject() author: Author, + @User() user: TokenPayload, + ) { + return this.service.remove(id, author, user.keyspace); } } diff --git a/src/calendar/calendar.module.ts b/src/calendar/calendar.module.ts index 913fed63..381b1f9d 100644 --- a/src/calendar/calendar.module.ts +++ b/src/calendar/calendar.module.ts @@ -1,15 +1,12 @@ -import { AstraModule } from '@cahllagerfeld/nestjs-astra'; import { Module } from '@nestjs/common'; +import { AstraService as AstraApiService } from '../astra/astra.service'; import { AuthModule } from '../auth/auth.module'; import { CalendarController } from './calendar.controller'; import { CalendarService } from './calendar.service'; @Module({ - imports: [ - AuthModule, - AstraModule.forFeature({ namespace: 'eddiehub', collection: 'calendar' }), - ], + imports: [AuthModule], controllers: [CalendarController], - providers: [CalendarService], + providers: [CalendarService, AstraApiService], }) export class CalendarModule {} diff --git a/src/calendar/calendar.service.ts b/src/calendar/calendar.service.ts index 59a218c1..bb02a626 100644 --- a/src/calendar/calendar.service.ts +++ b/src/calendar/calendar.service.ts @@ -2,24 +2,22 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { CalendarEventDTO } from './dto/calendar.dto'; import { CalendarEvent } from './interfaces/calendar.interface'; import { catchError, concatMap, filter } from 'rxjs/operators'; -import { - AstraService, - deleteItem, - documentId, -} from '@cahllagerfeld/nestjs-astra'; +import { deleteItem, documentId } from '@cahllagerfeld/nestjs-astra'; import { forkJoin, from, Observable } from 'rxjs'; import { Author } from '../auth/author-headers'; import { ValidationService } from '../auth/header-validation.service'; +import { AstraService as AstraApiService } from '../astra/astra.service'; @Injectable() export class CalendarService { constructor( - private readonly astraService: AstraService, + private readonly astraService: AstraApiService, private readonly validationService: ValidationService, ) {} createCalendarEvent( calendarEventBody: CalendarEventDTO, + keyspaceName: string, ): Observable { const newEvent: CalendarEvent = { name: calendarEventBody.name, @@ -33,48 +31,61 @@ export class CalendarService { updatedOn: new Date(), }; - return this.astraService.create(newEvent).pipe( - catchError(() => { - throw new HttpException( - 'Creation didnt pass as expected', - HttpStatus.BAD_REQUEST, - ); - }), - ); + return this.astraService + .create(newEvent, keyspaceName, 'calendar') + .pipe( + catchError(() => { + throw new HttpException( + 'Creation didnt pass as expected', + HttpStatus.BAD_REQUEST, + ); + }), + ); } - findAllEvents() { - const future = this.astraService.find({ - startDate: { $gt: new Date() }, - endDate: { $gt: new Date() }, - }); + findAllEvents(keyspaceName: string) { + const future = this.astraService.find( + keyspaceName, + 'calendar', + { + startDate: { $gt: new Date() }, + endDate: { $gt: new Date() }, + }, + ); - const ongoing = this.astraService.find({ - startDate: { $lt: new Date() }, - endDate: { $gt: new Date() }, - }); + const ongoing = this.astraService.find( + keyspaceName, + 'calendar', + { + startDate: { $lt: new Date() }, + endDate: { $gt: new Date() }, + }, + ); return forkJoin({ future, ongoing }).pipe(catchError(() => from([{}]))); } - findOne(id: string) { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - 'Creation didnt pass as expected', - HttpStatus.BAD_REQUEST, - ); - }), - ); + findOne(id: string, keyspaceName: string) { + return this.astraService + .get(id, keyspaceName, 'calendar') + .pipe( + catchError(() => { + throw new HttpException( + 'Creation didnt pass as expected', + HttpStatus.BAD_REQUEST, + ); + }), + ); } async updateOne( id: string, calendarDTO: CalendarEventDTO, authorObject: Author, + keyspaceName: string, ) { const oldDocument = await this.astraService - .get(id) + .get(id, keyspaceName, 'calendar') .pipe( catchError(() => { throw new HttpException( @@ -142,7 +153,7 @@ export class CalendarService { updateEvent.updatedOn = new Date(); const updateResponse = await this.astraService - .replace(id, updateEvent) + .replace(id, updateEvent, keyspaceName, 'calendar') .pipe( catchError(() => { throw new HttpException( @@ -156,39 +167,41 @@ export class CalendarService { return updateResponse; } - remove(id: string, authorObject: Author) { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - `no event for ${id} found`, - HttpStatus.NOT_FOUND, - ); - }), - filter((data: CalendarEvent) => { - if (!data) { + remove(id: string, authorObject: Author, keyspaceName: string) { + return this.astraService + .get(id, keyspaceName, 'calendar') + .pipe( + catchError(() => { throw new HttpException( `no event for ${id} found`, HttpStatus.NOT_FOUND, ); - } - - if ( - !this.validationService.validateAuthor( - data.author, - authorObject.uid, - authorObject.platform, - ) - ) { - throw new HttpException( - "deletion failed: author doesn't match", - HttpStatus.BAD_REQUEST, - ); - } - - return true; - }), - concatMap(() => this.astraService.delete(id)), - filter((data: deleteItem) => data.deleted === true), - ); + }), + filter((data: CalendarEvent) => { + if (!data) { + throw new HttpException( + `no event for ${id} found`, + HttpStatus.NOT_FOUND, + ); + } + + if ( + !this.validationService.validateAuthor( + data.author, + authorObject.uid, + authorObject.platform, + ) + ) { + throw new HttpException( + "deletion failed: author doesn't match", + HttpStatus.BAD_REQUEST, + ); + } + + return true; + }), + concatMap(() => this.astraService.delete(id, keyspaceName, 'calendar')), + filter((data: deleteItem) => data.deleted === true), + ); } } diff --git a/src/discord/discord.controller.ts b/src/discord/discord.controller.ts index ea895978..b9c15bb6 100644 --- a/src/discord/discord.controller.ts +++ b/src/discord/discord.controller.ts @@ -1,17 +1,24 @@ import { + Body, Controller, + Delete, Get, + HttpCode, + Param, Post, - Body, Put, - Param, - Delete, UseGuards, - HttpCode, } from '@nestjs/common'; -import { ApiHeader, ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { TokenGuard } from '../auth/token.strategy'; +import { ApiBearerAuth, ApiHeader, ApiTags } from '@nestjs/swagger'; import { Author, AuthorObject } from '../auth/author-headers'; +import { Scopes } from '../auth/decorators/scopes.decorator'; +import { User } from '../auth/decorators/user.decorator'; +import { ScopesGuard } from '../auth/guards/scopes.guard'; +import { + ScopesDictionary, + TokenPayload, +} from '../auth/interfaces/token-payload.interface'; +import { JWTGuard } from '../auth/jwt.strategy'; import { DiscordService } from './discord.service'; import { DiscordDTO } from './dto/discord.dto'; @ApiTags('Discord') @@ -20,40 +27,59 @@ export class DiscordController { constructor(private readonly discordService: DiscordService) {} @Post() - @UseGuards(TokenGuard) - @ApiSecurity('token') - create(@Body() createDiscordDto: DiscordDTO) { - return this.discordService.create(createDiscordDto); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) + create(@Body() createDiscordDto: DiscordDTO, @User() user: TokenPayload) { + return this.discordService.create(createDiscordDto, user.keyspace); } @Get() - findAll() { - return this.discordService.findAll(); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.READ) + findAll(@User() user: TokenPayload) { + return this.discordService.findAll(user.keyspace); } @Get(':id') - findOne(@Param('id') id: string) { - return this.discordService.findOne(id); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.READ) + findOne(@Param('id') id: string, @User() user: TokenPayload) { + return this.discordService.findOne(id, user.keyspace); } @Put(':id') - @ApiSecurity('token') - @UseGuards(TokenGuard) + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) @ApiHeader({ name: 'User-Uid', required: true }) update( @Param('id') id: string, @Body() updateDiscordDto: DiscordDTO, @AuthorObject() author: Author, + @User() user: TokenPayload, ) { - return this.discordService.update(id, updateDiscordDto, author); + return this.discordService.update( + id, + updateDiscordDto, + author, + user.keyspace, + ); } @Delete(':id') - @ApiSecurity('token') @HttpCode(204) @ApiHeader({ name: 'User-Uid', required: true }) - @UseGuards(TokenGuard) - remove(@Param('id') id: string, @AuthorObject() author: Author) { - return this.discordService.remove(id, author); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) + remove( + @Param('id') id: string, + @AuthorObject() author: Author, + @User() user: TokenPayload, + ) { + return this.discordService.remove(id, author, user.keyspace); } } diff --git a/src/discord/discord.module.ts b/src/discord/discord.module.ts index e757676c..a04a834a 100644 --- a/src/discord/discord.module.ts +++ b/src/discord/discord.module.ts @@ -1,15 +1,12 @@ import { Module } from '@nestjs/common'; import { DiscordService } from './discord.service'; import { DiscordController } from './discord.controller'; -import { AstraModule } from '@cahllagerfeld/nestjs-astra'; import { AuthModule } from '../auth/auth.module'; +import { AstraService as AstraApiService } from '../astra/astra.service'; @Module({ - imports: [ - AuthModule, - AstraModule.forFeature({ namespace: 'eddiehub', collection: 'discord' }), - ], + imports: [AuthModule], controllers: [DiscordController], - providers: [DiscordService], + providers: [DiscordService, AstraApiService], }) export class DiscordModule {} diff --git a/src/discord/discord.service.ts b/src/discord/discord.service.ts index 75673722..cee185de 100644 --- a/src/discord/discord.service.ts +++ b/src/discord/discord.service.ts @@ -1,8 +1,4 @@ -import { - AstraService, - deleteItem, - documentId, -} from '@cahllagerfeld/nestjs-astra'; +import { deleteItem, documentId } from '@cahllagerfeld/nestjs-astra'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { from, Observable } from 'rxjs'; import { catchError, concatMap, filter } from 'rxjs/operators'; @@ -10,15 +6,19 @@ import { ValidationService } from '../auth/header-validation.service'; import { Author } from '../auth/author-headers'; import { DiscordDTO } from './dto/discord.dto'; import { DiscordProfile } from './interfaces/discord.interface'; - +import { AstraService as AstraApiService } from '../astra/astra.service'; @Injectable() export class DiscordService { constructor( - private readonly astraService: AstraService, + private readonly astraService: AstraApiService, private readonly validationService: ValidationService, ) {} - create(createDiscordDto: DiscordDTO): Observable { + create( + createDiscordDto: DiscordDTO, + keyspaceName: string, + ): Observable { + console.log(keyspaceName); const discordUser: DiscordProfile = { author: { ...createDiscordDto.author }, bio: createDiscordDto.bio, @@ -27,40 +27,51 @@ export class DiscordService { updatedOn: new Date(), }; - return this.astraService.create(discordUser).pipe( - catchError(() => { - throw new HttpException( - 'Creation didnt pass as expected', - HttpStatus.BAD_REQUEST, - ); - }), - ); + return this.astraService + .create(discordUser, keyspaceName, 'discord') + .pipe( + catchError(() => { + throw new HttpException( + 'Creation didnt pass as expected', + HttpStatus.BAD_REQUEST, + ); + }), + ); } - findAll() { + findAll(keyspaceName: string) { return this.astraService - .find() + .find(keyspaceName, 'discord') .pipe(catchError(() => from([{}]))); } - findOne(id: string) { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - 'Creation didnt pass as expected', - HttpStatus.BAD_REQUEST, - ); - }), - ); + findOne(id: string, keyspaceName: string) { + return this.astraService + .get(id, keyspaceName, 'discord') + .pipe( + catchError(() => { + throw new HttpException( + 'Creation didnt pass as expected', + HttpStatus.BAD_REQUEST, + ); + }), + ); } - async update(id: string, updateDiscordDto: DiscordDTO, authorObject: Author) { + async update( + id: string, + updateDiscordDto: DiscordDTO, + authorObject: Author, + keyspaceName: string, + ) { const { author, bio, socials } = updateDiscordDto; let discordUser; try { - discordUser = await this.astraService.get(id).toPromise(); + discordUser = await this.astraService + .get(id, keyspaceName, 'discord') + .toPromise(); } catch (e) { throw new HttpException( `no discord-profile for ${id} found`, @@ -111,7 +122,7 @@ export class DiscordService { try { updateResponse = await this.astraService - .replace(id, updatedDiscord) + .replace(id, updatedDiscord, keyspaceName, 'discord') .toPromise(); } catch (e) { throw new HttpException( @@ -123,41 +134,43 @@ export class DiscordService { return updateResponse; } - remove(id: string, authorObject: Author) { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - `no discord-profile for ${id} found`, - HttpStatus.NOT_FOUND, - ); - }), - filter((data: DiscordProfile) => { - if (!data) { + remove(id: string, authorObject: Author, keyspaceName: string) { + return this.astraService + .get(id, keyspaceName, 'discord') + .pipe( + catchError(() => { throw new HttpException( `no discord-profile for ${id} found`, HttpStatus.NOT_FOUND, ); - } - - if ( - !this.validationService.validateAuthor( - data.author, - authorObject.uid, - 'discord', - ) - ) { - throw new HttpException( - "deletion failed: author doesn't match", - HttpStatus.BAD_REQUEST, - ); - } - return true; - }), - concatMap(() => - this.astraService - .delete(id) - .pipe(filter((data: deleteItem) => data.deleted === true)), - ), - ); + }), + filter((data: DiscordProfile) => { + if (!data) { + throw new HttpException( + `no discord-profile for ${id} found`, + HttpStatus.NOT_FOUND, + ); + } + + if ( + !this.validationService.validateAuthor( + data.author, + authorObject.uid, + 'discord', + ) + ) { + throw new HttpException( + "deletion failed: author doesn't match", + HttpStatus.BAD_REQUEST, + ); + } + return true; + }), + concatMap(() => + this.astraService + .delete(id, keyspaceName, 'discord') + .pipe(filter((data: deleteItem) => data.deleted === true)), + ), + ); } } diff --git a/src/github/github.controller.ts b/src/github/github.controller.ts index e5fe1417..e2e98086 100644 --- a/src/github/github.controller.ts +++ b/src/github/github.controller.ts @@ -9,8 +9,15 @@ import { Put, UseGuards, } from '@nestjs/common'; -import { ApiSecurity, ApiTags } from '@nestjs/swagger'; -import { TokenGuard } from '../auth/token.strategy'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Scopes } from '../auth/decorators/scopes.decorator'; +import { User } from '../auth/decorators/user.decorator'; +import { ScopesGuard } from '../auth/guards/scopes.guard'; +import { + ScopesDictionary, + TokenPayload, +} from '../auth/interfaces/token-payload.interface'; +import { JWTGuard } from '../auth/jwt.strategy'; import { GithubDTO } from './dto/github.dto'; import { GithubService } from './github.service'; @ApiTags('Github') @@ -18,35 +25,48 @@ import { GithubService } from './github.service'; export class GithubController { constructor(private readonly githubService: GithubService) {} @Post() - @ApiSecurity('token') - @UseGuards(TokenGuard) - async create(@Body() body: GithubDTO) { - return await this.githubService.create(body); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) + async create(@Body() body: GithubDTO, @User() user: TokenPayload) { + return await this.githubService.create(body, user.keyspace); } @Get() - findAll() { - return this.githubService.findAll(); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.READ) + findAll(@User() user: TokenPayload) { + return this.githubService.findAll(user.keyspace); } @Get(':id') - findOne(@Param('id') id: string) { - return this.githubService.findOne(id); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.READ) + findOne(@Param('id') id: string, @User() user: TokenPayload) { + return this.githubService.findOne(id, user.keyspace); } @Put(':id') - @UseGuards(TokenGuard) + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) @HttpCode(200) - @ApiSecurity('token') - async update(@Param('id') id: string, @Body() body: GithubDTO) { - return await this.githubService.update(id, body); + async update( + @Param('id') id: string, + @Body() body: GithubDTO, + @User() user: TokenPayload, + ) { + return await this.githubService.update(id, body, user.keyspace); } @Delete(':id') - @UseGuards(TokenGuard) - @ApiSecurity('token') + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) @HttpCode(204) - remove(@Param('id') id: string) { - return this.githubService.remove(id); + remove(@Param('id') id: string, @User() user: TokenPayload) { + return this.githubService.remove(id, user.keyspace); } } diff --git a/src/github/github.module.ts b/src/github/github.module.ts index 8696d1ff..6b977af1 100644 --- a/src/github/github.module.ts +++ b/src/github/github.module.ts @@ -1,18 +1,19 @@ import { HttpModule, Module } from '@nestjs/common'; -import { GithubController } from './github.controller'; -import { GithubService } from './github.service'; +import { AstraService as AstraApiService } from '../astra/astra.service'; +import { AuthModule } from '../auth/auth.module'; import { CommunitystatsMappingService } from './communitystats-mapping.service'; import { GeocodingService } from './geocoding.service'; -import { AuthModule } from '../auth/auth.module'; -import { AstraModule } from '@cahllagerfeld/nestjs-astra'; +import { GithubController } from './github.controller'; +import { GithubService } from './github.service'; @Module({ - imports: [ - HttpModule, - AuthModule, - AstraModule.forFeature({ namespace: 'eddiehub', collection: 'github' }), - ], + imports: [HttpModule, AuthModule], controllers: [GithubController], - providers: [GithubService, CommunitystatsMappingService, GeocodingService], + providers: [ + GithubService, + CommunitystatsMappingService, + GeocodingService, + AstraApiService, + ], }) export class GithubModule {} diff --git a/src/github/github.service.ts b/src/github/github.service.ts index 936ddc61..e21de155 100644 --- a/src/github/github.service.ts +++ b/src/github/github.service.ts @@ -3,30 +3,27 @@ import { CommunityStats, GithubProfile } from './interfaces/github.interface'; import { GithubDTO } from './dto/github.dto'; import { CommunitystatsMappingService } from './communitystats-mapping.service'; import { GeocodingService } from './geocoding.service'; -import { - AstraService, - deleteItem, - documentId, -} from '@cahllagerfeld/nestjs-astra'; +import { deleteItem, documentId } from '@cahllagerfeld/nestjs-astra'; import { from, Observable } from 'rxjs'; import { catchError, concatMap, filter } from 'rxjs/operators'; +import { AstraService as AstraApiService } from '../astra/astra.service'; @Injectable() export class GithubService { constructor( private readonly mappingService: CommunitystatsMappingService, private readonly geocodingService: GeocodingService, - private readonly astraService: AstraService, + private readonly astraService: AstraApiService, ) {} - async create(body: GithubDTO): Promise { + async create(body: GithubDTO, keyspaceName: string): Promise { let newGithubProfile: GithubProfile; let creationResponse; try { newGithubProfile = await this.createGithub(body); creationResponse = await this.astraService - .create(newGithubProfile) + .create(newGithubProfile, keyspaceName, 'github') .toPromise(); } catch (e) { throw new HttpException( @@ -38,24 +35,30 @@ export class GithubService { return creationResponse; } - findAll() { + findAll(keyspaceName: string) { return this.astraService - .find() + .find(keyspaceName, 'github') .pipe(catchError(() => from([{}]))); } - findOne(id: string): Observable { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - `no github-profile for ${id} found`, - HttpStatus.NOT_FOUND, - ); - }), - ); + findOne(id: string, keyspaceName: string): Observable { + return this.astraService + .get(id, keyspaceName, 'github') + .pipe( + catchError(() => { + throw new HttpException( + `no github-profile for ${id} found`, + HttpStatus.NOT_FOUND, + ); + }), + ); } - async update(id: string, body: GithubDTO): Promise { + async update( + id: string, + body: GithubDTO, + keyspaceName: string, + ): Promise { const { username, bio, @@ -70,7 +73,9 @@ export class GithubService { let oldDocument; try { - oldDocument = await this.astraService.get(id).toPromise(); + oldDocument = await this.astraService + .get(id, keyspaceName, 'github') + .toPromise(); } catch (e) { throw new HttpException( `no github-profile for ${id} found`, @@ -118,7 +123,7 @@ export class GithubService { let updateResponse; try { updateResponse = await this.astraService - .replace(id, updateGithubProfile) + .replace(id, updateGithubProfile, keyspaceName, 'github') .toPromise(); } catch (e) { throw new HttpException( @@ -130,30 +135,32 @@ export class GithubService { return updateResponse; } - remove(id: string): Observable { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - `no github-profile for ${id} found`, - HttpStatus.NOT_FOUND, - ); - }), - filter((data) => { - if (!data) { + remove(id: string, keyspaceName: string): Observable { + return this.astraService + .get(id, keyspaceName, 'github') + .pipe( + catchError(() => { throw new HttpException( `no github-profile for ${id} found`, HttpStatus.NOT_FOUND, ); - } - - return true; - }), - concatMap(() => - this.astraService - .delete(id) - .pipe(filter((data: deleteItem) => data.deleted === true)), - ), - ); + }), + filter((data) => { + if (!data) { + throw new HttpException( + `no github-profile for ${id} found`, + HttpStatus.NOT_FOUND, + ); + } + + return true; + }), + concatMap(() => + this.astraService + .delete(id, keyspaceName, 'github') + .pipe(filter((data: deleteItem) => data.deleted === true)), + ), + ); } private async createGithub(body: GithubDTO): Promise { diff --git a/src/standup/standup.controller.spec.ts b/src/standup/standup.controller.spec.ts index 6611af06..bea3835f 100644 --- a/src/standup/standup.controller.spec.ts +++ b/src/standup/standup.controller.spec.ts @@ -1,6 +1,7 @@ import { AstraModule } from '@cahllagerfeld/nestjs-astra'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { AstraApiModule } from '../astra/astra-api.module'; import { AstraConfigService } from '../astra/astra-config.service'; import { AuthModule } from '../auth/auth.module'; import { StandupController } from './standup.controller'; @@ -12,6 +13,7 @@ describe('StandupController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ + AstraApiModule, AuthModule, ConfigModule.forRoot({ isGlobal: true, diff --git a/src/standup/standup.controller.ts b/src/standup/standup.controller.ts index 6706537b..82d20c00 100644 --- a/src/standup/standup.controller.ts +++ b/src/standup/standup.controller.ts @@ -8,10 +8,19 @@ import { Post, Query, UseGuards, + UseInterceptors, } from '@nestjs/common'; -import { ApiHeader, ApiQuery, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiHeader, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { KeyspaceInterceptor } from '../astra/keyspace.interceptor'; import { Author, AuthorObject } from '../auth/author-headers'; -import { TokenGuard } from '../auth/token.strategy'; +import { Scopes } from '../auth/decorators/scopes.decorator'; +import { User } from '../auth/decorators/user.decorator'; +import { ScopesGuard } from '../auth/guards/scopes.guard'; +import { + ScopesDictionary, + TokenPayload, +} from '../auth/interfaces/token-payload.interface'; +import { JWTGuard } from '../auth/jwt.strategy'; import { StandupDTO } from './dto/standup.dto'; import { StandupService } from './standup.service'; @@ -21,35 +30,52 @@ export class StandupController { constructor(private readonly standupService: StandupService) {} @Post() - @UseGuards(TokenGuard) - @ApiSecurity('token') - createStandup(@Body() body: StandupDTO) { - return this.standupService.create(body); + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) + createStandup(@Body() body: StandupDTO, @User() user: TokenPayload) { + return this.standupService.create(body, user.keyspace); } @Get() - findAllStandups() { - return this.standupService.findAll(); + @ApiBearerAuth() + @UseInterceptors(KeyspaceInterceptor) + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.READ) + findAllStandups(@User() user) { + return this.standupService.findAll(user.keyspace); } @Get('search') @ApiQuery({ name: 'uid', type: 'string' }) - search(@Query('uid') uid: string) { - return this.standupService.search(uid); + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.READ) + search(@Query('uid') uid: string, @User() user: TokenPayload) { + return this.standupService.search(uid, user.keyspace); } @Get(':id') - findById(@Param('id') id: string) { - return this.standupService.findById(id); + @ApiQuery({ name: 'uid', type: 'string' }) + @ApiBearerAuth() + @UseGuards(JWTGuard, ScopesGuard) + @Scopes(ScopesDictionary.READ) + findById(@Param('id') id: string, @User() user: TokenPayload) { + return this.standupService.findById(id, user.keyspace); } @Delete(':id') - @UseGuards(TokenGuard) - @ApiSecurity('token') + @UseGuards(JWTGuard, ScopesGuard) + @ApiBearerAuth() + @Scopes(ScopesDictionary.WRITE) @HttpCode(204) @ApiHeader({ name: 'User-Uid', required: true }) @ApiHeader({ name: 'Platform', required: true }) - deleteStandup(@Param('id') id: string, @AuthorObject() author: Author) { - return this.standupService.deleteStandup(id, author); + deleteStandup( + @Param('id') id: string, + @AuthorObject() author: Author, + @User() user: TokenPayload, + ) { + return this.standupService.deleteStandup(id, author, user.keyspace); } } diff --git a/src/standup/standup.module.ts b/src/standup/standup.module.ts index b8013fd9..7f763453 100644 --- a/src/standup/standup.module.ts +++ b/src/standup/standup.module.ts @@ -2,14 +2,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { StandupController } from './standup.controller'; import { StandupService } from './standup.service'; -import { AstraModule } from '@cahllagerfeld/nestjs-astra'; +import { AstraService as AstraApiService } from '../astra/astra.service'; +import { AstraApiModule } from '../astra/astra-api.module'; @Module({ - imports: [ - AuthModule, - AstraModule.forFeature({ namespace: 'eddiehub', collection: 'standup' }), - ], + imports: [AuthModule, AstraApiModule], controllers: [StandupController], - providers: [StandupService], + providers: [StandupService, AstraApiService], }) export class StandupModule {} diff --git a/src/standup/standup.service.ts b/src/standup/standup.service.ts index b2de23a7..bf67e913 100644 --- a/src/standup/standup.service.ts +++ b/src/standup/standup.service.ts @@ -1,4 +1,4 @@ -import { AstraService, deleteItem } from '@cahllagerfeld/nestjs-astra'; +import { deleteItem, documentId } from '@cahllagerfeld/nestjs-astra'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { StandupDTO } from './dto/standup.dto'; import { Standup } from './interfaces/standup.interface'; @@ -6,15 +6,16 @@ import { catchError, concatMap, filter } from 'rxjs/operators'; import { Author } from '../auth/author-headers'; import { ValidationService } from '../auth/header-validation.service'; import { from } from 'rxjs'; +import { AstraService as AstraApiService } from '../astra/astra.service'; @Injectable() export class StandupService { constructor( - private readonly astraService: AstraService, + private readonly astraService: AstraApiService, private readonly validationService: ValidationService, ) {} - create(body: StandupDTO) { + create(body: StandupDTO, keyspaceName: string) { const { author, todayMessage, yesterdayMessage } = body; const newStandup: Standup = { @@ -24,33 +25,43 @@ export class StandupService { createdOn: new Date(Date.now()), }; - return this.astraService.create(newStandup).pipe( - catchError(() => { - throw new HttpException( - 'Creation didnt pass as expected', - HttpStatus.BAD_REQUEST, - ); - }), - ); + return this.astraService + .create(newStandup, keyspaceName, 'standup') + .pipe( + filter((data: documentId) => { + if (data === null) { + throw new HttpException( + 'Creation didnt pass as expected', + HttpStatus.BAD_REQUEST, + ); + } + return true; + }), + ); } - findAll() { - return this.astraService.find().pipe(catchError(() => from([{}]))); + findAll(keyspaceName: string) { + return this.astraService + .find(keyspaceName, 'standup') + .pipe(catchError(() => from([{}]))); } - findById(id: string) { - return this.astraService.get(id).pipe( - catchError(() => { - throw new HttpException( - `no standup for ${id} found`, - HttpStatus.NOT_FOUND, - ); + findById(id: string, keyspaceName: string) { + return this.astraService.get(id, keyspaceName, 'standup').pipe( + filter((data: Standup) => { + if (data === null) { + throw new HttpException( + `no standup for ${id} found`, + HttpStatus.NOT_FOUND, + ); + } + return true; }), ); } - deleteStandup(id: string, authorObject: Author) { - return this.astraService.get(id).pipe( + deleteStandup(id: string, authorObject: Author, keyspaceName: string) { + return this.astraService.get(id, keyspaceName, 'standup').pipe( catchError(() => { throw new HttpException( `no standup for ${id} found`, @@ -81,25 +92,25 @@ export class StandupService { }), concatMap(() => this.astraService - .delete(id) + .delete(id, 'eddiehub', 'standup') .pipe(filter((data: deleteItem) => data.deleted === true)), ), ); } - search(id: string) { - if (!id) { + search(uid: string, keyspaceName: string) { + if (!uid) { throw new HttpException( 'Please provide search context', HttpStatus.BAD_REQUEST, ); } return this.astraService - .find({ 'author.uid': { $eq: id } }) + .find(keyspaceName, 'standup', { 'author.uid': { $eq: uid } }) .pipe( catchError(() => { throw new HttpException( - `no standup for ${id} found`, + `no standup for ${uid} found`, HttpStatus.NOT_FOUND, ); }), diff --git a/src/swagger.ts b/src/swagger.ts index 510255b9..e231d6b1 100644 --- a/src/swagger.ts +++ b/src/swagger.ts @@ -4,5 +4,16 @@ export const swaggerConfig = new DocumentBuilder() .setTitle('EddieHubCommunity API') .setDescription('An API to manage our community data') .setVersion(process.env.npm_package_version) - .addSecurity('token', { type: 'apiKey', in: 'header', name: 'Client-Token' }) + .addSecurity('token', { + type: 'apiKey', + in: 'header', + name: 'Client-Token', + description: 'Token for writing data', + }) + .addBearerAuth({ + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Token for getting Data', + }) .build(); diff --git a/test/features/calendar.feature b/test/features/calendar.feature index 437fc6a6..faca6a81 100644 --- a/test/features/calendar.feature +++ b/test/features/calendar.feature @@ -2,7 +2,7 @@ Feature: calendar module Scenario: add a new event - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -13,6 +13,7 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/calendar/{id}" Then the response status code should be 200 And the response should contain: @@ -27,14 +28,15 @@ Feature: calendar module | updatedOn | "TYPE:DATE" | Scenario: get list of events - Given make a GET request to "/calendar" + Given authorization with "reading" permission + When make a GET request to "/calendar" Then the response status code should be 200 And the response should contain: | future | {} | | ongoing | {} | Scenario: add an empty event - Given authorisation + Given authorization with "writing" permission And make a POST request to "/calendar" with: | test | "test" | Then the response status code should be 400 @@ -54,7 +56,7 @@ Feature: calendar module | endDate should not be empty | Scenario: update an event - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -65,6 +67,7 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then set header "User-Uid" with value "hubber" Then set header "Platform" with value "discord" When make a PUT request to "/calendar/{id}" with: @@ -76,6 +79,7 @@ Feature: calendar module | startDate | "2021-01-01T00:00:00.000Z" | | endDate | "2021-01-01T00:00:00.000Z" | Then the response status code should be 200 + Given authorization with "reading" permission When make a GET request to "/calendar/{id}" Then the response status code should be 200 And the response should contain: @@ -90,7 +94,7 @@ Feature: calendar module | updatedOn | "TYPE:DATE" | Scenario: update an event with wrong author - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -101,6 +105,7 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When make a PUT request to "/calendar/{id}" with: | name | "Livestream YZ" | | description | "undescriptive Description" | @@ -115,7 +120,7 @@ Feature: calendar module | message | "update failed: author doesn't match" | Scenario: update an non-existing event - Given authorisation + Given authorization with "writing" permission When make a PUT request to "/calendar/321" with: | name | "Livestream YZ" | | description | "undescriptive Description" | @@ -130,7 +135,7 @@ Feature: calendar module | statusCode | 404 | Scenario: delete an event - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -141,13 +146,14 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then set header "User-Uid" with value "hubber" Then set header "Platform" with value "discord" When make a DELETE request to "/calendar/{id}" Then the response status code should be 204 Scenario: delete an event with wrong author - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -158,6 +164,7 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When make a DELETE request to "/calendar/{id}" Then the response status code should be 400 And the response should contain: @@ -165,7 +172,7 @@ Feature: calendar module | message | "deletion failed: author doesn't match" | Scenario: delete non-existing event - Given authorisation + Given authorization with "writing" permission When make a DELETE request to "/calendar/321" Then the response status code should be 404 And the response should contain: @@ -173,7 +180,7 @@ Feature: calendar module | message | "no event for 321 found" | Scenario: get event with authenticated request - Given authorisation + Given authorization with "writing" permission When make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -184,6 +191,7 @@ Feature: calendar module | endDate | "2021-01-01T00:00:00.000Z" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/calendar/{id}" Then the response status code should be 200 And the response should contain: @@ -212,7 +220,7 @@ Feature: calendar module | message | "Unauthorized" | Scenario: get sorted ongoing and future events - Given authorisation + Given authorization with "writing" permission And make a POST request to "/calendar" with: | name | "Livestream XY" | | description | "descriptive Description" | @@ -229,6 +237,7 @@ Feature: calendar module | author | {"platform":"discord","uid":"hubby"} | | startDate | "2021-01-01T00:00:00.000Z" | | endDate | "2022-01-01T00:00:00.000Z" | + Given authorization with "reading" permission When make a GET request to "/calendar" Then the response status code should be 200 And the response property "future" has a subobject with a field "name" that is equal to "Livestream XY" should contain: @@ -251,3 +260,18 @@ Feature: calendar module | endDate | "2022-01-01T00:00:00.000Z" | | createdOn | "TYPE:DATE" | | updatedOn | "TYPE:DATE" | + + Scenario: create event with wrong permissions + Given authorization with "reading" permission + And make a POST request to "/calendar" with: + | name | "Livestream XY" | + | description | "descriptive Description" | + | url | "https://domain.com" | + | platform | "YouTube" | + | author | {"platform":"discord","uid":"hubber"} | + | startDate | "2022-01-01T00:00:00.000Z" | + | endDate | "2023-01-01T00:00:00.000Z" | + Then the response status code should be 403 + And the response should contain: + | message | "Forbidden" | + | statusCode | 403 | diff --git a/test/features/discord.feature b/test/features/discord.feature index 37400fb8..47efc3a3 100644 --- a/test/features/discord.feature +++ b/test/features/discord.feature @@ -2,7 +2,7 @@ Feature: discord module Scenario: add a new user - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | @@ -12,13 +12,14 @@ Feature: discord module | documentId | "TYPE:ID" | Scenario: get list of users - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/discord" Then the response status code should be 200 And the response in item where property "author" has a subobject "uid" which contains a field that is equal to "hubber" should contain: @@ -29,7 +30,7 @@ Feature: discord module | createdOn | "TYPE:DATE" | Scenario: add an empty user - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | test | "test" | Then the response status code should be 400 @@ -40,18 +41,20 @@ Feature: discord module | author should not be empty | Scenario: update a user - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When set header "User-Uid" with value "hubber" Then make a PUT request to "/discord/{id}" with: | author | {"platform":"discord","uid":"hubby"} | | bio | "Updated user bio" | | socials | {"discord":"update-user"} | + Given authorization with "reading" permission When make a GET request to "/discord/{id}" Then the response status code should be 200 And the response should contain: @@ -62,13 +65,14 @@ Feature: discord module | createdOn | "TYPE:DATE" | Scenario: update a user with wrong author - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When make a PUT request to "/discord/{id}" with: | author | {"platform":"discord","uid":"hubby"} | | bio | "Updated user bio" | @@ -79,25 +83,27 @@ Feature: discord module | message | "update failed: author doesn't match" | Scenario: delete a user - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then set header "User-Uid" with value "hubber" When make a DELETE request to "/discord/{id}" Then the response status code should be 204 Scenario: delete a user with wrong author - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When make a DELETE request to "/discord/{id}" Then the response status code should be 400 And the response should contain: @@ -105,7 +111,7 @@ Feature: discord module | message | "deletion failed: author doesn't match" | Scenario: delete non-existing user - Given authorisation + Given authorization with "writing" permission When make a DELETE request to "/discord/321" Then the response status code should be 404 And the response should contain: @@ -113,13 +119,14 @@ Feature: discord module | message | "no discord-profile for 321 found" | Scenario: get user with authenticated request - Given authorisation + Given authorization with "writing" permission And make a POST request to "/discord" with: | bio | "This is a GitHub Campus Expert" | | author | {"platform":"discord","uid":"hubber"} | | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/discord/{id}" Then the response status code should be 200 And the response should contain: @@ -138,3 +145,14 @@ Feature: discord module And the response should contain: | statusCode | 401 | | message | "Unauthorized" | + + Scenario: create a user with wrong permissions + Given authorization with "reading" permission + And make a POST request to "/discord" with: + | bio | "This is a GitHub Campus Expert" | + | author | {"platform":"discord","uid":"hubber"} | + | socials | {"discord":"khattakdev","github":"khattakdev","linkedin":"khattakdev","twitter":"khattakdev"} | + Then the response status code should be 403 + And the response should contain: + | statusCode | 403 | + | message | "Forbidden" | diff --git a/test/features/github.feature b/test/features/github.feature index 497f516b..fe4b626d 100644 --- a/test/features/github.feature +++ b/test/features/github.feature @@ -2,7 +2,7 @@ Feature: github module Scenario: add a new githubprofile - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -18,7 +18,7 @@ Feature: github module | documentId | "TYPE:ID" | Scenario: get list of githubprofiles - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -31,6 +31,7 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/github" Then the response status code should be 200 And the response in item where field "username" is equal to "eddiehubber" should contain: @@ -47,7 +48,7 @@ Feature: github module | createdOn | "TYPE:DATE" | Scenario: add an empty githubprofile - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | test | "test" | Then the response status code should be 400 @@ -59,7 +60,7 @@ Feature: github module | username should not be empty | Scenario: delete a githubprofile - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -72,11 +73,12 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission When make a DELETE request to "/github/{id}" Then the response status code should be 204 Scenario: delete non-existent githubprofile - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -89,6 +91,7 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then make a DELETE request to "/github/66" Then the response status code should be 404 And the response should contain: @@ -96,7 +99,7 @@ Feature: github module | message | "no github-profile for 66 found" | Scenario: update githubprofile with previously used event - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -109,6 +112,7 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then make a PUT request to "/github/{id}" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -124,7 +128,7 @@ Feature: github module | documentId | "TYPE:ID" | Scenario: update githubprofile with previously unused event - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -137,6 +141,7 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then make a PUT request to "/github/{id}" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -152,7 +157,7 @@ Feature: github module | documentId | "TYPE:ID" | Scenario: get githubprofile with authenticated request - Given authorisation + Given authorization with "writing" permission And make a POST request to "/github" with: | username | "eddiehubber" | | bio | "I love to code" | @@ -165,6 +170,7 @@ Feature: github module | location | "London" | And the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/github/{id}" Then the response status code should be 200 And the response should contain: @@ -195,3 +201,20 @@ Feature: github module And the response should contain: | statusCode | 401 | | message | "Unauthorized" | + + Scenario: create a githubprofile with wrong permissions + Given authorization with "reading" permission + And make a POST request to "/github" with: + | username | "eddiehubber" | + | bio | "I love to code" | + | avatarUrl | "https://dummy.com/avatar" | + | followers | 500 | + | repos | 32 | + | event | "push" | + | blog | "https://www.myBlog.com" | + | organization | "Eddiehub" | + | location | "London" | + Then the response status code should be 403 + And the response should contain: + | statusCode | 403 | + | message | "Forbidden" | diff --git a/test/features/standup.feature b/test/features/standup.feature index d974522e..d240b997 100644 --- a/test/features/standup.feature +++ b/test/features/standup.feature @@ -2,7 +2,7 @@ Feature: Standup module Scenario: add a new standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | @@ -12,14 +12,15 @@ Feature: Standup module | documentId | "TYPE:ID" | Scenario: search existing standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | - Then make a GET request to "/standup/search?uid=hubber" + Given authorization with "reading" permission + And make a GET request to "/standup/search?uid=hubber" Then the response status code should be 200 And the response in item where field "todayMessage" is equal to "Today I'll do this" should contain: | author | {"platform":"discord","uid":"hubber"} | @@ -28,25 +29,27 @@ Feature: Standup module | createdOn | "TYPE:DATE" | Scenario: search non-existing standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | - Then make a GET request to "/standup/search?uid=benjamin" + Given authorization with "reading" permission + And make a GET request to "/standup/search?uid=benjamin" Then the response status code should be 200 And the response should be "{}" Scenario: provide no search context - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission Then make a GET request to "/standup/search" Then the response status code should be 400 And the response should contain: @@ -54,7 +57,7 @@ Feature: Standup module | message | "Please provide search context" | Scenario: add an empty standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | test | "test" | Then the response status code should be 400 @@ -69,26 +72,28 @@ Feature: Standup module | todayMessage must be a string | Scenario: delete standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then set header "User-Uid" with value "hubber" Then set header "Platform" with value "discord" Then make a DELETE request to "/standup/{id}" Then the response status code should be 204 Scenario: delete standup with wrong credentials - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then make a DELETE request to "/standup/{id}" Then the response status code should be 400 And the response should contain: @@ -96,13 +101,14 @@ Feature: Standup module | message | "deletion failed: author doesn't match" | Scenario: delete non-existent standup - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "writing" permission Then make a DELETE request to "/standup/66" Then the response status code should be 404 And the response should contain: @@ -110,13 +116,14 @@ Feature: Standup module | message | "no standup for 66 found" | Scenario: get standup with authenticated request - Given authorisation + Given authorization with "writing" permission And make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | Then the response should contain: | documentId | "TYPE:ID" | + Given authorization with "reading" permission When make a GET request to "/standup/{id}" Then the response status code should be 200 And the response should contain: @@ -126,7 +133,7 @@ Feature: Standup module | createdOn | "TYPE:DATE" | Scenario: create standup without authorization - Given make a POST request to "/standup" with: + When make a POST request to "/standup" with: | author | {"platform":"discord","uid":"hubber"} | | yesterdayMessage | "Yesterday I did this" | | todayMessage | "Today I'll do this" | @@ -134,3 +141,14 @@ Feature: Standup module And the response should contain: | statusCode | 401 | | message | "Unauthorized" | + + Scenario: create standup with wrong permissions + Given authorization with "reading" permission + And make a POST request to "/standup" with: + | author | {"platform":"discord","uid":"hubber"} | + | yesterdayMessage | "Yesterday I did this" | + | todayMessage | "Today I'll do this" | + Then the response status code should be 403 + And the response should contain: + | statusCode | 403 | + | message | "Forbidden" | diff --git a/test/step-definitions/requests.ts b/test/step-definitions/requests.ts index 59ac78b7..16c78b0b 100644 --- a/test/step-definitions/requests.ts +++ b/test/step-definitions/requests.ts @@ -1,11 +1,12 @@ -import { binding, given, when, before } from 'cucumber-tsflow'; +import { ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; import { exec } from 'child_process'; +import { BeforeAll, setDefaultTimeout } from 'cucumber'; +import { before, binding, given, when } from 'cucumber-tsflow'; +import { sign } from 'jsonwebtoken'; +import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; import Context from '../support/world'; -import { ValidationPipe } from '@nestjs/common'; -import { BeforeAll, setDefaultTimeout } from 'cucumber'; setDefaultTimeout(60 * 1000); @@ -52,6 +53,24 @@ export class requests { await this.context.app.init(); } + @given(/authorization with "([^"]*)" permission/) + public async generateReadToken(scope: string) { + let scopes = []; + switch (scope) { + case 'writing': + scopes = ['Data.Write']; + break; + case 'reading': + scopes = ['Data.Read']; + break; + } + const token = sign( + { scopes, keyspace: 'eddiehub' }, + process.env.JWT_SECRET, + ); + this.context.bearerToken = token; + } + @given(/authorisation/) public async authorisation() { this.context.token = 'abc'; @@ -60,9 +79,13 @@ export class requests { @given(/make a GET request to "([^"]*)"/) public async getRequest(url: string) { url = this.prepareURL(url); - this.context.response = await request(this.context.app.getHttpServer()).get( - url, - ); + + const get = request(this.context.app.getHttpServer()).get(url); + + if (this.context.bearerToken) { + get.set('Authorization', `Bearer ${this.context.bearerToken}`); + } + this.context.response = await get.send(); } @given(/make a POST request to "([^"]*)" with:/) @@ -75,11 +98,15 @@ export class requests { post.set('Client-Token', this.context.token); } + if (this.context.bearerToken) { + post.set('Authorization', `Bearer ${this.context.bearerToken}`); + } this.context.response = await post.send(this.context.tableToObject(table)); + } - this.context.preRequest = await request( - this.context.app.getHttpServer(), - ).get(url); + @when(/clear the bearer token/) + public clearBearer() { + this.context.bearerToken = null; } @when(/set header "([^"]*)" with value "([^"]*)"/) @@ -101,6 +128,10 @@ export class requests { putReq.set(this.context.headers); } + if (this.context.bearerToken) { + putReq.set('Authorization', `Bearer ${this.context.bearerToken}`); + } + this.context.response = await putReq.send( this.context.tableToObject(table), ); @@ -119,6 +150,10 @@ export class requests { deleteReq.set(this.context.headers); } + if (this.context.bearerToken) { + deleteReq.set('Authorization', `Bearer ${this.context.bearerToken}`); + } + this.context.response = await deleteReq.send(); } } diff --git a/test/step-definitions/responses.ts b/test/step-definitions/responses.ts index 67daed1b..c4209027 100644 --- a/test/step-definitions/responses.ts +++ b/test/step-definitions/responses.ts @@ -4,7 +4,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../src/app.module'; import Context from '../support/world'; import { getRegex } from '../support/regexes'; -import { ValidationPipe } from '@nestjs/common'; +import { + ExecutionContext, + UnauthorizedException, + ValidationPipe, +} from '@nestjs/common'; +import { JWTGuard } from '../../src/auth/jwt.strategy'; +import { TokenPayload } from '../../src/auth/interfaces/token-payload.interface'; +import { decode } from 'jsonwebtoken'; +import { Request } from 'express'; @binding([Context]) export class responses { @@ -14,7 +22,20 @@ export class responses { public async before(): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideGuard(JWTGuard) + .useValue({ + canActivate: (ctx: ExecutionContext) => { + const req: Request = ctx.switchToHttp().getRequest(); + if (req.headers.authorization) { + const accessToken = req.headers.authorization.split(' ')[1]; + req.user = decode(accessToken) as TokenPayload; + return true; + } + throw new UnauthorizedException(); + }, + }) + .compile(); this.context.app = moduleFixture.createNestApplication(); this.context.app.useGlobalPipes(new ValidationPipe({ transform: true })); @@ -22,7 +43,7 @@ export class responses { } @then( - /the response status code should be (200|201|204|400|401|404|413|500|503)/, + /the response status code should be (200|201|204|400|401|403|404|413|500|503)/, ) public statusResponse(status: string) { expect(this.context.response.status).to.equal(parseInt(status)); diff --git a/test/support/world.ts b/test/support/world.ts index beccea7f..2037eecb 100644 --- a/test/support/world.ts +++ b/test/support/world.ts @@ -4,6 +4,7 @@ export default class Context { public preRequest; public headers: { [field: string]: string } = {}; public token; + public bearerToken: string; public documentId: string; public tableToObject(table) {