diff --git a/.github/ci.env b/.github/ci.env index 504118f27..c90c60eb0 100644 --- a/.github/ci.env +++ b/.github/ci.env @@ -17,6 +17,9 @@ SYNCING_SERVER_LOG_LEVEL=debug FILES_SERVER_LOG_LEVEL=debug REVISIONS_SERVER_LOG_LEVEL=debug API_GATEWAY_LOG_LEVEL=debug +COOKIE_DOMAIN=localhost +COOKIE_SECURE=false +COOKIE_PARTITIONED=false MYSQL_DATABASE=standard_notes_db MYSQL_USER=std_notes_user diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ec9075ee2..c65c7807a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -98,32 +98,30 @@ jobs: - name: Test run: yarn test - # e2e-base: - # needs: build - # name: E2E Base Suite - # uses: standardnotes/server/.github/workflows/common-e2e.yml@main - # with: - # snjs_image_tag: 'latest' - # suite: 'base' + e2e-base: + needs: build + name: E2E Base Suite + uses: standardnotes/server/.github/workflows/common-e2e.yml@main + with: + snjs_image_tag: 'latest' + suite: 'base' - # e2e-vaults: - # needs: build - # name: E2E Vaults Suite - # uses: standardnotes/server/.github/workflows/common-e2e.yml@main - # with: - # snjs_image_tag: 'latest' - # suite: 'vaults' + e2e-vaults: + needs: build + name: E2E Vaults Suite + uses: standardnotes/server/.github/workflows/common-e2e.yml@main + with: + snjs_image_tag: 'latest' + suite: 'vaults' publish-self-hosting: - # needs: [ test, lint, e2e-base, e2e-vaults ] - needs: [ test, lint ] + needs: [ test, lint, e2e-base, e2e-vaults ] name: Publish Self Hosting Docker Image uses: standardnotes/server/.github/workflows/common-self-hosting.yml@main secrets: inherit publish-services: - # needs: [ test, lint, e2e-base, e2e-vaults ] - needs: [ test, lint ] + needs: [ test, lint, e2e-base, e2e-vaults ] runs-on: ubuntu-latest diff --git a/.pnp.cjs b/.pnp.cjs index a05b249f9..57b64b29e 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6356,7 +6356,7 @@ const RAW_RUNTIME_STATE = ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ ["mixpanel", "npm:0.17.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["prettier", "npm:3.0.3"],\ ["reflect-metadata", "npm:0.2.1"],\ ["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.1.0"],\ @@ -6396,6 +6396,7 @@ const RAW_RUNTIME_STATE = ["@standardnotes/grpc", "workspace:packages/grpc"],\ ["@standardnotes/security", "workspace:packages/security"],\ ["@standardnotes/time", "workspace:packages/time"],\ + ["@types/cookie-parser", "npm:1.4.6"],\ ["@types/cors", "npm:2.8.13"],\ ["@types/express", "npm:4.17.17"],\ ["@types/ioredis", "npm:5.0.0"],\ @@ -6407,6 +6408,7 @@ const RAW_RUNTIME_STATE = ["@typescript-eslint/parser", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:6.5.0"],\ ["agentkeepalive", "npm:4.5.0"],\ ["axios", "npm:1.6.1"],\ + ["cookie-parser", "npm:1.4.6"],\ ["cors", "npm:2.8.5"],\ ["dotenv", "npm:16.1.3"],\ ["eslint", "npm:8.41.0"],\ @@ -6457,6 +6459,7 @@ const RAW_RUNTIME_STATE = ["@standardnotes/sncrypto-node", "workspace:packages/sncrypto-node"],\ ["@standardnotes/time", "workspace:packages/time"],\ ["@types/bcryptjs", "npm:2.4.2"],\ + ["@types/cookie-parser", "npm:1.4.6"],\ ["@types/cors", "npm:2.8.13"],\ ["@types/express", "npm:4.17.17"],\ ["@types/ioredis", "npm:5.0.0"],\ @@ -6468,7 +6471,10 @@ const RAW_RUNTIME_STATE = ["@types/uuid", "npm:9.0.3"],\ ["@typescript-eslint/eslint-plugin", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:6.5.0"],\ ["@typescript-eslint/parser", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:6.5.0"],\ + ["agentkeepalive", "npm:4.5.0"],\ + ["axios", "npm:1.6.7"],\ ["bcryptjs", "npm:2.4.3"],\ + ["cookie-parser", "npm:1.4.6"],\ ["cors", "npm:2.8.5"],\ ["dayjs", "npm:1.11.7"],\ ["dotenv", "npm:16.1.3"],\ @@ -6479,7 +6485,7 @@ const RAW_RUNTIME_STATE = ["inversify-express-utils", "npm:6.4.3"],\ ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["otplib", "npm:12.0.1"],\ ["prettier", "npm:3.0.3"],\ ["prettyjson", "npm:1.2.5"],\ @@ -6689,10 +6695,12 @@ const RAW_RUNTIME_STATE = ["@standardnotes/files-server", "workspace:packages/files"],\ ["@standardnotes/revisions-server", "workspace:packages/revisions"],\ ["@standardnotes/syncing-server", "workspace:packages/syncing-server"],\ + ["@types/cookie-parser", "npm:1.4.6"],\ ["@types/cors", "npm:2.8.13"],\ ["@types/express", "npm:4.17.17"],\ ["@typescript-eslint/eslint-plugin", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:6.5.0"],\ ["@typescript-eslint/parser", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:6.5.0"],\ + ["cookie-parser", "npm:1.4.6"],\ ["cors", "npm:2.8.5"],\ ["dotenv", "npm:16.1.3"],\ ["eslint", "npm:8.41.0"],\ @@ -6790,7 +6798,7 @@ const RAW_RUNTIME_STATE = ["inversify-express-utils", "npm:6.4.3"],\ ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["prettier", "npm:3.0.3"],\ ["reflect-metadata", "npm:0.2.1"],\ ["sqlite3", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:5.1.6"],\ @@ -6826,7 +6834,7 @@ const RAW_RUNTIME_STATE = ["inversify", "npm:6.0.1"],\ ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["prettier", "npm:3.0.3"],\ ["reflect-metadata", "npm:0.2.1"],\ ["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.1.0"],\ @@ -6977,7 +6985,7 @@ const RAW_RUNTIME_STATE = ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ ["jsonwebtoken", "npm:9.0.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["prettier", "npm:3.0.3"],\ ["prettyjson", "npm:1.2.5"],\ ["reflect-metadata", "npm:0.2.1"],\ @@ -7057,7 +7065,7 @@ const RAW_RUNTIME_STATE = ["inversify-express-utils", "npm:6.4.3"],\ ["ioredis", "npm:5.3.2"],\ ["jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.5.0"],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["prettier", "npm:3.0.3"],\ ["reflect-metadata", "npm:0.2.1"],\ ["ts-jest", "virtual:fd909b174d079e30b336c4ce72c38a88c1e447767b1a8dd7655e07719a1e31b97807f0931368724fc78897ff15e6a6d00b83316c0f76d11f85111f342e08bb79#npm:29.1.0"],\ @@ -7237,6 +7245,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@types/cookie-parser", [\ + ["npm:1.4.6", {\ + "packageLocation": "./.yarn/cache/@types-cookie-parser-npm-1.4.6-27287e1e43-b1bbb17bc4.zip/node_modules/@types/cookie-parser/",\ + "packageDependencies": [\ + ["@types/cookie-parser", "npm:1.4.6"],\ + ["@types/express", "npm:4.17.17"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/cors", [\ ["npm:2.8.13", {\ "packageLocation": "./.yarn/cache/@types-cors-npm-2.8.13-4b8ac1068f-7ef197ea19.zip/node_modules/@types/cors/",\ @@ -8497,6 +8515,16 @@ const RAW_RUNTIME_STATE = ["proxy-from-env", "npm:1.1.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:1.6.7", {\ + "packageLocation": "./.yarn/cache/axios-npm-1.6.7-d7b9974d1b-a1932b089e.zip/node_modules/axios/",\ + "packageDependencies": [\ + ["axios", "npm:1.6.7"],\ + ["follow-redirects", "virtual:d7b9974d1bba76881cc57a280a16dd4914416a6fc4923c2efbb6328057412974da1e719cef1530b7a62b97d85d828f7e1d49b5f6de3b5b0854d49902ec87827c#npm:1.15.5"],\ + ["form-data", "npm:4.0.0"],\ + ["proxy-from-env", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["babel-jest", [\ @@ -9608,6 +9636,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["cookie", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/cookie-npm-0.4.1-cc5e2ebb42-0f2defd60a.zip/node_modules/cookie/",\ + "packageDependencies": [\ + ["cookie", "npm:0.4.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:0.5.0", {\ "packageLocation": "./.yarn/cache/cookie-npm-0.5.0-e2d58a161a-aae7911ddc.zip/node_modules/cookie/",\ "packageDependencies": [\ @@ -9616,6 +9651,17 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["cookie-parser", [\ + ["npm:1.4.6", {\ + "packageLocation": "./.yarn/cache/cookie-parser-npm-1.4.6-a68f84d02a-1e5a63aa82.zip/node_modules/cookie-parser/",\ + "packageDependencies": [\ + ["cookie-parser", "npm:1.4.6"],\ + ["cookie", "npm:0.4.1"],\ + ["cookie-signature", "npm:1.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cookie-signature", [\ ["npm:1.0.6", {\ "packageLocation": "./.yarn/cache/cookie-signature-npm-1.0.6-93f325f7f0-f4e1b0a98a.zip/node_modules/cookie-signature/",\ @@ -10864,6 +10910,26 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["npm:1.15.5", {\ + "packageLocation": "./.yarn/cache/follow-redirects-npm-1.15.5-9d14db76ca-d467f13c1c.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "npm:1.15.5"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d7b9974d1bba76881cc57a280a16dd4914416a6fc4923c2efbb6328057412974da1e719cef1530b7a62b97d85d828f7e1d49b5f6de3b5b0854d49902ec87827c#npm:1.15.5", {\ + "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-393395f3f6/0/cache/follow-redirects-npm-1.15.5-9d14db76ca-d467f13c1c.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "virtual:d7b9974d1bba76881cc57a280a16dd4914416a6fc4923c2efbb6328057412974da1e719cef1530b7a62b97d85d828f7e1d49b5f6de3b5b0854d49902ec87827c#npm:1.15.5"],\ + ["@types/debug", null],\ + ["debug", null]\ + ],\ + "packagePeers": [\ + "@types/debug",\ + "debug"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:ffaff76449f02e83712a7d24e03c564489516739c78ebeffb0fbcdb3893ad9a0e48504f9acfa70fe6f16debe9c8dabde3679d63bf648278ea98a5ff38cf77a9e#npm:1.15.2", {\ "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-c2d5794c26/0/cache/follow-redirects-npm-1.15.2-1ec1dd82be-8be0d39919.zip/node_modules/follow-redirects/",\ "packageDependencies": [\ @@ -13783,10 +13849,10 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["mysql2", [\ - ["npm:3.3.3", {\ - "packageLocation": "./.yarn/cache/mysql2-npm-3.3.3-d2fe8cf512-4bf7ace8f1.zip/node_modules/mysql2/",\ + ["npm:3.9.7", {\ + "packageLocation": "./.yarn/cache/mysql2-npm-3.9.7-8fe89e50ac-7f43b17cc0.zip/node_modules/mysql2/",\ "packageDependencies": [\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["denque", "npm:2.1.0"],\ ["generate-function", "npm:2.3.1"],\ ["iconv-lite", "npm:0.6.3"],\ @@ -16836,7 +16902,7 @@ const RAW_RUNTIME_STATE = ["mkdirp", "npm:2.1.6"],\ ["mongodb", null],\ ["mssql", null],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["oracledb", null],\ ["pg", null],\ ["pg-native", null],\ @@ -16928,7 +16994,7 @@ const RAW_RUNTIME_STATE = ["mkdirp", "npm:2.1.6"],\ ["mongodb", null],\ ["mssql", null],\ - ["mysql2", "npm:3.3.3"],\ + ["mysql2", "npm:3.9.7"],\ ["oracledb", null],\ ["pg", null],\ ["pg-native", null],\ diff --git a/.yarn/cache/@types-cookie-parser-npm-1.4.6-27287e1e43-b1bbb17bc4.zip b/.yarn/cache/@types-cookie-parser-npm-1.4.6-27287e1e43-b1bbb17bc4.zip new file mode 100644 index 000000000..8d5f31029 Binary files /dev/null and b/.yarn/cache/@types-cookie-parser-npm-1.4.6-27287e1e43-b1bbb17bc4.zip differ diff --git a/.yarn/cache/axios-npm-1.6.7-d7b9974d1b-a1932b089e.zip b/.yarn/cache/axios-npm-1.6.7-d7b9974d1b-a1932b089e.zip new file mode 100644 index 000000000..7a122000c Binary files /dev/null and b/.yarn/cache/axios-npm-1.6.7-d7b9974d1b-a1932b089e.zip differ diff --git a/.yarn/cache/cookie-npm-0.4.1-cc5e2ebb42-0f2defd60a.zip b/.yarn/cache/cookie-npm-0.4.1-cc5e2ebb42-0f2defd60a.zip new file mode 100644 index 000000000..2796ed2bc Binary files /dev/null and b/.yarn/cache/cookie-npm-0.4.1-cc5e2ebb42-0f2defd60a.zip differ diff --git a/.yarn/cache/cookie-parser-npm-1.4.6-a68f84d02a-1e5a63aa82.zip b/.yarn/cache/cookie-parser-npm-1.4.6-a68f84d02a-1e5a63aa82.zip new file mode 100644 index 000000000..6feae6f59 Binary files /dev/null and b/.yarn/cache/cookie-parser-npm-1.4.6-a68f84d02a-1e5a63aa82.zip differ diff --git a/.yarn/cache/follow-redirects-npm-1.15.5-9d14db76ca-d467f13c1c.zip b/.yarn/cache/follow-redirects-npm-1.15.5-9d14db76ca-d467f13c1c.zip new file mode 100644 index 000000000..289b766fb Binary files /dev/null and b/.yarn/cache/follow-redirects-npm-1.15.5-9d14db76ca-d467f13c1c.zip differ diff --git a/.yarn/cache/mysql2-npm-3.3.3-d2fe8cf512-4bf7ace8f1.zip b/.yarn/cache/mysql2-npm-3.3.3-d2fe8cf512-4bf7ace8f1.zip deleted file mode 100644 index c53afe119..000000000 Binary files a/.yarn/cache/mysql2-npm-3.3.3-d2fe8cf512-4bf7ace8f1.zip and /dev/null differ diff --git a/.yarn/cache/mysql2-npm-3.9.7-8fe89e50ac-7f43b17cc0.zip b/.yarn/cache/mysql2-npm-3.9.7-8fe89e50ac-7f43b17cc0.zip new file mode 100644 index 000000000..cac716b08 Binary files /dev/null and b/.yarn/cache/mysql2-npm-3.9.7-8fe89e50ac-7f43b17cc0.zip differ diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 4f0d7b5d8..f449774d4 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -54,7 +54,6 @@ services: ports: - 3306 restart: unless-stopped - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci volumes: - ./data/mysql:/var/lib/mysql - ./data/import:/docker-entrypoint-initdb.d diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 6bd1e9509..db0ff76f1 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -39,7 +39,6 @@ services: expose: - 3306 restart: unless-stopped - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci volumes: - ./data/mysql:/var/lib/mysql - ./data/import:/docker-entrypoint-initdb.d diff --git a/package.json b/package.json index ed7b03d04..bdff40b39 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "publish": "lerna publish from-git --yes --no-verify-access --loglevel verbose", "postversion": "./scripts/push-tags-one-by-one.sh", "e2e": "yarn build && PORT=3123 yarn workspace @standardnotes/home-server start", - "start": "yarn workspace @standardnotes/home-server run build && yarn workspace @standardnotes/home-server start" + "start": "yarn build && yarn workspace @standardnotes/home-server start" }, "devDependencies": { "@commitlint/cli": "^17.0.2", @@ -39,7 +39,7 @@ "ts-node": "^10.9.1", "typescript": "^5.0.4" }, - "packageManager": "yarn@4.0.2", + "packageManager": "yarn@4.1.0", "dependenciesMeta": { "grpc-tools@1.12.4": { "unplugged": true diff --git a/packages/analytics/Dockerfile b/packages/analytics/Dockerfile index 71097c352..dce4d4535 100644 --- a/packages/analytics/Dockerfile +++ b/packages/analytics/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/analytics ENTRYPOINT [ "/workspace/packages/analytics/docker/entrypoint.sh" ] diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 48325fca0..e636c4c58 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -24,7 +24,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "worker": "yarn node dist/bin/worker.js", "report": "yarn node dist/bin/report.js", "setup:env": "cp .env.sample .env", @@ -57,7 +57,7 @@ "inversify": "^6.0.1", "ioredis": "^5.2.4", "mixpanel": "^0.17.0", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "reflect-metadata": "^0.2.1", "typeorm": "^0.3.17", "winston": "^3.8.1" diff --git a/packages/api-gateway/CHANGELOG.md b/packages/api-gateway/CHANGELOG.md index aac134742..b281ce74b 100644 --- a/packages/api-gateway/CHANGELOG.md +++ b/packages/api-gateway/CHANGELOG.md @@ -3,24 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [1.91.0](https://github.com/standardnotes/server/compare/@standardnotes/api-gateway@1.90.3...@standardnotes/api-gateway@1.91.0) (2024-03-20) - -### Features - -* add CORS_ORIGIN_STRICT_MODE_ENABLED env var to determine if CORS origin should be restricted ([5c02435](https://github.com/standardnotes/server/commit/5c02435ee478b893747d3f9e41062aae12d7ff10)) - -## [1.90.3](https://github.com/standardnotes/server/compare/@standardnotes/api-gateway@1.90.2...@standardnotes/api-gateway@1.90.3) (2024-03-18) - -### Bug Fixes - -* **api-gateway:** response headers cors issue - fixes [#1046](https://github.com/standardnotes/server/issues/1046) ([be668d7](https://github.com/standardnotes/server/commit/be668d7d7a1d9128f625a2bfa807e6a91183b488)) - -## [1.90.2](https://github.com/standardnotes/server/compare/@standardnotes/api-gateway@1.90.1...@standardnotes/api-gateway@1.90.2) (2024-03-18) - -### Bug Fixes - -* cors issues on clients - fixes [#1046](https://github.com/standardnotes/server/issues/1046) ([#1049](https://github.com/standardnotes/server/issues/1049)) ([6d7ca1b](https://github.com/standardnotes/server/commit/6d7ca1b926fd45d744275bd3c1f4c05b010f76c8)) - ## [1.90.1](https://github.com/standardnotes/server/compare/@standardnotes/api-gateway@1.90.0...@standardnotes/api-gateway@1.90.1) (2024-01-19) **Note:** Version bump only for package @standardnotes/api-gateway diff --git a/packages/api-gateway/Dockerfile b/packages/api-gateway/Dockerfile index d410841ab..d6921443f 100644 --- a/packages/api-gateway/Dockerfile +++ b/packages/api-gateway/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/api-gateway ENTRYPOINT [ "/workspace/packages/api-gateway/docker/entrypoint.sh" ] diff --git a/packages/api-gateway/bin/server.ts b/packages/api-gateway/bin/server.ts index 34a067ff4..1dadb375d 100644 --- a/packages/api-gateway/bin/server.ts +++ b/packages/api-gateway/bin/server.ts @@ -27,6 +27,7 @@ import '../src/Controller/v2/RevisionsControllerV2' import helmet from 'helmet' import * as cors from 'cors' +import * as cookieParser from 'cookie-parser' import { text, json, Request, Response, NextFunction } from 'express' import * as winston from 'winston' // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -47,9 +48,24 @@ void container.load().then((container) => { ? `${+env.get('HTTP_REQUEST_PAYLOAD_LIMIT_MEGABYTES', true)}mb` : '50mb' + const logger: winston.Logger = container.get(TYPES.ApiGateway_Logger) + const server = new InversifyExpressServer(container) server.setConfig((app) => { + app.use((request: Request, _response: Response, next: NextFunction) => { + if (request.hostname.includes('standardnotes.org')) { + logger.warn('Request is using deprecated domain', { + origin: request.headers.origin, + method: request.method, + url: request.url, + snjs: request.headers['x-snjs-version'], + application: request.headers['x-application-version'], + }) + } + + next() + }) app.use((_request: Request, response: Response, next: NextFunction) => { response.setHeader('X-API-Gateway-Version', container.get(TYPES.ApiGateway_VERSION)) next() @@ -77,15 +93,15 @@ void container.load().then((container) => { }), ) + app.use(cookieParser()) + app.use(json({ limit: requestPayloadLimit })) app.use( text({ type: ['text/plain', 'application/x-www-form-urlencoded', 'application/x-www-form-urlencoded; charset=utf-8'], }), ) - const corsAllowedOrigins = env.get('CORS_ALLOWED_ORIGINS', true) - ? env.get('CORS_ALLOWED_ORIGINS', true).split(',') - : [] + const corsAllowedOrigins = container.get(TYPES.ApiGateway_CORS_ALLOWED_ORIGINS) app.use( cors({ credentials: true, @@ -136,13 +152,12 @@ void container.load().then((container) => { ) }) - const logger: winston.Logger = container.get(TYPES.ApiGateway_Logger) - server.setErrorConfig((app) => { app.use((error: Record, request: Request, response: Response, _next: NextFunction) => { const locals = response.locals as ResponseLocals logger.error(`${error.stack}`, { + origin: request.headers.origin, codeTag: 'server.ts', method: request.method, url: request.url, diff --git a/packages/api-gateway/package.json b/packages/api-gateway/package.json index 0474329a0..874200996 100644 --- a/packages/api-gateway/package.json +++ b/packages/api-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/api-gateway", - "version": "1.91.0", + "version": "1.90.1", "engines": { "node": ">=18.0.0 <21.0.0" }, @@ -41,6 +41,7 @@ "@standardnotes/time": "workspace:*", "agentkeepalive": "^4.5.0", "axios": "^1.6.1", + "cookie-parser": "^1.4.6", "cors": "2.8.5", "dotenv": "^16.0.1", "express": "^4.18.2", @@ -55,6 +56,7 @@ "winston": "^3.8.1" }, "devDependencies": { + "@types/cookie-parser": "^1", "@types/cors": "^2.8.9", "@types/express": "^4.17.14", "@types/ioredis": "^5.0.0", diff --git a/packages/api-gateway/src/Bootstrap/Container.ts b/packages/api-gateway/src/Bootstrap/Container.ts index d5bffdad0..583980ed1 100644 --- a/packages/api-gateway/src/Bootstrap/Container.ts +++ b/packages/api-gateway/src/Bootstrap/Container.ts @@ -142,6 +142,10 @@ export class ContainerConfigLoader { .bind(TYPES.ApiGateway_CROSS_SERVICE_TOKEN_CACHE_TTL) .toConstantValue(+env.get('CROSS_SERVICE_TOKEN_CACHE_TTL', true)) container.bind(TYPES.ApiGateway_IS_CONFIGURED_FOR_HOME_SERVER).toConstantValue(isConfiguredForHomeServer) + container + .bind(TYPES.ApiGateway_CORS_ALLOWED_ORIGINS) + .toConstantValue(env.get('CORS_ALLOWED_ORIGINS', true) ? env.get('CORS_ALLOWED_ORIGINS', true).split(',') : []) + container.bind(TYPES.ApiGateway_CAPTCHA_UI_URL).toConstantValue(env.get('CAPTCHA_UI_URL', true)) // Middleware container @@ -157,14 +161,14 @@ export class ContainerConfigLoader { // Services container.bind(TYPES.ApiGateway_Timer).toConstantValue(new Timer()) - if (isConfiguredForHomeServer) { + if (isConfiguredForInMemoryCache) { container .bind(TYPES.ApiGateway_CrossServiceTokenCache) .toConstantValue(new InMemoryCrossServiceTokenCache(container.get(TYPES.ApiGateway_Timer))) } else { container .bind(TYPES.ApiGateway_CrossServiceTokenCache) - .to(RedisCrossServiceTokenCache) + .toConstantValue(new RedisCrossServiceTokenCache(container.get(TYPES.ApiGateway_Redis))) } container .bind(TYPES.ApiGateway_EndpointResolver) diff --git a/packages/api-gateway/src/Bootstrap/Types.ts b/packages/api-gateway/src/Bootstrap/Types.ts index 8c355bfbe..72359feb6 100644 --- a/packages/api-gateway/src/Bootstrap/Types.ts +++ b/packages/api-gateway/src/Bootstrap/Types.ts @@ -5,6 +5,7 @@ export const TYPES = { ApiGateway_SNS: Symbol.for('ApiGateway_SNS'), ApiGateway_DomainEventPublisher: Symbol.for('ApiGateway_DomainEventPublisher'), // env vars + ApiGateway_CORS_ALLOWED_ORIGINS: Symbol.for('ApiGateway_CORS_ALLOWED_ORIGINS'), ApiGateway_SNS_TOPIC_ARN: Symbol.for('ApiGateway_SNS_TOPIC_ARN'), ApiGateway_SNS_AWS_REGION: Symbol.for('ApiGateway_SNS_AWS_REGION'), ApiGateway_SYNCING_SERVER_JS_URL: Symbol.for('ApiGateway_SYNCING_SERVER_JS_URL'), @@ -24,6 +25,7 @@ export const TYPES = { ApiGateway_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for( 'ApiGateway_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING', ), + ApiGateway_CAPTCHA_UI_URL: Symbol.for('ApiGateway_CAPTCHA_UI_URL'), // Middleware ApiGateway_RequiredCrossServiceTokenMiddleware: Symbol.for('ApiGateway_RequiredCrossServiceTokenMiddleware'), ApiGateway_OptionalCrossServiceTokenMiddleware: Symbol.for('ApiGateway_OptionalCrossServiceTokenMiddleware'), diff --git a/packages/api-gateway/src/Controller/AuthMiddleware.ts b/packages/api-gateway/src/Controller/AuthMiddleware.ts index 28f8dc08f..807c137bd 100644 --- a/packages/api-gateway/src/Controller/AuthMiddleware.ts +++ b/packages/api-gateway/src/Controller/AuthMiddleware.ts @@ -42,9 +42,33 @@ export abstract class AuthMiddleware extends BaseMiddleware { } if (crossServiceToken === null) { + const cookiesFromHeaders = new Map() + request.headers.cookie?.split(';').forEach((cookie) => { + const parts = cookie.split('=') + if (parts.length === 2) { + const existingCookies = cookiesFromHeaders.get(parts[0].trim()) + if (existingCookies) { + existingCookies.push(parts[1].trim()) + cookiesFromHeaders.set(parts[0].trim(), existingCookies) + } else { + cookiesFromHeaders.set(parts[0].trim(), [parts[1].trim()]) + } + } + }) const authResponse = await this.serviceProxy.validateSession({ - authorization: authHeaderValue, - sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue, + headers: { + authorization: authHeaderValue.replace('Bearer ', ''), + sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue, + }, + requestMetadata: { + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, + url: request.url, + method: request.method, + userAgent: request.headers['user-agent'], + secChUa: request.headers['sec-ch-ua'] as string, + }, + cookies: cookiesFromHeaders, }) if (!this.handleSessionValidationResponse(authResponse, response, next)) { diff --git a/packages/api-gateway/src/Controller/GRPCWebSocketAuthMiddleware.ts b/packages/api-gateway/src/Controller/GRPCWebSocketAuthMiddleware.ts index d375fec30..95c6ab7ad 100644 --- a/packages/api-gateway/src/Controller/GRPCWebSocketAuthMiddleware.ts +++ b/packages/api-gateway/src/Controller/GRPCWebSocketAuthMiddleware.ts @@ -100,6 +100,7 @@ export class GRPCWebSocketAuthMiddleware extends BaseMiddleware { roles: decodedToken.roles, isFreeUser: decodedToken.roles.length === 1 && decodedToken.roles[0].name === RoleName.NAMES.CoreUser, readOnlyAccess: decodedToken.session?.readonly_access ?? false, + hasContentLimit: decodedToken.hasContentLimit, } as ResponseLocals) } catch (error) { this.logger.error( diff --git a/packages/api-gateway/src/Controller/LegacyController.ts b/packages/api-gateway/src/Controller/LegacyController.ts index 1e176d0b0..fa348af46 100644 --- a/packages/api-gateway/src/Controller/LegacyController.ts +++ b/packages/api-gateway/src/Controller/LegacyController.ts @@ -20,8 +20,6 @@ export class LegacyController extends BaseHttpController { ['DELETE:/session', 'DELETE:session'], ['DELETE:/session/all', 'DELETE:session/all'], ['POST:/session/refresh', 'POST:session/refresh'], - ['POST:/auth/sign_in', 'POST:auth/sign_in'], - ['GET:/auth/params', 'GET:auth/params'], ]) this.PARAMETRIZED_AUTH_ROUTES = new Map([ diff --git a/packages/api-gateway/src/Controller/ResponseLocals.ts b/packages/api-gateway/src/Controller/ResponseLocals.ts index 903831864..79b400ecd 100644 --- a/packages/api-gateway/src/Controller/ResponseLocals.ts +++ b/packages/api-gateway/src/Controller/ResponseLocals.ts @@ -26,4 +26,5 @@ export interface ResponseLocals { sharedVaultOwnerContext?: { upload_bytes_limit: number } + hasContentLimit: boolean } diff --git a/packages/api-gateway/src/Controller/v1/ActionsController.ts b/packages/api-gateway/src/Controller/v1/ActionsController.ts index 79d97952e..44aaada08 100644 --- a/packages/api-gateway/src/Controller/v1/ActionsController.ts +++ b/packages/api-gateway/src/Controller/v1/ActionsController.ts @@ -4,12 +4,14 @@ import { BaseHttpController, controller, httpGet, httpPost } from 'inversify-exp import { TYPES } from '../../Bootstrap/Types' import { ServiceProxyInterface } from '../../Service/Proxy/ServiceProxyInterface' import { EndpointResolverInterface } from '../../Service/Resolver/EndpointResolverInterface' +import { JsonResult } from 'inversify-express-utils/lib/results' @controller('/v1') export class ActionsController extends BaseHttpController { constructor( @inject(TYPES.ApiGateway_ServiceProxy) private serviceProxy: ServiceProxyInterface, @inject(TYPES.ApiGateway_EndpointResolver) private endpointResolver: EndpointResolverInterface, + @inject(TYPES.ApiGateway_CAPTCHA_UI_URL) private captchaUIUrl: string, ) { super() } @@ -19,7 +21,7 @@ export class ActionsController extends BaseHttpController { await this.serviceProxy.callAuthServer( request, response, - this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'auth/sign_in'), + this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'auth/pkce_sign_in'), request.body, ) } @@ -29,7 +31,7 @@ export class ActionsController extends BaseHttpController { await this.serviceProxy.callAuthServer( request, response, - this.endpointResolver.resolveEndpointOrMethodIdentifier('GET', 'auth/params'), + this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'auth/pkce_params'), request.body, ) } @@ -83,4 +85,11 @@ export class ActionsController extends BaseHttpController { request.body, ) } + + @httpGet('/meta') + async serverMetadata(): Promise { + return this.json({ + captchaUIUrl: this.captchaUIUrl, + }) + } } diff --git a/packages/api-gateway/src/Controller/v1/UsersController.ts b/packages/api-gateway/src/Controller/v1/UsersController.ts index 0629cadfa..c85d6c550 100644 --- a/packages/api-gateway/src/Controller/v1/UsersController.ts +++ b/packages/api-gateway/src/Controller/v1/UsersController.ts @@ -6,7 +6,6 @@ import { controller, httpDelete, httpGet, - httpPatch, httpPost, httpPut, results, @@ -39,16 +38,6 @@ export class UsersController extends BaseHttpController { await this.httpService.callPaymentsServer(request, response, 'api/pro_users/send-activation-code', request.body) } - @httpPatch('/:userId', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware) - async updateUser(request: Request, response: Response): Promise { - await this.httpService.callAuthServer( - request, - response, - this.endpointResolver.resolveEndpointOrMethodIdentifier('PATCH', 'users/:userId', request.params.userId), - request.body, - ) - } - @httpPut('/:userUuid/password', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware) async changePassword(request: Request, response: Response): Promise { this.logger.debug( @@ -86,7 +75,7 @@ export class UsersController extends BaseHttpController { await this.httpService.callAuthServer( request, response, - this.endpointResolver.resolveEndpointOrMethodIdentifier('GET', 'auth/params'), + this.endpointResolver.resolveEndpointOrMethodIdentifier('POST', 'auth/pkce_params'), ) } @@ -142,6 +131,20 @@ export class UsersController extends BaseHttpController { ) } + @httpPut('/:userUuid/subscription-settings', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware) + async putSubscriptionSetting(request: Request, response: Response): Promise { + await this.httpService.callAuthServer( + request, + response, + this.endpointResolver.resolveEndpointOrMethodIdentifier( + 'PUT', + 'users/:userUuid/subscription-settings', + request.params.userUuid, + ), + request.body, + ) + } + @httpGet('/:userUuid/settings/:settingName', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware) async getSetting(request: Request, response: Response): Promise { await this.httpService.callAuthServer( diff --git a/packages/api-gateway/src/Infra/Redis/RedisCrossServiceTokenCache.ts b/packages/api-gateway/src/Infra/Redis/RedisCrossServiceTokenCache.ts index ab5502350..afda3a725 100644 --- a/packages/api-gateway/src/Infra/Redis/RedisCrossServiceTokenCache.ts +++ b/packages/api-gateway/src/Infra/Redis/RedisCrossServiceTokenCache.ts @@ -1,15 +1,12 @@ -import { inject, injectable } from 'inversify' import * as IORedis from 'ioredis' -import { TYPES } from '../../Bootstrap/Types' import { CrossServiceTokenCacheInterface } from '../../Service/Cache/CrossServiceTokenCacheInterface' -@injectable() export class RedisCrossServiceTokenCache implements CrossServiceTokenCacheInterface { private readonly PREFIX = 'cst' private readonly USER_CST_PREFIX = 'user-cst' - constructor(@inject(TYPES.ApiGateway_Redis) private redisClient: IORedis.Redis) {} + constructor(private redisClient: IORedis.Redis) {} async set(dto: { key: string diff --git a/packages/api-gateway/src/Service/DirectCall/DirectCallServiceProxy.ts b/packages/api-gateway/src/Service/DirectCall/DirectCallServiceProxy.ts index 3d5565821..b634816bf 100644 --- a/packages/api-gateway/src/Service/DirectCall/DirectCallServiceProxy.ts +++ b/packages/api-gateway/src/Service/DirectCall/DirectCallServiceProxy.ts @@ -10,23 +10,44 @@ export class DirectCallServiceProxy implements ServiceProxyInterface { private filesServerUrl: string, ) {} - async validateSession( + async validateSession(dto: { headers: { authorization: string sharedVaultOwnerContext?: string - }, - _retryAttempt?: number, - ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> { + } + cookies?: Map + snjs?: string + application?: string + retryAttempt?: number + }): Promise<{ + status: number + data: unknown + headers: { + contentType: string + } + }> { const authService = this.serviceContainer.get(ServiceIdentifier.create(ServiceIdentifier.NAMES.Auth).getValue()) if (!authService) { throw new Error('Auth service not found') } + let stringOfCookies = '' + for (const cookieName of dto.cookies?.keys() ?? []) { + for (const cookieValue of dto.cookies?.get(cookieName) as string[]) { + stringOfCookies += `${cookieName}=${cookieValue}; ` + } + } + const serviceResponse = (await authService.handleRequest( { + body: { + authTokenFromHeaders: dto.headers.authorization, + sharedVaultOwnerContext: dto.headers.sharedVaultOwnerContext, + }, headers: { - authorization: headers.authorization, - 'x-shared-vault-owner-context': headers.sharedVaultOwnerContext, + 'x-snjs-version': dto.snjs, + 'x-application-version': dto.application, + cookie: stringOfCookies.trim(), }, } as never, {} as never, diff --git a/packages/api-gateway/src/Service/Http/HttpServiceProxy.ts b/packages/api-gateway/src/Service/Http/HttpServiceProxy.ts index 2d9f70a90..75bc1decc 100644 --- a/packages/api-gateway/src/Service/Http/HttpServiceProxy.ts +++ b/packages/api-gateway/src/Service/Http/HttpServiceProxy.ts @@ -28,20 +28,51 @@ export class HttpServiceProxy implements ServiceProxyInterface { @inject(TYPES.ApiGateway_Timer) private timer: TimerInterface, ) {} - async validateSession( + async validateSession(dto: { headers: { authorization: string sharedVaultOwnerContext?: string - }, - retryAttempt?: number, - ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> { + } + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + cookies?: Map + retryAttempt?: number + }): Promise<{ + status: number + data: unknown + headers: { + contentType: string + } + }> { try { + let stringOfCookies = '' + for (const cookieName of dto.cookies?.keys() ?? []) { + for (const cookieValue of dto.cookies?.get(cookieName) as string[]) { + stringOfCookies += `${cookieName}=${cookieValue}; ` + } + } + const authResponse = await this.httpClient.request({ method: 'POST', headers: { - Authorization: headers.authorization, Accept: 'application/json', - 'x-shared-vault-owner-context': headers.sharedVaultOwnerContext, + Cookie: stringOfCookies.trim(), + 'x-snjs-version': dto.requestMetadata.snjs, + 'x-application-version': dto.requestMetadata.application, + 'x-origin-user-agent': dto.requestMetadata.userAgent, + 'x-origin-sec-ch-ua': dto.requestMetadata.secChUa, + 'x-origin-url': dto.requestMetadata.url, + 'x-origin-method': dto.requestMetadata.method, + }, + data: { + authTokenFromHeaders: dto.headers.authorization, + sharedVaultOwnerContext: dto.headers.sharedVaultOwnerContext, }, validateStatus: (status: number) => { return status >= 200 && status < 500 @@ -58,13 +89,18 @@ export class HttpServiceProxy implements ServiceProxyInterface { } } catch (error) { const requestDidNotMakeIt = this.requestTimedOutOrDidNotReachDestination(error as Record) - const tooManyRetryAttempts = retryAttempt && retryAttempt > 2 + const tooManyRetryAttempts = dto.retryAttempt && dto.retryAttempt > 2 if (!tooManyRetryAttempts && requestDidNotMakeIt) { await this.timer.sleep(50) - const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1 + const nextRetryAttempt = dto.retryAttempt ? dto.retryAttempt + 1 : 1 - return this.validateSession(headers, nextRetryAttempt) + return this.validateSession({ + headers: dto.headers, + cookies: dto.cookies, + requestMetadata: dto.requestMetadata, + retryAttempt: nextRetryAttempt, + }) } throw error @@ -186,9 +222,18 @@ export class HttpServiceProxy implements ServiceProxyInterface { headers[headerName] = request.headers[headerName] as string } + headers['x-origin-url'] = request.url + headers['x-origin-method'] = request.method + headers['x-snjs-version'] = request.headers['x-snjs-version'] as string + headers['x-application-version'] = request.headers['x-application-version'] as string + headers['x-origin-user-agent'] = request.headers['user-agent'] as string + headers['x-origin-sec-ch-ua'] = request.headers['sec-ch-ua'] as string + delete headers.host delete headers['content-length'] + headers.cookie = request.headers.cookie as string + if ('authToken' in locals && locals.authToken) { headers['X-Auth-Token'] = locals.authToken } diff --git a/packages/api-gateway/src/Service/Proxy/ServiceProxyInterface.ts b/packages/api-gateway/src/Service/Proxy/ServiceProxyInterface.ts index 80b2ebb98..b87e144ec 100644 --- a/packages/api-gateway/src/Service/Proxy/ServiceProxyInterface.ts +++ b/packages/api-gateway/src/Service/Proxy/ServiceProxyInterface.ts @@ -49,13 +49,22 @@ export interface ServiceProxyInterface { endpointOrMethodIdentifier: string, payload?: Record | string, ): Promise - validateSession( + validateSession(dto: { headers: { authorization: string sharedVaultOwnerContext?: string - }, - retryAttempt?: number, - ): Promise<{ + } + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + cookies?: Map + retryAttempt?: number + }): Promise<{ status: number data: unknown headers: { diff --git a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts index 3fcb6c1c6..f3abb5f72 100644 --- a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts +++ b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts @@ -7,8 +7,6 @@ export class EndpointResolver implements EndpointResolverInterface { // Auth Middleware ['[POST]:sessions/validate', 'auth.sessions.validate'], // Actions Controller - ['[POST]:auth/sign_in', 'auth.signIn'], - ['[GET]:auth/params', 'auth.params'], ['[POST]:auth/sign_out', 'auth.signOut'], ['[POST]:auth/recovery/codes', 'auth.generateRecoveryCodes'], ['[POST]:auth/recovery/login', 'auth.signInWithRecoveryCodes'], @@ -48,6 +46,7 @@ export class EndpointResolver implements EndpointResolverInterface { ['[PUT]:users/:userUuid/settings', 'auth.users.updateSetting'], ['[GET]:users/:userUuid/settings/:settingName', 'auth.users.getSetting'], ['[DELETE]:users/:userUuid/settings/:settingName', 'auth.users.deleteSetting'], + ['[PUT]:users/:userUuid/subscription-settings', 'auth.users.updateSubscriptionSetting'], ['[GET]:users/:userUuid/subscription-settings/:subscriptionSettingName', 'auth.users.getSubscriptionSetting'], ['[GET]:users/:userUuid/features', 'auth.users.getFeatures'], ['[GET]:users/:userUuid/subscription', 'auth.users.getSubscription'], diff --git a/packages/api-gateway/src/Service/gRPC/GRPCServiceProxy.ts b/packages/api-gateway/src/Service/gRPC/GRPCServiceProxy.ts index bd9832cdc..5fad1cf07 100644 --- a/packages/api-gateway/src/Service/gRPC/GRPCServiceProxy.ts +++ b/packages/api-gateway/src/Service/gRPC/GRPCServiceProxy.ts @@ -2,7 +2,7 @@ import { AxiosInstance, AxiosError, AxiosResponse, Method } from 'axios' import { Request, Response } from 'express' import { Logger } from 'winston' import { TimerInterface } from '@standardnotes/time' -import { IAuthClient, AuthorizationHeader, SessionValidationResponse } from '@standardnotes/grpc' +import { Cookie, IAuthClient, RequestValidationOptions, SessionValidationResponse } from '@standardnotes/grpc' import * as grpc from '@grpc/grpc-js' import { CrossServiceTokenCacheInterface } from '../Cache/CrossServiceTokenCacheInterface' @@ -30,23 +30,56 @@ export class GRPCServiceProxy implements ServiceProxyInterface { private gRPCSyncingServerServiceProxy: GRPCSyncingServerServiceProxy, ) {} - async validateSession( + async validateSession(dto: { headers: { authorization: string sharedVaultOwnerContext?: string - }, - retryAttempt?: number, - ): Promise<{ status: number; data: unknown; headers: { contentType: string } }> { + } + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + cookies?: Map + retryAttempt?: number + }): Promise<{ + status: number + data: unknown + headers: { + contentType: string + } + }> { const promise = new Promise((resolve, reject) => { try { - const request = new AuthorizationHeader() - request.setBearerToken(headers.authorization) + const request = new RequestValidationOptions() + request.setBearerToken(dto.headers.authorization) - const metadata = new grpc.Metadata() - metadata.set('x-shared-vault-owner-context', headers.sharedVaultOwnerContext ?? '') + for (const cookieName of dto.cookies?.keys() ?? []) { + for (const cookieValue of dto.cookies?.get(cookieName) as string[]) { + const cookie = new Cookie() + cookie.setName(cookieName) + cookie.setValue(cookieValue) + + request.addCookie(cookie) + } + } + if (dto.headers.sharedVaultOwnerContext) { + request.setSharedVaultOwnerContext(dto.headers.sharedVaultOwnerContext) + } this.logger.debug('[GRPCServiceProxy] Validating session via gRPC') + const metadata = new grpc.Metadata() + metadata.set('x-snjs-version', dto.requestMetadata.snjs as string) + metadata.set('x-application-version', dto.requestMetadata.application as string) + metadata.set('x-origin-user-agent', dto.requestMetadata.userAgent as string) + metadata.set('x-origin-sec-ch-ua', dto.requestMetadata.secChUa as string) + metadata.set('x-origin-url', dto.requestMetadata.url) + metadata.set('x-origin-method', dto.requestMetadata.method) + this.authClient.validate( request, metadata, @@ -90,8 +123,8 @@ export class GRPCServiceProxy implements ServiceProxyInterface { try { const result = await promise - if (retryAttempt) { - this.logger.debug(`Request to Auth Server succeeded after ${retryAttempt} retries`) + if (dto.retryAttempt) { + this.logger.info(`Request to Auth Server succeeded after ${dto.retryAttempt} retries`) } return result as { status: number; data: unknown; headers: { contentType: string } } @@ -99,15 +132,20 @@ export class GRPCServiceProxy implements ServiceProxyInterface { const requestDidNotMakeIt = 'code' in (error as Record) && (error as Record).code === Status.UNAVAILABLE - const tooManyRetryAttempts = retryAttempt && retryAttempt > 2 + const tooManyRetryAttempts = dto.retryAttempt && dto.retryAttempt > 2 if (!tooManyRetryAttempts && requestDidNotMakeIt) { await this.timer.sleep(50) - const nextRetryAttempt = retryAttempt ? retryAttempt + 1 : 1 + const nextRetryAttempt = dto.retryAttempt ? dto.retryAttempt + 1 : 1 - this.logger.debug(`Retrying request to Auth Server for the ${nextRetryAttempt} time`) + this.logger.warn(`Retrying request to Auth Server for the ${nextRetryAttempt} time`) - return this.validateSession(headers, nextRetryAttempt) + return this.validateSession({ + headers: dto.headers, + cookies: dto.cookies, + requestMetadata: dto.requestMetadata, + retryAttempt: nextRetryAttempt, + }) } throw error @@ -265,6 +303,8 @@ export class GRPCServiceProxy implements ServiceProxyInterface { delete headers.host delete headers['content-length'] + headers.cookie = request.headers.cookie as string + if ('authToken' in locals && locals.authToken) { headers['X-Auth-Token'] = locals.authToken } diff --git a/packages/api-gateway/src/Service/gRPC/GRPCSyncingServerServiceProxy.ts b/packages/api-gateway/src/Service/gRPC/GRPCSyncingServerServiceProxy.ts index 6648f61d2..c6b9fb108 100644 --- a/packages/api-gateway/src/Service/gRPC/GRPCSyncingServerServiceProxy.ts +++ b/packages/api-gateway/src/Service/gRPC/GRPCSyncingServerServiceProxy.ts @@ -45,6 +45,7 @@ export class GRPCSyncingServerServiceProxy { metadata.set('x-session-uuid', locals.session.uuid) } metadata.set('x-is-free-user', locals.isFreeUser ? 'true' : 'false') + metadata.set('x-has-content-limit', locals.hasContentLimit ? 'true' : 'false') this.syncingClient.syncItems(syncRequest, metadata, (error, syncResponse) => { if (error) { diff --git a/packages/auth/.env.sample b/packages/auth/.env.sample index 304a28eb2..4e4aa9f78 100644 --- a/packages/auth/.env.sample +++ b/packages/auth/.env.sample @@ -29,6 +29,11 @@ CACHE_TYPE=redis DISABLE_USER_REGISTRATION=false +COOKIE_DOMAIN= +COOKIE_SAME_SITE= +COOKIE_SECURE= +COOKIE_PARTITIONED= + ACCESS_TOKEN_AGE=5184000 REFRESH_TOKEN_AGE=31556926 @@ -49,6 +54,10 @@ VALET_TOKEN_TTL= WEB_SOCKET_CONNECTION_TOKEN_SECRET= +# Human verfication +CAPTCHA_SERVER_URL= +CAPTCHA_UI_URL= + # (Optional) U2F Setup U2F_RELYING_PARTY_ID= U2F_RELYING_PARTY_NAME= diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 5106fedef..fe0e2ec25 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -3,24 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## [1.178.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.178.2...@standardnotes/auth-server@1.178.3) (2024-03-18) - -### Bug Fixes - -* **auth:** allow registration on new api versions - fixes [#1046](https://github.com/standardnotes/server/issues/1046) ([#1048](https://github.com/standardnotes/server/issues/1048)) ([f939caf](https://github.com/standardnotes/server/commit/f939caf2d9a781d42989ad6e92a5c7150ff48e19)) - -## [1.178.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.178.1...@standardnotes/auth-server@1.178.2) (2024-03-15) - -### Bug Fixes - -* allow handling of new api version ([9d49764](https://github.com/standardnotes/server/commit/9d49764b841e73655e19523eddf10498addc9fb4)) - -## [1.178.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.178.0...@standardnotes/auth-server@1.178.1) (2024-02-09) - -### Bug Fixes - -* allow expired offline subscriptions to receive dashboard emails ([#1041](https://github.com/standardnotes/server/issues/1041)) ([4fe8e9a](https://github.com/standardnotes/server/commit/4fe8e9a79f652f3e39608d6683cb17cc08bb8717)) - # [1.178.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.177.20...@standardnotes/auth-server@1.178.0) (2024-01-19) ### Features diff --git a/packages/auth/Dockerfile b/packages/auth/Dockerfile index dbbe0b4b0..b10a1324b 100644 --- a/packages/auth/Dockerfile +++ b/packages/auth/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/auth ENTRYPOINT [ "/workspace/packages/auth/docker/entrypoint.sh" ] diff --git a/packages/auth/bin/server.ts b/packages/auth/bin/server.ts index 85f380f0f..6ba1416cc 100644 --- a/packages/auth/bin/server.ts +++ b/packages/auth/bin/server.ts @@ -20,6 +20,7 @@ import '../src/Infra/InversifyExpressUtils/AnnotatedHealthCheckController' import '../src/Infra/InversifyExpressUtils/AnnotatedFeaturesController' import * as cors from 'cors' +import * as cookieParser from 'cookie-parser' import * as grpc from '@grpc/grpc-js' import { urlencoded, json, Request, Response, NextFunction } from 'express' import * as winston from 'winston' @@ -53,6 +54,7 @@ void container.load().then((container) => { }) app.use(json()) app.use(urlencoded({ extended: true })) + app.use(cookieParser()) app.use(cors()) }) diff --git a/packages/auth/bin/user_email_backup.ts b/packages/auth/bin/user_email_backup.ts index 16c36421b..3de647018 100644 --- a/packages/auth/bin/user_email_backup.ts +++ b/packages/auth/bin/user_email_backup.ts @@ -9,28 +9,23 @@ import TYPES from '../src/Bootstrap/Types' import { Env } from '../src/Bootstrap/Env' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface' -import { SettingRepositoryInterface } from '../src/Domain/Setting/SettingRepositoryInterface' -import { MuteFailedBackupsEmailsOption } from '@standardnotes/settings' import { RoleServiceInterface } from '../src/Domain/Role/RoleServiceInterface' import { PermissionName } from '@standardnotes/features' import { UserRepositoryInterface } from '../src/Domain/User/UserRepositoryInterface' import { GetUserKeyParams } from '../src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams' -import { Email, SettingName } from '@standardnotes/domain-core' +import { Email } from '@standardnotes/domain-core' const inputArgs = process.argv.slice(2) const backupEmail = inputArgs[0] const requestBackups = async ( userRepository: UserRepositoryInterface, - settingRepository: SettingRepositoryInterface, roleService: RoleServiceInterface, domainEventFactory: DomainEventFactoryInterface, domainEventPublisher: DomainEventPublisherInterface, getUserKeyParamsUseCase: GetUserKeyParams, ): Promise => { const permissionName = PermissionName.DailyEmailBackup - const muteEmailsSettingName = SettingName.NAMES.MuteFailedBackupsEmails - const muteEmailsSettingValue = MuteFailedBackupsEmailsOption.Muted const emailOrError = Email.create(backupEmail) if (emailOrError.isFailed()) { @@ -48,24 +43,13 @@ const requestBackups = async ( throw new Error(`User ${backupEmail} is not permitted for email backups`) } - let userHasEmailsMuted = false - const emailsMutedSetting = await settingRepository.findOneByNameAndUserUuid(muteEmailsSettingName, user.uuid) - if (emailsMutedSetting !== null && emailsMutedSetting.props.value !== null) { - userHasEmailsMuted = emailsMutedSetting.props.value === muteEmailsSettingValue - } - const keyParamsResponse = await getUserKeyParamsUseCase.execute({ userUuid: user.uuid, authenticated: false, }) await domainEventPublisher.publish( - domainEventFactory.createEmailBackupRequestedEvent( - user.uuid, - emailsMutedSetting?.id.toString() as string, - userHasEmailsMuted, - keyParamsResponse.keyParams, - ), + domainEventFactory.createEmailBackupRequestedEvent(user.uuid, keyParamsResponse.keyParams), ) return @@ -82,7 +66,6 @@ void container.load().then((container) => { logger.info(`Starting email backup requesting for ${backupEmail} ...`) - const settingRepository: SettingRepositoryInterface = container.get(TYPES.Auth_SettingRepository) const userRepository: UserRepositoryInterface = container.get(TYPES.Auth_UserRepository) const roleService: RoleServiceInterface = container.get(TYPES.Auth_RoleService) const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.Auth_DomainEventFactory) @@ -90,14 +73,7 @@ void container.load().then((container) => { const getUserKeyParamsUseCase: GetUserKeyParams = container.get(TYPES.Auth_GetUserKeyParams) Promise.resolve( - requestBackups( - userRepository, - settingRepository, - roleService, - domainEventFactory, - domainEventPublisher, - getUserKeyParamsUseCase, - ), + requestBackups(userRepository, roleService, domainEventFactory, domainEventPublisher, getUserKeyParamsUseCase), ) .then(() => { logger.info(`Email backup requesting complete for ${backupEmail}`) diff --git a/packages/auth/migrations/mysql/1707759514236-user-roles-content-limit.ts b/packages/auth/migrations/mysql/1707759514236-user-roles-content-limit.ts new file mode 100644 index 000000000..377ee1e39 --- /dev/null +++ b/packages/auth/migrations/mysql/1707759514236-user-roles-content-limit.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class UserRolesContentLimit1707759514236 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("f8b4ced2-6a59-49f8-9ade-416a5f5ffc61", "server:content-limit")', + ) + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name, version) VALUES ("ab2e15c9-9252-43f3-829c-6f0af3315791", "CORE_USER", 4)', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (permission_uuid, role_uuid) VALUES \ + ("b04a7670-934e-4ab1-b8a3-0f27ff159511", "ab2e15c9-9252-43f3-829c-6f0af3315791"), \ + ("eb0575a2-6e26-49e3-9501-f2e75d7dbda3", "ab2e15c9-9252-43f3-829c-6f0af3315791"), \ + ("f8b4ced2-6a59-49f8-9ade-416a5f5ffc61", "ab2e15c9-9252-43f3-829c-6f0af3315791") \ + ', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="ab2e15c9-9252-43f3-829c-6f0af3315791"') + await queryRunner.query('DELETE FROM `permissions` WHERE uuid="f8b4ced2-6a59-49f8-9ade-416a5f5ffc61"') + await queryRunner.query('DELETE FROM `roles` WHERE uuid="ab2e15c9-9252-43f3-829c-6f0af3315791"') + } +} diff --git a/packages/auth/migrations/mysql/1707813542369-add-session-version.ts b/packages/auth/migrations/mysql/1707813542369-add-session-version.ts new file mode 100644 index 000000000..9ae56a332 --- /dev/null +++ b/packages/auth/migrations/mysql/1707813542369-add-session-version.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddSessionVersion1707813542369 implements MigrationInterface { + name = 'AddSessionVersion1707813542369' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` ADD `version` smallint NULL DEFAULT 1') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `version`') + } +} diff --git a/packages/auth/migrations/mysql/1709133001993-add-session-private-identifier.ts b/packages/auth/migrations/mysql/1709133001993-add-session-private-identifier.ts new file mode 100644 index 000000000..1abf0c4d4 --- /dev/null +++ b/packages/auth/migrations/mysql/1709133001993-add-session-private-identifier.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddSessionPrivateIdentifier1709133001993 implements MigrationInterface { + name = 'AddSessionPrivateIdentifier1709133001993' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE `sessions` ADD `private_identifier` varchar(36) NULL COMMENT 'Used to identify a session without exposing the UUID in client-side cookies.'", + ) + await queryRunner.query('CREATE INDEX `index_sessions_on_private_identifier` ON `sessions` (`private_identifier`)') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_sessions_on_private_identifier` ON `sessions`') + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `private_identifier`') + } +} diff --git a/packages/auth/migrations/mysql/1709206805226-add-revoked-session-private-identifier.ts b/packages/auth/migrations/mysql/1709206805226-add-revoked-session-private-identifier.ts new file mode 100644 index 000000000..4b634cdb8 --- /dev/null +++ b/packages/auth/migrations/mysql/1709206805226-add-revoked-session-private-identifier.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddRevokedSessionPrivateIdentifier1709206805226 implements MigrationInterface { + name = 'AddRevokedSessionPrivateIdentifier1709206805226' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE `revoked_sessions` ADD `private_identifier` varchar(36) NULL COMMENT 'Used to identify a session without exposing the UUID in client-side cookies.'", + ) + await queryRunner.query( + 'CREATE INDEX `index_revoked_sessions_on_private_identifier` ON `revoked_sessions` (`private_identifier`)', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_revoked_sessions_on_private_identifier` ON `revoked_sessions`') + await queryRunner.query('ALTER TABLE `revoked_sessions` DROP COLUMN `private_identifier`') + } +} diff --git a/packages/auth/migrations/mysql/1710236132439-add-application-and-snjs-to-sessions.ts b/packages/auth/migrations/mysql/1710236132439-add-application-and-snjs-to-sessions.ts new file mode 100644 index 000000000..fc80e0d43 --- /dev/null +++ b/packages/auth/migrations/mysql/1710236132439-add-application-and-snjs-to-sessions.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApplicationAndSnjsToSessions1710236132439 implements MigrationInterface { + name = 'AddApplicationAndSnjsToSessions1710236132439' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` ADD `application` varchar(255) NULL') + await queryRunner.query('ALTER TABLE `sessions` ADD `snjs` varchar(255) NULL') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `snjs`') + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `application`') + } +} diff --git a/packages/auth/migrations/sqlite/1707759514236-user-roles-content-limit-sqlite.ts b/packages/auth/migrations/sqlite/1707759514236-user-roles-content-limit-sqlite.ts new file mode 100644 index 000000000..377ee1e39 --- /dev/null +++ b/packages/auth/migrations/sqlite/1707759514236-user-roles-content-limit-sqlite.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class UserRolesContentLimit1707759514236 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("f8b4ced2-6a59-49f8-9ade-416a5f5ffc61", "server:content-limit")', + ) + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name, version) VALUES ("ab2e15c9-9252-43f3-829c-6f0af3315791", "CORE_USER", 4)', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (permission_uuid, role_uuid) VALUES \ + ("b04a7670-934e-4ab1-b8a3-0f27ff159511", "ab2e15c9-9252-43f3-829c-6f0af3315791"), \ + ("eb0575a2-6e26-49e3-9501-f2e75d7dbda3", "ab2e15c9-9252-43f3-829c-6f0af3315791"), \ + ("f8b4ced2-6a59-49f8-9ade-416a5f5ffc61", "ab2e15c9-9252-43f3-829c-6f0af3315791") \ + ', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="ab2e15c9-9252-43f3-829c-6f0af3315791"') + await queryRunner.query('DELETE FROM `permissions` WHERE uuid="f8b4ced2-6a59-49f8-9ade-416a5f5ffc61"') + await queryRunner.query('DELETE FROM `roles` WHERE uuid="ab2e15c9-9252-43f3-829c-6f0af3315791"') + } +} diff --git a/packages/auth/migrations/sqlite/1707813542369-add-session-version.ts b/packages/auth/migrations/sqlite/1707813542369-add-session-version.ts new file mode 100644 index 000000000..9ae56a332 --- /dev/null +++ b/packages/auth/migrations/sqlite/1707813542369-add-session-version.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddSessionVersion1707813542369 implements MigrationInterface { + name = 'AddSessionVersion1707813542369' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` ADD `version` smallint NULL DEFAULT 1') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `version`') + } +} diff --git a/packages/auth/migrations/sqlite/1709133169237-add-session-private-identifier.ts b/packages/auth/migrations/sqlite/1709133169237-add-session-private-identifier.ts new file mode 100644 index 000000000..703c82a39 --- /dev/null +++ b/packages/auth/migrations/sqlite/1709133169237-add-session-private-identifier.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddSessionPrivateIdentifier1709133169237 implements MigrationInterface { + name = 'AddSessionPrivateIdentifier1709133169237' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "index_sessions_on_updated_at"') + await queryRunner.query('DROP INDEX "index_sessions_on_user_uuid"') + await queryRunner.query( + 'CREATE TABLE "temporary_sessions" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(255), "hashed_access_token" varchar(255) NOT NULL, "hashed_refresh_token" varchar(255) NOT NULL, "access_expiration" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "refresh_expiration" datetime NOT NULL, "api_version" varchar(255), "user_agent" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "readonly_access" tinyint NOT NULL DEFAULT (0), "version" smallint DEFAULT (1), "private_identifier" varchar(36))', + ) + await queryRunner.query( + 'INSERT INTO "temporary_sessions"("uuid", "user_uuid", "hashed_access_token", "hashed_refresh_token", "access_expiration", "refresh_expiration", "api_version", "user_agent", "created_at", "updated_at", "readonly_access", "version") SELECT "uuid", "user_uuid", "hashed_access_token", "hashed_refresh_token", "access_expiration", "refresh_expiration", "api_version", "user_agent", "created_at", "updated_at", "readonly_access", "version" FROM "sessions"', + ) + await queryRunner.query('DROP TABLE "sessions"') + await queryRunner.query('ALTER TABLE "temporary_sessions" RENAME TO "sessions"') + await queryRunner.query('CREATE INDEX "index_sessions_on_updated_at" ON "sessions" ("updated_at") ') + await queryRunner.query('CREATE INDEX "index_sessions_on_user_uuid" ON "sessions" ("user_uuid") ') + await queryRunner.query('CREATE INDEX "index_sessions_on_private_identifier" ON "sessions" ("private_identifier") ') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "index_sessions_on_private_identifier"') + await queryRunner.query('DROP INDEX "index_sessions_on_user_uuid"') + await queryRunner.query('DROP INDEX "index_sessions_on_updated_at"') + await queryRunner.query('ALTER TABLE "sessions" RENAME TO "temporary_sessions"') + await queryRunner.query( + 'CREATE TABLE "sessions" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(255), "hashed_access_token" varchar(255) NOT NULL, "hashed_refresh_token" varchar(255) NOT NULL, "access_expiration" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "refresh_expiration" datetime NOT NULL, "api_version" varchar(255), "user_agent" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "readonly_access" tinyint NOT NULL DEFAULT (0), "version" smallint DEFAULT (1))', + ) + await queryRunner.query( + 'INSERT INTO "sessions"("uuid", "user_uuid", "hashed_access_token", "hashed_refresh_token", "access_expiration", "refresh_expiration", "api_version", "user_agent", "created_at", "updated_at", "readonly_access", "version") SELECT "uuid", "user_uuid", "hashed_access_token", "hashed_refresh_token", "access_expiration", "refresh_expiration", "api_version", "user_agent", "created_at", "updated_at", "readonly_access", "version" FROM "temporary_sessions"', + ) + await queryRunner.query('DROP TABLE "temporary_sessions"') + await queryRunner.query('CREATE INDEX "index_sessions_on_user_uuid" ON "sessions" ("user_uuid") ') + await queryRunner.query('CREATE INDEX "index_sessions_on_updated_at" ON "sessions" ("updated_at") ') + } +} diff --git a/packages/auth/migrations/sqlite/1709208455658-add-revoked-session-private-identifier.ts b/packages/auth/migrations/sqlite/1709208455658-add-revoked-session-private-identifier.ts new file mode 100644 index 000000000..2ae026630 --- /dev/null +++ b/packages/auth/migrations/sqlite/1709208455658-add-revoked-session-private-identifier.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddRevokedSessionPrivateIdentifier1709208455658 implements MigrationInterface { + name = 'AddRevokedSessionPrivateIdentifier1709208455658' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "index_revoked_sessions_on_user_uuid"') + await queryRunner.query( + 'CREATE TABLE "temporary_revoked_sessions" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(36) NOT NULL, "received" tinyint NOT NULL DEFAULT (0), "created_at" datetime NOT NULL, "received_at" datetime, "user_agent" text, "api_version" varchar(255), "private_identifier" varchar(36), CONSTRAINT "FK_edaf18faca67e682be39b5ecae5" FOREIGN KEY ("user_uuid") REFERENCES "users" ("uuid") ON DELETE CASCADE ON UPDATE NO ACTION)', + ) + await queryRunner.query( + 'INSERT INTO "temporary_revoked_sessions"("uuid", "user_uuid", "received", "created_at", "received_at", "user_agent", "api_version") SELECT "uuid", "user_uuid", "received", "created_at", "received_at", "user_agent", "api_version" FROM "revoked_sessions"', + ) + await queryRunner.query('DROP TABLE "revoked_sessions"') + await queryRunner.query('ALTER TABLE "temporary_revoked_sessions" RENAME TO "revoked_sessions"') + await queryRunner.query('CREATE INDEX "index_revoked_sessions_on_user_uuid" ON "revoked_sessions" ("user_uuid") ') + await queryRunner.query( + 'CREATE INDEX "index_revoked_sessions_on_private_identifier" ON "revoked_sessions" ("private_identifier") ', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "index_revoked_sessions_on_user_uuid"') + await queryRunner.query('ALTER TABLE "revoked_sessions" RENAME TO "temporary_revoked_sessions"') + await queryRunner.query( + 'CREATE TABLE "revoked_sessions" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(36) NOT NULL, "received" tinyint NOT NULL DEFAULT (0), "created_at" datetime NOT NULL, "received_at" datetime, "user_agent" text, "api_version" varchar(255), CONSTRAINT "FK_edaf18faca67e682be39b5ecae5" FOREIGN KEY ("user_uuid") REFERENCES "users" ("uuid") ON DELETE CASCADE ON UPDATE NO ACTION)', + ) + await queryRunner.query( + 'INSERT INTO "revoked_sessions"("uuid", "user_uuid", "received", "created_at", "received_at", "user_agent", "api_version") SELECT "uuid", "user_uuid", "received", "created_at", "received_at", "user_agent", "api_version" FROM "temporary_revoked_sessions"', + ) + await queryRunner.query('DROP TABLE "temporary_revoked_sessions"') + await queryRunner.query('CREATE INDEX "index_revoked_sessions_on_user_uuid" ON "revoked_sessions" ("user_uuid") ') + } +} diff --git a/packages/auth/migrations/sqlite/1710236132439-add-application-and-snjs-to-sessions.ts b/packages/auth/migrations/sqlite/1710236132439-add-application-and-snjs-to-sessions.ts new file mode 100644 index 000000000..fc80e0d43 --- /dev/null +++ b/packages/auth/migrations/sqlite/1710236132439-add-application-and-snjs-to-sessions.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddApplicationAndSnjsToSessions1710236132439 implements MigrationInterface { + name = 'AddApplicationAndSnjsToSessions1710236132439' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` ADD `application` varchar(255) NULL') + await queryRunner.query('ALTER TABLE `sessions` ADD `snjs` varchar(255) NULL') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `snjs`') + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `application`') + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json index 647c7e7d7..2245d9f66 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/auth-server", - "version": "1.178.3", + "version": "1.178.0", "engines": { "node": ">=18.0.0 <21.0.0" }, @@ -24,8 +24,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --fix --ext .ts", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "start": "yarn node dist/bin/server.js", "worker": "yarn node dist/bin/worker.js", "cleanup": "yarn node dist/bin/cleanup.js", @@ -60,7 +59,10 @@ "@standardnotes/sncrypto-common": "^1.13.4", "@standardnotes/sncrypto-node": "workspace:*", "@standardnotes/time": "workspace:*", + "agentkeepalive": "^4.5.0", + "axios": "^1.6.7", "bcryptjs": "2.4.3", + "cookie-parser": "^1.4.6", "cors": "2.8.5", "dayjs": "^1.11.6", "dotenv": "^16.0.1", @@ -68,7 +70,7 @@ "inversify": "^6.0.1", "inversify-express-utils": "^6.4.3", "ioredis": "^5.2.4", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "otplib": "12.0.1", "prettyjson": "^1.2.5", "reflect-metadata": "^0.2.1", @@ -80,6 +82,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.2", + "@types/cookie-parser": "^1", "@types/cors": "^2.8.9", "@types/express": "^4.17.14", "@types/ioredis": "^5.0.0", diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index 634d87315..1ebd4256b 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -1,6 +1,8 @@ import * as winston from 'winston' +import * as AgentKeepAlive from 'agentkeepalive' import Redis from 'ioredis' import { SNSClient, SNSClientConfig } from '@aws-sdk/client-sns' +import axios, { AxiosInstance } from 'axios' import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs' import { S3Client } from '@aws-sdk/client-s3' import { Container } from 'inversify' @@ -36,13 +38,11 @@ import { AuthResponseFactoryResolver } from '../Domain/Auth/AuthResponseFactoryR import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts' import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams' -import { UpdateUser } from '../Domain/UseCase/UpdateUser' import { RedisEphemeralSessionRepository } from '../Infra/Redis/RedisEphemeralSessionRepository' import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' import { DeleteOtherSessionsForUser } from '../Domain/UseCase/DeleteOtherSessionsForUser' import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser' import { Register } from '../Domain/UseCase/Register' -import { LockRepository } from '../Infra/Redis/LockRepository' import { TypeORMRevokedSessionRepository } from '../Infra/TypeORM/TypeORMRevokedSessionRepository' import { AuthenticationMethodResolver } from '../Domain/Auth/AuthenticationMethodResolver' import { RevokedSession } from '../Domain/Session/RevokedSession' @@ -286,6 +286,19 @@ import { FixStorageQuotaForUser } from '../Domain/UseCase/FixStorageQuotaForUser import { FileQuotaRecalculatedEventHandler } from '../Domain/Handler/FileQuotaRecalculatedEventHandler' import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface' import { SubscriptionStateFetchedEventHandler } from '../Domain/Handler/SubscriptionStateFetchedEventHandler' +import { CaptchaServerInterface } from '../Domain/HumanVerification/CaptchaServerInterface' +import { VerifyHumanInteraction } from '../Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction' +import { HttpCaptchaServer } from '../Infra/Http/HumanVerification/HttpCaptchaServer' +import { CookieFactoryInterface } from '../Domain/Auth/Cookies/CookieFactoryInterface' +import { CookieFactory } from '../Domain/Auth/Cookies/CookieFactory' +import { RedisLockRepository } from '../Infra/Redis/RedisLockRepository' +import { DeleteSessionByToken } from '../Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken' +import { GetSessionFromToken } from '../Domain/UseCase/GetSessionFromToken/GetSessionFromToken' +import { CooldownSessionTokens } from '../Domain/UseCase/CooldownSessionTokens/CooldownSessionTokens' +import { SessionTokensCooldownRepositoryInterface } from '../Domain/Session/SessionTokensCooldownRepositoryInterface' +import { RedisSessionTokensCooldownRepository } from '../Infra/Redis/RedisSessionTokensCooldownRepository' +import { InMemorySessionTokensCooldownRepository } from '../Infra/InMemory/InMemorySessionTokensCooldownRepository' +import { GetCooldownSessionTokens } from '../Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens' export class ContainerConfigLoader { constructor(private mode: 'server' | 'worker' = 'server') {} @@ -330,6 +343,8 @@ export class ContainerConfigLoader { const isConfiguredForSelfHosting = env.get('MODE', true) === 'self-hosted' const isConfiguredForHomeServerOrSelfHosting = isConfiguredForHomeServer || isConfiguredForSelfHosting const isConfiguredForInMemoryCache = env.get('CACHE_TYPE', true) === 'memory' + const captchaServerUrl = env.get('CAPTCHA_SERVER_URL', true) + const captchaUIUrl = env.get('CAPTCHA_UI_URL', true) container .bind(TYPES.Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING) @@ -597,9 +612,17 @@ export class ContainerConfigLoader { container .bind(TYPES.Auth_MAX_LOGIN_ATTEMPTS) .toConstantValue(env.get('MAX_LOGIN_ATTEMPTS', true) ? +env.get('MAX_LOGIN_ATTEMPTS', true) : 6) + container + .bind(TYPES.Auth_MAX_CAPTCHA_LOGIN_ATTEMPTS) + .toConstantValue(env.get('MAX_CAPTCHA_LOGIN_ATTEMPTS', true) ? +env.get('MAX_CAPTCHA_LOGIN_ATTEMPTS', true) : 6) container .bind(TYPES.Auth_FAILED_LOGIN_LOCKOUT) .toConstantValue(env.get('FAILED_LOGIN_LOCKOUT', true) ? +env.get('FAILED_LOGIN_LOCKOUT', true) : 3600) + container + .bind(TYPES.Auth_FAILED_LOGIN_CAPTCHA_LOCKOUT) + .toConstantValue( + env.get('FAILED_LOGIN_CAPTCHA_LOCKOUT', true) ? +env.get('FAILED_LOGIN_CAPTCHA_LOCKOUT', true) : 86400, + ) container.bind(TYPES.Auth_PSEUDO_KEY_PARAMS_KEY).toConstantValue(env.get('PSEUDO_KEY_PARAMS_KEY')) container .bind(TYPES.Auth_EPHEMERAL_SESSION_AGE) @@ -633,6 +656,10 @@ export class ContainerConfigLoader { container .bind(TYPES.Auth_READONLY_USERS) .toConstantValue(env.get('READONLY_USERS', true) ? env.get('READONLY_USERS', true).split(',') : []) + container.bind(TYPES.Auth_CAPTCHA_SERVER_URL).toConstantValue(captchaServerUrl) + container.bind(TYPES.Auth_CAPTCHA_UI_URL).toConstantValue(captchaUIUrl) + container.bind(TYPES.Auth_HUMAN_VERIFICATION_ENABLED).toConstantValue(!!captchaServerUrl && !!captchaUIUrl) + container.bind(TYPES.Auth_FORCE_LEGACY_SESSIONS).toConstantValue(env.get('E2E_TESTING', true) === 'true') if (isConfiguredForInMemoryCache) { container @@ -652,6 +679,7 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Timer), container.get(TYPES.Auth_MAX_LOGIN_ATTEMPTS), container.get(TYPES.Auth_FAILED_LOGIN_LOCKOUT), + container.get(TYPES.Auth_FAILED_LOGIN_CAPTCHA_LOCKOUT), ), ) container @@ -679,9 +707,21 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Timer), ), ) + container + .bind(TYPES.Auth_SessionTokensCooldownRepository) + .toConstantValue(new InMemorySessionTokensCooldownRepository()) } else { container.bind(TYPES.Auth_PKCERepository).to(RedisPKCERepository) - container.bind(TYPES.Auth_LockRepository).to(LockRepository) + container + .bind(TYPES.Auth_LockRepository) + .toConstantValue( + new RedisLockRepository( + container.get(TYPES.Auth_Redis), + container.get(TYPES.Auth_MAX_LOGIN_ATTEMPTS), + container.get(TYPES.Auth_FAILED_LOGIN_LOCKOUT), + container.get(TYPES.Auth_FAILED_LOGIN_CAPTCHA_LOCKOUT), + ), + ) container .bind(TYPES.Auth_EphemeralSessionRepository) .to(RedisEphemeralSessionRepository) @@ -691,6 +731,9 @@ export class ContainerConfigLoader { container .bind(TYPES.Auth_SubscriptionTokenRepository) .to(RedisSubscriptionTokenRepository) + container + .bind(TYPES.Auth_SessionTokensCooldownRepository) + .toConstantValue(new RedisSessionTokensCooldownRepository(container.get(TYPES.Auth_Redis))) } container @@ -740,6 +783,41 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_UserSubscriptionRepository), container.get(TYPES.Auth_READONLY_USERS), container.get(TYPES.Auth_GetSetting), + container.get(TYPES.Auth_FORCE_LEGACY_SESSIONS), + ), + ) + container + .bind(TYPES.Auth_GetCooldownSessionTokens) + .toConstantValue( + new GetCooldownSessionTokens( + container.get(TYPES.Auth_SessionTokensCooldownRepository), + ), + ) + container + .bind(TYPES.Auth_GetSessionFromToken) + .toConstantValue( + new GetSessionFromToken( + container.get(TYPES.Auth_SessionRepository), + container.get(TYPES.Auth_EphemeralSessionRepository), + container.get(TYPES.Auth_GetCooldownSessionTokens), + container.get(TYPES.Auth_Logger), + ), + ) + container + .bind(TYPES.Auth_DeleteSessionByToken) + .toConstantValue( + new DeleteSessionByToken( + container.get(TYPES.Auth_GetSessionFromToken), + container.get(TYPES.Auth_SessionRepository), + container.get(TYPES.Auth_EphemeralSessionRepository), + ), + ) + container + .bind(TYPES.Auth_CooldownSessionTokens) + .toConstantValue( + new CooldownSessionTokens( + env.get('COOLDOWN_SESSION_TOKENS_TTL', true) ? +env.get('COOLDOWN_SESSION_TOKENS_TTL', true) : 120, + container.get(TYPES.Auth_SessionTokensCooldownRepository), ), ) container.bind(TYPES.Auth_AuthResponseFactory20161215).to(AuthResponseFactory20161215) @@ -780,7 +858,16 @@ export class ContainerConfigLoader { .toConstantValue(new TokenEncoder(container.get(TYPES.Auth_VALET_TOKEN_SECRET))) container .bind(TYPES.Auth_AuthenticationMethodResolver) - .to(AuthenticationMethodResolver) + .toConstantValue( + new AuthenticationMethodResolver( + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_SessionService), + container.get>(TYPES.Auth_SessionTokenDecoder), + container.get>(TYPES.Auth_FallbackSessionTokenDecoder), + container.get(TYPES.Auth_GetSessionFromToken), + container.get(TYPES.Auth_Logger), + ), + ) container.bind(TYPES.Auth_DomainEventFactory).to(DomainEventFactory) container .bind(TYPES.Auth_SettingsAssociationService) @@ -819,6 +906,43 @@ export class ContainerConfigLoader { .bind>(TYPES.Auth_BooleanSelector) .toConstantValue(new DeterministicSelector()) + const httpAgentKeepAliveTimeout = env.get('HTTP_AGENT_KEEP_ALIVE_TIMEOUT', true) + ? +env.get('HTTP_AGENT_KEEP_ALIVE_TIMEOUT', true) + : 4_000 + + container.bind(TYPES.Auth_HTTPClient).toConstantValue( + axios.create({ + httpAgent: new AgentKeepAlive({ + keepAlive: true, + timeout: 2 * httpAgentKeepAliveTimeout, + freeSocketTimeout: httpAgentKeepAliveTimeout, + }), + }), + ) + + container + .bind(TYPES.Auth_CaptchaServer) + .toConstantValue( + new HttpCaptchaServer( + container.get(TYPES.Auth_Logger), + container.get(TYPES.Auth_HTTPClient), + container.get(TYPES.Auth_CAPTCHA_SERVER_URL), + ), + ) + + container + .bind(TYPES.Auth_CookieFactory) + .toConstantValue( + new CookieFactory( + ['None', 'Lax', 'Strict'].includes(env.get('COOKIE_SAME_SITE', true)) + ? (env.get('COOKIE_SAME_SITE', true) as 'None' | 'Lax' | 'Strict') + : 'None', + env.get('COOKIE_DOMAIN', true) ?? 'standardnotes.com', + env.get('COOKIE_SECURE', true) ? env.get('COOKIE_SECURE', true) === 'true' : true, + env.get('COOKIE_PARTITIONED', true) ? env.get('COOKIE_PARTITIONED', true) === 'true' : true, + ), + ) + // Middleware container.bind(TYPES.Auth_SessionMiddleware).to(SessionMiddleware) container.bind(TYPES.Auth_LockMiddleware).to(LockMiddleware) @@ -953,6 +1077,7 @@ export class ContainerConfigLoader { new SetSubscriptionSettingValue( container.get(TYPES.Auth_SubscriptionSettingRepository), container.get(TYPES.Auth_GetSubscriptionSetting), + container.get(TYPES.Auth_SettingsAssociationService), container.get(TYPES.Auth_Timer), ), ) @@ -997,10 +1122,36 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_DomainEventPublisher), container.get(TYPES.Auth_Timer), container.get(TYPES.Auth_GetSetting), + container.get(TYPES.Auth_CooldownSessionTokens), + container.get(TYPES.Auth_GetSessionFromToken), container.get(TYPES.Auth_Logger), ), ) - container.bind(TYPES.Auth_SignIn).to(SignIn) + container + .bind(TYPES.Auth_VerifyHumanInteraction) + .toConstantValue( + new VerifyHumanInteraction( + container.get(TYPES.Auth_HUMAN_VERIFICATION_ENABLED), + container.get(TYPES.Auth_CaptchaServer), + ), + ) + container + .bind(TYPES.Auth_SignIn) + .toConstantValue( + new SignIn( + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_AuthResponseFactoryResolver), + container.get(TYPES.Auth_DomainEventPublisher), + container.get(TYPES.Auth_DomainEventFactory), + container.get(TYPES.Auth_SessionService), + container.get(TYPES.Auth_PKCERepository), + container.get(TYPES.Auth_Crypter), + container.get(TYPES.Auth_Logger), + container.get(TYPES.Auth_MAX_LOGIN_ATTEMPTS), + container.get(TYPES.Auth_LockRepository), + container.get(TYPES.Auth_VerifyHumanInteraction), + ), + ) container .bind(TYPES.Auth_VerifyMFA) .toConstantValue( @@ -1017,8 +1168,24 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Logger), ), ) - container.bind(TYPES.Auth_ClearLoginAttempts).to(ClearLoginAttempts) - container.bind(TYPES.Auth_IncreaseLoginAttempts).to(IncreaseLoginAttempts) + container + .bind(TYPES.Auth_ClearLoginAttempts) + .toConstantValue( + new ClearLoginAttempts( + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_LockRepository), + container.get(TYPES.Auth_Logger), + ), + ) + container + .bind(TYPES.Auth_IncreaseLoginAttempts) + .toConstantValue( + new IncreaseLoginAttempts( + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_LockRepository), + container.get(TYPES.Auth_MAX_LOGIN_ATTEMPTS), + ), + ) container .bind(TYPES.Auth_GetUserKeyParamsRecovery) .toConstantValue( @@ -1029,7 +1196,6 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_GetSetting), ), ) - container.bind(TYPES.Auth_UpdateUser).to(UpdateUser) container .bind(TYPES.Auth_ApplyDefaultSettings) .toConstantValue( @@ -1130,6 +1296,9 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_ClearLoginAttempts), container.get(TYPES.Auth_DeleteSetting), container.get(TYPES.Auth_AuthenticatorRepository), + container.get(TYPES.Auth_MAX_LOGIN_ATTEMPTS), + container.get(TYPES.Auth_LockRepository), + container.get(TYPES.Auth_VerifyHumanInteraction), ), ) container @@ -1262,7 +1431,6 @@ export class ContainerConfigLoader { .toConstantValue( new TriggerEmailBackupForUser( container.get(TYPES.Auth_RoleService), - container.get(TYPES.Auth_GetSetting), container.get(TYPES.Auth_GetUserKeyParams), container.get(TYPES.Auth_DomainEventPublisher), container.get(TYPES.Auth_DomainEventFactory), @@ -1337,15 +1505,9 @@ export class ContainerConfigLoader { .bind(TYPES.Auth_AuthController) .toConstantValue( new AuthController( - container.get(TYPES.Auth_ClearLoginAttempts), - container.get(TYPES.Auth_Register), - container.get(TYPES.Auth_DomainEventPublisher), - container.get(TYPES.Auth_DomainEventFactory), - container.get(TYPES.Auth_SignInWithRecoveryCodes), - container.get(TYPES.Auth_GetUserKeyParamsRecovery), - container.get(TYPES.Auth_GenerateRecoveryCodes), - container.get(TYPES.Auth_Logger), - container.get(TYPES.Auth_SessionService), + container.get(TYPES.Auth_GetUserKeyParamsRecovery), + container.get(TYPES.Auth_GenerateRecoveryCodes), + container.get(TYPES.Auth_Logger), ), ) container @@ -1664,14 +1826,23 @@ export class ContainerConfigLoader { .bind(TYPES.Auth_BaseAuthController) .toConstantValue( new BaseAuthController( - container.get(TYPES.Auth_VerifyMFA), - container.get(TYPES.Auth_SignIn), - container.get(TYPES.Auth_GetUserKeyParams), - container.get(TYPES.Auth_ClearLoginAttempts), - container.get(TYPES.Auth_IncreaseLoginAttempts), - container.get(TYPES.Auth_Logger), - container.get(TYPES.Auth_AuthController), - container.get(TYPES.Auth_ControllerContainer), + container.get(TYPES.Auth_VerifyMFA), + container.get(TYPES.Auth_SignIn), + container.get(TYPES.Auth_GetUserKeyParams), + container.get(TYPES.Auth_ClearLoginAttempts), + container.get(TYPES.Auth_IncreaseLoginAttempts), + container.get(TYPES.Auth_Logger), + container.get(TYPES.Auth_AuthController), + container.get(TYPES.Auth_Register), + container.get(TYPES.Auth_DomainEventPublisher), + container.get(TYPES.Auth_DomainEventFactory), + container.get(TYPES.Auth_SessionService), + container.get(TYPES.Auth_VerifyHumanInteraction), + container.get(TYPES.Auth_CookieFactory), + container.get(TYPES.Auth_SignInWithRecoveryCodes), + container.get(TYPES.Auth_DeleteSessionByToken), + container.get(TYPES.Auth_CAPTCHA_UI_URL), + container.get(TYPES.Auth_ControllerContainer), ), ) @@ -1738,6 +1909,7 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_ClearLoginAttempts), container.get(TYPES.Auth_IncreaseLoginAttempts), container.get(TYPES.Auth_ChangeCredentials), + container.get(TYPES.Auth_CookieFactory), container.get(TYPES.Auth_ControllerContainer), ), ) @@ -1745,11 +1917,12 @@ export class ContainerConfigLoader { .bind(TYPES.Auth_BaseAdminController) .toConstantValue( new BaseAdminController( - container.get(TYPES.Auth_DeleteSetting), - container.get(TYPES.Auth_UserRepository), - container.get(TYPES.Auth_CreateSubscriptionToken), - container.get(TYPES.Auth_CreateOfflineSubscriptionToken), - container.get(TYPES.Auth_ControllerContainer), + container.get(TYPES.Auth_DeleteSetting), + container.get(TYPES.Auth_GetSetting), + container.get(TYPES.Auth_UserRepository), + container.get(TYPES.Auth_CreateSubscriptionToken), + container.get(TYPES.Auth_CreateOfflineSubscriptionToken), + container.get(TYPES.Auth_ControllerContainer), ), ) container @@ -1772,9 +1945,12 @@ export class ContainerConfigLoader { new BaseSubscriptionSettingsController( container.get(TYPES.Auth_GetSubscriptionSetting), container.get(TYPES.Auth_GetSharedOrRegularSubscriptionForUser), + container.get(TYPES.Auth_SetSubscriptionSettingValue), + container.get(TYPES.Auth_TriggerPostSettingUpdateActions), container.get>( TYPES.Auth_SubscriptionSettingHttpMapper, ), + container.get(TYPES.Auth_Logger), container.get(TYPES.Auth_ControllerContainer), ), ) @@ -1799,10 +1975,11 @@ export class ContainerConfigLoader { .bind(TYPES.Auth_BaseSessionController) .toConstantValue( new BaseSessionController( - container.get(TYPES.Auth_DeleteSessionForUser), - container.get(TYPES.Auth_DeleteOtherSessionsForUser), - container.get(TYPES.Auth_RefreshSessionToken), - container.get(TYPES.Auth_ControllerContainer), + container.get(TYPES.Auth_DeleteSessionForUser), + container.get(TYPES.Auth_DeleteOtherSessionsForUser), + container.get(TYPES.Auth_RefreshSessionToken), + container.get(TYPES.Auth_CookieFactory), + container.get(TYPES.Auth_ControllerContainer), ), ) container diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index 2a04b5000..118260349 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -34,6 +34,7 @@ const TYPES = { Auth_UserSubscriptionRepository: Symbol.for('Auth_UserSubscriptionRepository'), Auth_OfflineUserSubscriptionRepository: Symbol.for('Auth_OfflineUserSubscriptionRepository'), Auth_SubscriptionTokenRepository: Symbol.for('Auth_SubscriptionTokenRepository'), + Auth_SessionTokensCooldownRepository: Symbol.for('Auth_SessionTokensCooldownRepository'), Auth_OfflineSubscriptionTokenRepository: Symbol.for('Auth_OfflineSubscriptionTokenRepository'), Auth_SharedSubscriptionInvitationRepository: Symbol.for('Auth_SharedSubscriptionInvitationRepository'), Auth_PKCERepository: Symbol.for('Auth_PKCERepository'), @@ -84,7 +85,9 @@ const TYPES = { Auth_REFRESH_TOKEN_AGE: Symbol.for('Auth_REFRESH_TOKEN_AGE'), Auth_EPHEMERAL_SESSION_AGE: Symbol.for('Auth_EPHEMERAL_SESSION_AGE'), Auth_MAX_LOGIN_ATTEMPTS: Symbol.for('Auth_MAX_LOGIN_ATTEMPTS'), + Auth_MAX_CAPTCHA_LOGIN_ATTEMPTS: Symbol.for('Auth_MAX_CAPTCHA_LOGIN_ATTEMPTS'), Auth_FAILED_LOGIN_LOCKOUT: Symbol.for('Auth_FAILED_LOGIN_LOCKOUT'), + Auth_FAILED_LOGIN_CAPTCHA_LOCKOUT: Symbol.for('Auth_FAILED_LOGIN_CAPTCHA_LOCKOUT'), Auth_PSEUDO_KEY_PARAMS_KEY: Symbol.for('Auth_PSEUDO_KEY_PARAMS_KEY'), Auth_REDIS_URL: Symbol.for('Auth_REDIS_URL'), Auth_DISABLE_USER_REGISTRATION: Symbol.for('Auth_DISABLE_USER_REGISTRATION'), @@ -100,6 +103,10 @@ const TYPES = { Auth_U2F_REQUIRE_USER_VERIFICATION: Symbol.for('Auth_U2F_REQUIRE_USER_VERIFICATION'), Auth_READONLY_USERS: Symbol.for('Auth_READONLY_USERS'), Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for('Auth_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING'), + Auth_CAPTCHA_SERVER_URL: Symbol.for('Auth_CAPTCHA_SERVER_URL'), + Auth_CAPTCHA_UI_URL: Symbol.for('Auth_CAPTCHA_UI_URL'), + Auth_HUMAN_VERIFICATION_ENABLED: Symbol.for('Auth_HUMAN_VERIFICATION_ENABLED'), + Auth_FORCE_LEGACY_SESSIONS: Symbol.for('Auth_FORCE_LEGACY_SESSIONS'), // use cases Auth_AuthenticateUser: Symbol.for('Auth_AuthenticateUser'), Auth_AuthenticateRequest: Symbol.for('Auth_AuthenticateRequest'), @@ -109,7 +116,6 @@ const TYPES = { Auth_ClearLoginAttempts: Symbol.for('Auth_ClearLoginAttempts'), Auth_IncreaseLoginAttempts: Symbol.for('Auth_IncreaseLoginAttempts'), Auth_GetUserKeyParams: Symbol.for('Auth_GetUserKeyParams'), - Auth_UpdateUser: Symbol.for('Auth_UpdateUser'), Auth_Register: Symbol.for('Auth_Register'), Auth_GetActiveSessionsForUser: Symbol.for('Auth_GetActiveSessionsForUser'), Auth_DeleteOtherSessionsForUser: Symbol.for('Auth_DeleteOtherSessionsForUser'), @@ -158,6 +164,10 @@ const TYPES = { Auth_ApplyDefaultSettings: Symbol.for('Auth_ApplyDefaultSettings'), Auth_ActivatePremiumFeatures: Symbol.for('Auth_ActivatePremiumFeatures'), Auth_SignInWithRecoveryCodes: Symbol.for('Auth_SignInWithRecoveryCodes'), + Auth_GetSessionFromToken: Symbol.for('Auth_GetSessionFromToken'), + Auth_DeleteSessionByToken: Symbol.for('Auth_DeleteSessionByToken'), + Auth_CooldownSessionTokens: Symbol.for('Auth_CooldownSessionTokens'), + Auth_GetCooldownSessionTokens: Symbol.for('Auth_GetCooldownSessionTokens'), Auth_GetUserKeyParamsRecovery: Symbol.for('Auth_GetUserKeyParamsRecovery'), Auth_UpdateStorageQuotaUsedForUser: Symbol.for('Auth_UpdateStorageQuotaUsedForUser'), Auth_AddSharedVaultUser: Symbol.for('Auth_AddSharedVaultUser'), @@ -171,6 +181,7 @@ const TYPES = { Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'), Auth_RenewSharedSubscriptions: Symbol.for('Auth_RenewSharedSubscriptions'), Auth_FixStorageQuotaForUser: Symbol.for('Auth_FixStorageQuotaForUser'), + Auth_VerifyHumanInteraction: Symbol.for('Auth_VerifyHumanInteraction'), // Handlers Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'), Auth_AccountDeletionVerificationPassedEventHandler: Symbol.for('Auth_AccountDeletionVerificationPassedEventHandler'), @@ -207,6 +218,7 @@ const TYPES = { Auth_FileQuotaRecalculatedEventHandler: Symbol.for('Auth_FileQuotaRecalculatedEventHandler'), Auth_SubscriptionStateFetchedEventHandler: Symbol.for('Auth_SubscriptionStateFetchedEventHandler'), // Services + Auth_CookieFactory: Symbol.for('Auth_CookieFactory'), Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'), Auth_SessionService: Symbol.for('Auth_SessionService'), Auth_OfflineSettingService: Symbol.for('Auth_OfflineSettingService'), @@ -259,6 +271,8 @@ const TYPES = { Auth_BaseListedController: Symbol.for('Auth_BaseListedController'), Auth_BaseFeaturesController: Symbol.for('Auth_BaseFeaturesController'), Auth_CSVFileReader: Symbol.for('Auth_CSVFileReader'), + Auth_CaptchaServer: Symbol.for('Auth_CaptchaServer'), + Auth_HTTPClient: Symbol.for('Auth_HTTPClient'), } export default TYPES diff --git a/packages/auth/src/Controller/AuthController.spec.ts b/packages/auth/src/Controller/AuthController.spec.ts deleted file mode 100644 index a8faf2f88..000000000 --- a/packages/auth/src/Controller/AuthController.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import 'reflect-metadata' - -import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events' - -import { AuthController } from './AuthController' -import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' -import { User } from '../Domain/User/User' -import { Register } from '../Domain/UseCase/Register' -import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface' -import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' -import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' -import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery' -import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes' -import { Logger } from 'winston' -import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface' -import { ApiVersion } from '../Domain/Api/ApiVersion' - -describe('AuthController', () => { - let clearLoginAttempts: ClearLoginAttempts - let register: Register - let domainEventPublisher: DomainEventPublisherInterface - let domainEventFactory: DomainEventFactoryInterface - let event: DomainEventInterface - let user: User - let doSignInWithRecoveryCodes: SignInWithRecoveryCodes - let getUserKeyParamsRecovery: GetUserKeyParamsRecovery - let doGenerateRecoveryCodes: GenerateRecoveryCodes - let logger: Logger - let sessionService: SessionServiceInterface - - const createController = () => - new AuthController( - clearLoginAttempts, - register, - domainEventPublisher, - domainEventFactory, - doSignInWithRecoveryCodes, - getUserKeyParamsRecovery, - doGenerateRecoveryCodes, - logger, - sessionService, - ) - - beforeEach(() => { - register = {} as jest.Mocked - register.execute = jest.fn() - - user = {} as jest.Mocked - user.email = 'test@test.te' - - clearLoginAttempts = {} as jest.Mocked - clearLoginAttempts.execute = jest.fn() - - event = {} as jest.Mocked - - domainEventPublisher = {} as jest.Mocked - domainEventPublisher.publish = jest.fn() - - domainEventFactory = {} as jest.Mocked - domainEventFactory.createUserRegisteredEvent = jest.fn().mockReturnValue(event) - - logger = {} as jest.Mocked - logger.debug = jest.fn() - - sessionService = {} as jest.Mocked - sessionService.deleteSessionByToken = jest.fn().mockReturnValue('1-2-3') - }) - - it('should register a user', async () => { - register.execute = jest.fn().mockReturnValue({ success: true, authResponse: { user } }) - - const response = await createController().register({ - email: 'test@test.te', - password: 'asdzxc', - version: ProtocolVersion.V004, - api: ApiVersion.v20200115, - origination: KeyParamsOrigination.Registration, - userAgent: 'Google Chrome', - identifier: 'test@test.te', - pw_nonce: '11', - ephemeral: false, - }) - - expect(register.execute).toHaveBeenCalledWith({ - apiVersion: '20200115', - kpOrigination: 'registration', - updatedWithUserAgent: 'Google Chrome', - ephemeralSession: false, - version: '004', - email: 'test@test.te', - password: 'asdzxc', - pwNonce: '11', - }) - - expect(domainEventPublisher.publish).toHaveBeenCalledWith(event) - - expect(response.status).toEqual(200) - expect(response.data).toEqual({ user: { email: 'test@test.te' } }) - }) - - it('should not register a user if request param is missing', async () => { - const response = await createController().register({ - email: 'test@test.te', - password: '', - version: ProtocolVersion.V004, - api: ApiVersion.v20200115, - origination: KeyParamsOrigination.Registration, - userAgent: 'Google Chrome', - identifier: 'test@test.te', - pw_nonce: '11', - ephemeral: false, - }) - - expect(domainEventPublisher.publish).not.toHaveBeenCalled() - - expect(response.status).toEqual(400) - }) - - it('should respond with error if registering a user fails', async () => { - register.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' }) - - const response = await createController().register({ - email: 'test@test.te', - password: 'test', - version: ProtocolVersion.V004, - api: ApiVersion.v20200115, - origination: KeyParamsOrigination.Registration, - userAgent: 'Google Chrome', - identifier: 'test@test.te', - pw_nonce: '11', - ephemeral: false, - }) - - expect(domainEventPublisher.publish).not.toHaveBeenCalled() - - expect(response.status).toEqual(400) - }) - - it('should throw error on the delete user method as it is still a part of the payments server', async () => { - let caughtError = null - try { - await createController().deleteAccount({} as never) - } catch (error) { - caughtError = error - } - - expect(caughtError).not.toBeNull() - }) -}) diff --git a/packages/auth/src/Controller/AuthController.ts b/packages/auth/src/Controller/AuthController.ts index 7611fe3d9..c0190272c 100644 --- a/packages/auth/src/Controller/AuthController.ts +++ b/packages/auth/src/Controller/AuthController.ts @@ -1,42 +1,23 @@ -import { DomainEventPublisherInterface } from '@standardnotes/domain-events' -import { - UserRegistrationRequestParams, - UserServerInterface, - UserDeletionResponseBody, - UserRegistrationResponseBody, - UserUpdateRequestParams, -} from '@standardnotes/api' -import { ErrorTag, HttpResponse, HttpStatusCode } from '@standardnotes/responses' -import { ProtocolVersion } from '@standardnotes/common' +import { UserDeletionResponseBody, UserUpdateRequestParams } from '@standardnotes/api' +import { HttpResponse, HttpStatusCode } from '@standardnotes/responses' -import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' -import { Register } from '../Domain/UseCase/Register' -import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface' -import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' -import { SignInWithRecoveryCodesRequestParams } from '../Infra/Http/Request/SignInWithRecoveryCodesRequestParams' import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery' import { RecoveryKeyParamsRequestParams } from '../Infra/Http/Request/RecoveryKeyParamsRequestParams' -import { SignInWithRecoveryCodesResponseBody } from '../Infra/Http/Response/SignInWithRecoveryCodesResponseBody' import { RecoveryKeyParamsResponseBody } from '../Infra/Http/Response/RecoveryKeyParamsResponseBody' import { GenerateRecoveryCodesResponseBody } from '../Infra/Http/Response/GenerateRecoveryCodesResponseBody' import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes' import { GenerateRecoveryCodesRequestParams } from '../Infra/Http/Request/GenerateRecoveryCodesRequestParams' import { Logger } from 'winston' -import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface' -import { ApiVersion } from '../Domain/Api/ApiVersion' import { UserUpdateResponse } from '@standardnotes/api/dist/Domain/Response/User/UserUpdateResponse' -export class AuthController implements UserServerInterface { +/** + * DEPRECATED: This controller is deprecated and will be removed in the future. + */ +export class AuthController { constructor( - private clearLoginAttempts: ClearLoginAttempts, - private registerUser: Register, - private domainEventPublisher: DomainEventPublisherInterface, - private domainEventFactory: DomainEventFactoryInterface, - private doSignInWithRecoveryCodes: SignInWithRecoveryCodes, private getUserKeyParamsRecovery: GetUserKeyParamsRecovery, private doGenerateRecoveryCodes: GenerateRecoveryCodes, private logger: Logger, - private sessionService: SessionServiceInterface, ) {} async update(_params: UserUpdateRequestParams): Promise> { @@ -47,57 +28,6 @@ export class AuthController implements UserServerInterface { throw new Error('This method is implemented on the payments server.') } - async register(params: UserRegistrationRequestParams): Promise> { - if (!params.email || !params.password) { - return { - status: HttpStatusCode.BadRequest, - data: { - error: { - message: 'Please enter an email and a password to register.', - }, - }, - } - } - - const registerResult = await this.registerUser.execute({ - email: params.email, - password: params.password, - updatedWithUserAgent: params.userAgent as string, - apiVersion: params.api, - ephemeralSession: params.ephemeral, - pwNonce: params.pw_nonce, - kpOrigination: params.origination, - kpCreated: params.created, - version: params.version, - }) - - if (!registerResult.success) { - return { - status: HttpStatusCode.BadRequest, - data: { - error: { - message: registerResult.errorMessage, - }, - }, - } - } - - await this.clearLoginAttempts.execute({ email: registerResult.authResponse.user.email as string }) - - await this.domainEventPublisher.publish( - this.domainEventFactory.createUserRegisteredEvent({ - userUuid: registerResult.authResponse.user.uuid, - email: registerResult.authResponse.user.email, - protocolVersion: (registerResult.authResponse.user.protocolVersion) as ProtocolVersion, - }), - ) - - return { - status: HttpStatusCode.Success, - data: registerResult.authResponse, - } - } - async generateRecoveryCodes( params: GenerateRecoveryCodesRequestParams, ): Promise> { @@ -124,62 +54,11 @@ export class AuthController implements UserServerInterface { } } - async signInWithRecoveryCodes( - params: SignInWithRecoveryCodesRequestParams, - ): Promise> { - if (params.apiVersion !== ApiVersion.v20200115) { - return { - status: HttpStatusCode.BadRequest, - data: { - error: { - message: 'Invalid API version.', - }, - }, - } - } - - const result = await this.doSignInWithRecoveryCodes.execute({ - userAgent: params.userAgent, - username: params.username, - password: params.password, - codeVerifier: params.codeVerifier, - recoveryCodes: params.recoveryCodes, - }) - - if (result.isFailed()) { - this.logger.debug(`Failed to sign in with recovery codes: ${result.getError()}`) - - return { - status: HttpStatusCode.Unauthorized, - data: { - error: { - message: 'Invalid login credentials.', - }, - }, - } - } - - return { - status: HttpStatusCode.Success, - data: result.getValue(), - } - } - async recoveryKeyParams( params: RecoveryKeyParamsRequestParams, ): Promise> { - if (params.apiVersion !== ApiVersion.v20200115) { - return { - status: HttpStatusCode.BadRequest, - data: { - error: { - message: 'Invalid API version.', - }, - }, - } - } - const result = await this.getUserKeyParamsRecovery.execute({ + apiVersion: params.apiVersion, username: params.username, codeChallenge: params.codeChallenge, recoveryCodes: params.recoveryCodes, @@ -205,33 +84,4 @@ export class AuthController implements UserServerInterface { }, } } - - async signOut(params: Record): Promise { - if (params.readOnlyAccess) { - return { - status: HttpStatusCode.Unauthorized, - data: { - error: { - tag: ErrorTag.ReadOnlyAccess, - message: 'Session has read-only access.', - }, - }, - } - } - - const userUuid = await this.sessionService.deleteSessionByToken( - (params.authorizationHeader as string).replace('Bearer ', ''), - ) - - let headers = undefined - if (userUuid !== null) { - headers = new Map([['x-invalidate-cache', userUuid]]) - } - - return { - status: HttpStatusCode.NoContent, - data: {}, - headers, - } - } } diff --git a/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts b/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts index 247507cfb..67491756f 100644 --- a/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts +++ b/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts @@ -53,7 +53,10 @@ describe('SubscriptionInvitesController', () => { invitations: [], }) - const result = await createController().listInvites({ api: ApiVersion.v20200115, inviterEmail: 'test@test.te' }) + const result = await createController().listInvites({ + api: ApiVersion.VERSIONS.v20200115, + inviterEmail: 'test@test.te', + }) expect(listSharedSubscriptionInvitations.execute).toHaveBeenCalledWith({ inviterEmail: 'test@test.te', @@ -68,7 +71,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().cancelInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', inviterEmail: 'test@test.te', }) @@ -87,7 +90,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().cancelInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', }) @@ -100,7 +103,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().declineInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', }) @@ -117,7 +120,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().declineInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', }) @@ -134,7 +137,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().acceptInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', }) @@ -151,7 +154,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().acceptInvite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, inviteUuid: '1-2-3', }) @@ -168,7 +171,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().invite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, identifier: 'invitee@test.te', inviterUuid: '1-2-3', inviterEmail: 'test@test.te', @@ -187,7 +190,7 @@ describe('SubscriptionInvitesController', () => { it('should not invite to user subscription if the identifier is missing in request', async () => { const result = await createController().invite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, identifier: '', inviterUuid: '1-2-3', inviterEmail: 'test@test.te', @@ -205,7 +208,7 @@ describe('SubscriptionInvitesController', () => { }) const result = await createController().invite({ - api: ApiVersion.v20200115, + api: ApiVersion.VERSIONS.v20200115, identifier: 'invitee@test.te', inviterUuid: '1-2-3', inviterEmail: 'test@test.te', diff --git a/packages/auth/src/Domain/Api/ApiVersion.spec.ts b/packages/auth/src/Domain/Api/ApiVersion.spec.ts new file mode 100644 index 000000000..7e5345a39 --- /dev/null +++ b/packages/auth/src/Domain/Api/ApiVersion.spec.ts @@ -0,0 +1,46 @@ +import { ApiVersion } from './ApiVersion' + +describe('ApiVersion', () => { + it('should create a value object', () => { + const valueOrError = ApiVersion.create(ApiVersion.VERSIONS.v20200115) + + expect(valueOrError.isFailed()).toBeFalsy() + expect(valueOrError.getValue().value).toEqual('20200115') + }) + + it('should not create an invalid value object', () => { + for (const value of ['', undefined, null, 0, 'SOME_VERSION']) { + const valueOrError = ApiVersion.create(value as string) + + expect(valueOrError.isFailed()).toBeTruthy() + } + }) + + it('should tell if the version is supported for registration', () => { + const version = ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue() + + expect(version.isSupportedForRegistration()).toBeTruthy() + + const version2 = ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue() + + expect(version2.isSupportedForRegistration()).toBeTruthy() + + const version3 = ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue() + + expect(version3.isSupportedForRegistration()).toBeFalsy() + }) + + it('should tell if the version is supported for recovery sign in', () => { + const version = ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue() + + expect(version.isSupportedForRecoverySignIn()).toBeTruthy() + + const version2 = ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue() + + expect(version2.isSupportedForRecoverySignIn()).toBeTruthy() + + const version3 = ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue() + + expect(version3.isSupportedForRecoverySignIn()).toBeFalsy() + }) +}) diff --git a/packages/auth/src/Domain/Api/ApiVersion.ts b/packages/auth/src/Domain/Api/ApiVersion.ts index 90d2a9f0b..be804f341 100644 --- a/packages/auth/src/Domain/Api/ApiVersion.ts +++ b/packages/auth/src/Domain/Api/ApiVersion.ts @@ -1,6 +1,37 @@ -export enum ApiVersion { - v20161215 = '20161215', - v20190520 = '20190520', - v20200115 = '20200115', - v20240226 = '20240226', +import { Result, ValueObject } from '@standardnotes/domain-core' + +import { ApiVersionProps } from './ApiVersionProps' + +export class ApiVersion extends ValueObject { + static readonly VERSIONS = { + v20161215: '20161215', + v20190520: '20190520', + v20200115: '20200115', + v20240226: '20240226', + } + + get value(): string { + return this.props.value + } + + private constructor(props: ApiVersionProps) { + super(props) + } + + static create(version: string): Result { + const isValidVersion = Object.values(this.VERSIONS).includes(version) + if (!isValidVersion) { + return Result.fail(`Invalid api version: ${version}`) + } else { + return Result.ok(new ApiVersion({ value: version })) + } + } + + isSupportedForRegistration(): boolean { + return [ApiVersion.VERSIONS.v20200115, ApiVersion.VERSIONS.v20240226].includes(this.props.value) + } + + isSupportedForRecoverySignIn(): boolean { + return [ApiVersion.VERSIONS.v20200115, ApiVersion.VERSIONS.v20240226].includes(this.props.value) + } } diff --git a/packages/auth/src/Domain/Api/ApiVersionProps.ts b/packages/auth/src/Domain/Api/ApiVersionProps.ts new file mode 100644 index 000000000..0b211eaf0 --- /dev/null +++ b/packages/auth/src/Domain/Api/ApiVersionProps.ts @@ -0,0 +1,3 @@ +export interface ApiVersionProps { + value: string +} diff --git a/packages/auth/src/Domain/Auth/AuthResponse20200115.ts b/packages/auth/src/Domain/Auth/AuthResponse20200115.ts index 3efdd1a78..9ded0462f 100644 --- a/packages/auth/src/Domain/Auth/AuthResponse20200115.ts +++ b/packages/auth/src/Domain/Auth/AuthResponse20200115.ts @@ -3,6 +3,6 @@ import { KeyParamsData, SessionBody } from '@standardnotes/responses' import { AuthResponse } from './AuthResponse' export interface AuthResponse20200115 extends AuthResponse { - session: SessionBody - key_params: KeyParamsData + sessionBody: SessionBody + keyParams: KeyParamsData } diff --git a/packages/auth/src/Domain/Auth/AuthResponseCreationResult.ts b/packages/auth/src/Domain/Auth/AuthResponseCreationResult.ts new file mode 100644 index 000000000..43aaffddc --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseCreationResult.ts @@ -0,0 +1,10 @@ +import { Session } from '../Session/Session' +import { AuthResponse20161215 } from './AuthResponse20161215' +import { AuthResponse20200115 } from './AuthResponse20200115' + +export interface AuthResponseCreationResult { + response?: AuthResponse20200115 + legacyResponse?: AuthResponse20161215 + session?: Session + cookies?: { accessToken: string; refreshToken: string } +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts index a2f855c08..433d7a4e2 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts @@ -6,6 +6,7 @@ import { Logger } from 'winston' import { ProjectorInterface } from '../../Projection/ProjectorInterface' import { User } from '../User/User' import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' +import { ApiVersion } from '../Api/ApiVersion' describe('AuthResponseFactory20161215', () => { let userProjector: ProjectorInterface @@ -32,13 +33,13 @@ describe('AuthResponseFactory20161215', () => { it('should create a 20161215 auth response', async () => { const result = await createFactory().createResponse({ user, - apiVersion: '20161215', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: false, }) - expect(result.response).toEqual({ + expect(result.legacyResponse).toEqual({ user: { foo: 'bar' }, token: 'foobar', }) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts index 80710b0ed..a44266c72 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts @@ -8,10 +8,9 @@ import TYPES from '../../Bootstrap/Types' import { ProjectorInterface } from '../../Projection/ProjectorInterface' import { User } from '../User/User' -import { AuthResponse20161215 } from './AuthResponse20161215' -import { AuthResponse20200115 } from './AuthResponse20200115' import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface' -import { Session } from '../Session/Session' +import { AuthResponseCreationResult } from './AuthResponseCreationResult' +import { ApiVersion } from '../Api/ApiVersion' @injectable() export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface { @@ -23,11 +22,13 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface async createResponse(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string ephemeralSession: boolean readonlyAccess: boolean - }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> { + snjs?: string + application?: string + }): Promise { this.logger.debug(`Creating JWT auth response for user ${dto.user.uuid}`) const data: SessionTokenData = { @@ -40,7 +41,7 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface this.logger.debug(`Created JWT token for user ${dto.user.uuid}: ${token}`) return { - response: { + legacyResponse: { user: this.userProjector.projectSimple(dto.user) as { uuid: string email: string diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts index 1e9344d6e..86641b47e 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts @@ -5,6 +5,7 @@ import { Logger } from 'winston' import { ProjectorInterface } from '../../Projection/ProjectorInterface' import { User } from '../User/User' import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' +import { ApiVersion } from '../Api/ApiVersion' describe('AuthResponseFactory20190520', () => { let userProjector: ProjectorInterface @@ -31,13 +32,13 @@ describe('AuthResponseFactory20190520', () => { it('should create a 20161215 auth response', async () => { const result = await createFactory().createResponse({ user, - apiVersion: '20161215', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: false, }) - expect(result.response).toEqual({ + expect(result.legacyResponse).toEqual({ user: { foo: 'bar' }, token: 'foobar', }) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts index f6e1b030e..2b08c36e2 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts @@ -12,6 +12,7 @@ import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' import { Session } from '../Session/Session' +import { ApiVersion } from '../Api/ApiVersion' describe('AuthResponseFactory20200115', () => { let sessionService: SessionServiceInterface @@ -51,10 +52,10 @@ describe('AuthResponseFactory20200115', () => { sessionService = {} as jest.Mocked sessionService.createNewSessionForUser = jest .fn() - .mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked }) + .mockReturnValue({ sessionHttpRepresentation: sessionPayload, sessionBody: {} as jest.Mocked }) sessionService.createNewEphemeralSessionForUser = jest .fn() - .mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked }) + .mockReturnValue({ sessionHttpRepresentation: sessionPayload, sessionBody: {} as jest.Mocked }) keyParamsFactory = {} as jest.Mocked keyParamsFactory.create = jest.fn().mockReturnValue({ @@ -83,13 +84,13 @@ describe('AuthResponseFactory20200115', () => { const result = await createFactory().createResponse({ user, - apiVersion: '20161215', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: false, }) - expect(result.response).toEqual({ + expect(result.legacyResponse).toEqual({ user: { foo: 'bar' }, token: expect.any(String), }) @@ -100,18 +101,18 @@ describe('AuthResponseFactory20200115', () => { const result = await createFactory().createResponse({ user, - apiVersion: '20200115', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: false, }) expect(result.response).toEqual({ - key_params: { + keyParams: { key1: 'value1', key2: 'value2', }, - session: { + sessionBody: { access_token: 'access_token', refresh_token: 'refresh_token', access_expiration: 123, @@ -131,18 +132,18 @@ describe('AuthResponseFactory20200115', () => { const result = await createFactory().createResponse({ user, - apiVersion: '20200115', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: false, }) expect(result.response).toEqual({ - key_params: { + keyParams: { key1: 'value1', key2: 'value2', }, - session: { + sessionBody: { access_token: 'access_token', refresh_token: 'refresh_token', access_expiration: 123, @@ -160,18 +161,18 @@ describe('AuthResponseFactory20200115', () => { const result = await createFactory().createResponse({ user, - apiVersion: '20200115', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', ephemeralSession: true, readonlyAccess: false, }) expect(result.response).toEqual({ - key_params: { + keyParams: { key1: 'value1', key2: 'value2', }, - session: { + sessionBody: { access_token: 'access_token', refresh_token: 'refresh_token', access_expiration: 123, @@ -192,23 +193,23 @@ describe('AuthResponseFactory20200115', () => { ...sessionPayload, readonly_access: true, }, - session: {} as jest.Mocked, + sessionBody: {} as jest.Mocked, }) const result = await createFactory().createResponse({ user, - apiVersion: '20200115', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', ephemeralSession: false, readonlyAccess: true, }) expect(result.response).toEqual({ - key_params: { + keyParams: { key1: 'value1', key2: 'value2', }, - session: { + sessionBody: { access_token: 'access_token', refresh_token: 'refresh_token', access_expiration: 123, diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts index aca69c781..5682c2fc7 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts @@ -4,7 +4,6 @@ import { TokenEncoderInterface, } from '@standardnotes/security' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' -import { SessionBody } from '@standardnotes/responses' import { inject, injectable } from 'inversify' import { Logger } from 'winston' @@ -17,9 +16,9 @@ import { User } from '../User/User' import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' -import { AuthResponse20161215 } from './AuthResponse20161215' -import { AuthResponse20200115 } from './AuthResponse20200115' -import { Session } from '../Session/Session' +import { SessionCreationResult } from '../Session/SessionCreationResult' +import { AuthResponseCreationResult } from './AuthResponseCreationResult' +import { ApiVersion } from '../Api/ApiVersion' @injectable() export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { @@ -37,11 +36,13 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { override async createResponse(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string ephemeralSession: boolean readonlyAccess: boolean - }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> { + snjs?: string + application?: string + }): Promise { if (!dto.user.supportsSessions()) { this.logger.debug(`User ${dto.user.uuid} does not support sessions. Falling back to JWT auth response`) @@ -50,29 +51,31 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { const sessionCreationResult = await this.createSession(dto) - this.logger.debug( - 'Created session payload for user %s: %O', - dto.user.uuid, - sessionCreationResult.sessionHttpRepresentation, - ) + this.logger.debug('Created session payload for user', { + userId: dto.user.uuid, + session: sessionCreationResult, + }) return { response: { - session: sessionCreationResult.sessionHttpRepresentation, - key_params: this.keyParamsFactory.create(dto.user, true), + sessionBody: sessionCreationResult.sessionHttpRepresentation, + keyParams: this.keyParamsFactory.create(dto.user, true), user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection, }, session: sessionCreationResult.session, + cookies: sessionCreationResult.sessionCookieRepresentation, } } private async createSession(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string ephemeralSession: boolean readonlyAccess: boolean - }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> { + snjs?: string + application?: string + }): Promise { if (dto.ephemeralSession) { return this.sessionService.createNewEphemeralSessionForUser(dto) } diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts index 25c64e0cf..611223c98 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts @@ -1,14 +1,15 @@ -import { Session } from '../Session/Session' +import { ApiVersion } from '../Api/ApiVersion' import { User } from '../User/User' -import { AuthResponse20161215 } from './AuthResponse20161215' -import { AuthResponse20200115 } from './AuthResponse20200115' +import { AuthResponseCreationResult } from './AuthResponseCreationResult' export interface AuthResponseFactoryInterface { createResponse(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string ephemeralSession: boolean readonlyAccess: boolean - }): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> + snjs?: string + application?: string + }): Promise } diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts index 08f446859..97a12adba 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts @@ -5,6 +5,7 @@ import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' import { AuthResponseFactoryResolver } from './AuthResponseFactoryResolver' +import { ApiVersion } from '../Api/ApiVersion' describe('AuthResponseFactoryResolver', () => { let authResponseFactory20161215: AuthResponseFactory20161215 @@ -30,18 +31,26 @@ describe('AuthResponseFactoryResolver', () => { }) it('should resolve 2016 response factory', () => { - expect(createResolver().resolveAuthResponseFactoryVersion('20161215')).toEqual(authResponseFactory20161215) + expect( + createResolver().resolveAuthResponseFactoryVersion(ApiVersion.create(ApiVersion.VERSIONS.v20161215).getValue()), + ).toEqual(authResponseFactory20161215) }) it('should resolve 2019 response factory', () => { - expect(createResolver().resolveAuthResponseFactoryVersion('20190520')).toEqual(authResponseFactory20190520) + expect( + createResolver().resolveAuthResponseFactoryVersion(ApiVersion.create(ApiVersion.VERSIONS.v20190520).getValue()), + ).toEqual(authResponseFactory20190520) }) it('should resolve 2020 response factory', () => { - expect(createResolver().resolveAuthResponseFactoryVersion('20200115')).toEqual(authResponseFactory20200115) + expect( + createResolver().resolveAuthResponseFactoryVersion(ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue()), + ).toEqual(authResponseFactory20200115) }) - it('should resolve 2016 response factory as default', () => { - expect(createResolver().resolveAuthResponseFactoryVersion('')).toEqual(authResponseFactory20161215) + it('should resolve 2024 response factory', () => { + expect( + createResolver().resolveAuthResponseFactoryVersion(ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue()), + ).toEqual(authResponseFactory20200115) }) }) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts index 29a079f39..67d0ffca2 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts @@ -17,14 +17,14 @@ export class AuthResponseFactoryResolver implements AuthResponseFactoryResolverI @inject(TYPES.Auth_Logger) private logger: Logger, ) {} - resolveAuthResponseFactoryVersion(apiVersion: string): AuthResponseFactoryInterface { - this.logger.debug(`Resolving auth response factory for api version: ${apiVersion}`) + resolveAuthResponseFactoryVersion(apiVersion: ApiVersion): AuthResponseFactoryInterface { + this.logger.debug(`Resolving auth response factory for api version: ${apiVersion.value}`) - switch (apiVersion) { - case ApiVersion.v20190520: + switch (apiVersion.value) { + case ApiVersion.VERSIONS.v20190520: return this.authResponseFactory20190520 - case ApiVersion.v20200115: - case ApiVersion.v20240226: + case ApiVersion.VERSIONS.v20200115: + case ApiVersion.VERSIONS.v20240226: return this.authResponseFactory20200115 default: return this.authResponseFactory20161215 diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts index 3663288aa..7baded09b 100644 --- a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts @@ -1,5 +1,6 @@ +import { ApiVersion } from '../Api/ApiVersion' import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface' export interface AuthResponseFactoryResolverInterface { - resolveAuthResponseFactoryVersion(apiVersion: string): AuthResponseFactoryInterface + resolveAuthResponseFactoryVersion(apiVersion: ApiVersion): AuthResponseFactoryInterface } diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethod.ts b/packages/auth/src/Domain/Auth/AuthenticationMethod.ts index 627174847..f493d3f98 100644 --- a/packages/auth/src/Domain/Auth/AuthenticationMethod.ts +++ b/packages/auth/src/Domain/Auth/AuthenticationMethod.ts @@ -7,5 +7,6 @@ export type AuthenticationMethod = { user: User | null claims?: Record session?: Session + givenTokensWereInCooldown?: boolean revokedSession?: RevokedSession } diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts index 68f98ebc5..63211901d 100644 --- a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts @@ -10,19 +10,29 @@ import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { AuthenticationMethodResolver } from './AuthenticationMethodResolver' import { Logger } from 'winston' +import { GetSessionFromToken } from '../UseCase/GetSessionFromToken/GetSessionFromToken' +import { Result } from '@standardnotes/domain-core' describe('AuthenticationMethodResolver', () => { let userRepository: UserRepositoryInterface let sessionService: SessionServiceInterface let sessionTokenDecoder: TokenDecoderInterface let fallbackTokenDecoder: TokenDecoderInterface + let getSessionFromToken: GetSessionFromToken let user: User let session: Session let revokedSession: RevokedSession let logger: Logger const createResolver = () => - new AuthenticationMethodResolver(userRepository, sessionService, sessionTokenDecoder, fallbackTokenDecoder, logger) + new AuthenticationMethodResolver( + userRepository, + sessionService, + sessionTokenDecoder, + fallbackTokenDecoder, + getSessionFromToken, + logger, + ) beforeEach(() => { logger = {} as jest.Mocked @@ -41,10 +51,12 @@ describe('AuthenticationMethodResolver', () => { userRepository.findOneByUuid = jest.fn().mockReturnValue(user) sessionService = {} as jest.Mocked - sessionService.getSessionFromToken = jest.fn().mockReturnValue({ session: undefined, isEphemeral: false }) sessionService.getRevokedSessionFromToken = jest.fn() sessionService.markRevokedSessionAsReceived = jest.fn().mockReturnValue(revokedSession) + getSessionFromToken = {} as jest.Mocked + getSessionFromToken.execute = jest.fn().mockReturnValue(Result.fail('No session found.')) + sessionTokenDecoder = {} as jest.Mocked> sessionTokenDecoder.decodeToken = jest.fn() @@ -55,7 +67,12 @@ describe('AuthenticationMethodResolver', () => { it('should resolve jwt authentication method', async () => { sessionTokenDecoder.decodeToken = jest.fn().mockReturnValue({ user_uuid: '00000000-0000-0000-0000-000000000000' }) - expect(await createResolver().resolve('test')).toEqual({ + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toEqual({ claims: { user_uuid: '00000000-0000-0000-0000-000000000000', }, @@ -67,31 +84,56 @@ describe('AuthenticationMethodResolver', () => { it('should not resolve jwt authentication method with invalid user uuid', async () => { sessionTokenDecoder.decodeToken = jest.fn().mockReturnValue({ user_uuid: 'invalid' }) - expect(await createResolver().resolve('test')).toBeUndefined + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toBeUndefined }) it('should resolve session authentication method', async () => { - sessionService.getSessionFromToken = jest.fn().mockReturnValue({ session, isEphemeral: false }) + getSessionFromToken.execute = jest + .fn() + .mockReturnValue(Result.ok({ session, isEphemeral: false, givenTokensWereInCooldown: false })) - expect(await createResolver().resolve('test')).toEqual({ + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toEqual({ session, type: 'session_token', user, + givenTokensWereInCooldown: false, }) }) it('should not resolve session authentication method with invalid user uuid on session', async () => { - sessionService.getSessionFromToken = jest + getSessionFromToken.execute = jest .fn() - .mockReturnValue({ session: { userUuid: 'invalid' }, isEphemeral: false }) + .mockReturnValue( + Result.ok({ session: { userUuid: 'invalid' }, isEphemeral: false, givenTokensWereInCooldown: false }), + ) - expect(await createResolver().resolve('test')).toBeUndefined + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toBeUndefined }) it('should resolve archvied session authentication method', async () => { sessionService.getRevokedSessionFromToken = jest.fn().mockReturnValue(revokedSession) - expect(await createResolver().resolve('test')).toEqual({ + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toEqual({ revokedSession, type: 'revoked', user: null, @@ -101,6 +143,11 @@ describe('AuthenticationMethodResolver', () => { }) it('should indicated that authentication method cannot be resolved', async () => { - expect(await createResolver().resolve('test')).toBeUndefined + expect( + await createResolver().resolve({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }), + ).toBeUndefined }) }) diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts index 3b231fb00..35fca409a 100644 --- a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts @@ -1,30 +1,39 @@ import { SessionTokenData, TokenDecoderInterface } from '@standardnotes/security' -import { inject, injectable } from 'inversify' -import TYPES from '../../Bootstrap/Types' import { SessionServiceInterface } from '../Session/SessionServiceInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { AuthenticationMethod } from './AuthenticationMethod' import { AuthenticationMethodResolverInterface } from './AuthenticationMethodResolverInterface' import { Logger } from 'winston' import { Uuid } from '@standardnotes/domain-core' +import { GetSessionFromToken } from '../UseCase/GetSessionFromToken/GetSessionFromToken' -@injectable() export class AuthenticationMethodResolver implements AuthenticationMethodResolverInterface { constructor( - @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface, - @inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface, - @inject(TYPES.Auth_SessionTokenDecoder) private sessionTokenDecoder: TokenDecoderInterface, - @inject(TYPES.Auth_FallbackSessionTokenDecoder) + private userRepository: UserRepositoryInterface, + private sessionService: SessionServiceInterface, + private sessionTokenDecoder: TokenDecoderInterface, private fallbackSessionTokenDecoder: TokenDecoderInterface, - @inject(TYPES.Auth_Logger) private logger: Logger, + private getSessionFromToken: GetSessionFromToken, + private logger: Logger, ) {} - async resolve(token: string): Promise { - let decodedToken: SessionTokenData | undefined = this.sessionTokenDecoder.decodeToken(token) + async resolve(dto: { + authTokenFromHeaders: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + }): Promise { + let decodedToken: SessionTokenData | undefined = this.sessionTokenDecoder.decodeToken(dto.authTokenFromHeaders) if (decodedToken === undefined) { this.logger.debug('Could not decode token with primary decoder, trying fallback decoder.') - decodedToken = this.fallbackSessionTokenDecoder.decodeToken(token) + decodedToken = this.fallbackSessionTokenDecoder.decodeToken(dto.authTokenFromHeaders) } if (decodedToken) { @@ -47,8 +56,10 @@ export class AuthenticationMethodResolver implements AuthenticationMethodResolve } } - const { session } = await this.sessionService.getSessionFromToken(token) - if (session) { + const resultOrError = await this.getSessionFromToken.execute(dto) + if (!resultOrError.isFailed()) { + const { session, givenTokensWereInCooldown } = resultOrError.getValue() + this.logger.debug('Token decoded successfully. Session found.') const userUuidOrError = Uuid.create(session.userUuid) @@ -61,10 +72,11 @@ export class AuthenticationMethodResolver implements AuthenticationMethodResolve type: 'session_token', user: await this.userRepository.findOneByUuid(userUuid), session: session, + givenTokensWereInCooldown: givenTokensWereInCooldown, } } - const revokedSession = await this.sessionService.getRevokedSessionFromToken(token) + const revokedSession = await this.sessionService.getRevokedSessionFromToken(dto.authTokenFromHeaders) if (revokedSession) { this.logger.debug('Token decoded successfully. Revoked session found.') diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts index d36fff1dd..e4212a350 100644 --- a/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts @@ -1,5 +1,16 @@ import { AuthenticationMethod } from './AuthenticationMethod' export interface AuthenticationMethodResolverInterface { - resolve(token: string): Promise + resolve(dto: { + authTokenFromHeaders: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + }): Promise } diff --git a/packages/auth/src/Domain/Auth/Cookies/CookieFactory.ts b/packages/auth/src/Domain/Auth/Cookies/CookieFactory.ts new file mode 100644 index 000000000..5ce2dcc38 --- /dev/null +++ b/packages/auth/src/Domain/Auth/Cookies/CookieFactory.ts @@ -0,0 +1,28 @@ +import { CookieFactoryInterface } from './CookieFactoryInterface' + +export class CookieFactory implements CookieFactoryInterface { + constructor( + private sameSite: 'None' | 'Lax' | 'Strict', + private domain: string, + private secure: boolean, + private partitioned: boolean, + ) {} + + createCookieHeaderValue(dto: { + sessionUuid: string + accessToken: string + refreshToken: string + refreshTokenExpiration: Date + }): string[] { + return [ + `access_token_${dto.sessionUuid}=${dto.accessToken}; HttpOnly;${this.secure ? 'Secure; ' : ' '}Path=/;${ + this.partitioned ? 'Partitioned; ' : ' ' + }SameSite=${this.sameSite}; Domain=${this.domain}; Expires=${dto.refreshTokenExpiration.toUTCString()};`, + `refresh_token_${dto.sessionUuid}=${dto.refreshToken}; HttpOnly;${ + this.secure ? 'Secure; ' : ' ' + }Path=/v1/sessions/refresh;${this.partitioned ? 'Partitioned; ' : ' '}SameSite=${this.sameSite}; Domain=${ + this.domain + }; Expires=${dto.refreshTokenExpiration.toUTCString()};`, + ] + } +} diff --git a/packages/auth/src/Domain/Auth/Cookies/CookieFactoryInterface.ts b/packages/auth/src/Domain/Auth/Cookies/CookieFactoryInterface.ts new file mode 100644 index 000000000..f42a91681 --- /dev/null +++ b/packages/auth/src/Domain/Auth/Cookies/CookieFactoryInterface.ts @@ -0,0 +1,8 @@ +export interface CookieFactoryInterface { + createCookieHeaderValue(dto: { + sessionUuid: string + accessToken: string + refreshToken: string + refreshTokenExpiration: Date + }): string[] +} diff --git a/packages/auth/src/Domain/Event/DomainEventFactory.ts b/packages/auth/src/Domain/Event/DomainEventFactory.ts index 4d3940923..8b5e300b8 100644 --- a/packages/auth/src/Domain/Event/DomainEventFactory.ts +++ b/packages/auth/src/Domain/Event/DomainEventFactory.ts @@ -305,12 +305,7 @@ export class DomainEventFactory implements DomainEventFactoryInterface { } } - createEmailBackupRequestedEvent( - userUuid: string, - muteEmailsSettingUuid: string, - userHasEmailsMuted: boolean, - keyParams: KeyParamsData, - ): EmailBackupRequestedEvent { + createEmailBackupRequestedEvent(userUuid: string, keyParams: KeyParamsData): EmailBackupRequestedEvent { return { type: 'EMAIL_BACKUP_REQUESTED', createdAt: this.timer.getUTCDate(), @@ -323,8 +318,6 @@ export class DomainEventFactory implements DomainEventFactoryInterface { }, payload: { userUuid, - userHasEmailsMuted, - muteEmailsSettingUuid, keyParams, }, } diff --git a/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts index 693d5b04b..a67283172 100644 --- a/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts @@ -43,12 +43,7 @@ export interface DomainEventFactoryInterface { email: string protocolVersion: ProtocolVersion }): UserRegisteredEvent - createEmailBackupRequestedEvent( - userUuid: string, - muteEmailsSettingUuid: string, - userHasEmailsMuted: boolean, - keyParams: KeyParamsData, - ): EmailBackupRequestedEvent + createEmailBackupRequestedEvent(userUuid: string, keyParams: KeyParamsData): EmailBackupRequestedEvent createAccountDeletionRequestedEvent(dto: { userUuid: string email: string diff --git a/packages/auth/src/Domain/HumanVerification/CaptchaServerInterface.ts b/packages/auth/src/Domain/HumanVerification/CaptchaServerInterface.ts new file mode 100644 index 000000000..09879d93b --- /dev/null +++ b/packages/auth/src/Domain/HumanVerification/CaptchaServerInterface.ts @@ -0,0 +1,3 @@ +export interface CaptchaServerInterface { + verify(hvmToken: string): Promise +} diff --git a/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts index 276160cec..b01af2e60 100644 --- a/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts +++ b/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts @@ -2,6 +2,7 @@ import { EphemeralSession } from './EphemeralSession' export interface EphemeralSessionRepositoryInterface { findOneByUuid(uuid: string): Promise + findOneByPrivateIdentifier(privateIdentifier: string): Promise findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise findAllByUserUuid(userUuid: string): Promise> deleteOne(uuid: string, userUuid: string): Promise diff --git a/packages/auth/src/Domain/Session/RevokedSession.ts b/packages/auth/src/Domain/Session/RevokedSession.ts index 306ff6985..31e230ae2 100644 --- a/packages/auth/src/Domain/Session/RevokedSession.ts +++ b/packages/auth/src/Domain/Session/RevokedSession.ts @@ -6,6 +6,16 @@ export class RevokedSession { @PrimaryGeneratedColumn('uuid') declare uuid: string + @Column({ + name: 'private_identifier', + length: 36, + nullable: true, + type: 'varchar', + comment: 'Used to identify a session without exposing the UUID in client-side cookies.', + }) + @Index('index_revoked_sessions_on_private_identifier') + declare privateIdentifier: string | null + @Column({ name: 'user_uuid', length: 36, diff --git a/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts index 9277bb373..4745e4235 100644 --- a/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts +++ b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts @@ -2,6 +2,7 @@ import { RevokedSession } from './RevokedSession' export interface RevokedSessionRepositoryInterface { findOneByUuid(uuid: string): Promise + findOneByPrivateIdentifier(privateIdentifier: string): Promise findAllByUserUuid(userUuid: string): Promise> insert(revokedSession: RevokedSession): Promise update(revokedSession: RevokedSession): Promise diff --git a/packages/auth/src/Domain/Session/Session.ts b/packages/auth/src/Domain/Session/Session.ts index 3dd5f9da7..9c689c4c3 100644 --- a/packages/auth/src/Domain/Session/Session.ts +++ b/packages/auth/src/Domain/Session/Session.ts @@ -13,6 +13,16 @@ export class Session { @Index('index_sessions_on_user_uuid') declare userUuid: string + @Column({ + name: 'private_identifier', + length: 36, + nullable: true, + type: 'varchar', + comment: 'Used to identify a session without exposing the UUID in client-side cookies.', + }) + @Index('index_sessions_on_private_identifier') + declare privateIdentifier: string | null + @Column({ name: 'hashed_access_token', length: 255, @@ -75,4 +85,28 @@ export class Session { default: 0, }) declare readonlyAccess: boolean + + @Column({ + name: 'version', + type: 'smallint', + nullable: true, + default: 1, + }) + declare version: number | null + + @Column({ + name: 'application', + type: 'varchar', + length: 255, + nullable: true, + }) + declare application: string | null + + @Column({ + name: 'snjs', + type: 'varchar', + length: 255, + nullable: true, + }) + declare snjs: string | null } diff --git a/packages/auth/src/Domain/Session/SessionCreationResult.ts b/packages/auth/src/Domain/Session/SessionCreationResult.ts new file mode 100644 index 000000000..c08870251 --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionCreationResult.ts @@ -0,0 +1,12 @@ +import { SessionBody } from '@standardnotes/responses' + +import { Session } from './Session' + +export interface SessionCreationResult { + sessionHttpRepresentation: SessionBody + sessionCookieRepresentation: { + accessToken: string + refreshToken: string + } + session: Session +} diff --git a/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts index a75402668..3f563934d 100644 --- a/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts +++ b/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts @@ -4,6 +4,7 @@ import { Session } from './Session' export interface SessionRepositoryInterface { findOneByUuid(uuid: string): Promise + findOneByPrivateIdentifier(privateIdentifier: string): Promise findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise findAllByRefreshExpirationAndUserUuid(userUuid: string): Promise> findAllByUserUuid(userUuid: string): Promise> diff --git a/packages/auth/src/Domain/Session/SessionService.spec.ts b/packages/auth/src/Domain/Session/SessionService.spec.ts index b5fbf6daf..adec59552 100644 --- a/packages/auth/src/Domain/Session/SessionService.spec.ts +++ b/packages/auth/src/Domain/Session/SessionService.spec.ts @@ -36,7 +36,7 @@ describe('SessionService', () => { let userSubscriptionRepository: UserSubscriptionRepositoryInterface const readonlyUsers = ['demo@standardnotes.com'] - const createService = () => + const createService = (forceLegacySessions = false) => new SessionService( sessionRepository, ephemeralSessionRepository, @@ -51,6 +51,7 @@ describe('SessionService', () => { userSubscriptionRepository, readonlyUsers, getSetting, + forceLegacySessions, ) beforeEach(() => { @@ -58,16 +59,18 @@ describe('SessionService', () => { existingSession.uuid = '2e1e43' existingSession.userUuid = '1-2-3' existingSession.userAgent = 'Chrome' - existingSession.apiVersion = ApiVersion.v20200115 + existingSession.apiVersion = ApiVersion.VERSIONS.v20200115 existingSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' existingSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' existingSession.readonlyAccess = false + existingSession.version = SessionService.HEADER_BASED_SESSION_VERSION revokedSession = {} as jest.Mocked revokedSession.uuid = '2e1e43' sessionRepository = {} as jest.Mocked sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockReturnValue(null) sessionRepository.deleteOneByUuid = jest.fn() sessionRepository.insert = jest.fn() sessionRepository.update = jest.fn() @@ -79,6 +82,7 @@ describe('SessionService', () => { ephemeralSessionRepository.insert = jest.fn() ephemeralSessionRepository.update = jest.fn() ephemeralSessionRepository.findOneByUuid = jest.fn() + ephemeralSessionRepository.findOneByPrivateIdentifier = jest.fn() ephemeralSessionRepository.deleteOne = jest.fn() revokedSessionRepository = {} as jest.Mocked @@ -140,25 +144,91 @@ describe('SessionService', () => { }) it('should refresh access and refresh tokens for a session', async () => { - expect(await createService().refreshTokens({ session: existingSession, isEphemeral: false })).toEqual({ - access_expiration: 123, - access_token: expect.any(String), - refresh_token: expect.any(String), - refresh_expiration: 123, - readonly_access: false, + expect( + await createService().refreshTokens({ + session: existingSession, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + }), + ).toEqual({ + sessionHttpRepresentation: { + access_expiration: 123, + access_token: expect.any(String), + refresh_token: expect.any(String), + refresh_expiration: 123, + readonly_access: false, + }, + sessionCookieRepresentation: { + accessToken: 'foobar', + refreshToken: 'foobar', + }, + session: existingSession, }) expect(sessionRepository.update).toHaveBeenCalled() expect(ephemeralSessionRepository.update).not.toHaveBeenCalled() }) + it('should refresh access and refresh tokens for a session and turn it into a cookie based session', async () => { + expect( + await createService().refreshTokens({ + session: existingSession, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue(), + }), + ).toEqual({ + sessionHttpRepresentation: { + access_expiration: 123, + access_token: expect.any(String), + refresh_token: expect.any(String), + refresh_expiration: 123, + readonly_access: false, + }, + sessionCookieRepresentation: { + accessToken: 'foobar', + refreshToken: 'foobar', + }, + session: existingSession, + }) + + expect(sessionRepository.update).toHaveBeenCalledWith({ + apiVersion: ApiVersion.VERSIONS.v20240226, + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), + readonlyAccess: false, + userAgent: 'Chrome', + userUuid: '1-2-3', + uuid: expect.any(String), + version: 2, + accessExpiration: expect.any(Date), + snjs: null, + application: null, + }) + expect(ephemeralSessionRepository.update).not.toHaveBeenCalled() + }) + it('should refresh access and refresh tokens for an ephemeral session', async () => { - expect(await createService().refreshTokens({ session: existingEphemeralSession, isEphemeral: true })).toEqual({ - access_expiration: 123, - access_token: expect.any(String), - refresh_token: expect.any(String), - refresh_expiration: 123, - readonly_access: false, + expect( + await createService().refreshTokens({ + session: existingEphemeralSession, + isEphemeral: true, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + }), + ).toEqual({ + sessionHttpRepresentation: { + access_expiration: 123, + access_token: expect.any(String), + refresh_token: expect.any(String), + refresh_expiration: 123, + readonly_access: false, + }, + sessionCookieRepresentation: { + accessToken: 'foobar', + refreshToken: 'foobar', + }, + session: existingEphemeralSession, }) expect(sessionRepository.update).not.toHaveBeenCalled() @@ -171,7 +241,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -179,16 +249,137 @@ describe('SessionService', () => { expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) expect(sessionRepository.insert).toHaveBeenCalledWith({ accessExpiration: expect.any(Date), - apiVersion: '003', + apiVersion: ApiVersion.VERSIONS.v20200115, createdAt: expect.any(Date), hashedAccessToken: expect.any(String), hashedRefreshToken: expect.any(String), refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), + updatedAt: expect.any(Date), + userAgent: 'Google Chrome', + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + version: 1, + snjs: null, + application: null, + }) + + expect(result.sessionHttpRepresentation).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should create new cookie based session for a user', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + const result = await createService().createNewSessionForUser({ + user, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue(), + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) + expect(sessionRepository.insert).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: ApiVersion.VERSIONS.v20240226, + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), + updatedAt: expect.any(Date), + userAgent: 'Google Chrome', + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + version: 2, + snjs: null, + application: null, + }) + + expect(result.sessionHttpRepresentation).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should create new legacy session for a user if cookie mode is disabled', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + const result = await createService(true).createNewSessionForUser({ + user, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20240226).getValue(), + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) + expect(sessionRepository.insert).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: ApiVersion.VERSIONS.v20240226, + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), + updatedAt: expect.any(Date), + userAgent: 'Google Chrome', + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + version: 1, + snjs: null, + application: null, + }) + + expect(result.sessionHttpRepresentation).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should create new session for a user', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + const result = await createService().createNewSessionForUser({ + user, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) + expect(sessionRepository.insert).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: ApiVersion.VERSIONS.v20200115, + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + privateIdentifier: expect.any(String), + refreshExpiration: expect.any(Date), updatedAt: expect.any(Date), userAgent: 'Google Chrome', userUuid: '123', uuid: expect.any(String), readonlyAccess: false, + version: 1, + snjs: null, + application: null, }) expect(result.sessionHttpRepresentation).toEqual({ @@ -207,7 +398,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -215,16 +406,20 @@ describe('SessionService', () => { expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) expect(sessionRepository.insert).toHaveBeenCalledWith({ accessExpiration: expect.any(Date), - apiVersion: '003', + apiVersion: ApiVersion.VERSIONS.v20200115, createdAt: expect.any(Date), hashedAccessToken: expect.any(String), hashedRefreshToken: expect.any(String), refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), updatedAt: expect.any(Date), userAgent: 'Google Chrome', userUuid: '123', uuid: expect.any(String), readonlyAccess: true, + version: 1, + snjs: null, + application: null, }) expect(result.sessionHttpRepresentation).toEqual({ @@ -249,7 +444,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -257,15 +452,19 @@ describe('SessionService', () => { expect(sessionRepository.insert).toHaveBeenCalledWith(expect.any(Session)) expect(sessionRepository.insert).toHaveBeenCalledWith({ accessExpiration: expect.any(Date), - apiVersion: '003', + apiVersion: ApiVersion.VERSIONS.v20200115, createdAt: expect.any(Date), hashedAccessToken: expect.any(String), hashedRefreshToken: expect.any(String), refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), updatedAt: expect.any(Date), userUuid: '123', uuid: expect.any(String), readonlyAccess: false, + version: 1, + snjs: null, + application: null, }) expect(result.sessionHttpRepresentation).toEqual({ @@ -284,7 +483,7 @@ describe('SessionService', () => { await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -304,7 +503,7 @@ describe('SessionService', () => { await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -325,7 +524,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -353,7 +552,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -381,7 +580,7 @@ describe('SessionService', () => { const result = await createService().createNewSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -406,7 +605,7 @@ describe('SessionService', () => { const result = await createService().createNewEphemeralSessionForUser({ user, - apiVersion: '003', + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), userAgent: 'Google Chrome', readonlyAccess: false, }) @@ -414,16 +613,20 @@ describe('SessionService', () => { expect(ephemeralSessionRepository.insert).toHaveBeenCalledWith(expect.any(EphemeralSession)) expect(ephemeralSessionRepository.insert).toHaveBeenCalledWith({ accessExpiration: expect.any(Date), - apiVersion: '003', + apiVersion: ApiVersion.VERSIONS.v20200115, createdAt: expect.any(Date), hashedAccessToken: expect.any(String), hashedRefreshToken: expect.any(String), refreshExpiration: expect.any(Date), + privateIdentifier: expect.any(String), updatedAt: expect.any(Date), userAgent: 'Google Chrome', userUuid: '123', uuid: expect.any(String), readonlyAccess: false, + version: 1, + snjs: null, + application: null, }) expect(result.sessionHttpRepresentation).toEqual({ @@ -435,57 +638,6 @@ describe('SessionService', () => { }) }) - it('should delete a session by token', async () => { - sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingSession - } - - return null - }) - - await createService().deleteSessionByToken('1:2:3') - - expect(sessionRepository.deleteOneByUuid).toHaveBeenCalledWith('2e1e43') - expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() - }) - - it('should delete an ephemeral session by token', async () => { - ephemeralSessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingEphemeralSession - } - - return null - }) - - await createService().deleteSessionByToken('1:2:3') - - expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() - expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '1-2-3') - }) - - it('should not delete a session by token if session is not found', async () => { - sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingSession - } - - return null - }) - - await createService().deleteSessionByToken('1:4:3') - - expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() - expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() - }) - - it('should determine if a refresh token is valid', async () => { - expect(createService().isRefreshTokenMatchingHashedSessionToken(existingSession, '1:2:3')).toBeTruthy() - expect(createService().isRefreshTokenMatchingHashedSessionToken(existingSession, '1:2:4')).toBeFalsy() - expect(createService().isRefreshTokenMatchingHashedSessionToken(existingSession, '1:2')).toBeFalsy() - }) - it('should return device info based on user agent', () => { expect(createService().getDeviceInfo(existingSession)).toEqual('Chrome 69.0 on Mac 10.13') }) @@ -626,67 +778,6 @@ describe('SessionService', () => { expect(createService().getDeviceInfo(existingSession)).toEqual('Unknown Client on Unknown OS') }) - it('should retrieve a session from a session token', async () => { - sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingSession - } - - return null - }) - - const { session, isEphemeral } = await createService().getSessionFromToken('1:2:3') - - expect(session).toEqual(session) - expect(isEphemeral).toBeFalsy() - }) - - it('should retrieve an ephemeral session from a session token', async () => { - ephemeralSessionRepository.findOneByUuid = jest.fn().mockReturnValue(existingEphemeralSession) - sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) - - const { session, isEphemeral } = await createService().getSessionFromToken('1:2:3') - - expect(session).toEqual(existingEphemeralSession) - expect(isEphemeral).toBeTruthy() - }) - - it('should not retrieve a session from a session token that has access token missing', async () => { - sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingSession - } - - return null - }) - - const { session } = await createService().getSessionFromToken('1:2') - - expect(session).toBeUndefined() - }) - - it('should not retrieve a session that is missing', async () => { - sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) - - const { session } = await createService().getSessionFromToken('1:2:3') - - expect(session).toBeUndefined() - }) - - it('should not retrieve a session from a session token that has invalid access token', async () => { - sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { - if (uuid === '2') { - return existingSession - } - - return null - }) - - const { session } = await createService().getSessionFromToken('1:2:4') - - expect(session).toBeUndefined() - }) - it('should revoked a session', async () => { await createService().createRevokedSession(existingSession) @@ -714,4 +805,26 @@ describe('SessionService', () => { expect(result).toBeNull() }) + + it('should retrieve a revoked cookie session from a session token', async () => { + revokedSessionRepository.findOneByPrivateIdentifier = jest.fn().mockReturnValue(revokedSession) + + const result = await createService().getRevokedSessionFromToken('2:3') + + expect(result).toEqual(revokedSession) + }) + + it('should not retrieve a revoked session if session id is missing from token', async () => { + revokedSessionRepository.findOneByPrivateIdentifier = jest.fn().mockReturnValue(null) + + const result = await createService().getRevokedSessionFromToken('2') + + expect(result).toBeNull() + }) + + it('should not retrieve a revoked session if session token has unrecognizable version', async () => { + const result = await createService().getRevokedSessionFromToken('3:2') + + expect(result).toBeNull() + }) }) diff --git a/packages/auth/src/Domain/Session/SessionService.ts b/packages/auth/src/Domain/Session/SessionService.ts index 5c2c10a99..336dfb0c3 100644 --- a/packages/auth/src/Domain/Session/SessionService.ts +++ b/packages/auth/src/Domain/Session/SessionService.ts @@ -20,9 +20,14 @@ import { RevokedSessionRepositoryInterface } from './RevokedSessionRepositoryInt import { TraceSession } from '../UseCase/TraceSession/TraceSession' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' import { GetSetting } from '../UseCase/GetSetting/GetSetting' +import { SessionCreationResult } from './SessionCreationResult' +import { ApiVersion } from '../Api/ApiVersion' export class SessionService implements SessionServiceInterface { static readonly SESSION_TOKEN_VERSION = 1 + static readonly COOKIE_SESSION_TOKEN_VERSION = 2 + static readonly HEADER_BASED_SESSION_VERSION = 1 + static readonly COOKIE_BASED_SESSION_VERSION = 2 constructor( private sessionRepository: SessionRepositoryInterface, @@ -38,20 +43,23 @@ export class SessionService implements SessionServiceInterface { private userSubscriptionRepository: UserSubscriptionRepositoryInterface, private readonlyUsers: string[], private getSetting: GetSetting, + private forceLegacySessions: boolean, ) {} async createNewSessionForUser(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string readonlyAccess: boolean - }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> { + snjs?: string + application?: string + }): Promise { const session = await this.createSession({ ephemeral: false, ...dto, }) - const sessionPayload = await this.createTokens(session) + const sessionPayload = await this.createTokens(session, dto.apiVersion) await this.sessionRepository.insert(session) @@ -70,34 +78,49 @@ export class SessionService implements SessionServiceInterface { } return { - sessionHttpRepresentation: sessionPayload, + ...sessionPayload, session, } } async createNewEphemeralSessionForUser(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string readonlyAccess: boolean - }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> { + snjs?: string + application?: string + }): Promise { const ephemeralSession = await this.createSession({ ephemeral: true, ...dto, }) - const sessionPayload = await this.createTokens(ephemeralSession) + const sessionPayload = await this.createTokens(ephemeralSession, dto.apiVersion) await this.ephemeralSessionRepository.insert(ephemeralSession) return { - sessionHttpRepresentation: sessionPayload, + ...sessionPayload, session: ephemeralSession, } } - async refreshTokens(dto: { session: Session; isEphemeral: boolean }): Promise { - const sessionPayload = await this.createTokens(dto.session) + async refreshTokens(dto: { + session: Session + isEphemeral: boolean + apiVersion: ApiVersion + snjs?: string + application?: string + }): Promise { + const sessionPayload = await this.createTokens(dto.session, dto.apiVersion) + + dto.session.apiVersion = dto.apiVersion.value + dto.session.version = this.shouldOperateOnCookieBasedSessions(dto.apiVersion) + ? SessionService.COOKIE_BASED_SESSION_VERSION + : SessionService.HEADER_BASED_SESSION_VERSION + dto.session.snjs = dto.snjs ?? null + dto.session.application = dto.application ?? null if (dto.isEphemeral) { await this.ephemeralSessionRepository.update(dto.session) @@ -105,19 +128,10 @@ export class SessionService implements SessionServiceInterface { await this.sessionRepository.update(dto.session) } - return sessionPayload - } - - isRefreshTokenMatchingHashedSessionToken(session: Session, token: string): boolean { - const tokenParts = token.split(':') - const refreshToken = tokenParts[2] - if (!refreshToken) { - return false + return { + ...sessionPayload, + session: dto.session, } - - const hashedRefreshToken = crypto.createHash('sha256').update(refreshToken).digest('hex') - - return crypto.timingSafeEqual(Buffer.from(hashedRefreshToken), Buffer.from(session.hashedRefreshToken)) } getOperatingSystemInfoFromUserAgent(userAgent: string): string { @@ -182,35 +196,30 @@ export class SessionService implements SessionServiceInterface { return `${browserInfo} on ${osInfo}` } - async getSessionFromToken(token: string): Promise<{ session: Session | undefined; isEphemeral: boolean }> { - const tokenParts = token.split(':') - const sessionUuid = tokenParts[1] - const accessToken = tokenParts[2] - if (!accessToken) { - return { session: undefined, isEphemeral: false } - } - - const { session, isEphemeral } = await this.getSession(sessionUuid) - if (!session) { - return { session: undefined, isEphemeral: false } - } - - const hashedAccessToken = crypto.createHash('sha256').update(accessToken).digest('hex') - if (crypto.timingSafeEqual(Buffer.from(session.hashedAccessToken), Buffer.from(hashedAccessToken))) { - return { session, isEphemeral } - } - - return { session: undefined, isEphemeral: false } - } - async getRevokedSessionFromToken(token: string): Promise { const tokenParts = token.split(':') - const sessionUuid = tokenParts[1] - if (!sessionUuid) { - return null + const tokenVersion = parseInt(tokenParts[0]) + + switch (tokenVersion) { + case SessionService.SESSION_TOKEN_VERSION: { + const sessionUuid = tokenParts[1] + if (!sessionUuid) { + return null + } + + return this.revokedSessionRepository.findOneByUuid(sessionUuid) + } + case SessionService.COOKIE_SESSION_TOKEN_VERSION: { + const privateIdentifier = tokenParts[1] + if (!privateIdentifier) { + return null + } + + return this.revokedSessionRepository.findOneByPrivateIdentifier(privateIdentifier) + } } - return this.revokedSessionRepository.findOneByUuid(sessionUuid) + return null } async markRevokedSessionAsReceived(revokedSession: RevokedSession): Promise { @@ -222,22 +231,6 @@ export class SessionService implements SessionServiceInterface { return revokedSession } - async deleteSessionByToken(token: string): Promise { - const { session, isEphemeral } = await this.getSessionFromToken(token) - - if (session) { - if (isEphemeral) { - await this.ephemeralSessionRepository.deleteOne(session.uuid, session.userUuid) - } else { - await this.sessionRepository.deleteOneByUuid(session.uuid) - } - - return session.userUuid - } - - return null - } - async createRevokedSession(session: Session): Promise { const revokedSession = new RevokedSession() revokedSession.uuid = session.uuid @@ -245,6 +238,7 @@ export class SessionService implements SessionServiceInterface { revokedSession.createdAt = this.timer.getUTCDate() revokedSession.apiVersion = session.apiVersion revokedSession.userAgent = session.userAgent + revokedSession.privateIdentifier = session.privateIdentifier await this.revokedSessionRepository.insert(revokedSession) @@ -253,23 +247,31 @@ export class SessionService implements SessionServiceInterface { private async createSession(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string ephemeral: boolean readonlyAccess: boolean + snjs?: string + application?: string }): Promise { let session = new Session() if (dto.ephemeral) { session = new EphemeralSession() } session.uuid = uuidv4() + session.privateIdentifier = await this.cryptoNode.generateRandomKey(128) if (await this.isLoggingUserAgentEnabledOnSessions(dto.user)) { session.userAgent = dto.userAgent } + session.snjs = dto.snjs ?? null + session.application = dto.application ?? null session.userUuid = dto.user.uuid - session.apiVersion = dto.apiVersion + session.apiVersion = dto.apiVersion.value session.createdAt = this.timer.getUTCDate() session.updatedAt = this.timer.getUTCDate() + session.version = this.shouldOperateOnCookieBasedSessions(dto.apiVersion) + ? SessionService.COOKIE_BASED_SESSION_VERSION + : SessionService.HEADER_BASED_SESSION_VERSION const userIsReadonly = this.readonlyUsers.includes(dto.user.email) session.readonlyAccess = userIsReadonly || dto.readonlyAccess @@ -277,22 +279,16 @@ export class SessionService implements SessionServiceInterface { return session } - private async getSession(uuid: string): Promise<{ - session: Session | null - isEphemeral: boolean - }> { - let session = await this.ephemeralSessionRepository.findOneByUuid(uuid) - let isEphemeral = true - - if (!session) { - session = await this.sessionRepository.findOneByUuid(uuid) - isEphemeral = false + private async createTokens( + session: Session, + apiVersion: ApiVersion, + ): Promise<{ + sessionHttpRepresentation: SessionBody + sessionCookieRepresentation: { + accessToken: string + refreshToken: string } - - return { session, isEphemeral } - } - - private async createTokens(session: Session): Promise { + }> { const accessToken = this.cryptoNode.base64URLEncode(await this.cryptoNode.generateRandomKey(48)) const refreshToken = this.cryptoNode.base64URLEncode(await this.cryptoNode.generateRandomKey(48)) @@ -305,13 +301,29 @@ export class SessionService implements SessionServiceInterface { const refreshTokenExpiration = dayjs.utc().add(this.refreshTokenAge, 'second').toDate() session.accessExpiration = accessTokenExpiration session.refreshExpiration = refreshTokenExpiration + if (!session.privateIdentifier) { + session.privateIdentifier = await this.cryptoNode.generateRandomKey(128) + } + + const accessTokenForHeaderPurposes = this.shouldOperateOnCookieBasedSessions(apiVersion) + ? `${SessionService.COOKIE_SESSION_TOKEN_VERSION}:${session.privateIdentifier}` + : `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${accessToken}` + const refreshTokenForHeaderPurposes = this.shouldOperateOnCookieBasedSessions(apiVersion) + ? `${SessionService.COOKIE_SESSION_TOKEN_VERSION}:${session.privateIdentifier}` + : `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${refreshToken}` return { - access_token: `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${accessToken}`, - refresh_token: `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${refreshToken}`, - access_expiration: this.timer.convertStringDateToMilliseconds(accessTokenExpiration.toString()), - refresh_expiration: this.timer.convertStringDateToMilliseconds(refreshTokenExpiration.toString()), - readonly_access: session.readonlyAccess, + sessionHttpRepresentation: { + access_token: accessTokenForHeaderPurposes, + refresh_token: refreshTokenForHeaderPurposes, + access_expiration: this.timer.convertStringDateToMilliseconds(accessTokenExpiration.toString()), + refresh_expiration: this.timer.convertStringDateToMilliseconds(refreshTokenExpiration.toString()), + readonly_access: session.readonlyAccess, + }, + sessionCookieRepresentation: { + accessToken, + refreshToken, + }, } } @@ -329,4 +341,12 @@ export class SessionService implements SessionServiceInterface { return loggingSetting.decryptedValue === LogSessionUserAgentOption.Enabled } + + private shouldOperateOnCookieBasedSessions(apiVersion: ApiVersion): boolean { + if (this.forceLegacySessions) { + return false + } + + return ApiVersion.VERSIONS.v20240226 === apiVersion.value + } } diff --git a/packages/auth/src/Domain/Session/SessionServiceInterface.ts b/packages/auth/src/Domain/Session/SessionServiceInterface.ts index 1b107c0cd..87c0caa2a 100644 --- a/packages/auth/src/Domain/Session/SessionServiceInterface.ts +++ b/packages/auth/src/Domain/Session/SessionServiceInterface.ts @@ -1,27 +1,35 @@ -import { SessionBody } from '@standardnotes/responses' import { User } from '../User/User' import { RevokedSession } from './RevokedSession' import { Session } from './Session' +import { SessionCreationResult } from './SessionCreationResult' +import { ApiVersion } from '../Api/ApiVersion' export interface SessionServiceInterface { createNewSessionForUser(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string readonlyAccess: boolean - }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> + snjs?: string + application?: string + }): Promise createNewEphemeralSessionForUser(dto: { user: User - apiVersion: string + apiVersion: ApiVersion userAgent: string readonlyAccess: boolean - }): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> - refreshTokens(dto: { session: Session; isEphemeral: boolean }): Promise - getSessionFromToken(token: string): Promise<{ session: Session | undefined; isEphemeral: boolean }> + snjs?: string + application?: string + }): Promise + refreshTokens(dto: { + session: Session + isEphemeral: boolean + apiVersion: ApiVersion + snjs?: string + application?: string + }): Promise getRevokedSessionFromToken(token: string): Promise markRevokedSessionAsReceived(revokedSession: RevokedSession): Promise - deleteSessionByToken(token: string): Promise - isRefreshTokenMatchingHashedSessionToken(session: Session, token: string): boolean getDeviceInfo(session: Session): string getOperatingSystemInfoFromUserAgent(userAgent: string): string getBrowserInfoFromUserAgent(userAgent: string): string diff --git a/packages/auth/src/Domain/Session/SessionTokensCooldownRepositoryInterface.ts b/packages/auth/src/Domain/Session/SessionTokensCooldownRepositoryInterface.ts new file mode 100644 index 000000000..1ae0f62ba --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionTokensCooldownRepositoryInterface.ts @@ -0,0 +1,14 @@ +import { Uuid } from '@standardnotes/domain-core' + +export interface SessionTokensCooldownRepositoryInterface { + setCooldown(dto: { + sessionUuid: Uuid + hashedAccessToken: string + hashedRefreshToken: string + cooldownPeriodInSeconds: number + }): Promise + getHashedTokens(sessionUuid: Uuid): Promise<{ + hashedAccessToken: string + hashedRefreshToken: string + } | null> +} diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationService.ts b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts index c1215aa03..8262120b8 100644 --- a/packages/auth/src/Domain/Setting/SettingsAssociationService.ts +++ b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts @@ -12,8 +12,6 @@ import { SettingsAssociationServiceInterface } from './SettingsAssociationServic export class SettingsAssociationService implements SettingsAssociationServiceInterface { private readonly UNENCRYPTED_SETTINGS = [ SettingName.NAMES.EmailBackupFrequency, - SettingName.NAMES.MuteFailedBackupsEmails, - SettingName.NAMES.MuteFailedCloudBackupsEmails, SettingName.NAMES.MuteSignInEmails, SettingName.NAMES.MuteMarketingEmails, SettingName.NAMES.DropboxBackupFrequency, @@ -27,8 +25,6 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt SettingName.NAMES.GoogleDriveBackupFrequency, SettingName.NAMES.OneDriveBackupFrequency, SettingName.NAMES.EmailBackupFrequency, - SettingName.NAMES.MuteFailedBackupsEmails, - SettingName.NAMES.MuteFailedCloudBackupsEmails, SettingName.NAMES.MuteSignInEmails, SettingName.NAMES.MuteMarketingEmails, SettingName.NAMES.ListedAuthorSecrets, diff --git a/packages/auth/src/Domain/UseCase/ApplyDefaultSubscriptionSettings/ApplyDefaultSubscriptionSettings.spec.ts b/packages/auth/src/Domain/UseCase/ApplyDefaultSubscriptionSettings/ApplyDefaultSubscriptionSettings.spec.ts index 3f228f181..742bbb606 100644 --- a/packages/auth/src/Domain/UseCase/ApplyDefaultSubscriptionSettings/ApplyDefaultSubscriptionSettings.spec.ts +++ b/packages/auth/src/Domain/UseCase/ApplyDefaultSubscriptionSettings/ApplyDefaultSubscriptionSettings.spec.ts @@ -136,10 +136,7 @@ describe('ApplyDefaultSubscriptionSettings', () => { .fn() .mockReturnValue( new Map([ - [ - SettingName.NAMES.MuteFailedCloudBackupsEmails, - { value: 'value1', sensitive: false, serverEncryptionVersion: 0 }, - ], + [SettingName.NAMES.LogSessionUserAgent, { value: 'value1', sensitive: false, serverEncryptionVersion: 0 }], ]), ) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts index a05c48c73..74dac1097 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts @@ -34,7 +34,10 @@ describe('AuthenticateRequest', () => { session, }) - const response = await createUseCase().execute({ authorizationHeader: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeTruthy() expect(response.responseCode).toEqual(200) @@ -43,7 +46,9 @@ describe('AuthenticateRequest', () => { }) it('should not authorize if authorization header is missing', async () => { - const response = await createUseCase().execute({}) + const response = await createUseCase().execute({ + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() expect(response.responseCode).toEqual(401) @@ -55,7 +60,10 @@ describe('AuthenticateRequest', () => { throw new Error('something bad happened') }) - const response = await createUseCase().execute({ authorizationHeader: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() expect(response.responseCode).toEqual(401) @@ -68,7 +76,10 @@ describe('AuthenticateRequest', () => { failureType: 'INVALID_AUTH', }) - const response = await createUseCase().execute({ authorizationHeader: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() expect(response.responseCode).toEqual(401) @@ -81,7 +92,26 @@ describe('AuthenticateRequest', () => { failureType: 'EXPIRED_TOKEN', }) - const response = await createUseCase().execute({ authorizationHeader: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(498) + expect(response.errorTag).toEqual('expired-access-token') + }) + + it('should not authorize user if the token is cooled down', async () => { + authenticateUser.execute = jest.fn().mockReturnValue({ + success: false, + failureType: 'COOLEDDOWN_TOKEN', + }) + + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() expect(response.responseCode).toEqual(498) @@ -94,7 +124,10 @@ describe('AuthenticateRequest', () => { failureType: 'REVOKED_SESSION', }) - const response = await createUseCase().execute({ authorizationHeader: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() expect(response.responseCode).toEqual(401) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts index b6edac68e..98e38fe9e 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts @@ -15,8 +15,8 @@ export class AuthenticateRequest implements UseCaseInterface { ) {} async execute(dto: AuthenticateRequestDTO): Promise { - if (!dto.authorizationHeader) { - this.logger.debug('[authenticate-request] Authorization header not provided.') + if (!dto.authTokenFromHeaders) { + this.logger.debug('[authenticate-request] Authorization not provided.') return { success: false, @@ -29,7 +29,9 @@ export class AuthenticateRequest implements UseCaseInterface { let authenticateResponse: AuthenticateUserResponse try { authenticateResponse = await this.authenticateUser.execute({ - token: dto.authorizationHeader.replace('Bearer ', ''), + authTokenFromHeaders: dto.authTokenFromHeaders, + authCookies: dto.authCookies, + requestMetadata: dto.requestMetadata, }) } catch (error) { this.logger.error( @@ -47,6 +49,7 @@ export class AuthenticateRequest implements UseCaseInterface { if (!authenticateResponse.success) { switch (authenticateResponse.failureType) { case 'EXPIRED_TOKEN': + case 'COOLEDDOWN_TOKEN': return { success: false, responseCode: 498, diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts index b8e7e8211..1375c7cb8 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts @@ -1,3 +1,12 @@ export type AuthenticateRequestDTO = { - authorizationHeader?: string + authTokenFromHeaders?: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } } diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts index 2df8ef75d..58d2a9ea1 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts @@ -55,7 +55,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeTruthy() }) @@ -71,7 +74,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -84,7 +90,10 @@ describe('AuthenticateUser', () => { }, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -100,7 +109,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -114,11 +126,33 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeTruthy() }) + it('should not authenticate a user from a session token that is in cooldown', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'session_token', + session, + user, + givenTokensWereInCooldown: true, + }) + + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + + expect(response.success).toBeFalsy() + expect(response.failureType).toEqual('COOLEDDOWN_TOKEN') + }) + it('should not authenticate a user from a session token if session is expired', async () => { timer.getUTCDate = jest.fn().mockReturnValue(new Date(200)) user.supportsSessions = jest.fn().mockReturnValue(true) @@ -129,7 +163,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -144,7 +181,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -159,7 +199,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -172,7 +215,10 @@ describe('AuthenticateUser', () => { user, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -183,7 +229,10 @@ describe('AuthenticateUser', () => { revokedSession, }) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) @@ -191,7 +240,10 @@ describe('AuthenticateUser', () => { it('should not authenticate a user if authentication method could not be determined', async () => { authenticationMethodResolver.resolve = jest.fn().mockReturnValue(undefined) - const response = await createUseCase().execute({ token: 'test' }) + const response = await createUseCase().execute({ + authTokenFromHeaders: 'test', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) expect(response.success).toBeFalsy() }) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUser.ts b/packages/auth/src/Domain/UseCase/AuthenticateUser.ts index cab0e4019..2014b23e1 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateUser.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateUser.ts @@ -22,9 +22,9 @@ export class AuthenticateUser implements UseCaseInterface { ) {} async execute(dto: AuthenticateUserDTO): Promise { - const authenticationMethod = await this.authenticationMethodResolver.resolve(dto.token) + const authenticationMethod = await this.authenticationMethodResolver.resolve(dto) if (!authenticationMethod) { - this.logger.debug(`[authenticate-user] No authentication method found for token: ${dto.token}`) + this.logger.debug(`[authenticate-user] No authentication method found for tokens: ${JSON.stringify(dto)}`) return { success: false, @@ -33,7 +33,7 @@ export class AuthenticateUser implements UseCaseInterface { } if (authenticationMethod.type === 'revoked') { - this.logger.debug(`[authenticate-user] Session has been revoked: ${dto.token}`) + this.logger.debug(`[authenticate-user] Session has been revoked: ${dto.authTokenFromHeaders}`) return { success: false, @@ -43,7 +43,7 @@ export class AuthenticateUser implements UseCaseInterface { const user = authenticationMethod.user if (!user) { - this.logger.debug(`[authenticate-user] No user found for authentication method. Token: ${dto.token}`) + this.logger.debug(`[authenticate-user] No user found for authentication method. Token: ${JSON.stringify(dto)}`) return { success: false, @@ -106,6 +106,25 @@ export class AuthenticateUser implements UseCaseInterface { } } + if (authenticationMethod.givenTokensWereInCooldown) { + /* istanbul ignore next */ + this.logger.warn('Request was authenticated with tokens that were in cooldown.', { + userId: user.uuid, + sessionUuid: session.uuid, + snjs: dto.requestMetadata.snjs, + application: dto.requestMetadata.application, + url: dto.requestMetadata.url, + method: dto.requestMetadata.method, + userAgent: session.userAgent, + secChUa: session.userAgent ? dto.requestMetadata.secChUa : undefined, + }) + + return { + success: false, + failureType: 'COOLEDDOWN_TOKEN', + } + } + break } } diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts index 5b9846a33..618559d73 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts @@ -1,3 +1,12 @@ export type AuthenticateUserDTO = { - token: string + authTokenFromHeaders: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } } diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts b/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts index e9057afcd..047ba7a63 100644 --- a/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts +++ b/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts @@ -3,7 +3,7 @@ import { User } from '../User/User' export type AuthenticateUserResponse = { success: boolean - failureType?: 'INVALID_AUTH' | 'EXPIRED_TOKEN' | 'REVOKED_SESSION' + failureType?: 'INVALID_AUTH' | 'EXPIRED_TOKEN' | 'REVOKED_SESSION' | 'COOLEDDOWN_TOKEN' user?: User session?: Session } diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts index 3bfa481f8..9d64145b3 100644 --- a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts @@ -76,7 +76,56 @@ describe('ChangeCredentials', () => { it('should change password', async () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, + currentPassword: 'qweqwe123123', + newPassword: 'test234', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + kpCreated: '123', + kpOrigination: 'password-change', + }) + + expect(result.isFailed()).toBeFalsy() + + expect(userRepository.save).toHaveBeenCalledWith({ + encryptedPassword: expect.any(String), + pwNonce: 'asdzxc', + kpCreated: '123', + email: 'test@test.te', + uuid: '1-2-3', + kpOrigination: 'password-change', + updatedAt: new Date(1), + }) + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled() + expect(deleteOtherSessionsForUser.execute).toHaveBeenCalled() + }) + + it('should not change password if api version is invalid', async () => { + const result = await createUseCase().execute({ + username: Username.create('test@test.te').getValue(), + apiVersion: 'invalid', + currentPassword: 'qweqwe123123', + newPassword: 'test234', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + kpCreated: '123', + kpOrigination: 'password-change', + }) + + expect(result.isFailed()).toBeTruthy() + }) + + it('should change password on legacy users', async () => { + authResponseFactory.createResponse = jest + .fn() + .mockReturnValue({ legacyResponse: { foo: 'bar' }, session: { uuid: '1-2-3' } as jest.Mocked }) + + authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) + + const result = await createUseCase().execute({ + username: Username.create('test@test.te').getValue(), + apiVersion: ApiVersion.VERSIONS.v20161215, currentPassword: 'qweqwe123123', newPassword: 'test234', pwNonce: 'asdzxc', @@ -106,7 +155,7 @@ describe('ChangeCredentials', () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', newEmail: 'new@test.te', @@ -139,7 +188,7 @@ describe('ChangeCredentials', () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', newEmail: 'new@test.te', @@ -159,7 +208,7 @@ describe('ChangeCredentials', () => { it('should not change email if the new email is invalid', async () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', newEmail: '', @@ -181,7 +230,7 @@ describe('ChangeCredentials', () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', newEmail: '', @@ -202,7 +251,7 @@ describe('ChangeCredentials', () => { it('should not change password if current password is incorrect', async () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'test123', newPassword: 'test234', pwNonce: 'asdzxc', @@ -217,7 +266,7 @@ describe('ChangeCredentials', () => { it('should update protocol version while changing password', async () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', pwNonce: 'asdzxc', @@ -241,7 +290,7 @@ describe('ChangeCredentials', () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'qweqwe123123', newEmail: undefined, @@ -271,7 +320,7 @@ describe('ChangeCredentials', () => { const result = await createUseCase().execute({ username: Username.create('test@test.te').getValue(), - apiVersion: ApiVersion.v20200115, + apiVersion: ApiVersion.VERSIONS.v20200115, currentPassword: 'qweqwe123123', newPassword: 'test234', pwNonce: 'asdzxc', diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts index bd0dc5c09..3c22b2471 100644 --- a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts @@ -9,13 +9,13 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { ChangeCredentialsDTO } from './ChangeCredentialsDTO' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser' -import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215' -import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115' import { Session } from '../../Session/Session' import { getBody, getSubject } from '../../Email/UserEmailChanged' import { Logger } from 'winston' +import { AuthResponseCreationResult } from '../../Auth/AuthResponseCreationResult' +import { ApiVersion } from '../../Api/ApiVersion' -export class ChangeCredentials implements UseCaseInterface { +export class ChangeCredentials implements UseCaseInterface { constructor( private userRepository: UserRepositoryInterface, private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, @@ -26,7 +26,13 @@ export class ChangeCredentials implements UseCaseInterface> { + async execute(dto: ChangeCredentialsDTO): Promise> { + const apiVersionOrError = ApiVersion.create(dto.apiVersion) + if (apiVersionOrError.isFailed()) { + return Result.fail(apiVersionOrError.getError()) + } + const apiVersion = apiVersionOrError.getValue() + const user = await this.userRepository.findOneByUsernameOrEmail(dto.username) if (!user) { return Result.fail('User not found.') @@ -81,21 +87,23 @@ export class ChangeCredentials implements UseCaseInterface { }) it('should do nothing if a user identifier is invalid', async () => { - expect(await createUseCase().execute({ email: ' ' })).toEqual({ success: false }) + const result = await createUseCase().execute({ email: ' ' }) + + expect(result.isFailed()).toEqual(true) expect(lockRepository.resetLockCounter).toHaveBeenCalledTimes(0) }) it('should unlock an user by email and uuid', async () => { - expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + const result = await createUseCase().execute({ email: 'test@test.te' }) + expect(result.isFailed()).toEqual(false) expect(lockRepository.resetLockCounter).toHaveBeenCalledTimes(2) expect(lockRepository.resetLockCounter).toHaveBeenNthCalledWith(1, 'test@test.te') @@ -45,7 +48,8 @@ describe('ClearLoginAttempts', () => { it('should unlock an user by email and uuid if user does not exist', async () => { userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null) - expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + const result = await createUseCase().execute({ email: 'test@test.te' }) + expect(result.isFailed()).toEqual(false) expect(lockRepository.resetLockCounter).toHaveBeenCalledTimes(1) expect(lockRepository.resetLockCounter).toHaveBeenCalledWith('test@test.te') diff --git a/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts index e2af712fa..956259264 100644 --- a/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts +++ b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts @@ -1,25 +1,21 @@ -import { Username } from '@standardnotes/domain-core' -import { inject, injectable } from 'inversify' +import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core' import { Logger } from 'winston' -import TYPES from '../../Bootstrap/Types' + import { LockRepositoryInterface } from '../User/LockRepositoryInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { ClearLoginAttemptsDTO } from './ClearLoginAttemptsDTO' -import { ClearLoginAttemptsResponse } from './ClearLoginAttemptsResponse' -import { UseCaseInterface } from './UseCaseInterface' -@injectable() -export class ClearLoginAttempts implements UseCaseInterface { +export class ClearLoginAttempts implements UseCaseInterface { constructor( - @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface, - @inject(TYPES.Auth_LockRepository) private lockRepository: LockRepositoryInterface, - @inject(TYPES.Auth_Logger) private logger: Logger, + private userRepository: UserRepositoryInterface, + private lockRepository: LockRepositoryInterface, + private logger: Logger, ) {} - async execute(dto: ClearLoginAttemptsDTO): Promise { + async execute(dto: ClearLoginAttemptsDTO): Promise> { const usernameOrError = Username.create(dto.email) if (usernameOrError.isFailed()) { - return { success: false } + return Result.fail(usernameOrError.getError()) } const username = usernameOrError.getValue() @@ -28,13 +24,15 @@ export class ClearLoginAttempts implements UseCaseInterface { const user = await this.userRepository.findOneByUsernameOrEmail(username) if (!user) { - return { success: true } + return Result.ok() } - this.logger.debug(`Resetting lock counter for user ${user.uuid}`) + this.logger.debug('Resetting lock counter for user', { + userId: user.uuid, + }) await this.lockRepository.resetLockCounter(user.uuid) - return { success: true } + return Result.ok() } } diff --git a/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokens.ts b/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokens.ts new file mode 100644 index 000000000..bcbfdb231 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokens.ts @@ -0,0 +1,28 @@ +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { CooldownSessionTokensDTO } from './CooldownSessionTokensDTO' +import { SessionTokensCooldownRepositoryInterface } from '../../Session/SessionTokensCooldownRepositoryInterface' + +export class CooldownSessionTokens implements UseCaseInterface { + constructor( + private cooldownPeriodInSeconds: number, + private sessionTokensCooldownRepository: SessionTokensCooldownRepositoryInterface, + ) {} + + async execute(dto: CooldownSessionTokensDTO): Promise> { + const sessionUuidOrError = Uuid.create(dto.sessionUuid) + if (sessionUuidOrError.isFailed()) { + return Result.fail(sessionUuidOrError.getError()) + } + const sessionUuid = sessionUuidOrError.getValue() + + await this.sessionTokensCooldownRepository.setCooldown({ + sessionUuid, + hashedAccessToken: dto.hashedAccessToken, + hashedRefreshToken: dto.hashedRefreshToken, + cooldownPeriodInSeconds: this.cooldownPeriodInSeconds, + }) + + return Result.ok() + } +} diff --git a/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokensDTO.ts b/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokensDTO.ts new file mode 100644 index 000000000..bd2bb92ab --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CooldownSessionTokens/CooldownSessionTokensDTO.ts @@ -0,0 +1,5 @@ +export interface CooldownSessionTokensDTO { + sessionUuid: string + hashedAccessToken: string + hashedRefreshToken: string +} diff --git a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts index 2228f9848..8b6a2f176 100644 --- a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts +++ b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts @@ -10,6 +10,7 @@ import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { CreateCrossServiceToken } from './CreateCrossServiceToken' import { Result, + RoleName, SettingName, SharedVaultUser, SharedVaultUserPermission, @@ -23,6 +24,7 @@ import { UserSubscription } from '../../Subscription/UserSubscription' import { SubscriptionSetting } from '../../Setting/SubscriptionSetting' import { EncryptionVersion } from '../../Encryption/EncryptionVersion' import { GetActiveSessionsForUser } from '../GetActiveSessionsForUser' +import { Permission } from '../../Permission/Permission' describe('CreateCrossServiceToken', () => { let userProjector: ProjectorInterface @@ -39,6 +41,7 @@ describe('CreateCrossServiceToken', () => { let session: Session let user: User let role: Role + let permission: Permission const createUseCase = () => new CreateCrossServiceToken( @@ -55,11 +58,20 @@ describe('CreateCrossServiceToken', () => { ) beforeEach(() => { + permission = { + name: 'server:content-limit', + } as jest.Mocked + session = {} as jest.Mocked getActiveSessionsForUser = {} as jest.Mocked getActiveSessionsForUser.execute = jest.fn().mockReturnValue({ sessions: [session] }) + role = { + name: 'test', + } as jest.Mocked + role.permissions = Promise.resolve([]) + user = { uuid: '00000000-0000-0000-0000-000000000000', email: 'test@test.te', @@ -140,6 +152,47 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, + }, + 60, + ) + }) + + it('should create a cross service token for user with content limitation', async () => { + role.name = RoleName.NAMES.CoreUser + role.permissions = Promise.resolve([permission]) + + user.roles = Promise.resolve([role]) + + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + await createUseCase().execute({ + user, + session, + }) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + roles: [ + { + name: 'role1', + uuid: '1-3-4', + }, + ], + belongs_to_shared_vaults: [ + { + shared_vault_uuid: '00000000-0000-0000-0000-000000000000', + permission: 'read', + }, + ], + session: { + test: 'test', + }, + user: { + email: 'test@test.te', + uuid: '00000000-0000-0000-0000-000000000000', + }, + hasContentLimit: true, }, 60, ) @@ -168,6 +221,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, }, 60, ) @@ -196,6 +250,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, }, 60, ) @@ -228,6 +283,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, }, 60, ) @@ -259,6 +315,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, }, 60, ) @@ -317,6 +374,7 @@ describe('CreateCrossServiceToken', () => { email: 'test@test.te', uuid: '00000000-0000-0000-0000-000000000000', }, + hasContentLimit: false, }, 60, ) diff --git a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts index 7b3dcafed..1955e31cc 100644 --- a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts +++ b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.ts @@ -1,5 +1,5 @@ -import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security' -import { Result, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/security' +import { Result, RoleName, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core' import { ProjectorInterface } from '../../../Projection/ProjectorInterface' import { Role } from '../../Role/Role' @@ -44,6 +44,13 @@ export class CreateCrossServiceToken implements UseCaseInterface { } const roles = await user.roles + const coreUserRole = roles.find((role) => role.name === RoleName.NAMES.CoreUser) + let hasContentLimit = false + + if (coreUserRole) { + const permissions = await coreUserRole.permissions + hasContentLimit = permissions.find((permission) => permission.name === 'server:content-limit') !== undefined + } const sharedVaultAssociations = await this.sharedVaultUserRepository.findByUserUuid( Uuid.create(user.uuid).getValue(), @@ -57,6 +64,7 @@ export class CreateCrossServiceToken implements UseCaseInterface { shared_vault_uuid: association.props.sharedVaultUuid.value, permission: association.props.permission.value, })), + hasContentLimit: hasContentLimit, } if (dto.sharedVaultOwnerContext !== undefined) { diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.spec.ts b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.spec.ts new file mode 100644 index 000000000..64117c541 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.spec.ts @@ -0,0 +1,90 @@ +import { Result } from '@standardnotes/domain-core' +import { EphemeralSessionRepositoryInterface } from '../../Session/EphemeralSessionRepositoryInterface' +import { SessionRepositoryInterface } from '../../Session/SessionRepositoryInterface' +import { GetSessionFromToken } from '../GetSessionFromToken/GetSessionFromToken' +import { DeleteSessionByToken } from './DeleteSessionByToken' +import { Session } from '../../Session/Session' +import { ApiVersion } from '../../Api/ApiVersion' +import { SessionService } from '../../Session/SessionService' +import { EphemeralSession } from '../../Session/EphemeralSession' + +describe('DeleteSessionByToken', () => { + let getSessionFromToken: GetSessionFromToken + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let existingSession: Session + let existingEphemeralSession: EphemeralSession + + const createUseCase = () => + new DeleteSessionByToken(getSessionFromToken, sessionRepository, ephemeralSessionRepository) + + beforeEach(() => { + existingSession = {} as jest.Mocked + existingSession.uuid = '2e1e43' + existingSession.userUuid = '1-2-3' + existingSession.userAgent = 'Chrome' + existingSession.apiVersion = ApiVersion.VERSIONS.v20200115 + existingSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingSession.readonlyAccess = false + existingSession.version = SessionService.HEADER_BASED_SESSION_VERSION + + existingEphemeralSession = {} as jest.Mocked + existingEphemeralSession.uuid = '2-3-4' + existingEphemeralSession.userUuid = '1-2-3' + existingEphemeralSession.userAgent = 'Mozilla Firefox' + existingEphemeralSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingEphemeralSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingEphemeralSession.readonlyAccess = false + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.deleteOne = jest.fn() + + getSessionFromToken = {} as jest.Mocked + getSessionFromToken.execute = jest.fn().mockResolvedValue(Result.ok({ session: existingSession })) + + sessionRepository = {} as jest.Mocked + sessionRepository.deleteOneByUuid = jest.fn() + }) + + it('should delete a session by token', async () => { + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + + expect(result.isFailed()).toBeFalsy() + + expect(sessionRepository.deleteOneByUuid).toHaveBeenCalledWith('2e1e43') + expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() + }) + + it('should delete an ephemeral session by token', async () => { + getSessionFromToken.execute = jest + .fn() + .mockResolvedValue(Result.ok({ session: existingEphemeralSession, isEphemeral: true })) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + + expect(result.isFailed()).toBeFalsy() + + expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() + expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '1-2-3') + }) + + it('should not delete a session by token if session is not found', async () => { + getSessionFromToken.execute = jest.fn().mockResolvedValue(Result.fail('Session not found')) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:4:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + + expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() + expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.ts b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.ts new file mode 100644 index 000000000..c3362bb50 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken.ts @@ -0,0 +1,30 @@ +import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { DeleteSessionByTokenDTO } from './DeleteSessionByTokenDTO' +import { GetSessionFromToken } from '../GetSessionFromToken/GetSessionFromToken' +import { SessionRepositoryInterface } from '../../Session/SessionRepositoryInterface' +import { EphemeralSessionRepositoryInterface } from '../../Session/EphemeralSessionRepositoryInterface' +import { Session } from '../../Session/Session' + +export class DeleteSessionByToken implements UseCaseInterface { + constructor( + private getSessionFromToken: GetSessionFromToken, + private sessionRepository: SessionRepositoryInterface, + private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + ) {} + + async execute(dto: DeleteSessionByTokenDTO): Promise> { + const resultOrError = await this.getSessionFromToken.execute(dto) + if (resultOrError.isFailed()) { + return Result.fail(resultOrError.getError()) + } + const result = resultOrError.getValue() + + if (result.isEphemeral) { + await this.ephemeralSessionRepository.deleteOne(result.session.uuid, result.session.userUuid) + } else { + await this.sessionRepository.deleteOneByUuid(result.session.uuid) + } + + return Result.ok(result.session) + } +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByTokenDTO.ts b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByTokenDTO.ts new file mode 100644 index 000000000..87f445f8e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionByToken/DeleteSessionByTokenDTO.ts @@ -0,0 +1,12 @@ +export interface DeleteSessionByTokenDTO { + authTokenFromHeaders: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts index 5fb2cf0f8..fbec272bc 100644 --- a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts @@ -44,6 +44,22 @@ describe('DeleteSetting', () => { expect(settingRepository.deleteByUserUuid).toHaveBeenCalledWith({ settingName: 'test', userUuid: '1-2-3' }) }) + it('should delete recovery codes setting if MFA secret is deleted', async () => { + await createUseCase().execute({ + settingName: SettingName.NAMES.MfaSecret, + userUuid: '1-2-3', + }) + + expect(settingRepository.deleteByUserUuid).toHaveBeenNthCalledWith(1, { + settingName: SettingName.NAMES.MfaSecret, + userUuid: '1-2-3', + }) + expect(settingRepository.deleteByUserUuid).toHaveBeenNthCalledWith(2, { + settingName: SettingName.NAMES.RecoveryCodes, + userUuid: '1-2-3', + }) + }) + it('should delete a setting by uuid', async () => { await createUseCase().execute({ settingName: 'test', diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts index 8a0ae9a53..ad9c0bbe0 100644 --- a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts @@ -1,12 +1,13 @@ import { inject, injectable } from 'inversify' +import { SettingName, Timestamps } from '@standardnotes/domain-core' +import { TimerInterface } from '@standardnotes/time' + import { DeleteSettingDto } from './DeleteSettingDto' import { DeleteSettingResponse } from './DeleteSettingResponse' import { UseCaseInterface } from '../UseCaseInterface' import TYPES from '../../../Bootstrap/Types' import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' -import { TimerInterface } from '@standardnotes/time' import { Setting } from '../../Setting/Setting' -import { Timestamps } from '@standardnotes/domain-core' @injectable() export class DeleteSetting implements UseCaseInterface { @@ -44,6 +45,13 @@ export class DeleteSetting implements UseCaseInterface { }) } + if (settingName === SettingName.NAMES.MfaSecret) { + await this.settingRepository.deleteByUserUuid({ + userUuid: dto.userUuid, + settingName: SettingName.NAMES.RecoveryCodes, + }) + } + return { success: true, settingName, diff --git a/packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.ts b/packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.ts index 08654fc07..851d2ca66 100644 --- a/packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.ts +++ b/packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.ts @@ -58,10 +58,6 @@ export class DisableEmailSettingBasedOnEmailSubscription implements UseCaseInter private getSettingNameFromLevel(level: string): Result { /* istanbul ignore next */ switch (level) { - case EmailLevel.LEVELS.FailedCloudBackup: - return Result.ok(SettingName.create(SettingName.NAMES.MuteFailedCloudBackupsEmails).getValue()) - case EmailLevel.LEVELS.FailedEmailBackup: - return Result.ok(SettingName.create(SettingName.NAMES.MuteFailedBackupsEmails).getValue()) case EmailLevel.LEVELS.Marketing: return Result.ok(SettingName.create(SettingName.NAMES.MuteMarketingEmails).getValue()) case EmailLevel.LEVELS.SignIn: diff --git a/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.spec.ts b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.spec.ts new file mode 100644 index 000000000..63f5fa057 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.spec.ts @@ -0,0 +1,47 @@ +import { SessionTokensCooldownRepositoryInterface } from '../../Session/SessionTokensCooldownRepositoryInterface' +import { GetCooldownSessionTokens } from './GetCooldownSessionTokens' + +describe('GetCooldownSessionTokens', () => { + let sessionTokensCooldownRepository: SessionTokensCooldownRepositoryInterface + + const createUseCase = () => new GetCooldownSessionTokens(sessionTokensCooldownRepository) + + beforeEach(() => { + sessionTokensCooldownRepository = {} as jest.Mocked + sessionTokensCooldownRepository.getHashedTokens = jest.fn().mockReturnValue(null) + }) + + it('should return an error if the sessionUuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ sessionUuid: 'invalidUuid' }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return an error if no tokens are found', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ sessionUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return the hashed tokens', async () => { + sessionTokensCooldownRepository.getHashedTokens = jest.fn().mockReturnValue({ + hashedAccessToken: 'hashedAccessToken', + hashedRefreshToken: 'hashedRefreshToken', + }) + + const useCase = createUseCase() + + const result = await useCase.execute({ sessionUuid: '00000000-0000-0000-0000-000000000000' }) + + expect(result.isFailed()).toBeFalsy() + + const value = result.getValue() + + expect(value.hashedAccessToken).toBe('hashedAccessToken') + expect(value.hashedRefreshToken).toBe('hashedRefreshToken') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.ts b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.ts new file mode 100644 index 000000000..f988e51e5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokens.ts @@ -0,0 +1,27 @@ +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { GetCooldownSessionTokensResponse } from './GetCooldownSessionTokensResponse' +import { GetCooldownSessionTokensDTO } from './GetCooldownSessionTokensDTO' +import { SessionTokensCooldownRepositoryInterface } from '../../Session/SessionTokensCooldownRepositoryInterface' + +export class GetCooldownSessionTokens implements UseCaseInterface { + constructor(private sessionTokensCooldownRepository: SessionTokensCooldownRepositoryInterface) {} + + async execute(dto: GetCooldownSessionTokensDTO): Promise> { + const sessionUuidOrError = Uuid.create(dto.sessionUuid) + if (sessionUuidOrError.isFailed()) { + return Result.fail(sessionUuidOrError.getError()) + } + const sessionUuid = sessionUuidOrError.getValue() + + const hashedTokens = await this.sessionTokensCooldownRepository.getHashedTokens(sessionUuid) + if (!hashedTokens) { + return Result.fail('No tokens found') + } + + return Result.ok({ + hashedAccessToken: hashedTokens.hashedAccessToken, + hashedRefreshToken: hashedTokens.hashedRefreshToken, + }) + } +} diff --git a/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensDTO.ts b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensDTO.ts new file mode 100644 index 000000000..9604425f6 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensDTO.ts @@ -0,0 +1,3 @@ +export interface GetCooldownSessionTokensDTO { + sessionUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensResponse.ts b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensResponse.ts new file mode 100644 index 000000000..ee5af18b9 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetCooldownSessionTokens/GetCooldownSessionTokensResponse.ts @@ -0,0 +1,4 @@ +export interface GetCooldownSessionTokensResponse { + hashedAccessToken: string + hashedRefreshToken: string +} diff --git a/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.spec.ts b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.spec.ts new file mode 100644 index 000000000..f4108c65a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.spec.ts @@ -0,0 +1,272 @@ +import { Logger } from 'winston' +import { EphemeralSession } from '../../Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../../Session/EphemeralSessionRepositoryInterface' +import { Session } from '../../Session/Session' +import { SessionRepositoryInterface } from '../../Session/SessionRepositoryInterface' +import { SessionService } from '../../Session/SessionService' +import { GetSessionFromToken } from './GetSessionFromToken' +import { ApiVersion } from '../../Api/ApiVersion' +import { GetCooldownSessionTokens } from '../GetCooldownSessionTokens/GetCooldownSessionTokens' +import { Result } from '@standardnotes/domain-core' + +describe('GetSessionFromToken', () => { + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let existingSession: Session + let existingEphemeralSession: EphemeralSession + let getCooldownSessionTokens: GetCooldownSessionTokens + let logger: Logger + + const createUseCase = () => + new GetSessionFromToken(sessionRepository, ephemeralSessionRepository, getCooldownSessionTokens, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.error = jest.fn() + + existingSession = {} as jest.Mocked + existingSession.uuid = '2e1e43' + existingSession.userUuid = '1-2-3' + existingSession.userAgent = 'Chrome' + existingSession.apiVersion = ApiVersion.VERSIONS.v20200115 + existingSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingSession.readonlyAccess = false + existingSession.version = SessionService.HEADER_BASED_SESSION_VERSION + + sessionRepository = {} as jest.Mocked + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockReturnValue(null) + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.findOneByUuid = jest.fn() + ephemeralSessionRepository.findOneByPrivateIdentifier = jest.fn() + + existingEphemeralSession = {} as jest.Mocked + existingEphemeralSession.uuid = '2-3-4' + existingEphemeralSession.userUuid = '1-2-3' + existingEphemeralSession.userAgent = 'Mozilla Firefox' + existingEphemeralSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingEphemeralSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + existingEphemeralSession.readonlyAccess = false + + getCooldownSessionTokens = {} as jest.Mocked + getCooldownSessionTokens.execute = jest.fn().mockReturnValue(Result.fail('No tokens found')) + }) + + it('should retrieve a session from a session token', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeFalsy() + + const { session, isEphemeral } = result.getValue() + + expect(session).toEqual(session) + expect(isEphemeral).toBeFalsy() + }) + + it('should retrieve a cookie session from a cookie session token', async () => { + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockImplementation((privateIdentifier: string) => { + if (privateIdentifier === '00000000-0000-0000-0000-000000000000') { + existingSession.privateIdentifier = '00000000-0000-0000-0000-000000000000' + existingSession.uuid = '00000000-0000-0000-0000-000000000001' + existingSession.version = SessionService.COOKIE_BASED_SESSION_VERSION + + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + authCookies: new Map([['access_token_00000000-0000-0000-0000-000000000001', ['3']]]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeFalsy() + + const { session, isEphemeral } = result.getValue() + + expect(session).toEqual(session) + expect(isEphemeral).toBeFalsy() + }) + + it('should retrieve a session by a cooldown access token', async () => { + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockImplementation((privateIdentifier: string) => { + if (privateIdentifier === '00000000-0000-0000-0000-000000000000') { + existingSession.privateIdentifier = '00000000-0000-0000-0000-000000000000' + existingSession.uuid = '00000000-0000-0000-0000-000000000001' + existingSession.version = SessionService.COOKIE_BASED_SESSION_VERSION + + return existingSession + } + + return null + }) + + getCooldownSessionTokens.execute = jest.fn().mockReturnValue( + Result.ok({ + hashedAccessToken: 'd4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35', + hashedRefreshToken: 'd4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35', + }), + ) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + authCookies: new Map([['access_token_00000000-0000-0000-0000-000000000001', ['2']]]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeFalsy() + + const { session, isEphemeral } = result.getValue() + + expect(session).toEqual(session) + expect(isEphemeral).toBeFalsy() + }) + + it('should not retrieve a header session from a cookie session token', async () => { + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockImplementation((privateIdentifier: string) => { + if (privateIdentifier === '00000000-0000-0000-0000-000000000000') { + existingSession.privateIdentifier = '00000000-0000-0000-0000-000000000000' + existingSession.uuid = '00000000-0000-0000-0000-000000000001' + existingSession.version = SessionService.HEADER_BASED_SESSION_VERSION + + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + authCookies: new Map([['access_token_00000000-0000-0000-0000-000000000001', ['3']]]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) + + it('should not retrieve a cookie session from a cookie session token that has invalid private identifier', async () => { + const result = await createUseCase().execute({ + authTokenFromHeaders: '2', + authCookies: new Map([['access_token_4', ['3']]]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) + + it('should not retrieve a cookie session from a cookie session token that has an invalid uuid', async () => { + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockImplementation((privateIdentifier: string) => { + if (privateIdentifier === '00000000-0000-0000-0000-000000000000') { + existingSession.privateIdentifier = '00000000-0000-0000-0000-000000000000' + existingSession.uuid = '00000000-0000-0000-0000-000000000002' + existingSession.version = SessionService.COOKIE_BASED_SESSION_VERSION + + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + authCookies: new Map([['access_token_00000000-0000-0000-0000-000000000001', ['3']]]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) + + it('should not retrieve a cookie session if cookies are missing', async () => { + sessionRepository.findOneByPrivateIdentifier = jest.fn().mockImplementation((privateIdentifier: string) => { + if (privateIdentifier === '00000000-0000-0000-0000-000000000000') { + existingSession.privateIdentifier = '00000000-0000-0000-0000-000000000000' + existingSession.uuid = '00000000-0000-0000-0000-000000000001' + existingSession.version = SessionService.COOKIE_BASED_SESSION_VERSION + + return existingSession + } + + return null + }) + + const result1 = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result1.isFailed()).toBeTruthy() + + const result2 = await createUseCase().execute({ + authTokenFromHeaders: '2:00000000-0000-0000-0000-000000000000', + authCookies: new Map([]), + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result2.isFailed()).toBeTruthy() + }) + + it('should retrieve an ephemeral session from a session token', async () => { + ephemeralSessionRepository.findOneByUuid = jest.fn().mockReturnValue(existingEphemeralSession) + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeFalsy() + + const { session, isEphemeral } = result.getValue() + + expect(session).toEqual(existingEphemeralSession) + expect(isEphemeral).toBeTruthy() + }) + + it('should not retrieve a session from a session token that has access token missing', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) + + it('should not retrieve a session that is missing', async () => { + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) + + it('should not retrieve a session from a session token that has invalid access token', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return existingSession + } + + return null + }) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '1:2:4', + requestMetadata: { url: '/foobar', method: 'GET' }, + }) + expect(result.isFailed()).toBeTruthy() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.ts b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.ts new file mode 100644 index 000000000..cff636127 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromToken.ts @@ -0,0 +1,160 @@ +import * as crypto from 'crypto' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' + +import { Session } from '../../Session/Session' +import { GetSessionFromTokenDTO } from './GetSessionFromTokenDTO' +import { SessionService } from '../../Session/SessionService' +import { SessionRepositoryInterface } from '../../Session/SessionRepositoryInterface' +import { EphemeralSessionRepositoryInterface } from '../../Session/EphemeralSessionRepositoryInterface' +import { Logger } from 'winston' +import { GetSessionFromTokenResult } from './GetSessionFromTokenResult' +import { GetCooldownSessionTokens } from '../GetCooldownSessionTokens/GetCooldownSessionTokens' + +export class GetSessionFromToken implements UseCaseInterface { + constructor( + private sessionRepository: SessionRepositoryInterface, + private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + private getCooldownSessionTokens: GetCooldownSessionTokens, + private logger: Logger, + ) {} + + async execute(dto: GetSessionFromTokenDTO): Promise> { + const tokenParts = dto.authTokenFromHeaders.split(':') + const tokenVersion = parseInt(tokenParts[0]) + + let accessTokens: string[] = [] + let isSessionEphemeral = false + let retrievedSession = undefined + switch (tokenVersion) { + case SessionService.SESSION_TOKEN_VERSION: { + if (!tokenParts[2]) { + return Result.fail('Invalid token') + } + + accessTokens = [tokenParts[2]] + + const sessionUuid = tokenParts[1] + + const { session, isEphemeral } = await this.getSession(sessionUuid) + isSessionEphemeral = isEphemeral + + if (!session || session.version === SessionService.COOKIE_BASED_SESSION_VERSION) { + return Result.fail('Invalid token') + } + + retrievedSession = session + + break + } + case SessionService.COOKIE_SESSION_TOKEN_VERSION: { + const privateIdentifier = tokenParts[1] + if (!privateIdentifier) { + return Result.fail('Invalid token') + } + + const { session, isEphemeral } = await this.getSessionByPrivateIdentifier(privateIdentifier) + isSessionEphemeral = isEphemeral + + if (!session || session.version === SessionService.HEADER_BASED_SESSION_VERSION) { + return Result.fail('Invalid token') + } + + retrievedSession = session + + if (!dto.authCookies || dto.authCookies.size === 0) { + /* istanbul ignore next */ + this.logger.error('No cookies provided for cookie-based session token.', { + userId: session.userUuid, + sessionUuid: session.uuid, + snjs: dto.requestMetadata.snjs, + application: dto.requestMetadata.application, + url: dto.requestMetadata.url, + method: dto.requestMetadata.method, + userAgent: session.userAgent, + secChUa: session.userAgent ? dto.requestMetadata.secChUa : undefined, + }) + + return Result.fail('Invalid token') + } + + const accessTokensFromAuthCookies = dto.authCookies?.get(`access_token_${session.uuid}`) + if (accessTokensFromAuthCookies === undefined) { + return Result.fail('Invalid token') + } + + accessTokens = accessTokensFromAuthCookies + + break + } + } + + /* istanbul ignore next */ + if (accessTokens.length === 0 || !retrievedSession) { + return Result.fail('Invalid token') + } + + const currentSessionTokensMatching = this.areTokensMatching(accessTokens, retrievedSession.hashedAccessToken) + if (currentSessionTokensMatching) { + return Result.ok({ session: retrievedSession, isEphemeral: isSessionEphemeral, givenTokensWereInCooldown: false }) + } + + const cooldownTokensResult = await this.getCooldownSessionTokens.execute({ sessionUuid: retrievedSession.uuid }) + if (!cooldownTokensResult.isFailed()) { + const cooldownTokens = cooldownTokensResult.getValue() + if (this.areTokensMatching(accessTokens, cooldownTokens.hashedAccessToken)) { + return Result.ok({ + session: retrievedSession, + isEphemeral: isSessionEphemeral, + givenTokensWereInCooldown: true, + cooldownHashedRefreshToken: cooldownTokens.hashedRefreshToken, + }) + } + } + + return Result.fail('Session not found') + } + + private areTokensMatching(inputTokens: string[], hashedToken: string): boolean { + for (const inputToken of inputTokens) { + const hashedInputToken = crypto.createHash('sha256').update(inputToken).digest('hex') + + const areMatching = crypto.timingSafeEqual(Buffer.from(hashedToken), Buffer.from(hashedInputToken)) + + if (areMatching) { + return true + } + } + + return false + } + + private async getSession(uuid: string): Promise<{ + session: Session | null + isEphemeral: boolean + }> { + let session = await this.ephemeralSessionRepository.findOneByUuid(uuid) + let isEphemeral = true + + if (!session) { + session = await this.sessionRepository.findOneByUuid(uuid) + isEphemeral = false + } + + return { session, isEphemeral } + } + + private async getSessionByPrivateIdentifier(privateIdentifier: string): Promise<{ + session: Session | null + isEphemeral: boolean + }> { + let session = await this.ephemeralSessionRepository.findOneByPrivateIdentifier(privateIdentifier) + let isEphemeral = true + + if (!session) { + session = await this.sessionRepository.findOneByPrivateIdentifier(privateIdentifier) + isEphemeral = false + } + + return { session, isEphemeral } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenDTO.ts b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenDTO.ts new file mode 100644 index 000000000..5368d4f06 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenDTO.ts @@ -0,0 +1,12 @@ +export interface GetSessionFromTokenDTO { + authTokenFromHeaders: string + authCookies?: Map + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } +} diff --git a/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenResult.ts b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenResult.ts new file mode 100644 index 000000000..9c78eadef --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSessionFromToken/GetSessionFromTokenResult.ts @@ -0,0 +1,8 @@ +import { Session } from '../../Session/Session' + +export interface GetSessionFromTokenResult { + session: Session + isEphemeral: boolean + givenTokensWereInCooldown: boolean + cooldownHashedRefreshToken?: string +} diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts index d7fc24eec..ba887f5dc 100644 --- a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts @@ -6,6 +6,7 @@ import { User } from '../../User/User' import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { GetSetting } from '../GetSetting/GetSetting' import { GetUserKeyParamsRecovery } from './GetUserKeyParamsRecovery' +import { ApiVersion } from '../../Api/ApiVersion' describe('GetUserKeyParamsRecovery', () => { let keyParamsFactory: KeyParamsFactoryInterface @@ -40,6 +41,7 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: '', recoveryCodes: '1234 5678', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result.isFailed()).toBe(true) @@ -51,6 +53,7 @@ describe('GetUserKeyParamsRecovery', () => { username: '', codeChallenge: 'code-challenge', recoveryCodes: '1234 5678', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result.isFailed()).toBe(true) @@ -62,6 +65,7 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: 'codeChallenge', recoveryCodes: '', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result.isFailed()).toBe(true) @@ -75,6 +79,7 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: 'codeChallenge', recoveryCodes: '1234 5678', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(keyParamsFactory.createPseudoParams).toHaveBeenCalled() @@ -88,6 +93,7 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: 'codeChallenge', recoveryCodes: '1234 5678', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result.isFailed()).toBe(true) @@ -99,6 +105,7 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: 'codeChallenge', recoveryCodes: '1234 5678', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result.isFailed()).toBe(true) @@ -110,9 +117,34 @@ describe('GetUserKeyParamsRecovery', () => { username: 'username', codeChallenge: 'codeChallenge', recoveryCodes: 'foo', + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(keyParamsFactory.create).toHaveBeenCalled() expect(result.isFailed()).toBe(false) }) + + it('should return error if api version is invalid', async () => { + const result = await createUseCase().execute({ + username: 'username', + codeChallenge: 'codeChallenge', + recoveryCodes: 'foo', + apiVersion: 'invalid', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Invalid api version: invalid') + }) + + it('should return error if api version does not support recovery sign in', async () => { + const result = await createUseCase().execute({ + username: 'username', + codeChallenge: 'codeChallenge', + recoveryCodes: 'foo', + apiVersion: ApiVersion.VERSIONS.v20190520, + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Unsupported api version') + }) }) diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts index 63467748b..8df307292 100644 --- a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts @@ -7,6 +7,7 @@ import { GetUserKeyParamsRecoveryDTO } from './GetUserKeyParamsRecoveryDTO' import { User } from '../../User/User' import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface' import { GetSetting } from '../GetSetting/GetSetting' +import { ApiVersion } from '../../Api/ApiVersion' export class GetUserKeyParamsRecovery implements UseCaseInterface { constructor( @@ -17,6 +18,16 @@ export class GetUserKeyParamsRecovery implements UseCaseInterface ) {} async execute(dto: GetUserKeyParamsRecoveryDTO): Promise> { + const apiVersionOrError = ApiVersion.create(dto.apiVersion) + if (apiVersionOrError.isFailed()) { + return Result.fail(apiVersionOrError.getError()) + } + const apiVersion = apiVersionOrError.getValue() + + if (!apiVersion.isSupportedForRecoverySignIn()) { + return Result.fail('Unsupported api version') + } + const usernameOrError = Username.create(dto.username) if (usernameOrError.isFailed()) { return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`) diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts index b50620948..c000f94b6 100644 --- a/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts @@ -1,4 +1,5 @@ export interface GetUserKeyParamsRecoveryDTO { + apiVersion: string codeChallenge: string username: string recoveryCodes: string diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts index 5e0c31c9a..87eac7cb2 100644 --- a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts @@ -1,6 +1,5 @@ import 'reflect-metadata' -import { Logger } from 'winston' import { LockRepositoryInterface } from '../User/LockRepositoryInterface' import { User } from '../User/User' @@ -12,14 +11,10 @@ describe('IncreaseLoginAttempts', () => { let lockRepository: LockRepositoryInterface const maxLoginAttempts = 6 let user: User - let logger: Logger - const createUseCase = () => new IncreaseLoginAttempts(userRepository, lockRepository, maxLoginAttempts, logger) + const createUseCase = () => new IncreaseLoginAttempts(userRepository, lockRepository, maxLoginAttempts) beforeEach(() => { - logger = {} as jest.Mocked - logger.debug = jest.fn() - user = {} as jest.Mocked user.uuid = '123' @@ -28,42 +23,41 @@ describe('IncreaseLoginAttempts', () => { lockRepository = {} as jest.Mocked lockRepository.getLockCounter = jest.fn() - lockRepository.lockUser = jest.fn() lockRepository.updateLockCounter = jest.fn() }) it('should do nothing if a user identifier is invalid', async () => { - expect(await createUseCase().execute({ email: ' ' })).toEqual({ success: false }) + const result = await createUseCase().execute({ email: ' ' }) + expect(result.isFailed()).toEqual(true) expect(lockRepository.updateLockCounter).not.toHaveBeenCalled() - expect(lockRepository.lockUser).not.toHaveBeenCalled() - }) - - it('should lock a user if the number of failed login attempts is breached', async () => { - lockRepository.getLockCounter = jest.fn().mockReturnValue(5) - - expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) - - expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 6) - expect(lockRepository.lockUser).toHaveBeenCalledWith('123') }) it('should update the lock counter if a user is not exceeding the max failed login attempts', async () => { - lockRepository.getLockCounter = jest.fn().mockReturnValue(4) + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(4).mockReturnValueOnce(0) - expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + const result = await createUseCase().execute({ email: 'test@test.te' }) + expect(result.isFailed()).toEqual(false) - expect(lockRepository.lockUser).not.toHaveBeenCalled() - expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 5) + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 5, 'non-captcha') + }) + + it('should update the captcha lock counter if a user is exceeding the max failed login attempts', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(6).mockReturnValueOnce(0) + + const result = await createUseCase().execute({ email: 'test@test.te' }) + expect(result.isFailed()).toEqual(false) + + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 1, 'captcha') }) it('should should update the lock counter based on email if user is not found', async () => { - lockRepository.getLockCounter = jest.fn().mockReturnValue(4) + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(4).mockReturnValueOnce(0) userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null) - expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + const result = await createUseCase().execute({ email: 'test@test.te' }) + expect(result.isFailed()).toEqual(false) - expect(lockRepository.lockUser).not.toHaveBeenCalled() - expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('test@test.te', 5) + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('test@test.te', 5, 'non-captcha') }) }) diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts index 51431b255..b937221c8 100644 --- a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts @@ -1,28 +1,23 @@ -import { Username } from '@standardnotes/domain-core' -import { inject, injectable } from 'inversify' -import { Logger } from 'winston' -import TYPES from '../../Bootstrap/Types' +import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core' + import { LockRepositoryInterface } from '../User/LockRepositoryInterface' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' import { IncreaseLoginAttemptsDTO } from './IncreaseLoginAttemptsDTO' import { IncreaseLoginAttemptsResponse } from './IncreaseLoginAttemptsResponse' -import { UseCaseInterface } from './UseCaseInterface' -@injectable() -export class IncreaseLoginAttempts implements UseCaseInterface { +export class IncreaseLoginAttempts implements UseCaseInterface { constructor( - @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface, - @inject(TYPES.Auth_LockRepository) private lockRepository: LockRepositoryInterface, - @inject(TYPES.Auth_MAX_LOGIN_ATTEMPTS) private maxLoginAttempts: number, - @inject(TYPES.Auth_Logger) private logger: Logger, + private userRepository: UserRepositoryInterface, + private lockRepository: LockRepositoryInterface, + private maxNonCaptchaAttempts: number, ) {} - async execute(dto: IncreaseLoginAttemptsDTO): Promise { + async execute(dto: IncreaseLoginAttemptsDTO): Promise> { let identifier = dto.email const usernameOrError = Username.create(dto.email) if (usernameOrError.isFailed()) { - return { success: false } + return Result.fail(usernameOrError.getError()) } const username = usernameOrError.getValue() @@ -31,18 +26,29 @@ export class IncreaseLoginAttempts implements UseCaseInterface { identifier = user.uuid } - let numberOfFailedAttempts = await this.lockRepository.getLockCounter(identifier) + const numberOfFailedAttempts = await this.lockRepository.getLockCounter(identifier, 'non-captcha') + const numberOfFailedAttemptsInCaptchaMode = await this.lockRepository.getLockCounter(identifier, 'captcha') - numberOfFailedAttempts += 1 + const isEligibleForNonCaptchaMode = + numberOfFailedAttemptsInCaptchaMode === 0 && numberOfFailedAttempts < this.maxNonCaptchaAttempts + const isNonCaptchaLimitReached = + numberOfFailedAttempts + 1 >= this.maxNonCaptchaAttempts || numberOfFailedAttemptsInCaptchaMode > 0 - await this.lockRepository.updateLockCounter(identifier, numberOfFailedAttempts) - - if (numberOfFailedAttempts >= this.maxLoginAttempts) { - this.logger.debug(`User ${identifier} breached number of allowed login attempts. Locking user.`) - - await this.lockRepository.lockUser(identifier) + let newCounter: number + if (isEligibleForNonCaptchaMode) { + newCounter = numberOfFailedAttempts + 1 + } else { + newCounter = numberOfFailedAttemptsInCaptchaMode + 1 } - return { success: true } + await this.lockRepository.updateLockCounter( + identifier, + newCounter, + isEligibleForNonCaptchaMode ? 'non-captcha' : 'captcha', + ) + + return Result.ok({ + isNonCaptchaLimitReached, + }) } } diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts index 9a58e8e4d..eae9766ab 100644 --- a/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts @@ -1,3 +1,3 @@ -export type IncreaseLoginAttemptsResponse = { - success: boolean +export interface IncreaseLoginAttemptsResponse { + isNonCaptchaLimitReached: boolean } diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts index 6f94de520..1694d8c0d 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts @@ -11,6 +11,11 @@ import { GetSetting } from './GetSetting/GetSetting' import { Result } from '@standardnotes/domain-core' import { LogSessionUserAgentOption } from '@standardnotes/settings' import { Setting } from '../Setting/Setting' +import { SessionService } from '../Session/SessionService' +import { SessionCreationResult } from '../Session/SessionCreationResult' +import { ApiVersion } from '../Api/ApiVersion' +import { CooldownSessionTokens } from './CooldownSessionTokens/CooldownSessionTokens' +import { GetSessionFromToken } from './GetSessionFromToken/GetSessionFromToken' describe('RefreshSessionToken', () => { let sessionService: SessionServiceInterface @@ -20,27 +25,43 @@ describe('RefreshSessionToken', () => { let timer: TimerInterface let getSetting: GetSetting let logger: Logger + let sessionCreationResult: SessionCreationResult + let cooldownSessionTokens: CooldownSessionTokens + let getSessionFromToken: GetSessionFromToken const createUseCase = () => - new RefreshSessionToken(sessionService, domainEventFactory, domainEventPublisher, timer, getSetting, logger) + new RefreshSessionToken( + sessionService, + domainEventFactory, + domainEventPublisher, + timer, + getSetting, + cooldownSessionTokens, + getSessionFromToken, + logger, + ) beforeEach(() => { session = {} as jest.Mocked session.uuid = '1-2-3' session.refreshExpiration = new Date(123) + session.version = SessionService.HEADER_BASED_SESSION_VERSION + session.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + session.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + + sessionCreationResult = {} as jest.Mocked getSetting = {} as jest.Mocked getSetting.execute = jest.fn().mockReturnValue(Result.fail('not found')) sessionService = {} as jest.Mocked - sessionService.isRefreshTokenMatchingHashedSessionToken = jest.fn().mockReturnValue(true) - sessionService.getSessionFromToken = jest.fn().mockReturnValue({ session, isEphemeral: false }) - sessionService.refreshTokens = jest.fn().mockReturnValue({ - access_token: 'token1', - refresh_token: 'token2', - access_expiration: 123, - refresh_expiration: 234, - }) + sessionService.refreshTokens = jest.fn().mockReturnValue(sessionCreationResult) + + getSessionFromToken = {} as jest.Mocked + getSessionFromToken.execute = jest.fn().mockReturnValue(Result.ok({ session, isEphemeral: false })) + + cooldownSessionTokens = {} as jest.Mocked + cooldownSessionTokens.execute = jest.fn() domainEventFactory = {} as jest.Mocked domainEventFactory.createSessionRefreshedEvent = jest.fn().mockReturnValue({}) @@ -53,25 +74,52 @@ describe('RefreshSessionToken', () => { logger = {} as jest.Mocked logger.error = jest.fn() + logger.debug = jest.fn() }) it('should refresh session token', async () => { const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) - expect(sessionService.refreshTokens).toHaveBeenCalledWith({ session, isEphemeral: false }) + expect(sessionService.refreshTokens).toHaveBeenCalledWith({ + session, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + }) expect(result).toEqual({ success: true, - sessionPayload: { - access_token: 'token1', - refresh_token: 'token2', - access_expiration: 123, - refresh_expiration: 234, - }, + result: sessionCreationResult, + }) + + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should refresh cookie token', async () => { + session.version = SessionService.COOKIE_BASED_SESSION_VERSION + getSessionFromToken.execute = jest.fn().mockReturnValue(Result.ok({ session, isEphemeral: false })) + + const result = await createUseCase().execute({ + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '2:2:3', + authCookies: new Map([['refresh_token_1-2-3', ['3']]]), + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, + }) + + expect(result).toEqual({ + success: true, + result: sessionCreationResult, + }) + + expect(sessionService.refreshTokens).toHaveBeenCalledWith({ + session, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), }) expect(domainEventPublisher.publish).toHaveBeenCalled() @@ -86,21 +134,21 @@ describe('RefreshSessionToken', () => { ) const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) - expect(sessionService.refreshTokens).toHaveBeenCalledWith({ session, isEphemeral: false }) + expect(sessionService.refreshTokens).toHaveBeenCalledWith({ + session, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + }) expect(result).toEqual({ success: true, - sessionPayload: { - access_token: 'token1', - refresh_token: 'token2', - access_expiration: 123, - refresh_expiration: 234, - }, + result: sessionCreationResult, }) expect(domainEventPublisher.publish).toHaveBeenCalled() @@ -110,31 +158,32 @@ describe('RefreshSessionToken', () => { domainEventPublisher.publish = jest.fn().mockRejectedValue(new Error('test')) const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) - expect(sessionService.refreshTokens).toHaveBeenCalledWith({ session, isEphemeral: false }) + expect(sessionService.refreshTokens).toHaveBeenCalledWith({ + session, + isEphemeral: false, + apiVersion: ApiVersion.create(ApiVersion.VERSIONS.v20200115).getValue(), + }) expect(result).toEqual({ success: true, - sessionPayload: { - access_token: 'token1', - refresh_token: 'token2', - access_expiration: 123, - refresh_expiration: 234, - }, + result: sessionCreationResult, }) }) it('should not refresh a session token if session is not found', async () => { - sessionService.getSessionFromToken = jest.fn().mockReturnValue({ session: undefined, isEphemeral: false }) + getSessionFromToken.execute = jest.fn().mockReturnValue(Result.fail('No session found.')) const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '234', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result).toEqual({ @@ -145,12 +194,30 @@ describe('RefreshSessionToken', () => { }) it('should not refresh a session token if refresh token is not valid', async () => { - sessionService.isRefreshTokenMatchingHashedSessionToken = jest.fn().mockReturnValue(false) + const result = await createUseCase().execute({ + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '2345', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, + }) + + expect(result).toEqual({ + success: false, + errorTag: 'invalid-refresh-token', + errorMessage: 'The refresh token is not valid.', + }) + }) + + it('should not refresh a session if the session is cookies based and the refresh token is missing from cookies', async () => { + session.version = SessionService.COOKIE_BASED_SESSION_VERSION + getSessionFromToken.execute = jest.fn().mockReturnValue(Result.ok({ session, isEphemeral: false })) const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '1:2', + refreshTokenFromHeaders: '2:3', + authCookies: new Map([['access_token_2-3-4', ['1:2']]]), + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result).toEqual({ @@ -164,9 +231,10 @@ describe('RefreshSessionToken', () => { timer.getUTCDate = jest.fn().mockReturnValue(new Date(200)) const result = await createUseCase().execute({ - accessToken: '123', - refreshToken: '234', - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '1:2:3', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: ApiVersion.VERSIONS.v20200115, }) expect(result).toEqual({ @@ -175,4 +243,19 @@ describe('RefreshSessionToken', () => { errorMessage: 'The refresh token has expired.', }) }) + + it('should not refresh a session token if the api version is invalid', async () => { + const result = await createUseCase().execute({ + authTokenFromHeaders: '123', + refreshTokenFromHeaders: '234', + requestMetadata: { url: '/foobar', method: 'GET', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + apiVersion: '', + }) + + expect(result).toEqual({ + success: false, + errorTag: 'invalid-parameters', + errorMessage: 'The provided parameters are not valid.', + }) + }) }) diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts index 846f81e22..fe959b456 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' import { TimerInterface } from '@standardnotes/time' import { SettingName } from '@standardnotes/domain-core' @@ -10,6 +11,10 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac import { RefreshSessionTokenResponse } from './RefreshSessionTokenResponse' import { RefreshSessionTokenDTO } from './RefreshSessionTokenDTO' import { GetSetting } from './GetSetting/GetSetting' +import { SessionService } from '../Session/SessionService' +import { ApiVersion } from '../Api/ApiVersion' +import { CooldownSessionTokens } from './CooldownSessionTokens/CooldownSessionTokens' +import { GetSessionFromToken } from './GetSessionFromToken/GetSessionFromToken' export class RefreshSessionToken { constructor( @@ -18,20 +23,87 @@ export class RefreshSessionToken { private domainEventPublisher: DomainEventPublisherInterface, private timer: TimerInterface, private getSetting: GetSetting, + private cooldownSessionTokens: CooldownSessionTokens, + private getSessionFromToken: GetSessionFromToken, private logger: Logger, ) {} async execute(dto: RefreshSessionTokenDTO): Promise { - const { session, isEphemeral } = await this.sessionService.getSessionFromToken(dto.accessToken) - if (!session) { + const apiVersionOrError = ApiVersion.create(dto.apiVersion) + if (apiVersionOrError.isFailed()) { + this.logger.debug(`Invalid API version: ${dto.apiVersion}`, { + codeTag: 'RefreshSessionToken', + }) + return { success: false, errorTag: 'invalid-parameters', errorMessage: 'The provided parameters are not valid.', } } + const apiVersion = apiVersionOrError.getValue() + + const resultOrError = await this.getSessionFromToken.execute({ + authCookies: dto.authCookies, + authTokenFromHeaders: dto.authTokenFromHeaders, + requestMetadata: dto.requestMetadata, + }) + if (resultOrError.isFailed()) { + this.logger.debug('No session found for auth token from headers and cookies', { + codeTag: 'RefreshSessionToken', + }) + + return { + success: false, + errorTag: 'invalid-parameters', + errorMessage: 'The provided parameters are not valid.', + } + } + const { session, isEphemeral, givenTokensWereInCooldown, cooldownHashedRefreshToken } = resultOrError.getValue() + + let hashedRefreshToken = session.hashedRefreshToken + /* istanbul ignore next */ + if (givenTokensWereInCooldown) { + this.logger.warn('Given tokens were in cooldown', { + codeTag: 'RefreshSessionToken', + userId: session?.userUuid, + sessionUuid: session?.uuid, + snjs: dto.requestMetadata.snjs, + application: dto.requestMetadata.application, + url: dto.requestMetadata.url, + method: dto.requestMetadata.method, + userAgent: session.userAgent, + secChUa: session.userAgent ? dto.requestMetadata.secChUa : undefined, + }) + + hashedRefreshToken = cooldownHashedRefreshToken as string + } + + const refreshTokens = + session.version === SessionService.COOKIE_BASED_SESSION_VERSION + ? dto.authCookies?.get(`refresh_token_${session.uuid}`) + : [dto.refreshTokenFromHeaders] + if (!refreshTokens || refreshTokens.length === 0) { + this.logger.debug('No refresh token found for session', { + codeTag: 'RefreshSessionToken', + }) + + return { + success: false, + errorTag: 'invalid-refresh-token', + errorMessage: 'The refresh token is not valid.', + } + } + + const anyRefreshTokenMatchingHashedSessionToken = refreshTokens.some((refreshToken) => + this.isRefreshTokenMatchingHashedSessionToken(session.version, hashedRefreshToken, refreshToken), + ) + + if (!anyRefreshTokenMatchingHashedSessionToken) { + this.logger.debug('Refresh token does not match session', { + codeTag: 'RefreshSessionToken', + }) - if (!this.sessionService.isRefreshTokenMatchingHashedSessionToken(session, dto.refreshToken)) { return { success: false, errorTag: 'invalid-refresh-token', @@ -40,6 +112,10 @@ export class RefreshSessionToken { } if (session.refreshExpiration < this.timer.getUTCDate()) { + this.logger.debug('Refresh token has expired', { + codeTag: 'RefreshSessionToken', + }) + return { success: false, errorTag: 'expired-refresh-token', @@ -48,10 +124,25 @@ export class RefreshSessionToken { } if (await this.isLoggingUserAgentEnabledOnSessions(session.userUuid)) { - session.userAgent = dto.userAgent + session.userAgent = dto.requestMetadata.userAgent as string } - const sessionPayload = await this.sessionService.refreshTokens({ session, isEphemeral }) + const hashedAccessTokenBeforeCooldown = session.hashedAccessToken + const hashedRefreshTokenBeforeCooldown = session.hashedRefreshToken + + const sessionCreationResult = await this.sessionService.refreshTokens({ + session, + isEphemeral, + apiVersion, + snjs: dto.requestMetadata.snjs, + application: dto.requestMetadata.application, + }) + + await this.cooldownSessionTokens.execute({ + sessionUuid: session.uuid, + hashedAccessToken: hashedAccessTokenBeforeCooldown, + hashedRefreshToken: hashedRefreshTokenBeforeCooldown, + }) try { await this.domainEventPublisher.publish( @@ -63,7 +154,7 @@ export class RefreshSessionToken { return { success: true, - sessionPayload, + result: sessionCreationResult, userUuid: session.userUuid, } } @@ -82,4 +173,30 @@ export class RefreshSessionToken { return loggingSetting.decryptedValue === LogSessionUserAgentOption.Enabled } + + private isRefreshTokenMatchingHashedSessionToken( + sessionVersion: number | null, + sessionHashedRefreshToken: string, + token: string, + ): boolean { + let refreshToken = null + switch (sessionVersion) { + case SessionService.COOKIE_BASED_SESSION_VERSION: + refreshToken = token + break + case SessionService.HEADER_BASED_SESSION_VERSION: { + const tokenParts = token.split(':') + refreshToken = tokenParts[2] + break + } + } + + if (!refreshToken) { + return false + } + + const hashedRefreshToken = crypto.createHash('sha256').update(refreshToken).digest('hex') + + return crypto.timingSafeEqual(Buffer.from(hashedRefreshToken), Buffer.from(sessionHashedRefreshToken)) + } } diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts b/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts index 2a10a6d43..46c93dd64 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts @@ -1,5 +1,14 @@ export type RefreshSessionTokenDTO = { - accessToken: string - refreshToken: string - userAgent: string + authTokenFromHeaders: string + refreshTokenFromHeaders: string + apiVersion: string + requestMetadata: { + url: string + method: string + snjs?: string + application?: string + userAgent?: string + secChUa?: string + } + authCookies?: Map } diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts b/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts index 1dfcec810..173168c9a 100644 --- a/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts +++ b/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts @@ -1,9 +1,13 @@ -import { SessionBody } from '@standardnotes/responses' +import { SessionCreationResult } from '../Session/SessionCreationResult' -export type RefreshSessionTokenResponse = { - success: boolean - userUuid?: string - errorTag?: string - errorMessage?: string - sessionPayload?: SessionBody -} +export type RefreshSessionTokenResponse = + | { + success: true + result: SessionCreationResult + userUuid: string + } + | { + success: false + errorTag: string + errorMessage: string + } diff --git a/packages/auth/src/Domain/UseCase/Register.spec.ts b/packages/auth/src/Domain/UseCase/Register.spec.ts index ea4a92826..41baab857 100644 --- a/packages/auth/src/Domain/UseCase/Register.spec.ts +++ b/packages/auth/src/Domain/UseCase/Register.spec.ts @@ -21,6 +21,7 @@ describe('Register', () => { let user: User let crypter: CrypterInterface let timer: TimerInterface + let session: Session const createUseCase = () => new Register(userRepository, roleRepository, authResponseFactory, crypter, false, timer, applyDefaultSettings) @@ -39,10 +40,9 @@ describe('Register', () => { roleRepository = {} as jest.Mocked roleRepository.findOneByName = jest.fn().mockReturnValue(null) + session = {} as jest.Mocked authResponseFactory = {} as jest.Mocked - authResponseFactory.createResponse = jest - .fn() - .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked }) + authResponseFactory.createResponse = jest.fn().mockReturnValue({ response: { foo: 'bar' }, session }) crypter = {} as jest.Mocked crypter.generateEncryptedUserServerKey = jest.fn().mockReturnValue('test') @@ -69,7 +69,7 @@ describe('Register', () => { pwSalt: 'qweqwe', pwNonce: undefined, }), - ).toEqual({ success: true, authResponse: { foo: 'bar' } }) + ).toEqual({ success: true, result: { response: { foo: 'bar' }, session } }) expect(userRepository.save).toHaveBeenCalledWith({ email: 'test@test.te', @@ -108,44 +108,7 @@ describe('Register', () => { pwSalt: 'qweqwe', pwNonce: undefined, }), - ).toEqual({ success: true, authResponse: { foo: 'bar' } }) - - expect(userRepository.save).toHaveBeenCalledWith({ - email: 'test@test.te', - encryptedPassword: expect.any(String), - encryptedServerKey: 'test', - serverEncryptionVersion: 1, - pwCost: 11, - pwNonce: undefined, - pwSalt: 'qweqwe', - updatedWithUserAgent: 'Mozilla', - uuid: expect.any(String), - version: '004', - createdAt: new Date(1), - updatedAt: new Date(1), - roles: Promise.resolve([role]), - }) - }) - - it('should register a new user with default set of roles on new api version', async () => { - const role = new Role() - role.name = RoleName.NAMES.CoreUser - - roleRepository.findOneByName = jest.fn().mockReturnValueOnce(role) - - expect( - await createUseCase().execute({ - email: 'test@test.te', - password: 'asdzxc', - updatedWithUserAgent: 'Mozilla', - apiVersion: '20240226', - ephemeralSession: false, - version: '004', - pwCost: 11, - pwSalt: 'qweqwe', - pwNonce: undefined, - }), - ).toEqual({ success: true, authResponse: { foo: 'bar' } }) + ).toEqual({ success: true, result: { response: { foo: 'bar' }, session } }) expect(userRepository.save).toHaveBeenCalledWith({ email: 'test@test.te', @@ -280,4 +243,25 @@ describe('Register', () => { expect(userRepository.save).not.toHaveBeenCalled() }) + + it('should fail to register if api version is invalid', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '', + ephemeralSession: false, + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ + success: false, + errorMessage: 'Invalid api version: ', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + }) }) diff --git a/packages/auth/src/Domain/UseCase/Register.ts b/packages/auth/src/Domain/UseCase/Register.ts index d7558b832..4754ab930 100644 --- a/packages/auth/src/Domain/UseCase/Register.ts +++ b/packages/auth/src/Domain/UseCase/Register.ts @@ -11,7 +11,6 @@ import { UseCaseInterface } from './UseCaseInterface' import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' import { CrypterInterface } from '../Encryption/CrypterInterface' import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115' -import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' import { ApiVersion } from '../Api/ApiVersion' import { ApplyDefaultSettings } from './ApplyDefaultSettings/ApplyDefaultSettings' @@ -36,7 +35,16 @@ export class Register implements UseCaseInterface { const { email, password, apiVersion, ephemeralSession, ...registrationFields } = dto - if (![ApiVersion.v20200115, ApiVersion.v20240226].includes(apiVersion as ApiVersion)) { + const apiVersionOrError = ApiVersion.create(apiVersion) + if (apiVersionOrError.isFailed()) { + return { + success: false, + errorMessage: apiVersionOrError.getError(), + } + } + const apiVersionVO = apiVersionOrError.getValue() + + if (!apiVersionVO.isSupportedForRegistration()) { return { success: false, errorMessage: `Unsupported api version: ${apiVersion}`, @@ -93,15 +101,17 @@ export class Register implements UseCaseInterface { const result = await this.authResponseFactory20200115.createResponse({ user, - apiVersion, + apiVersion: apiVersionVO, userAgent: dto.updatedWithUserAgent, ephemeralSession, readonlyAccess: false, + snjs: dto.snjs, + application: dto.application, }) return { success: true, - authResponse: result.response as AuthResponse20200115, + result, } } } diff --git a/packages/auth/src/Domain/UseCase/RegisterDTO.ts b/packages/auth/src/Domain/UseCase/RegisterDTO.ts index 85bc1276e..f254eceb0 100644 --- a/packages/auth/src/Domain/UseCase/RegisterDTO.ts +++ b/packages/auth/src/Domain/UseCase/RegisterDTO.ts @@ -10,4 +10,6 @@ export type RegisterDTO = { kpOrigination?: string kpCreated?: string version?: string + snjs?: string + application?: string } diff --git a/packages/auth/src/Domain/UseCase/RegisterResponse.ts b/packages/auth/src/Domain/UseCase/RegisterResponse.ts index 3c05cd103..1bf1368aa 100644 --- a/packages/auth/src/Domain/UseCase/RegisterResponse.ts +++ b/packages/auth/src/Domain/UseCase/RegisterResponse.ts @@ -1,9 +1,9 @@ -import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' +import { AuthResponseCreationResult } from '../Auth/AuthResponseCreationResult' export type RegisterResponse = | { success: true - authResponse: AuthResponse20200115 + result: AuthResponseCreationResult } | { success: false diff --git a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.spec.ts b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.spec.ts index 1ea8240c8..9b2c43c03 100644 --- a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.spec.ts +++ b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.spec.ts @@ -5,16 +5,26 @@ import { SetSubscriptionSettingValue } from './SetSubscriptionSettingValue' import { Result, SettingName, Timestamps, Uuid } from '@standardnotes/domain-core' import { EncryptionVersion } from '../../Encryption/EncryptionVersion' import { SubscriptionSetting } from '../../Setting/SubscriptionSetting' +import { SettingsAssociationServiceInterface } from '../../Setting/SettingsAssociationServiceInterface' describe('SetSubscriptionSettingValue', () => { let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface let getSubscriptionSetting: GetSubscriptionSetting + let settingsAssociationService: SettingsAssociationServiceInterface let timer: TimerInterface const createUseCase = () => - new SetSubscriptionSettingValue(subscriptionSettingRepository, getSubscriptionSetting, timer) + new SetSubscriptionSettingValue( + subscriptionSettingRepository, + getSubscriptionSetting, + settingsAssociationService, + timer, + ) beforeEach(() => { + settingsAssociationService = {} as jest.Mocked + settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(true) + subscriptionSettingRepository = {} as jest.Mocked subscriptionSettingRepository.insert = jest.fn() subscriptionSettingRepository.update = jest.fn() @@ -112,6 +122,21 @@ describe('SetSubscriptionSettingValue', () => { expect(subscriptionSettingRepository.insert).toHaveBeenCalled() }) + it('should return error if subscription setting is not mutable by client and a permission check is in order', async () => { + settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + + const result = await useCase.execute({ + userSubscriptionUuid: '00000000-0000-0000-0000-000000000000', + settingName: SettingName.NAMES.MuteSignInEmails, + value: 'encrypted', + checkUserPermissions: true, + }) + + expect(result.isFailed()).toBe(true) + }) + it('should return error if subscription setting could not be created', async () => { const mock = jest.spyOn(SubscriptionSetting, 'create') mock.mockReturnValue(Result.fail('Oops')) diff --git a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.ts b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.ts index 9336b4ffd..7ae7941ea 100644 --- a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.ts +++ b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue.ts @@ -6,15 +6,17 @@ import { SubscriptionSettingRepositoryInterface } from '../../Setting/Subscripti import { GetSubscriptionSetting } from '../GetSubscriptionSetting/GetSubscriptionSetting' import { SubscriptionSetting } from '../../Setting/SubscriptionSetting' import { EncryptionVersion } from '../../Encryption/EncryptionVersion' +import { SettingsAssociationServiceInterface } from '../../Setting/SettingsAssociationServiceInterface' -export class SetSubscriptionSettingValue implements UseCaseInterface { +export class SetSubscriptionSettingValue implements UseCaseInterface { constructor( private subscriptionSettingRepository: SubscriptionSettingRepositoryInterface, private getSubscriptionSetting: GetSubscriptionSetting, + private settingsAssociationService: SettingsAssociationServiceInterface, private timer: TimerInterface, ) {} - async execute(dto: SetSubscriptionSettingValueDTO): Promise> { + async execute(dto: SetSubscriptionSettingValueDTO): Promise> { const userSubscriptionUuidOrError = Uuid.create(dto.userSubscriptionUuid) if (userSubscriptionUuidOrError.isFailed()) { return Result.fail(userSubscriptionUuidOrError.getError()) @@ -40,6 +42,10 @@ export class SetSubscriptionSettingValue implements UseCaseInterface { return Result.fail(`Setting ${settingName.value} is not a subscription setting!`) } + if (dto.checkUserPermissions && !(await this.userHasPermissionToUpdateSetting(settingName))) { + return Result.fail(`User does not have permission to update setting ${settingName.value}.`) + } + const settingExists = await this.getSubscriptionSetting.execute({ userSubscriptionUuid: userSubscriptionUuid.value, settingName: settingName.value, @@ -81,6 +87,12 @@ export class SetSubscriptionSettingValue implements UseCaseInterface { await this.subscriptionSettingRepository.update(setting) - return Result.ok() + return Result.ok(setting) + } + + private async userHasPermissionToUpdateSetting(settingName: SettingName): Promise { + const settingIsMutableByClient = this.settingsAssociationService.isSettingMutableByClient(settingName) + + return settingIsMutableByClient } } diff --git a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValueDTO.ts b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValueDTO.ts index f2995dfdc..fe9a57225 100644 --- a/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValueDTO.ts +++ b/packages/auth/src/Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValueDTO.ts @@ -3,4 +3,5 @@ export interface SetSubscriptionSettingValueDTO { userSubscriptionUuid: string value: string | null newUserSubscriptionUuid?: string + checkUserPermissions?: boolean } diff --git a/packages/auth/src/Domain/UseCase/SignIn.spec.ts b/packages/auth/src/Domain/UseCase/SignIn.spec.ts index cd4f7dc0e..b7519dac4 100644 --- a/packages/auth/src/Domain/UseCase/SignIn.spec.ts +++ b/packages/auth/src/Domain/UseCase/SignIn.spec.ts @@ -14,6 +14,9 @@ import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface' import { CrypterInterface } from '../Encryption/CrypterInterface' import { ProtocolVersion } from '@standardnotes/common' import { Session } from '../Session/Session' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { VerifyHumanInteraction } from './VerifyHumanInteraction/VerifyHumanInteraction' +import { Result } from '@standardnotes/domain-core' describe('SignIn', () => { let user: User @@ -26,6 +29,10 @@ describe('SignIn', () => { let logger: Logger let pkceRepository: PKCERepositoryInterface let crypter: CrypterInterface + let session: Session + let maxNonCaptchaAttempts: number + let lockRepository: LockRepositoryInterface + let verifyHumanInteractionUseCase: VerifyHumanInteraction const createUseCase = () => new SignIn( @@ -37,6 +44,9 @@ describe('SignIn', () => { pkceRepository, crypter, logger, + maxNonCaptchaAttempts, + lockRepository, + verifyHumanInteractionUseCase, ) beforeEach(() => { @@ -50,10 +60,9 @@ describe('SignIn', () => { userRepository = {} as jest.Mocked userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user) + session = {} as jest.Mocked authResponseFactory = {} as jest.Mocked - authResponseFactory.createResponse = jest - .fn() - .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked }) + authResponseFactory.createResponse = jest.fn().mockReturnValue({ response: { foo: 'bar' }, session }) authResponseFactoryResolver = {} as jest.Mocked authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) @@ -78,9 +87,16 @@ describe('SignIn', () => { logger = {} as jest.Mocked logger.debug = jest.fn() logger.error = jest.fn() + + lockRepository = {} as jest.Mocked + lockRepository.getLockCounter = jest.fn().mockReturnValue(0) + + maxNonCaptchaAttempts = 6 }) - it('should sign in a legacy user without code verifier', async () => { + it('should fail sign in a legacy user without code verifier', async () => { + pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false) + user.version = ProtocolVersion.V003 userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user) @@ -91,17 +107,18 @@ describe('SignIn', () => { userAgent: 'Google Chrome', apiVersion: '20190520', ephemeralSession: false, + codeVerifier: '', }), ).toEqual({ - success: true, - authResponse: { foo: 'bar' }, + success: false, + errorCode: 410, + errorMessage: 'Please update your client application.', }) - - expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled() - expect(domainEventPublisher.publish).toHaveBeenCalled() }) it('should not sign in 004 user without code verifier', async () => { + pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false) + expect( await createUseCase().execute({ email: 'test@test.te', @@ -109,6 +126,7 @@ describe('SignIn', () => { userAgent: 'Google Chrome', apiVersion: '20190520', ephemeralSession: false, + codeVerifier: '', }), ).toEqual({ success: false, @@ -118,6 +136,8 @@ describe('SignIn', () => { }) it('should not sign in 005 user without code verifier', async () => { + pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false) + user = { uuid: '1-2-3', email: 'test@test.com', @@ -131,6 +151,7 @@ describe('SignIn', () => { userAgent: 'Google Chrome', apiVersion: '20190520', ephemeralSession: false, + codeVerifier: '', }), ).toEqual({ success: false, @@ -158,6 +179,25 @@ describe('SignIn', () => { expect(domainEventPublisher.publish).not.toHaveBeenCalled() }) + it('should not sign in a user with invalid api version', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: 'invalid', + ephemeralSession: false, + codeVerifier: 'test', + }), + ).toEqual({ + success: false, + errorMessage: 'Invalid api version: invalid', + }) + + expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + it('should sign in a user with valid code verifier', async () => { expect( await createUseCase().execute({ @@ -170,7 +210,10 @@ describe('SignIn', () => { }), ).toEqual({ success: true, - authResponse: { foo: 'bar' }, + result: { + response: { foo: 'bar' }, + session, + }, }) expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled() @@ -193,7 +236,10 @@ describe('SignIn', () => { }), ).toEqual({ success: true, - authResponse: { foo: 'bar' }, + result: { + response: { foo: 'bar' }, + session, + }, }) }) @@ -248,4 +294,75 @@ describe('SignIn', () => { errorMessage: 'Invalid email or password', }) }) + + it('should sign in a user with valid code verifier and invalid hvm token but not requiring human verification', async () => { + verifyHumanInteractionUseCase = {} as jest.Mocked + verifyHumanInteractionUseCase.execute = jest + .fn() + .mockReturnValueOnce(Result.fail('Human verification step failed.')) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + codeVerifier: 'test', + }), + ).toEqual({ + success: true, + result: { + response: { foo: 'bar' }, + session, + }, + }) + }) + + it('should sign in a user with valid code verifier and valid hvm token requiring human verification', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts) + verifyHumanInteractionUseCase = {} as jest.Mocked + verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValueOnce(Result.ok()) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + codeVerifier: 'test', + hvmToken: 'foobar', + }), + ).toEqual({ + success: true, + result: { + response: { foo: 'bar' }, + session, + }, + }) + }) + + it('should not sign in a user with valid code verifier and invalid hvm token requiring human verification', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts) + verifyHumanInteractionUseCase = {} as jest.Mocked + verifyHumanInteractionUseCase.execute = jest + .fn() + .mockReturnValueOnce(Result.fail('Human verification step failed.')) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + codeVerifier: 'test', + hvmToken: 'foobar', + }), + ).toEqual({ + success: false, + errorMessage: 'Human verification step failed.', + }) + }) }) diff --git a/packages/auth/src/Domain/UseCase/SignIn.ts b/packages/auth/src/Domain/UseCase/SignIn.ts index 32d7304c6..80c3f19e2 100644 --- a/packages/auth/src/Domain/UseCase/SignIn.ts +++ b/packages/auth/src/Domain/UseCase/SignIn.ts @@ -1,9 +1,7 @@ import * as bcrypt from 'bcryptjs' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' -import { inject, injectable } from 'inversify' import { Logger } from 'winston' -import TYPES from '../../Bootstrap/Types' import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' import { SessionServiceInterface } from '../Session/SessionServiceInterface' @@ -14,40 +12,56 @@ import { SignInResponse } from './SignInResponse' import { UseCaseInterface } from './UseCaseInterface' import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface' import { CrypterInterface } from '../Encryption/CrypterInterface' -import { SignInDTOV2Challenged } from './SignInDTOV2Challenged' -import { leftVersionGreaterThanOrEqualToRight, ProtocolVersion } from '@standardnotes/common' -import { HttpStatusCode } from '@standardnotes/responses' -import { EmailLevel, Username } from '@standardnotes/domain-core' +import { EmailLevel, Result, Username } from '@standardnotes/domain-core' import { getBody, getSubject } from '../Email/UserSignedIn' +import { ApiVersion } from '../Api/ApiVersion' +import { HttpStatusCode } from '@standardnotes/responses' +import { VerifyHumanInteraction } from './VerifyHumanInteraction/VerifyHumanInteraction' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' -@injectable() export class SignIn implements UseCaseInterface { constructor( - @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface, - @inject(TYPES.Auth_AuthResponseFactoryResolver) + private userRepository: UserRepositoryInterface, private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, - @inject(TYPES.Auth_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, - @inject(TYPES.Auth_DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, - @inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface, - @inject(TYPES.Auth_PKCERepository) private pkceRepository: PKCERepositoryInterface, - @inject(TYPES.Auth_Crypter) private crypter: CrypterInterface, - @inject(TYPES.Auth_Logger) private logger: Logger, + private domainEventPublisher: DomainEventPublisherInterface, + private domainEventFactory: DomainEventFactoryInterface, + private sessionService: SessionServiceInterface, + private pkceRepository: PKCERepositoryInterface, + private crypter: CrypterInterface, + private logger: Logger, + private maxNonCaptchaAttempts: number, + private lockRepository: LockRepositoryInterface, + private verifyHumanInteractionUseCase: VerifyHumanInteraction, ) {} async execute(dto: SignInDTO): Promise { - const performingCodeChallengedSignIn = this.isCodeChallengedVersion(dto) - if (performingCodeChallengedSignIn) { - const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier) - if (!validCodeVerifier) { - this.logger.debug('Code verifier does not match') - - return { - success: false, - errorMessage: 'Invalid email or password', - } + if (!dto.codeVerifier) { + return { + success: false, + errorMessage: 'Please update your client application.', + errorCode: HttpStatusCode.Gone, } } + const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier) + if (!validCodeVerifier) { + this.logger.debug('Code verifier does not match') + + return { + success: false, + errorMessage: 'Invalid email or password', + } + } + + const apiVersionOrError = ApiVersion.create(dto.apiVersion) + if (apiVersionOrError.isFailed()) { + return { + success: false, + errorMessage: apiVersionOrError.getError(), + } + } + const apiVersion = apiVersionOrError.getValue() + const usernameOrError = Username.create(dto.email) if (usernameOrError.isFailed()) { return { @@ -58,6 +72,18 @@ export class SignIn implements UseCaseInterface { const username = usernameOrError.getValue() const user = await this.userRepository.findOneByUsernameOrEmail(username) + const userIdentifier = user?.uuid ?? dto.email + + const humanVerificationBeforeCheckingUsernameAndPasswordResult = await this.checkHumanVerificationIfNeeded( + userIdentifier, + dto.hvmToken, + ) + if (humanVerificationBeforeCheckingUsernameAndPasswordResult.isFailed()) { + return { + success: false, + errorMessage: humanVerificationBeforeCheckingUsernameAndPasswordResult.getError(), + } + } if (!user) { this.logger.debug(`User with email ${dto.email} was not found`) @@ -68,19 +94,6 @@ export class SignIn implements UseCaseInterface { } } - const userVersionIs004OrGreater = leftVersionGreaterThanOrEqualToRight( - user.version as ProtocolVersion, - ProtocolVersion.V004, - ) - - if (userVersionIs004OrGreater && !performingCodeChallengedSignIn) { - return { - success: false, - errorMessage: 'Please update your client application.', - errorCode: HttpStatusCode.Gone, - } - } - const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword) if (!passwordMatches) { this.logger.debug('Password does not match') @@ -91,21 +104,23 @@ export class SignIn implements UseCaseInterface { } } - const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion) + const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion) await this.sendSignInEmailNotification(user, dto.userAgent) const result = await authResponseFactory.createResponse({ user, - apiVersion: dto.apiVersion, + apiVersion, userAgent: dto.userAgent, ephemeralSession: dto.ephemeralSession, readonlyAccess: false, + snjs: dto.snjs, + application: dto.application, }) return { success: true, - authResponse: result.response, + result, } } @@ -139,7 +154,17 @@ export class SignIn implements UseCaseInterface { } } - private isCodeChallengedVersion(dto: SignInDTO): dto is SignInDTOV2Challenged { - return (dto as SignInDTOV2Challenged).codeVerifier !== undefined + private async checkHumanVerificationIfNeeded(userIdentifier: string, hvmToken?: string): Promise> { + const numberOfFailedAttempts = await this.lockRepository.getLockCounter(userIdentifier, 'non-captcha') + const numberOfFailedAttemptsInCaptchaMode = await this.lockRepository.getLockCounter(userIdentifier, 'captcha') + + const isEligibleForNonCaptchaMode = + numberOfFailedAttemptsInCaptchaMode === 0 && numberOfFailedAttempts < this.maxNonCaptchaAttempts + + if (isEligibleForNonCaptchaMode) { + return Result.ok() + } + + return this.verifyHumanInteractionUseCase.execute(hvmToken) } } diff --git a/packages/auth/src/Domain/UseCase/SignInDTO.ts b/packages/auth/src/Domain/UseCase/SignInDTO.ts index b8e58c1d0..c0c7467c3 100644 --- a/packages/auth/src/Domain/UseCase/SignInDTO.ts +++ b/packages/auth/src/Domain/UseCase/SignInDTO.ts @@ -1,4 +1,11 @@ -import { SignInDTOV1Unchallenged } from './SignInDTOV1Unchallenged' -import { SignInDTOV2Challenged } from './SignInDTOV2Challenged' - -export type SignInDTO = SignInDTOV1Unchallenged | SignInDTOV2Challenged +export type SignInDTO = { + apiVersion: string + userAgent: string + email: string + password: string + ephemeralSession: boolean + codeVerifier: string + hvmToken?: string + snjs?: string + application?: string +} diff --git a/packages/auth/src/Domain/UseCase/SignInResponse.ts b/packages/auth/src/Domain/UseCase/SignInResponse.ts index 91e40cce1..43576493f 100644 --- a/packages/auth/src/Domain/UseCase/SignInResponse.ts +++ b/packages/auth/src/Domain/UseCase/SignInResponse.ts @@ -1,11 +1,14 @@ import { HttpStatusCode } from '@standardnotes/responses' -import { AuthResponse20161215 } from '../Auth/AuthResponse20161215' -import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' +import { AuthResponseCreationResult } from '../Auth/AuthResponseCreationResult' -export type SignInResponse = { - success: boolean - authResponse?: AuthResponse20161215 | AuthResponse20200115 - errorMessage?: string - errorCode?: HttpStatusCode -} +export type SignInResponse = + | { + success: false + errorMessage: string + errorCode?: HttpStatusCode + } + | { + success: true + result: AuthResponseCreationResult + } diff --git a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts index 7d55e5d58..b94396766 100644 --- a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts +++ b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts @@ -14,6 +14,9 @@ import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecovery import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts' import { SignInWithRecoveryCodes } from './SignInWithRecoveryCodes' import { GetSetting } from '../GetSetting/GetSetting' +import { ApiVersion } from '../../Api/ApiVersion' +import { LockRepositoryInterface } from '../../User/LockRepositoryInterface' +import { VerifyHumanInteraction } from '../VerifyHumanInteraction/VerifyHumanInteraction' describe('SignInWithRecoveryCodes', () => { let userRepository: UserRepositoryInterface @@ -26,6 +29,9 @@ describe('SignInWithRecoveryCodes', () => { let deleteSetting: DeleteSetting let authenticatorRepository: AuthenticatorRepositoryInterface let getSetting: GetSetting + let maxNonCaptchaAttempts: number + let lockRepository: LockRepositoryInterface + let verifyHumanInteractionUseCase: VerifyHumanInteraction const createUseCase = () => new SignInWithRecoveryCodes( @@ -39,6 +45,9 @@ describe('SignInWithRecoveryCodes', () => { clearLoginAttempts, deleteSetting, authenticatorRepository, + maxNonCaptchaAttempts, + lockRepository, + verifyHumanInteractionUseCase, ) beforeEach(() => { @@ -77,10 +86,16 @@ describe('SignInWithRecoveryCodes', () => { authenticatorRepository = {} as jest.Mocked authenticatorRepository.removeByUserUuid = jest.fn() + + lockRepository = {} as jest.Mocked + lockRepository.getLockCounter = jest.fn().mockReturnValue(0) + + maxNonCaptchaAttempts = 6 }) it('should return error if password is not provided', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: '', @@ -94,6 +109,7 @@ describe('SignInWithRecoveryCodes', () => { it('should return error if username is not provided', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: '', password: 'qweqwe123123', @@ -107,6 +123,7 @@ describe('SignInWithRecoveryCodes', () => { it('should return error if code verifier is not provided', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'username', password: 'qweqwe123123', @@ -120,6 +137,7 @@ describe('SignInWithRecoveryCodes', () => { it('should return error if recovery codes are not provided', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'username', password: 'qweqwe123123', @@ -135,6 +153,7 @@ describe('SignInWithRecoveryCodes', () => { pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false) const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -150,6 +169,7 @@ describe('SignInWithRecoveryCodes', () => { userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(undefined) const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -163,6 +183,7 @@ describe('SignInWithRecoveryCodes', () => { it('should return error if recovery codes are invalid', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -174,8 +195,35 @@ describe('SignInWithRecoveryCodes', () => { expect(result.getError()).toBe('Invalid recovery codes') }) + it('should return error if api version is invalid', async () => { + const result = await createUseCase().execute({ + apiVersion: 'invalid', + userAgent: 'user-agent', + username: 'test@test.te', + password: 'qweqwe123123', + codeVerifier: 'code-verifier', + recoveryCodes: '1234 5678', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return error if api version does not support recovery sign in', async () => { + const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20161215, + userAgent: 'user-agent', + username: 'test@test.te', + password: 'qweqwe123123', + codeVerifier: 'code-verifier', + recoveryCodes: '1234 5678', + }) + + expect(result.isFailed()).toBe(true) + }) + it('should return error if password does not match', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'asdasd123123', @@ -191,6 +239,7 @@ describe('SignInWithRecoveryCodes', () => { getSetting.execute = jest.fn().mockReturnValue(Result.fail('not found')) const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -206,6 +255,7 @@ describe('SignInWithRecoveryCodes', () => { generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.fail('Oops')) const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -224,6 +274,7 @@ describe('SignInWithRecoveryCodes', () => { } as jest.Mocked) const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', @@ -235,8 +286,49 @@ describe('SignInWithRecoveryCodes', () => { expect(result.getError()).toBe('Invalid user uuid') }) + it('should return error if user requires human verification but no hvmtoken provided', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts) + verifyHumanInteractionUseCase = {} as jest.Mocked + verifyHumanInteractionUseCase.execute = jest + .fn() + .mockReturnValueOnce(Result.fail('Human verification step failed.')) + + const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, + userAgent: 'user-agent', + username: 'test@test.te', + password: 'qweqwe123123', + codeVerifier: 'code-verifier', + recoveryCodes: 'foo', + }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toBe('Human verification step failed.') + }) + + it('should return auth response with human verification required and passing', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValueOnce(maxNonCaptchaAttempts) + verifyHumanInteractionUseCase = {} as jest.Mocked + verifyHumanInteractionUseCase.execute = jest.fn().mockReturnValueOnce(Result.ok()) + + const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, + userAgent: 'user-agent', + username: 'test@test.te', + password: 'qweqwe123123', + codeVerifier: 'code-verifier', + recoveryCodes: 'foo', + }) + + expect(clearLoginAttempts.execute).toHaveBeenCalled() + expect(deleteSetting.execute).toHaveBeenCalled() + expect(authenticatorRepository.removeByUserUuid).toHaveBeenCalled() + expect(result.isFailed()).toBe(false) + }) + it('should return auth response', async () => { const result = await createUseCase().execute({ + apiVersion: ApiVersion.VERSIONS.v20200115, userAgent: 'user-agent', username: 'test@test.te', password: 'qweqwe123123', diff --git a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts index 7e318c588..bf7e829fe 100644 --- a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts +++ b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts @@ -15,6 +15,8 @@ import { DeleteSetting } from '../DeleteSetting/DeleteSetting' import { AuthenticatorRepositoryInterface } from '../../Authenticator/AuthenticatorRepositoryInterface' import { ApiVersion } from '../../Api/ApiVersion' import { GetSetting } from '../GetSetting/GetSetting' +import { LockRepositoryInterface } from '../../User/LockRepositoryInterface' +import { VerifyHumanInteraction } from '../VerifyHumanInteraction/VerifyHumanInteraction' export class SignInWithRecoveryCodes implements UseCaseInterface { constructor( @@ -28,15 +30,39 @@ export class SignInWithRecoveryCodes implements UseCaseInterface> { + const apiVersionOrError = ApiVersion.create(dto.apiVersion) + if (apiVersionOrError.isFailed()) { + return Result.fail(apiVersionOrError.getError()) + } + const apiVersion = apiVersionOrError.getValue() + + if (!apiVersion.isSupportedForRecoverySignIn()) { + return Result.fail('Unsupported api version') + } + const usernameOrError = Username.create(dto.username) if (usernameOrError.isFailed()) { return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`) } const username = usernameOrError.getValue() + const user = await this.userRepository.findOneByUsernameOrEmail(username) + const userIdentifier = user?.uuid + + const humanVerificationBeforeCheckingUsernameAndPasswordResult = await this.checkHumanVerificationIfNeeded( + userIdentifier, + dto.hvmToken, + ) + if (humanVerificationBeforeCheckingUsernameAndPasswordResult.isFailed()) { + return Result.fail(humanVerificationBeforeCheckingUsernameAndPasswordResult.getError()) + } + const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier) if (!validCodeVerifier) { await this.increaseLoginAttempts.execute({ email: username.value }) @@ -58,8 +84,6 @@ export class SignInWithRecoveryCodes implements UseCaseInterface> { + if (!userIdentifier) { + return Result.ok() + } + + const numberOfFailedAttempts = await this.lockRepository.getLockCounter(userIdentifier, 'non-captcha') + const numberOfFailedAttemptsInCaptchaMode = await this.lockRepository.getLockCounter(userIdentifier, 'captcha') + + const isEligibleForNonCaptchaMode = + numberOfFailedAttemptsInCaptchaMode === 0 && numberOfFailedAttempts < this.maxNonCaptchaAttempts + + if (isEligibleForNonCaptchaMode) { + return Result.ok() + } + + return this.verifyHumanInteractionUseCase.execute(hvmToken) + } } diff --git a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts index f921ba528..7ae63b14e 100644 --- a/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts +++ b/packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts @@ -1,7 +1,11 @@ export interface SignInWithRecoveryCodesDTO { + apiVersion: string userAgent: string username: string password: string codeVerifier: string recoveryCodes: string + hvmToken?: string + snjs?: string + application?: string } diff --git a/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.spec.ts b/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.spec.ts index ded2aa598..342aaf3d2 100644 --- a/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.spec.ts +++ b/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.spec.ts @@ -1,46 +1,23 @@ import { DomainEventPublisherInterface, EmailBackupRequestedEvent } from '@standardnotes/domain-events' -import { Result, SettingName, Timestamps, Uuid } from '@standardnotes/domain-core' import { RoleServiceInterface } from '../../Role/RoleServiceInterface' -import { GetSetting } from '../GetSetting/GetSetting' import { GetUserKeyParams } from '../GetUserKeyParams/GetUserKeyParams' import { TriggerEmailBackupForUser } from './TriggerEmailBackupForUser' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' -import { Setting } from '../../Setting/Setting' -import { EncryptionVersion } from '../../Encryption/EncryptionVersion' describe('TriggerEmailBackupForUser', () => { let roleService: RoleServiceInterface - let getSetting: GetSetting let getUserKeyParamsUseCase: GetUserKeyParams let domainEventPublisher: DomainEventPublisherInterface let domainEventFactory: DomainEventFactoryInterface const createUseCase = () => - new TriggerEmailBackupForUser( - roleService, - getSetting, - getUserKeyParamsUseCase, - domainEventPublisher, - domainEventFactory, - ) + new TriggerEmailBackupForUser(roleService, getUserKeyParamsUseCase, domainEventPublisher, domainEventFactory) beforeEach(() => { roleService = {} as jest.Mocked roleService.userHasPermission = jest.fn().mockResolvedValue(true) - const setting = Setting.create({ - name: SettingName.NAMES.ListedAuthorSecrets, - value: null, - serverEncryptionVersion: EncryptionVersion.Default, - userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), - sensitive: false, - timestamps: Timestamps.create(123, 123).getValue(), - }).getValue() - - getSetting = {} as jest.Mocked - getSetting.execute = jest.fn().mockResolvedValue(Result.ok({ setting, decryptedValue: 'not_muted' })) - getUserKeyParamsUseCase = {} as jest.Mocked getUserKeyParamsUseCase.execute = jest.fn().mockResolvedValue({ keyParams: {} }) diff --git a/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.ts b/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.ts index f753cc530..ce9bec073 100644 --- a/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.ts +++ b/packages/auth/src/Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser.ts @@ -1,10 +1,8 @@ -import { Result, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' import { PermissionName } from '@standardnotes/features' import { TriggerEmailBackupForUserDTO } from './TriggerEmailBackupForUserDTO' import { RoleServiceInterface } from '../../Role/RoleServiceInterface' -import { GetSetting } from '../GetSetting/GetSetting' -import { MuteFailedBackupsEmailsOption } from '@standardnotes/settings' import { GetUserKeyParams } from '../GetUserKeyParams/GetUserKeyParams' import { DomainEventPublisherInterface } from '@standardnotes/domain-events' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' @@ -12,7 +10,6 @@ import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInter export class TriggerEmailBackupForUser implements UseCaseInterface { constructor( private roleService: RoleServiceInterface, - private getSetting: GetSetting, private getUserKeyParamsUseCase: GetUserKeyParams, private domainEventPublisher: DomainEventPublisherInterface, private domainEventFactory: DomainEventFactoryInterface, @@ -34,31 +31,13 @@ export class TriggerEmailBackupForUser implements UseCaseInterface { return Result.fail(`User ${userUuid.value} is not permitted for email backups`) } - let userHasEmailsMuted = false - const emailsMutedSettingOrError = await this.getSetting.execute({ - allowSensitiveRetrieval: true, - decrypted: true, - settingName: SettingName.NAMES.MuteFailedBackupsEmails, - userUuid: userUuid.value, - }) - let emailsMutedSetting = null - if (!emailsMutedSettingOrError.isFailed()) { - emailsMutedSetting = emailsMutedSettingOrError.getValue() - userHasEmailsMuted = emailsMutedSetting.decryptedValue === MuteFailedBackupsEmailsOption.Muted - } - const keyParamsResponse = await this.getUserKeyParamsUseCase.execute({ userUuid: userUuid.value, authenticated: false, }) await this.domainEventPublisher.publish( - this.domainEventFactory.createEmailBackupRequestedEvent( - userUuid.value, - emailsMutedSetting?.setting.id.toString() as string, - userHasEmailsMuted, - keyParamsResponse.keyParams, - ), + this.domainEventFactory.createEmailBackupRequestedEvent(userUuid.value, keyParamsResponse.keyParams), ) return Result.ok() diff --git a/packages/auth/src/Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions.ts b/packages/auth/src/Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions.ts index e8096e562..338f3ecf1 100644 --- a/packages/auth/src/Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions.ts +++ b/packages/auth/src/Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions.ts @@ -9,8 +9,6 @@ import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecovery export class TriggerPostSettingUpdateActions implements UseCaseInterface { private readonly emailSettingToSubscriptionRejectionLevelMap: Map = new Map([ - [SettingName.NAMES.MuteFailedBackupsEmails, EmailLevel.LEVELS.FailedEmailBackup], - [SettingName.NAMES.MuteFailedCloudBackupsEmails, EmailLevel.LEVELS.FailedCloudBackup], [SettingName.NAMES.MuteMarketingEmails, EmailLevel.LEVELS.Marketing], [SettingName.NAMES.MuteSignInEmails, EmailLevel.LEVELS.SignIn], ]) @@ -47,12 +45,7 @@ export class TriggerPostSettingUpdateActions implements UseCaseInterface { } private isChangingMuteEmailsSetting(settingName: string): boolean { - return [ - SettingName.NAMES.MuteFailedBackupsEmails, - SettingName.NAMES.MuteFailedCloudBackupsEmails, - SettingName.NAMES.MuteMarketingEmails, - SettingName.NAMES.MuteSignInEmails, - ].includes(settingName) + return [SettingName.NAMES.MuteMarketingEmails, SettingName.NAMES.MuteSignInEmails].includes(settingName) } private isEnablingEmailBackupSetting(settingName: string, newValue: string | null): boolean { diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts b/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts deleted file mode 100644 index 7ac155ff5..000000000 --- a/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import 'reflect-metadata' - -import { TimerInterface } from '@standardnotes/time' - -import { User } from '../User/User' -import { UserRepositoryInterface } from '../User/UserRepositoryInterface' -import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterface' -import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' - -import { UpdateUser } from './UpdateUser' -import { Session } from '../Session/Session' - -describe('UpdateUser', () => { - let userRepository: UserRepositoryInterface - let authResponseFactoryResolver: AuthResponseFactoryResolverInterface - let authResponseFactory: AuthResponseFactoryInterface - let user: User - let timer: TimerInterface - - const createUseCase = () => new UpdateUser(userRepository, authResponseFactoryResolver, timer) - - beforeEach(() => { - userRepository = {} as jest.Mocked - userRepository.save = jest.fn() - userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(undefined) - - authResponseFactory = {} as jest.Mocked - authResponseFactory.createResponse = jest - .fn() - .mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked }) - - authResponseFactoryResolver = {} as jest.Mocked - authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) - - user = {} as jest.Mocked - user.uuid = '123' - user.email = 'test@test.te' - user.createdAt = new Date(1) - - timer = {} as jest.Mocked - timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) - }) - - it('should update user fields and save it', async () => { - expect( - await createUseCase().execute({ - user, - updatedWithUserAgent: 'Mozilla', - apiVersion: '20190520', - }), - ).toEqual({ success: true, authResponse: { foo: 'bar' } }) - - expect(userRepository.save).toHaveBeenCalledWith({ - createdAt: new Date(1), - email: 'test@test.te', - uuid: '123', - updatedAt: new Date(1), - }) - }) -}) diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.ts b/packages/auth/src/Domain/UseCase/UpdateUser.ts deleted file mode 100644 index ed831ed5d..000000000 --- a/packages/auth/src/Domain/UseCase/UpdateUser.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TimerInterface } from '@standardnotes/time' -import { inject, injectable } from 'inversify' -import TYPES from '../../Bootstrap/Types' -import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' -import { UserRepositoryInterface } from '../User/UserRepositoryInterface' -import { UpdateUserDTO } from './UpdateUserDTO' -import { UpdateUserResponse } from './UpdateUserResponse' -import { UseCaseInterface } from './UseCaseInterface' - -@injectable() -export class UpdateUser implements UseCaseInterface { - constructor( - @inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface, - @inject(TYPES.Auth_AuthResponseFactoryResolver) - private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, - @inject(TYPES.Auth_Timer) private timer: TimerInterface, - ) {} - - async execute(dto: UpdateUserDTO): Promise { - dto.user.updatedAt = this.timer.getUTCDate() - - const updatedUser = await this.userRepository.save(dto.user) - - const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion) - - const result = await authResponseFactory.createResponse({ - user: updatedUser, - apiVersion: dto.apiVersion, - userAgent: dto.updatedWithUserAgent, - ephemeralSession: false, - readonlyAccess: false, - }) - - return { - success: true, - authResponse: result.response, - } - } -} diff --git a/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts b/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts deleted file mode 100644 index efc2dd13d..000000000 --- a/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { User } from '../User/User' - -export type UpdateUserDTO = { - user: User - apiVersion: string - updatedWithUserAgent: string -} diff --git a/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts b/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts deleted file mode 100644 index 81e8ab629..000000000 --- a/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AuthResponse20161215 } from '../Auth/AuthResponse20161215' -import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' - -export type UpdateUserResponse = { - success: boolean - authResponse?: AuthResponse20161215 | AuthResponse20200115 -} diff --git a/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.spec.ts b/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.spec.ts new file mode 100644 index 000000000..070892bcc --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.spec.ts @@ -0,0 +1,43 @@ +import { VerifyHumanInteraction } from './VerifyHumanInteraction' +import { CaptchaServerInterface } from '../../HumanVerification/CaptchaServerInterface' + +describe('HumanVerification', () => { + let captchaServer: CaptchaServerInterface + const createHumanVerification = () => new VerifyHumanInteraction(true, captchaServer) + + beforeEach(() => { + captchaServer = {} as jest.Mocked + captchaServer.verify = jest.fn().mockReturnValue(true) + }) + + describe('Verified', () => { + it('should pass human verification', async () => { + captchaServer.verify = jest.fn().mockReturnValue(true) + const result = await createHumanVerification().execute('foobar') + expect(result.isFailed()).toBeFalsy() + }) + + it('should not pass human verification if token is missing', async () => { + const result = await createHumanVerification().execute() + expect(result.isFailed()).toBeTruthy() + }) + + it('should pass human verification when verification disabled', async () => { + const result = await new VerifyHumanInteraction(false, captchaServer).execute('foobar') + expect(result.isFailed()).toBeFalsy() + }) + + it('should pass human verification when no captcha url defined', async () => { + const result = await new VerifyHumanInteraction(true, captchaServer).execute('foobar') + expect(result.isFailed()).toBeFalsy() + }) + }) + + describe('Unverified', () => { + it('should pass not human verification', async () => { + captchaServer.verify = jest.fn().mockReturnValue(false) + const result = await createHumanVerification().execute('foobar') + expect(result.isFailed()).toBeTruthy() + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.ts b/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.ts new file mode 100644 index 000000000..cee5293bf --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction.ts @@ -0,0 +1,23 @@ +import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { CaptchaServerInterface } from '../../HumanVerification/CaptchaServerInterface' + +export class VerifyHumanInteraction implements UseCaseInterface { + constructor( + private isEnabled: boolean, + private captchaServer: CaptchaServerInterface, + ) {} + + async execute(hvmToken?: string): Promise> { + if (!this.isEnabled) { + return Result.ok() + } + + if (!hvmToken) { + return Result.fail('No HVM token available.') + } + + const isSuccess = await this.captchaServer.verify(hvmToken) + + return isSuccess ? Result.ok() : Result.fail('Human verification step failed.') + } +} diff --git a/packages/auth/src/Domain/User/LockRepositoryInterface.ts b/packages/auth/src/Domain/User/LockRepositoryInterface.ts index 820b5bf93..bfe53b352 100644 --- a/packages/auth/src/Domain/User/LockRepositoryInterface.ts +++ b/packages/auth/src/Domain/User/LockRepositoryInterface.ts @@ -1,8 +1,7 @@ export interface LockRepositoryInterface { resetLockCounter(userIdentifier: string): Promise - updateLockCounter(userIdentifier: string, counter: number): Promise - getLockCounter(userIdentifier: string): Promise - lockUser(userIdentifier: string): Promise + updateLockCounter(userIdentifier: string, counter: number, mode: 'captcha' | 'non-captcha'): Promise + getLockCounter(userIdentifier: string, mode: 'captcha' | 'non-captcha'): Promise isUserLocked(userIdentifier: string): Promise lockSuccessfullOTP(userIdentifier: string, otp: string): Promise isOTPLocked(userIdentifier: string, otp: string): Promise diff --git a/packages/auth/src/Infra/Http/HumanVerification/HttpCaptchaServer.ts b/packages/auth/src/Infra/Http/HumanVerification/HttpCaptchaServer.ts new file mode 100644 index 000000000..4d31a0f13 --- /dev/null +++ b/packages/auth/src/Infra/Http/HumanVerification/HttpCaptchaServer.ts @@ -0,0 +1,31 @@ +import { AxiosInstance } from 'axios' +import { Logger } from 'winston' + +import { CaptchaServerInterface } from '../../../Domain/HumanVerification/CaptchaServerInterface' + +export class HttpCaptchaServer implements CaptchaServerInterface { + constructor( + private logger: Logger, + private httpClient: AxiosInstance, + private captchaServerUrl?: string, + ) {} + + async verify(hvmToken: string): Promise { + if (!this.captchaServerUrl) { + return true + } + + try { + const response = await this.httpClient.request({ + method: 'GET', + url: `${this.captchaServerUrl}/verify?token=${hvmToken}`, + }) + const data = response.data + + return data.status === 'pass' + } catch (error) { + this.logger.error('Could not get result from captcha server', error) + return false + } + } +} diff --git a/packages/auth/src/Infra/InMemory/InMemorySessionTokensCooldownRepository.ts b/packages/auth/src/Infra/InMemory/InMemorySessionTokensCooldownRepository.ts new file mode 100644 index 000000000..9d758f774 --- /dev/null +++ b/packages/auth/src/Infra/InMemory/InMemorySessionTokensCooldownRepository.ts @@ -0,0 +1,41 @@ +import { Uuid } from '@standardnotes/domain-core' +import { SessionTokensCooldownRepositoryInterface } from '../../Domain/Session/SessionTokensCooldownRepositoryInterface' + +export class InMemorySessionTokensCooldownRepository implements SessionTokensCooldownRepositoryInterface { + private inMemoryStore: Map + private readonly COOLDOWN_FORMAT_VERSION = 1 + + constructor() { + this.inMemoryStore = new Map() + } + + async getHashedTokens(sessionUuid: Uuid): Promise<{ hashedAccessToken: string; hashedRefreshToken: string } | null> { + const result = this.inMemoryStore.get(sessionUuid.value) + if (!result) { + return null + } + + const [version, hashedAccessToken, hashedRefreshToken] = result.split(':') + + if (parseInt(version) !== this.COOLDOWN_FORMAT_VERSION) { + return null + } + + return { + hashedAccessToken, + hashedRefreshToken, + } + } + + async setCooldown(dto: { + sessionUuid: Uuid + hashedAccessToken: string + hashedRefreshToken: string + cooldownPeriodInSeconds: number + }): Promise { + this.inMemoryStore.set( + dto.sessionUuid.value, + `${this.COOLDOWN_FORMAT_VERSION}:${dto.hashedAccessToken}:${dto.hashedRefreshToken}`, + ) + } +} diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts index ea8cb156c..02ba113af 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAdminController.ts @@ -13,18 +13,20 @@ import { BaseAdminController } from './Base/BaseAdminController' import { CreateOfflineSubscriptionToken } from '../../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' import { CreateSubscriptionToken } from '../../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' import { DeleteSetting } from '../../Domain/UseCase/DeleteSetting/DeleteSetting' +import { GetSetting } from './../../Domain/UseCase/GetSetting/GetSetting' import { UserRepositoryInterface } from '../../Domain/User/UserRepositoryInterface' @controller('/admin') export class AnnotatedAdminController extends BaseAdminController { constructor( @inject(TYPES.Auth_DeleteSetting) override doDeleteSetting: DeleteSetting, + @inject(TYPES.Auth_GetSetting) override doGetSetting: GetSetting, @inject(TYPES.Auth_UserRepository) override userRepository: UserRepositoryInterface, @inject(TYPES.Auth_CreateSubscriptionToken) override createSubscriptionToken: CreateSubscriptionToken, @inject(TYPES.Auth_CreateOfflineSubscriptionToken) override createOfflineSubscriptionToken: CreateOfflineSubscriptionToken, ) { - super(doDeleteSetting, userRepository, createSubscriptionToken, createOfflineSubscriptionToken) + super(doDeleteSetting, doGetSetting, userRepository, createSubscriptionToken, createOfflineSubscriptionToken) } @httpGet('/user/:email') @@ -32,6 +34,11 @@ export class AnnotatedAdminController extends BaseAdminController { return super.getUser(request) } + @httpGet('/users/:userUuid/listed-code') + override async getListedCode(request: Request): Promise { + return super.getListedCode(request) + } + @httpDelete('/users/:userUuid/mfa') override async deleteMFASetting(request: Request): Promise { return super.deleteMFASetting(request) diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAuthController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAuthController.ts index 6a8a8bff4..07fda89aa 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAuthController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedAuthController.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express' import { controller, - httpGet, httpPost, // eslint-disable-next-line @typescript-eslint/no-unused-vars results, @@ -17,6 +16,14 @@ import { GetUserKeyParams } from '../../Domain/UseCase/GetUserKeyParams/GetUserK import { AuthController } from '../../Controller/AuthController' import { inject } from 'inversify' import { BaseAuthController } from './Base/BaseAuthController' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../Domain/Event/DomainEventFactoryInterface' +import { Register } from '../../Domain/UseCase/Register' +import { SessionServiceInterface } from '../../Domain/Session/SessionServiceInterface' +import { VerifyHumanInteraction } from '../../Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction' +import { CookieFactoryInterface } from '../../Domain/Auth/Cookies/CookieFactoryInterface' +import { SignInWithRecoveryCodes } from '../../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' +import { DeleteSessionByToken } from '../../Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken' @controller('/auth') export class AnnotatedAuthController extends BaseAuthController { @@ -28,18 +35,34 @@ export class AnnotatedAuthController extends BaseAuthController { @inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts, @inject(TYPES.Auth_Logger) override logger: Logger, @inject(TYPES.Auth_AuthController) override authController: AuthController, + @inject(TYPES.Auth_Register) override registerUser: Register, + @inject(TYPES.Auth_DomainEventPublisher) override domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.Auth_DomainEventFactory) override domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.Auth_SessionService) override sessionService: SessionServiceInterface, + @inject(TYPES.Auth_VerifyHumanInteraction) override humanVerificationUseCase: VerifyHumanInteraction, + @inject(TYPES.Auth_CookieFactory) override cookieFactory: CookieFactoryInterface, + @inject(TYPES.Auth_SignInWithRecoveryCodes) override signInWithRecoveryCodes: SignInWithRecoveryCodes, + @inject(TYPES.Auth_DeleteSessionByToken) override deleteSessionByToken: DeleteSessionByToken, + @inject(TYPES.Auth_CAPTCHA_UI_URL) override captchaUIUrl: string, ) { - super(verifyMFA, signInUseCase, getUserKeyParams, clearLoginAttempts, increaseLoginAttempts, logger, authController) - } - - @httpGet('/params', TYPES.Auth_OptionalCrossServiceTokenMiddleware) - override async params(request: Request, response: Response): Promise { - return super.params(request, response) - } - - @httpPost('/sign_in', TYPES.Auth_LockMiddleware) - override async signIn(request: Request): Promise { - return super.signIn(request) + super( + verifyMFA, + signInUseCase, + getUserKeyParams, + clearLoginAttempts, + increaseLoginAttempts, + logger, + authController, + registerUser, + domainEventPublisher, + domainEventFactory, + sessionService, + humanVerificationUseCase, + cookieFactory, + signInWithRecoveryCodes, + deleteSessionByToken, + captchaUIUrl, + ) } @httpPost('/pkce_params', TYPES.Auth_OptionalCrossServiceTokenMiddleware) @@ -48,8 +71,8 @@ export class AnnotatedAuthController extends BaseAuthController { } @httpPost('/pkce_sign_in', TYPES.Auth_LockMiddleware) - override async pkceSignIn(request: Request): Promise { - return super.pkceSignIn(request) + override async pkceSignIn(request: Request, response: Response): Promise { + return super.pkceSignIn(request, response) } @httpPost('/recovery/codes', TYPES.Auth_RequiredCrossServiceTokenMiddleware) @@ -58,8 +81,8 @@ export class AnnotatedAuthController extends BaseAuthController { } @httpPost('/recovery/login', TYPES.Auth_LockMiddleware) - override async recoveryLogin(request: Request): Promise { - return super.recoveryLogin(request) + override async recoveryLogin(request: Request, response: Response): Promise { + return super.recoveryLogin(request, response) } @httpPost('/recovery/params') @@ -73,7 +96,7 @@ export class AnnotatedAuthController extends BaseAuthController { } @httpPost('/') - override async register(request: Request): Promise { - return super.register(request) + override async register(request: Request, response: Response): Promise { + return super.register(request, response) } } diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts index d03b1169d..4453659e9 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSessionController.ts @@ -12,6 +12,7 @@ import { DeleteOtherSessionsForUser } from '../../Domain/UseCase/DeleteOtherSess import { DeleteSessionForUser } from '../../Domain/UseCase/DeleteSessionForUser' import { RefreshSessionToken } from '../../Domain/UseCase/RefreshSessionToken' import { BaseSessionController } from './Base/BaseSessionController' +import { CookieFactoryInterface } from '../../Domain/Auth/Cookies/CookieFactoryInterface' @controller('/session') export class AnnotatedSessionController extends BaseSessionController { @@ -20,8 +21,9 @@ export class AnnotatedSessionController extends BaseSessionController { @inject(TYPES.Auth_DeleteOtherSessionsForUser) override deleteOtherSessionsForUser: DeleteOtherSessionsForUser, @inject(TYPES.Auth_RefreshSessionToken) override refreshSessionToken: RefreshSessionToken, + @inject(TYPES.Auth_CookieFactory) override cookieFactory: CookieFactoryInterface, ) { - super(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken) + super(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken, cookieFactory) } @httpDelete('/', TYPES.Auth_RequiredCrossServiceTokenMiddleware, TYPES.Auth_SessionMiddleware) diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSubscriptionSettingsController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSubscriptionSettingsController.ts index 1c9e2efe3..fca92625a 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSubscriptionSettingsController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedSubscriptionSettingsController.ts @@ -3,6 +3,7 @@ import { inject } from 'inversify' import { controller, httpGet, + httpPut, // eslint-disable-next-line @typescript-eslint/no-unused-vars results, } from 'inversify-express-utils' @@ -13,6 +14,9 @@ import { GetSubscriptionSetting } from '../../Domain/UseCase/GetSubscriptionSett import { MapperInterface } from '@standardnotes/domain-core' import { SubscriptionSetting } from '../../Domain/Setting/SubscriptionSetting' import { SubscriptionSettingHttpRepresentation } from '../../Mapping/Http/SubscriptionSettingHttpRepresentation' +import { SetSubscriptionSettingValue } from '../../Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue' +import { TriggerPostSettingUpdateActions } from '../../Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions' +import { Logger } from 'winston' @controller('/users/:userUuid') export class AnnotatedSubscriptionSettingsController extends BaseSubscriptionSettingsController { @@ -20,14 +24,33 @@ export class AnnotatedSubscriptionSettingsController extends BaseSubscriptionSet @inject(TYPES.Auth_GetSubscriptionSetting) override doGetSetting: GetSubscriptionSetting, @inject(TYPES.Auth_GetSharedOrRegularSubscriptionForUser) override getSharedOrRegularSubscription: GetSharedOrRegularSubscriptionForUser, + @inject(TYPES.Auth_SetSubscriptionSettingValue) override setSubscriptionSettingValue: SetSubscriptionSettingValue, + @inject(TYPES.Auth_TriggerPostSettingUpdateActions) + override triggerPostSettingUpdateActions: TriggerPostSettingUpdateActions, @inject(TYPES.Auth_SubscriptionSettingHttpMapper) override subscriptionSettingMapper: MapperInterface, + @inject(TYPES.Auth_Logger) override logger: Logger, ) { - super(doGetSetting, getSharedOrRegularSubscription, subscriptionSettingMapper) + super( + doGetSetting, + getSharedOrRegularSubscription, + setSubscriptionSettingValue, + triggerPostSettingUpdateActions, + subscriptionSettingMapper, + logger, + ) } @httpGet('/subscription-settings/:subscriptionSettingName', TYPES.Auth_RequiredCrossServiceTokenMiddleware) override async getSubscriptionSetting(request: Request, response: Response): Promise { return super.getSubscriptionSetting(request, response) } + + @httpPut('/subscription-settings', TYPES.Auth_RequiredCrossServiceTokenMiddleware) + override async updateSubscriptionSetting( + request: Request, + response: Response, + ): Promise { + return super.updateSubscriptionSetting(request, response) + } } diff --git a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts index 07f1f0996..2d6136404 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/AnnotatedUsersController.ts @@ -15,6 +15,7 @@ import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts' import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts' import { ChangeCredentials } from '../../Domain/UseCase/ChangeCredentials/ChangeCredentials' import { BaseUsersController } from './Base/BaseUsersController' +import { CookieFactoryInterface } from '../../Domain/Auth/Cookies/CookieFactoryInterface' @controller('/users') export class AnnotatedUsersController extends BaseUsersController { @@ -24,8 +25,16 @@ export class AnnotatedUsersController extends BaseUsersController { @inject(TYPES.Auth_ClearLoginAttempts) override clearLoginAttempts: ClearLoginAttempts, @inject(TYPES.Auth_IncreaseLoginAttempts) override increaseLoginAttempts: IncreaseLoginAttempts, @inject(TYPES.Auth_ChangeCredentials) override changeCredentialsUseCase: ChangeCredentials, + @inject(TYPES.Auth_CookieFactory) override cookieFactory: CookieFactoryInterface, ) { - super(doDeleteAccount, doGetUserSubscription, clearLoginAttempts, increaseLoginAttempts, changeCredentialsUseCase) + super( + doDeleteAccount, + doGetUserSubscription, + clearLoginAttempts, + increaseLoginAttempts, + changeCredentialsUseCase, + cookieFactory, + ) } @httpDelete('/:userUuid', TYPES.Auth_RequiredCrossServiceTokenMiddleware) diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAdminController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAdminController.ts index a17f87826..22f40b8cf 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAdminController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAdminController.ts @@ -4,12 +4,15 @@ import { Request } from 'express' import { CreateOfflineSubscriptionToken } from '../../../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' import { CreateSubscriptionToken } from '../../../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' +import { GetSetting } from './../../../Domain/UseCase/GetSetting/GetSetting' import { DeleteSetting } from '../../../Domain/UseCase/DeleteSetting/DeleteSetting' import { UserRepositoryInterface } from '../../../Domain/User/UserRepositoryInterface' +import { ListedAuthorSecretsData } from '@standardnotes/settings' export class BaseAdminController extends BaseHttpController { constructor( protected doDeleteSetting: DeleteSetting, + protected doGetSetting: GetSetting, protected userRepository: UserRepositoryInterface, protected createSubscriptionToken: CreateSubscriptionToken, protected createOfflineSubscriptionToken: CreateOfflineSubscriptionToken, @@ -77,6 +80,31 @@ export class BaseAdminController extends BaseHttpController { return this.json(result, 400) } + async getListedCode(request: Request): Promise { + const { userUuid } = request.params + + const result = await this.doGetSetting.execute({ + userUuid, + settingName: SettingName.NAMES.ListedAuthorSecrets, + allowSensitiveRetrieval: false, + decrypted: true, + }) + + if (result.isFailed()) { + return this.json('No listed code found', 404) + } + + const decryptedValue = result.getValue().decryptedValue + + if (!decryptedValue) { + return this.json({ error: 'No listed code found' }, 404) + } + + const data: ListedAuthorSecretsData = JSON.parse(decryptedValue as string) + + return this.json(data) + } + async createToken(request: Request): Promise { const { userUuid } = request.params const result = await this.createSubscriptionToken.execute({ diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAuthController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAuthController.ts index 9acb04625..a6500f68a 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAuthController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseAuthController.ts @@ -10,6 +10,18 @@ import { VerifyMFA } from '../../../Domain/UseCase/VerifyMFA' import { AuthController } from '../../../Controller/AuthController' import { ResponseLocals } from '../ResponseLocals' import { BaseHttpController, results } from 'inversify-express-utils' +import { Session } from '../../../Domain/Session/Session' +import { ErrorTag, HttpStatusCode } from '@standardnotes/responses' +import { Register } from '../../../Domain/UseCase/Register' +import { ProtocolVersion } from '@standardnotes/common' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../../Domain/Event/DomainEventFactoryInterface' +import { SessionServiceInterface } from '../../../Domain/Session/SessionServiceInterface' +import { AuthResponse20161215 } from '../../../Domain/Auth/AuthResponse20161215' +import { VerifyHumanInteraction } from '../../../Domain/UseCase/VerifyHumanInteraction/VerifyHumanInteraction' +import { CookieFactoryInterface } from '../../../Domain/Auth/Cookies/CookieFactoryInterface' +import { SignInWithRecoveryCodes } from '../../../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' +import { DeleteSessionByToken } from '../../../Domain/UseCase/DeleteSessionByToken/DeleteSessionByToken' export class BaseAuthController extends BaseHttpController { constructor( @@ -20,13 +32,20 @@ export class BaseAuthController extends BaseHttpController { protected increaseLoginAttempts: IncreaseLoginAttempts, protected logger: Logger, protected authController: AuthController, - private controllerContainer?: ControllerContainerInterface, + protected registerUser: Register, + protected domainEventPublisher: DomainEventPublisherInterface, + protected domainEventFactory: DomainEventFactoryInterface, + protected sessionService: SessionServiceInterface, + protected humanVerificationUseCase: VerifyHumanInteraction, + protected cookieFactory: CookieFactoryInterface, + protected signInWithRecoveryCodes: SignInWithRecoveryCodes, + protected deleteSessionByToken: DeleteSessionByToken, + protected captchaUIUrl: string, + protected controllerContainer?: ControllerContainerInterface, ) { super() if (this.controllerContainer !== undefined) { - this.controllerContainer.register('auth.params', this.params.bind(this)) - this.controllerContainer.register('auth.signIn', this.signIn.bind(this)) this.controllerContainer.register('auth.pkceParams', this.pkceParams.bind(this)) this.controllerContainer.register('auth.pkceSignIn', this.pkceSignIn.bind(this)) this.controllerContainer.register('auth.users.register', this.register.bind(this)) @@ -37,116 +56,6 @@ export class BaseAuthController extends BaseHttpController { } } - async params(request: Request, response: Response): Promise { - const locals = response.locals as ResponseLocals - - if (locals.session) { - const result = await this.getUserKeyParams.execute({ - email: locals.user.email, - authenticated: true, - }) - - return this.json(result.keyParams) - } - - if (!request.query.email) { - return this.json( - { - error: { - message: 'Please provide an email address.', - }, - }, - 400, - ) - } - - const verifyMFAResponse = await this.verifyMFA.execute({ - email: request.query.email, - requestParams: request.query, - preventOTPFromFurtherUsage: false, - }) - - if (!verifyMFAResponse.success) { - return this.json( - { - error: { - tag: verifyMFAResponse.errorTag, - message: verifyMFAResponse.errorMessage, - payload: verifyMFAResponse.errorPayload, - }, - }, - 401, - ) - } - - const result = await this.getUserKeyParams.execute({ - email: request.query.email, - authenticated: false, - }) - - return this.json(result.keyParams) - } - - async signIn(request: Request): Promise { - if (!request.body.email || !request.body.password) { - this.logger.debug('/auth/sign_in request missing credentials: %O', request.body) - - return this.json( - { - error: { - tag: 'invalid-auth', - message: 'Invalid login credentials.', - }, - }, - 401, - ) - } - - const verifyMFAResponse = await this.verifyMFA.execute({ - email: request.body.email, - requestParams: request.body, - preventOTPFromFurtherUsage: true, - }) - - if (!verifyMFAResponse.success) { - return this.json( - { - error: { - tag: verifyMFAResponse.errorTag, - message: verifyMFAResponse.errorMessage, - payload: verifyMFAResponse.errorPayload, - }, - }, - 401, - ) - } - - const signInResult = await this.signInUseCase.execute({ - apiVersion: request.body.api, - userAgent: request.headers['user-agent'], - email: request.body.email, - password: request.body.password, - ephemeralSession: request.body.ephemeral ?? false, - }) - - if (!signInResult.success) { - await this.increaseLoginAttempts.execute({ email: request.body.email }) - - return this.json( - { - error: { - message: signInResult.errorMessage, - }, - }, - signInResult.errorCode ?? 401, - ) - } - - await this.clearLoginAttempts.execute({ email: request.body.email }) - - return this.json(signInResult.authResponse) - } - async pkceParams(request: Request, response: Response): Promise { const locals = response.locals as ResponseLocals @@ -210,9 +119,9 @@ export class BaseAuthController extends BaseHttpController { return this.json(result.keyParams) } - async pkceSignIn(request: Request): Promise { + async pkceSignIn(request: Request, response: Response): Promise { if (!request.body.email || !request.body.password || !request.body.code_verifier) { - this.logger.debug('/auth/sign_in request missing credentials: %O', request.body) + this.logger.debug('/auth/pkce_sign_in request missing credentials: %O', request.body) return this.json( { @@ -232,10 +141,21 @@ export class BaseAuthController extends BaseHttpController { password: request.body.password, ephemeralSession: request.body.ephemeral ?? false, codeVerifier: request.body.code_verifier, + hvmToken: request.body.hvm_token, + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, }) if (!signInResult.success) { - await this.increaseLoginAttempts.execute({ email: request.body.email }) + const resultOrError = await this.increaseLoginAttempts.execute({ email: request.body.email }) + if (resultOrError.isFailed()) { + this.logger.error(`Failed to increase login attempts ${resultOrError.getError()}`) + } else { + const result = resultOrError.getValue() + if (result.isNonCaptchaLimitReached) { + response.setHeader('x-captcha-required', this.captchaUIUrl) + } + } return this.json( { @@ -249,7 +169,28 @@ export class BaseAuthController extends BaseHttpController { await this.clearLoginAttempts.execute({ email: request.body.email }) - return this.json(signInResult.authResponse) + if (signInResult.result.response !== undefined) { + const session = signInResult.result.session as Session + const user = signInResult.result.response.user + + response.setHeader( + 'Set-Cookie', + this.cookieFactory.createCookieHeaderValue({ + sessionUuid: session.uuid, + accessToken: signInResult.result.cookies?.accessToken as string, + refreshToken: signInResult.result.cookies?.refreshToken as string, + refreshTokenExpiration: session.refreshExpiration, + }), + ) + + return this.json({ + session: signInResult.result.response.sessionBody, + key_params: signInResult.result.response.keyParams, + user, + }) + } + + return this.json(signInResult.result.legacyResponse) } async generateRecoveryCodes(_request: Request, response: Response): Promise { @@ -262,17 +203,53 @@ export class BaseAuthController extends BaseHttpController { return this.json(result.data, result.status) } - async recoveryLogin(request: Request): Promise { - const result = await this.authController.signInWithRecoveryCodes({ + async recoveryLogin(request: Request, response: Response): Promise { + const result = await this.signInWithRecoveryCodes.execute({ apiVersion: request.body.api_version, userAgent: request.headers['user-agent'], codeVerifier: request.body.code_verifier, username: request.body.username, recoveryCodes: request.body.recovery_codes, password: request.body.password, + hvmToken: request.body.hvm_token, + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, }) - return this.json(result.data, result.status) + if (result.isFailed()) { + this.logger.debug(`Failed to sign in with recovery codes: ${result.getError()}`) + + const increasLoginAttemtpsResultOrError = await this.increaseLoginAttempts.execute({ + email: request.body.username, + }) + if (increasLoginAttemtpsResultOrError.isFailed()) { + this.logger.error(`Failed to increase login attempts ${increasLoginAttemtpsResultOrError.getError()}`) + } else { + const increasLoginAttemtpsResult = increasLoginAttemtpsResultOrError.getValue() + if (increasLoginAttemtpsResult.isNonCaptchaLimitReached) { + response.setHeader('x-captcha-required', this.captchaUIUrl) + } + } + + return this.json( + { + error: { + message: 'Invalid login credentials.', + }, + }, + HttpStatusCode.Unauthorized, + ) + } + + await this.clearLoginAttempts.execute({ email: request.body.username }) + + const signInWithRecoveryCodesResult = result.getValue() + + return this.json({ + session: signInWithRecoveryCodesResult.sessionBody, + key_params: signInWithRecoveryCodesResult.keyParams, + user: signInWithRecoveryCodesResult.user, + }) } async recoveryParams(request: Request): Promise { @@ -289,24 +266,160 @@ export class BaseAuthController extends BaseHttpController { async signOut(request: Request, response: Response): Promise { const locals = response.locals as ResponseLocals - const result = await this.authController.signOut({ - readOnlyAccess: locals.readOnlyAccess, - authorizationHeader: request.headers.authorization, - }) - - if (result.headers?.has('x-invalidate-cache')) { - response.setHeader('x-invalidate-cache', result.headers.get('x-invalidate-cache') as string) + if (locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + HttpStatusCode.Unauthorized, + ) } - return this.json(result.data, result.status) - } - - async register(request: Request): Promise { - const response = await this.authController.register({ - ...request.body, - userAgent: request.headers['user-agent'], + const authCookies = new Map() + request.headers.cookie?.split(';').forEach((cookie) => { + const parts = cookie.split('=') + if (parts.length === 2 && parts[0].trim().startsWith('access_token_')) { + const existingCookies = authCookies.get(parts[0].trim()) + if (existingCookies) { + existingCookies.push(parts[1].trim()) + authCookies.set(parts[0].trim(), existingCookies) + } else { + authCookies.set(parts[0].trim(), [parts[1].trim()]) + } + } }) - return this.json(response.data, response.status) + const authTokenFromHeaders = (request.headers.authorization as string).replace('Bearer ', '') + + const resultOrError = await this.deleteSessionByToken.execute({ + authTokenFromHeaders, + authCookies, + requestMetadata: { + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, + url: request.headers['x-origin-url'] as string, + method: request.headers['x-origin-method'] as string, + userAgent: request.headers['x-origin-user-agent'] as string, + secChUa: request.headers['x-origin-sec-ch-ua'] as string, + }, + }) + if (resultOrError.isFailed()) { + return this.json( + { + error: { + message: 'Invalid session token.', + }, + }, + HttpStatusCode.Unauthorized, + ) + } + const session = resultOrError.getValue() + + response.setHeader( + 'Set-Cookie', + this.cookieFactory.createCookieHeaderValue({ + sessionUuid: session.uuid, + accessToken: '0', + refreshToken: '0', + refreshTokenExpiration: new Date(1), + }), + ) + + if (session.userUuid !== null) { + response.setHeader('x-invalidate-cache', session.userUuid) + } + + return this.json({}, HttpStatusCode.NoContent) + } + + async register(request: Request, response: Response): Promise { + const hvmToken = request.body.hvm_token + const humanVerificationResult = await this.humanVerificationUseCase.execute(hvmToken) + + if (humanVerificationResult.isFailed()) { + return this.json( + { + error: { + message: humanVerificationResult.getError(), + }, + }, + HttpStatusCode.BadRequest, + ) + } + + if (!request.body.email || !request.body.password) { + return this.json( + { + error: { + message: 'Please enter an email and a password to register.', + }, + }, + HttpStatusCode.BadRequest, + ) + } + + const registerResult = await this.registerUser.execute({ + email: request.body.email, + password: request.body.password, + updatedWithUserAgent: request.headers['user-agent'] as string, + apiVersion: request.body.api, + ephemeralSession: request.body.ephemeral, + pwNonce: request.body.pw_nonce, + kpOrigination: request.body.origination, + kpCreated: request.body.created, + version: request.body.version, + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, + }) + + if (!registerResult.success) { + return this.json( + { + error: { + message: registerResult.errorMessage, + }, + }, + HttpStatusCode.BadRequest, + ) + } + + const registeredUser = registerResult.result.response + ? registerResult.result.response.user + : (registerResult.result.legacyResponse as AuthResponse20161215).user + + await this.clearLoginAttempts.execute({ email: registeredUser.email }) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createUserRegisteredEvent({ + userUuid: registeredUser.uuid, + email: registeredUser.email, + protocolVersion: registeredUser.protocolVersion as ProtocolVersion, + }), + ) + + if (registerResult.result.response === undefined) { + return this.json(registerResult.result.legacyResponse) + } + + const session = registerResult.result.session as Session + + response.setHeader( + 'Set-Cookie', + this.cookieFactory.createCookieHeaderValue({ + sessionUuid: session.uuid, + accessToken: registerResult.result.cookies?.accessToken as string, + refreshToken: registerResult.result.cookies?.refreshToken as string, + refreshTokenExpiration: session.refreshExpiration, + }), + ) + + return this.json({ + session: registerResult.result.response.sessionBody, + key_params: registerResult.result.response.keyParams, + user: registeredUser, + }) } } diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts index 8e6243ad9..6b05f3992 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionController.ts @@ -7,12 +7,16 @@ import { DeleteOtherSessionsForUser } from '../../../Domain/UseCase/DeleteOtherS import { DeleteSessionForUser } from '../../../Domain/UseCase/DeleteSessionForUser' import { RefreshSessionToken } from '../../../Domain/UseCase/RefreshSessionToken' import { ResponseLocals } from '../ResponseLocals' +import { Session } from '../../../Domain/Session/Session' +import { ApiVersion } from '../../../Domain/Api/ApiVersion' +import { CookieFactoryInterface } from '../../../Domain/Auth/Cookies/CookieFactoryInterface' export class BaseSessionController extends BaseHttpController { constructor( protected deleteSessionForUser: DeleteSessionForUser, protected deleteOtherSessionsForUser: DeleteOtherSessionsForUser, protected refreshSessionToken: RefreshSessionToken, + protected cookieFactory: CookieFactoryInterface, private controllerContainer?: ControllerContainerInterface, ) { super() @@ -134,27 +138,65 @@ export class BaseSessionController extends BaseHttpController { ) } - const result = await this.refreshSessionToken.execute({ - accessToken: request.body.access_token, - refreshToken: request.body.refresh_token, - userAgent: request.headers['user-agent'], + const authCookies = new Map() + request.headers.cookie?.split(';').forEach((cookie) => { + const parts = cookie.split('=') + if ( + parts.length === 2 && + (parts[0].trim().startsWith('access_token_') || parts[0].trim().startsWith('refresh_token_')) + ) { + const existingCookies = authCookies.get(parts[0].trim()) + if (existingCookies) { + existingCookies.push(parts[1].trim()) + authCookies.set(parts[0].trim(), existingCookies) + } else { + authCookies.set(parts[0].trim(), [parts[1].trim()]) + } + } }) - if (!result.success) { + const refreshResult = await this.refreshSessionToken.execute({ + apiVersion: request.body.api ?? ApiVersion.VERSIONS.v20200115, + authTokenFromHeaders: request.body.access_token, + refreshTokenFromHeaders: request.body.refresh_token, + requestMetadata: { + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, + url: request.headers['x-origin-url'] as string, + method: request.headers['x-origin-method'] as string, + userAgent: request.headers['x-origin-user-agent'] as string, + secChUa: request.headers['x-origin-sec-ch-ua'] as string, + }, + authCookies, + }) + + if (!refreshResult.success) { return this.json( { error: { - tag: result.errorTag, - message: result.errorMessage, + tag: refreshResult.errorTag, + message: refreshResult.errorMessage, }, }, 400, ) } - response.setHeader('x-invalidate-cache', result.userUuid as string) + const session = refreshResult.result.session as Session + + response.setHeader('x-invalidate-cache', refreshResult.userUuid as string) + response.setHeader( + 'Set-Cookie', + this.cookieFactory.createCookieHeaderValue({ + sessionUuid: session.uuid, + accessToken: refreshResult.result.sessionCookieRepresentation.accessToken, + refreshToken: refreshResult.result.sessionCookieRepresentation.refreshToken, + refreshTokenExpiration: session.refreshExpiration, + }), + ) + return this.json({ - session: result.sessionPayload, + session: refreshResult.result.sessionHttpRepresentation, }) } } diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionsController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionsController.ts index 89d69f6f8..49eb2fc32 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionsController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSessionsController.ts @@ -28,8 +28,31 @@ export class BaseSessionsController extends BaseHttpController { } async validate(request: Request): Promise { + const authCookies = new Map() + request.headers.cookie?.split(';').forEach((cookie) => { + const parts = cookie.split('=') + if (parts.length === 2 && parts[0].trim().startsWith('access_token_')) { + const existingCookies = authCookies.get(parts[0].trim()) + if (existingCookies) { + existingCookies.push(parts[1].trim()) + authCookies.set(parts[0].trim(), existingCookies) + } else { + authCookies.set(parts[0].trim(), [parts[1].trim()]) + } + } + }) + const authenticateRequestResponse = await this.authenticateRequest.execute({ - authorizationHeader: request.headers.authorization, + authTokenFromHeaders: request.body.authTokenFromHeaders, + authCookies, + requestMetadata: { + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, + url: request.headers['x-origin-url'] as string, + method: request.headers['x-origin-method'] as string, + userAgent: request.headers['x-origin-user-agent'] as string, + secChUa: request.headers['x-origin-sec-ch-ua'] as string, + }, }) if (!authenticateRequestResponse.success) { @@ -46,7 +69,7 @@ export class BaseSessionsController extends BaseHttpController { const user = authenticateRequestResponse.user as User - const sharedVaultOwnerContext = request.headers['x-shared-vault-owner-context'] as string | undefined + const sharedVaultOwnerContext = request.body.sharedVaultOwnerContext as string | undefined const resultOrError = await this.createCrossServiceToken.execute({ user, diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSubscriptionSettingsController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSubscriptionSettingsController.ts index 262178f50..e5f42d84a 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSubscriptionSettingsController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseSubscriptionSettingsController.ts @@ -1,24 +1,35 @@ import { ControllerContainerInterface, MapperInterface } from '@standardnotes/domain-core' import { BaseHttpController, results } from 'inversify-express-utils' import { Request, Response } from 'express' +import { Logger } from 'winston' +import { ErrorTag } from '@standardnotes/responses' import { GetSubscriptionSetting } from '../../../Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting' import { GetSharedOrRegularSubscriptionForUser } from '../../../Domain/UseCase/GetSharedOrRegularSubscriptionForUser/GetSharedOrRegularSubscriptionForUser' import { SubscriptionSetting } from '../../../Domain/Setting/SubscriptionSetting' import { SubscriptionSettingHttpRepresentation } from '../../../Mapping/Http/SubscriptionSettingHttpRepresentation' import { ResponseLocals } from '../ResponseLocals' +import { SetSubscriptionSettingValue } from '../../../Domain/UseCase/SetSubscriptionSettingValue/SetSubscriptionSettingValue' +import { TriggerPostSettingUpdateActions } from '../../../Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions' export class BaseSubscriptionSettingsController extends BaseHttpController { constructor( protected doGetSetting: GetSubscriptionSetting, protected getSharedOrRegularSubscription: GetSharedOrRegularSubscriptionForUser, + protected setSubscriptionSettingValue: SetSubscriptionSettingValue, + protected triggerPostSettingUpdateActions: TriggerPostSettingUpdateActions, protected subscriptionSettingMapper: MapperInterface, + protected logger: Logger, private controllerContainer?: ControllerContainerInterface, ) { super() if (this.controllerContainer !== undefined) { this.controllerContainer.register('auth.users.getSubscriptionSetting', this.getSubscriptionSetting.bind(this)) + this.controllerContainer.register( + 'auth.users.updateSubscriptionSetting', + this.updateSubscriptionSetting.bind(this), + ) } } @@ -64,4 +75,76 @@ export class BaseSubscriptionSettingsController extends BaseHttpController { setting: this.subscriptionSettingMapper.toProjection(settingAndValue.setting), }) } + + async updateSubscriptionSetting( + request: Request, + response: Response, + ): Promise { + const locals = response.locals as ResponseLocals + + if (locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + const subscriptionOrError = await this.getSharedOrRegularSubscription.execute({ + userUuid: locals.user.uuid, + }) + if (subscriptionOrError.isFailed()) { + return this.json( + { + error: { + message: subscriptionOrError.getError(), + }, + }, + 400, + ) + } + const subscription = subscriptionOrError.getValue() + + const { name, value } = request.body + + const result = await this.setSubscriptionSettingValue.execute({ + settingName: name, + value, + userSubscriptionUuid: subscription.uuid, + checkUserPermissions: true, + }) + + if (result.isFailed()) { + return this.json( + { + error: { + message: result.getError(), + }, + }, + 400, + ) + } + const subscriptionSetting = result.getValue() + + const triggerResult = await this.triggerPostSettingUpdateActions.execute({ + updatedSettingName: subscriptionSetting.props.name, + userUuid: locals.user.uuid, + userEmail: locals.user.email, + unencryptedValue: value, + }) + if (triggerResult.isFailed()) { + this.logger.error(`Failed to trigger post setting update actions: ${triggerResult.getError()}`) + } + + return this.json({ + success: true, + setting: subscriptionSetting.props.sensitive + ? undefined + : this.subscriptionSettingMapper.toProjection(subscriptionSetting), + }) + } } diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts index 8fa203bc9..f78a7e76b 100644 --- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts +++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts @@ -9,6 +9,7 @@ import { GetUserSubscription } from '../../../Domain/UseCase/GetUserSubscription import { IncreaseLoginAttempts } from '../../../Domain/UseCase/IncreaseLoginAttempts' import { ErrorTag } from '@standardnotes/responses' import { ResponseLocals } from '../ResponseLocals' +import { CookieFactoryInterface } from '../../../Domain/Auth/Cookies/CookieFactoryInterface' export class BaseUsersController extends BaseHttpController { constructor( @@ -17,6 +18,7 @@ export class BaseUsersController extends BaseHttpController { protected clearLoginAttempts: ClearLoginAttempts, protected increaseLoginAttempts: IncreaseLoginAttempts, protected changeCredentialsUseCase: ChangeCredentials, + protected cookieFactory: CookieFactoryInterface, private controllerContainer?: ControllerContainerInterface, ) { super() @@ -157,6 +159,8 @@ export class BaseUsersController extends BaseHttpController { kpOrigination: request.body.origination, updatedWithUserAgent: request.headers['user-agent'], protocolVersion: request.body.version, + snjs: request.headers['x-snjs-version'] as string, + application: request.headers['x-application-version'] as string, }) if (changeCredentialsResult.isFailed()) { @@ -174,8 +178,27 @@ export class BaseUsersController extends BaseHttpController { await this.clearLoginAttempts.execute({ email: locals.user.email }) - response.setHeader('x-invalidate-cache', locals.user.uuid) + const changeCredentialsResultValue = changeCredentialsResult.getValue() + const session = changeCredentialsResultValue.session - return this.json(changeCredentialsResult.getValue()) + response.setHeader('x-invalidate-cache', locals.user.uuid) + if (session) { + response.setHeader( + 'Set-Cookie', + this.cookieFactory.createCookieHeaderValue({ + sessionUuid: session.uuid, + accessToken: changeCredentialsResultValue.cookies?.accessToken as string, + refreshToken: changeCredentialsResultValue.cookies?.refreshToken as string, + refreshTokenExpiration: session.refreshExpiration, + }), + ) + return this.json({ + session: changeCredentialsResultValue.response?.sessionBody, + key_params: changeCredentialsResultValue.response?.keyParams, + user: changeCredentialsResultValue.response?.user, + }) + } + + return this.json(changeCredentialsResultValue.legacyResponse) } } diff --git a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts index e6451b27e..aaf6d3f7b 100644 --- a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts +++ b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts @@ -8,6 +8,7 @@ import { EphemeralSessionRepositoryInterface } from '../../Domain/Session/Epheme @injectable() export class RedisEphemeralSessionRepository implements EphemeralSessionRepositoryInterface { private readonly PREFIX = 'session' + private readonly PREFIX_PRIVATE_ID = 'session-private-id' private readonly USER_SESSIONS_PREFIX = 'user-sessions' constructor( @@ -15,6 +16,15 @@ export class RedisEphemeralSessionRepository implements EphemeralSessionReposito @inject(TYPES.Auth_EPHEMERAL_SESSION_AGE) private ephemeralSessionAge: number, ) {} + async findOneByPrivateIdentifier(privateIdentifier: string): Promise { + const stringifiedSession = await this.redisClient.get(`${this.PREFIX_PRIVATE_ID}:${privateIdentifier}`) + if (!stringifiedSession) { + return null + } + + return JSON.parse(stringifiedSession) + } + async deleteOne(uuid: string, userUuid: string): Promise { const pipeline = this.redisClient.pipeline() @@ -86,6 +96,7 @@ export class RedisEphemeralSessionRepository implements EphemeralSessionReposito pipeline.setex(`${this.PREFIX}:${ephemeralSession.uuid}:${ephemeralSession.userUuid}`, ttl, stringifiedSession) pipeline.setex(`${this.PREFIX}:${ephemeralSession.uuid}`, ttl, stringifiedSession) + pipeline.setex(`${this.PREFIX_PRIVATE_ID}:${ephemeralSession.privateIdentifier}`, ttl, stringifiedSession) pipeline.sadd(`${this.USER_SESSIONS_PREFIX}:${ephemeralSession.userUuid}`, ephemeralSession.uuid) pipeline.expire(`${this.USER_SESSIONS_PREFIX}:${ephemeralSession.userUuid}`, ttl) diff --git a/packages/auth/src/Infra/Redis/RedisLockRepository.ts b/packages/auth/src/Infra/Redis/RedisLockRepository.ts new file mode 100644 index 000000000..d1a1d35f0 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisLockRepository.ts @@ -0,0 +1,60 @@ +import * as IORedis from 'ioredis' + +import { LockRepositoryInterface } from '../../Domain/User/LockRepositoryInterface' + +export class RedisLockRepository implements LockRepositoryInterface { + private readonly PREFIX = 'lock' + private readonly CAPTCHA_PREFIX = 'captcha-lock' + private readonly OTP_PREFIX = 'otp-lock' + + constructor( + private redisClient: IORedis.Redis, + private maxLoginAttempts: number, + private nonCaptchaLockTTL: number, + private captchaLockTTL: number, + ) {} + + async lockSuccessfullOTP(userIdentifier: string, otp: string): Promise { + await this.redisClient.setex(`${this.OTP_PREFIX}:${userIdentifier}`, 60, otp) + } + + async isOTPLocked(userIdentifier: string, otp: string): Promise { + const lock = await this.redisClient.get(`${this.OTP_PREFIX}:${userIdentifier}`) + + return lock === otp + } + + async resetLockCounter(userIdentifier: string): Promise { + const pipeline = this.redisClient.pipeline() + + pipeline.del(`${this.PREFIX}:${userIdentifier}`) + pipeline.del(`${this.CAPTCHA_PREFIX}:${userIdentifier}`) + + await pipeline.exec() + } + + async updateLockCounter(userIdentifier: string, counter: number, mode: 'captcha' | 'non-captcha'): Promise { + const prefix = mode === 'captcha' ? this.CAPTCHA_PREFIX : this.PREFIX + const lockTTL = mode === 'captcha' ? this.captchaLockTTL : this.nonCaptchaLockTTL + + await this.redisClient.setex(`${prefix}:${userIdentifier}`, lockTTL, counter) + } + + async getLockCounter(userIdentifier: string, mode: 'captcha' | 'non-captcha'): Promise { + const prefix = mode === 'captcha' ? this.CAPTCHA_PREFIX : this.PREFIX + + const counter = await this.redisClient.get(`${prefix}:${userIdentifier}`) + + if (!counter) { + return 0 + } + + return +counter + } + + async isUserLocked(userIdentifier: string): Promise { + const counter = await this.getLockCounter(userIdentifier, 'captcha') + + return counter >= this.maxLoginAttempts + } +} diff --git a/packages/auth/src/Infra/Redis/RedisSessionTokensCooldownRepository.ts b/packages/auth/src/Infra/Redis/RedisSessionTokensCooldownRepository.ts new file mode 100644 index 000000000..24d765cd7 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisSessionTokensCooldownRepository.ts @@ -0,0 +1,42 @@ +import * as IORedis from 'ioredis' +import { Uuid } from '@standardnotes/domain-core' + +import { SessionTokensCooldownRepositoryInterface } from '../../Domain/Session/SessionTokensCooldownRepositoryInterface' + +export class RedisSessionTokensCooldownRepository implements SessionTokensCooldownRepositoryInterface { + private readonly PREFIX = 'cooldown:session-tokens' + private readonly COOLDOWN_FORMAT_VERSION = 1 + + constructor(private redisClient: IORedis.Redis) {} + + async getHashedTokens(sessionUuid: Uuid): Promise<{ hashedAccessToken: string; hashedRefreshToken: string } | null> { + const result = await this.redisClient.get(`${this.PREFIX}:${sessionUuid.value}`) + if (!result) { + return null + } + + const [version, hashedAccessToken, hashedRefreshToken] = result.split(':') + + if (parseInt(version) !== this.COOLDOWN_FORMAT_VERSION) { + return null + } + + return { + hashedAccessToken, + hashedRefreshToken, + } + } + + async setCooldown(dto: { + sessionUuid: Uuid + hashedAccessToken: string + hashedRefreshToken: string + cooldownPeriodInSeconds: number + }): Promise { + await this.redisClient.setex( + `${this.PREFIX}:${dto.sessionUuid.value}`, + dto.cooldownPeriodInSeconds, + `${this.COOLDOWN_FORMAT_VERSION}:${dto.hashedAccessToken}:${dto.hashedRefreshToken}`, + ) + } +} diff --git a/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts index fbf99c9bb..76d30392a 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMEphemeralSessionRepository.ts @@ -6,6 +6,7 @@ import { EphemeralSessionRepositoryInterface } from '../../Domain/Session/Epheme export class TypeORMEphemeralSessionRepository implements EphemeralSessionRepositoryInterface { private readonly PREFIX = 'session' + private readonly PREFIX_PRIVATE_ID = 'session-private-id' private readonly USER_SESSIONS_PREFIX = 'user-sessions' constructor( @@ -14,6 +15,17 @@ export class TypeORMEphemeralSessionRepository implements EphemeralSessionReposi private timer: TimerInterface, ) {} + async findOneByPrivateIdentifier(privateIdentifier: string): Promise { + const stringifiedSession = await this.cacheEntryRepository.findUnexpiredOneByKey( + `${this.PREFIX_PRIVATE_ID}:${privateIdentifier}`, + ) + if (!stringifiedSession) { + return null + } + + return JSON.parse(stringifiedSession.props.value) + } + async deleteOne(uuid: string, userUuid: string): Promise { await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}`) await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}:${userUuid}`) @@ -98,6 +110,14 @@ export class TypeORMEphemeralSessionRepository implements EphemeralSessionReposi }).getValue(), ) + await this.cacheEntryRepository.save( + CacheEntry.create({ + key: `${this.PREFIX_PRIVATE_ID}:${ephemeralSession.privateIdentifier}`, + value: stringifiedSession, + expiresAt: this.timer.getUTCDateNSecondsAhead(ttl), + }).getValue(), + ) + const ephemeralSessionUuidsJSON = await this.cacheEntryRepository.findUnexpiredOneByKey( `${this.USER_SESSIONS_PREFIX}:${ephemeralSession.userUuid}`, ) diff --git a/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts index 871c67c77..d5522d6cd 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMLockRepository.ts @@ -5,13 +5,15 @@ import { LockRepositoryInterface } from '../../Domain/User/LockRepositoryInterfa export class TypeORMLockRepository implements LockRepositoryInterface { private readonly PREFIX = 'lock' + private readonly CAPTCHA_PREFIX = 'captcha-lock' private readonly OTP_PREFIX = 'otp-lock' constructor( private cacheEntryRepository: CacheEntryRepositoryInterface, private timer: TimerInterface, private maxLoginAttempts: number, - private failedLoginLockout: number, + private nonCaptchaLockTTL: number, + private captchaLockTTL: number, ) {} async lockSuccessfullOTP(userIdentifier: string, otp: string): Promise { @@ -38,26 +40,32 @@ export class TypeORMLockRepository implements LockRepositoryInterface { async resetLockCounter(userIdentifier: string): Promise { await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${userIdentifier}`) + await this.cacheEntryRepository.removeByKey(`${this.CAPTCHA_PREFIX}:${userIdentifier}`) } - async updateLockCounter(userIdentifier: string, counter: number): Promise { - let cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) + async updateLockCounter(userIdentifier: string, counter: number, mode: 'captcha' | 'non-captcha'): Promise { + const prefix = mode === 'captcha' ? this.CAPTCHA_PREFIX : this.PREFIX + const lockTTL = mode === 'captcha' ? this.captchaLockTTL : this.nonCaptchaLockTTL + + let cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(`${prefix}:${userIdentifier}`) if (!cacheEntry) { cacheEntry = CacheEntry.create({ - key: `${this.PREFIX}:${userIdentifier}`, + key: `${prefix}:${userIdentifier}`, value: counter.toString(), - expiresAt: this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout), + expiresAt: this.timer.getUTCDateNSecondsAhead(lockTTL), }).getValue() } else { cacheEntry.props.value = counter.toString() - cacheEntry.props.expiresAt = this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout) + cacheEntry.props.expiresAt = this.timer.getUTCDateNSecondsAhead(lockTTL) } await this.cacheEntryRepository.save(cacheEntry) } - async getLockCounter(userIdentifier: string): Promise { - const counter = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) + async getLockCounter(userIdentifier: string, mode: 'captcha' | 'non-captcha'): Promise { + const prefix = mode === 'captcha' ? this.CAPTCHA_PREFIX : this.PREFIX + + const counter = await this.cacheEntryRepository.findUnexpiredOneByKey(`${prefix}:${userIdentifier}`) if (!counter) { return 0 @@ -66,17 +74,8 @@ export class TypeORMLockRepository implements LockRepositoryInterface { return +counter.props.value } - async lockUser(userIdentifier: string): Promise { - const cacheEntry = await this.cacheEntryRepository.findUnexpiredOneByKey(`${this.PREFIX}:${userIdentifier}`) - if (cacheEntry !== null) { - cacheEntry.props.expiresAt = this.timer.getUTCDateNSecondsAhead(this.failedLoginLockout) - - await this.cacheEntryRepository.save(cacheEntry) - } - } - async isUserLocked(userIdentifier: string): Promise { - const counter = await this.getLockCounter(userIdentifier) + const counter = await this.getLockCounter(userIdentifier, 'captcha') return counter >= this.maxLoginAttempts } diff --git a/packages/auth/src/Infra/TypeORM/TypeORMRevokedSessionRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMRevokedSessionRepository.ts index 4e30177d2..39e102f27 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMRevokedSessionRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMRevokedSessionRepository.ts @@ -12,6 +12,13 @@ export class TypeORMRevokedSessionRepository implements RevokedSessionRepository private ormRepository: Repository, ) {} + async findOneByPrivateIdentifier(privateIdentifier: string): Promise { + return this.ormRepository + .createQueryBuilder('revoked_session') + .where('revoked_session.private_identifier = :privateIdentifier', { privateIdentifier }) + .getOne() + } + async insert(revokedSession: RevokedSession): Promise { await this.ormRepository.insert(revokedSession) } diff --git a/packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts index 7ddcf4a48..511cbcc33 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMSessionRepository.ts @@ -17,6 +17,13 @@ export class TypeORMSessionRepository implements SessionRepositoryInterface { @inject(TYPES.Auth_Timer) private timer: TimerInterface, ) {} + async findOneByPrivateIdentifier(privateIdentifier: string): Promise { + return this.ormRepository + .createQueryBuilder('session') + .where('session.private_identifier = :privateIdentifier', { privateIdentifier }) + .getOne() + } + async insert(session: Session): Promise { session.updatedAt = this.timer.getUTCDate() diff --git a/packages/auth/src/Infra/gRPC/AuthServer.ts b/packages/auth/src/Infra/gRPC/AuthServer.ts index e04c30471..93918f7ca 100644 --- a/packages/auth/src/Infra/gRPC/AuthServer.ts +++ b/packages/auth/src/Infra/gRPC/AuthServer.ts @@ -2,7 +2,7 @@ import * as grpc from '@grpc/grpc-js' import { Status } from '@grpc/grpc-js/build/src/constants' import { - AuthorizationHeader, + RequestValidationOptions, ConnectionValidationResponse, IAuthServer, SessionValidationResponse, @@ -88,14 +88,34 @@ export class AuthServer implements IAuthServer { } async validate( - call: grpc.ServerUnaryCall, + call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData, ): Promise { try { this.logger.debug('[SessionsServer] Validating session via gRPC') + const cookies = new Map() + for (const cookie of call.request.getCookieList()) { + const existingCookies = cookies.get(cookie.getName()) + if (existingCookies) { + existingCookies.push(cookie.getValue()) + cookies.set(cookie.getName(), existingCookies) + } else { + cookies.set(cookie.getName(), [cookie.getValue()]) + } + } + const authenticateRequestResponse = await this.authenticateRequest.execute({ - authorizationHeader: call.request.getBearerToken(), + authTokenFromHeaders: call.request.getBearerToken(), + authCookies: cookies, + requestMetadata: { + snjs: call.metadata.get('x-snjs-version').pop() as string, + application: call.metadata.get('x-application-version').pop() as string, + url: call.metadata.get('x-origin-url').pop() as string, + method: call.metadata.get('x-origin-method').pop() as string, + userAgent: call.metadata.get('x-origin-user-agent').pop() as string, + secChUa: call.metadata.get('x-origin-sec-ch-ua').pop() as string, + }, }) if (!authenticateRequestResponse.success) { @@ -116,10 +136,9 @@ export class AuthServer implements IAuthServer { const user = authenticateRequestResponse.user as User - const sharedVaultOwnerMetadata = call.metadata.get('x-shared-vault-owner-context') let sharedVaultOwnerContext = undefined - if (sharedVaultOwnerMetadata.length > 0 && sharedVaultOwnerMetadata[0].length > 0) { - sharedVaultOwnerContext = sharedVaultOwnerMetadata[0].toString() + if (call.request.hasSharedVaultOwnerContext()) { + sharedVaultOwnerContext = call.request.getSharedVaultOwnerContext() } const resultOrError = await this.createCrossServiceToken.execute({ diff --git a/packages/common/package.json b/packages/common/package.json index dbe4bae44..7aa4039a1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -26,7 +26,7 @@ "clean": "rm -fr dist", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest --coverage --no-cache" + "test": "jest --coverage --no-cache --maxWorkers=2" }, "devDependencies": { "@types/jest": "^29.5.1", diff --git a/packages/domain-core/package.json b/packages/domain-core/package.json index 11ffb8e2a..8a5466263 100644 --- a/packages/domain-core/package.json +++ b/packages/domain-core/package.json @@ -27,7 +27,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "test": "jest --coverage --no-cache --passWithNoTests" + "test": "jest --coverage --no-cache --passWithNoTests --maxWorkers=2" }, "dependencies": { "uuid": "^9.0.0" diff --git a/packages/domain-core/src/Domain/Email/EmailLevel.ts b/packages/domain-core/src/Domain/Email/EmailLevel.ts index 4fcd8bd82..9df82e097 100644 --- a/packages/domain-core/src/Domain/Email/EmailLevel.ts +++ b/packages/domain-core/src/Domain/Email/EmailLevel.ts @@ -9,7 +9,6 @@ export class EmailLevel extends ValueObject { SignIn: 'SIGN_IN', Marketing: 'MARKETING', FailedCloudBackup: 'FAILED_CLOUD_BACKUP', - FailedEmailBackup: 'FAILED_EMAIL_BACKUP', } get value(): string { diff --git a/packages/domain-core/src/Domain/Setting/SettingName.ts b/packages/domain-core/src/Domain/Setting/SettingName.ts index 59b82c10e..cf5340df7 100644 --- a/packages/domain-core/src/Domain/Setting/SettingName.ts +++ b/packages/domain-core/src/Domain/Setting/SettingName.ts @@ -14,8 +14,6 @@ export class SettingName extends ValueObject { OneDriveBackupToken: 'ONE_DRIVE_BACKUP_TOKEN', GoogleDriveBackupFrequency: 'GOOGLE_DRIVE_BACKUP_FREQUENCY', GoogleDriveBackupToken: 'GOOGLE_DRIVE_BACKUP_TOKEN', - MuteFailedBackupsEmails: 'MUTE_FAILED_BACKUPS_EMAILS', - MuteFailedCloudBackupsEmails: 'MUTE_FAILED_CLOUD_BACKUPS_EMAILS', MuteSignInEmails: 'MUTE_SIGN_IN_EMAILS', MuteMarketingEmails: 'MUTE_MARKETING_EMAILS', ListedAuthorSecrets: 'LISTED_AUTHOR_SECRETS', diff --git a/packages/domain-events-infra/package.json b/packages/domain-events-infra/package.json index 247e6085e..df06d4fbb 100644 --- a/packages/domain-events-infra/package.json +++ b/packages/domain-events-infra/package.json @@ -27,7 +27,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "test": "jest --coverage --no-cache" + "test": "jest --coverage --no-cache --maxWorkers=2" }, "dependencies": { "@aws-sdk/client-sns": "^3.484.0", diff --git a/packages/domain-events-infra/src/Infra/Redis/RedisDomainEventPublisher.spec.ts b/packages/domain-events-infra/src/Infra/Redis/RedisDomainEventPublisher.spec.ts index f6e4b1f9c..a7ef8f2bc 100644 --- a/packages/domain-events-infra/src/Infra/Redis/RedisDomainEventPublisher.spec.ts +++ b/packages/domain-events-infra/src/Infra/Redis/RedisDomainEventPublisher.spec.ts @@ -24,9 +24,6 @@ describe('RedisDomainEventPublisher', () => { it('should publish an event to a channel', async () => { await createPublisher().publish(event) - expect(redisClient.publish).toHaveBeenCalledWith( - 'events', - 'eJyrViqpLEhVslIKcQ0OUdJRKkiszMlPTFGyqlZKy89XslJKSixSqq0FAPbUDIQ=', - ) + expect(redisClient.publish).toHaveBeenCalledWith('events', expect.any(String)) }) }) diff --git a/packages/domain-events/package.json b/packages/domain-events/package.json index d71042794..20c93cc1d 100644 --- a/packages/domain-events/package.json +++ b/packages/domain-events/package.json @@ -26,7 +26,7 @@ "clean": "rm -fr dist", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest --coverage --no-cache --passWithNoTests" + "test": "jest --coverage --no-cache --passWithNoTests --maxWorkers=2" }, "dependencies": { "@standardnotes/predicates": "workspace:*", diff --git a/packages/domain-events/src/Domain/Event/EmailBackupRequestedEventPayload.ts b/packages/domain-events/src/Domain/Event/EmailBackupRequestedEventPayload.ts index 204c319e0..400231800 100644 --- a/packages/domain-events/src/Domain/Event/EmailBackupRequestedEventPayload.ts +++ b/packages/domain-events/src/Domain/Event/EmailBackupRequestedEventPayload.ts @@ -1,6 +1,4 @@ export interface EmailBackupRequestedEventPayload { userUuid: string - userHasEmailsMuted: boolean - muteEmailsSettingUuid: string keyParams: Record } diff --git a/packages/files/CHANGELOG.md b/packages/files/CHANGELOG.md index daf5a0803..413556b4a 100644 --- a/packages/files/CHANGELOG.md +++ b/packages/files/CHANGELOG.md @@ -3,18 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [1.38.0](https://github.com/standardnotes/server/compare/@standardnotes/files-server@1.37.12...@standardnotes/files-server@1.38.0) (2024-03-20) - -### Features - -* add CORS_ORIGIN_STRICT_MODE_ENABLED env var to determine if CORS origin should be restricted ([5c02435](https://github.com/standardnotes/server/commit/5c02435ee478b893747d3f9e41062aae12d7ff10)) - -## [1.37.12](https://github.com/standardnotes/server/compare/@standardnotes/files-server@1.37.11...@standardnotes/files-server@1.37.12) (2024-03-18) - -### Bug Fixes - -* cors issues on clients - fixes [#1046](https://github.com/standardnotes/server/issues/1046) ([#1049](https://github.com/standardnotes/server/issues/1049)) ([6d7ca1b](https://github.com/standardnotes/server/commit/6d7ca1b926fd45d744275bd3c1f4c05b010f76c8)) - ## [1.37.11](https://github.com/standardnotes/server/compare/@standardnotes/files-server@1.37.10...@standardnotes/files-server@1.37.11) (2024-01-19) **Note:** Version bump only for package @standardnotes/files-server diff --git a/packages/files/Dockerfile b/packages/files/Dockerfile index c6bcfcd83..32a80ef0b 100644 --- a/packages/files/Dockerfile +++ b/packages/files/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/files ENTRYPOINT [ "/workspace/packages/files/docker/entrypoint.sh" ] diff --git a/packages/files/bin/server.ts b/packages/files/bin/server.ts index 276976610..c7d0be08e 100644 --- a/packages/files/bin/server.ts +++ b/packages/files/bin/server.ts @@ -66,9 +66,8 @@ void container.load().then((container) => { app.use(raw({ limit: requestPayloadLimit, type: 'application/octet-stream' })) app.use(urlencoded({ extended: true, limit: requestPayloadLimit })) - const corsAllowedOrigins = env.get('CORS_ALLOWED_ORIGINS', true) - ? env.get('CORS_ALLOWED_ORIGINS', true).split(',') - : [] + const corsAllowedOrigins = container.get(TYPES.Files_CORS_ALLOWED_ORIGINS) + app.use( cors({ credentials: true, diff --git a/packages/files/package.json b/packages/files/package.json index a1c5a904d..b23404212 100644 --- a/packages/files/package.json +++ b/packages/files/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/files-server", - "version": "1.38.0", + "version": "1.37.11", "engines": { "node": ">=18.0.0 <21.0.0" }, @@ -26,8 +26,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --fix --ext .ts", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "start": "yarn node dist/bin/server.js", "worker": "yarn node dist/bin/worker.js" }, diff --git a/packages/files/src/Bootstrap/Container.ts b/packages/files/src/Bootstrap/Container.ts index d042f3655..8feb51c60 100644 --- a/packages/files/src/Bootstrap/Container.ts +++ b/packages/files/src/Bootstrap/Container.ts @@ -54,6 +54,8 @@ import { MoveFile } from '../Domain/UseCase/MoveFile/MoveFile' import { SharedVaultValetTokenAuthMiddleware } from '../Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware' import { RecalculateQuota } from '../Domain/UseCase/RecalculateQuota/RecalculateQuota' import { FileQuotaRecalculationRequestedEventHandler } from '../Domain/Handler/FileQuotaRecalculationRequestedEventHandler' +import { ValetTokenRepositoryInterface } from '../Domain/ValetToken/ValetTokenRepositoryInterface' +import { RedisValetTokenRepository } from '../Infra/Redis/RedisValetTokenRepository' export class ContainerConfigLoader { constructor(private mode: 'server' | 'worker' = 'server') {} @@ -72,6 +74,9 @@ export class ContainerConfigLoader { const container = new Container() // env vars + container + .bind(TYPES.Files_CORS_ALLOWED_ORIGINS) + .toConstantValue(env.get('CORS_ALLOWED_ORIGINS', true) ? env.get('CORS_ALLOWED_ORIGINS', true).split(',') : []) container.bind(TYPES.Files_VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET')) container .bind(TYPES.Files_MAX_CHUNK_BYTES) @@ -100,6 +105,19 @@ export class ContainerConfigLoader { container.bind(TYPES.Files_Timer).toConstantValue(new Timer()) + container.bind(TYPES.Files_REDIS_URL).toConstantValue(env.get('REDIS_URL')) + + const redisUrl = container.get(TYPES.Files_REDIS_URL) as string + const isRedisInClusterMode = redisUrl.indexOf(',') > 0 + let redis + if (isRedisInClusterMode) { + redis = new Redis.Cluster(redisUrl.split(',')) + } else { + redis = new Redis(redisUrl) + } + + container.bind(TYPES.Files_Redis).toConstantValue(redis) + // services container .bind>(TYPES.Files_ValetTokenDecoder) @@ -108,24 +126,15 @@ export class ContainerConfigLoader { .bind(TYPES.Files_DomainEventFactory) .toConstantValue(new DomainEventFactory(container.get(TYPES.Files_Timer))) + container + .bind(TYPES.Files_ValetTokenRepository) + .toConstantValue(new RedisValetTokenRepository(container.get(TYPES.Files_Redis))) + if (isConfiguredForInMemoryCache) { container .bind(TYPES.Files_UploadRepository) .toConstantValue(new InMemoryUploadRepository(container.get(TYPES.Files_Timer))) } else { - container.bind(TYPES.Files_REDIS_URL).toConstantValue(env.get('REDIS_URL')) - - const redisUrl = container.get(TYPES.Files_REDIS_URL) as string - const isRedisInClusterMode = redisUrl.indexOf(',') > 0 - let redis - if (isRedisInClusterMode) { - redis = new Redis.Cluster(redisUrl.split(',')) - } else { - redis = new Redis(redisUrl) - } - - container.bind(TYPES.Files_Redis).toConstantValue(redis) - container.bind(TYPES.Files_UploadRepository).to(RedisUploadRepository) } @@ -223,6 +232,7 @@ export class ContainerConfigLoader { container.get(TYPES.Files_UploadRepository), container.get(TYPES.Files_DomainEventPublisher), container.get(TYPES.Files_DomainEventFactory), + container.get(TYPES.Files_ValetTokenRepository), ), ) container diff --git a/packages/files/src/Bootstrap/Types.ts b/packages/files/src/Bootstrap/Types.ts index 37ac0ce7f..f985a0963 100644 --- a/packages/files/src/Bootstrap/Types.ts +++ b/packages/files/src/Bootstrap/Types.ts @@ -29,6 +29,7 @@ const TYPES = { // repositories Files_UploadRepository: Symbol.for('Files_UploadRepository'), + Files_ValetTokenRepository: Symbol.for('Files_ValetTokenRepository'), // middleware Files_ValetTokenAuthMiddleware: Symbol.for('Files_ValetTokenAuthMiddleware'), @@ -50,6 +51,7 @@ const TYPES = { Files_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING: Symbol.for( 'Files_IS_CONFIGURED_FOR_HOME_SERVER_OR_SELF_HOSTING', ), + Files_CORS_ALLOWED_ORIGINS: Symbol.for('Files_CORS_ALLOWED_ORIGINS'), // Handlers Files_DomainEventMessageHandler: Symbol.for('Files_DomainEventMessageHandler'), diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts index 9cc07526f..cf8ed4cf4 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.spec.ts @@ -9,17 +9,28 @@ import { FileUploaderInterface } from '../../Services/FileUploaderInterface' import { UploadRepositoryInterface } from '../../Upload/UploadRepositoryInterface' import { FinishUploadSession } from './FinishUploadSession' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' describe('FinishUploadSession', () => { let fileUploader: FileUploaderInterface let uploadRepository: UploadRepositoryInterface let domainEventPublisher: DomainEventPublisherInterface let domainEventFactory: DomainEventFactoryInterface + let valetTokenRepository: ValetTokenRepositoryInterface const createUseCase = () => - new FinishUploadSession(fileUploader, uploadRepository, domainEventPublisher, domainEventFactory) + new FinishUploadSession( + fileUploader, + uploadRepository, + domainEventPublisher, + domainEventFactory, + valetTokenRepository, + ) beforeEach(() => { + valetTokenRepository = {} as jest.Mocked + valetTokenRepository.markAsUsed = jest.fn() + fileUploader = {} as jest.Mocked fileUploader.finishUploadSession = jest.fn().mockReturnValue('ETag123') @@ -45,6 +56,7 @@ describe('FinishUploadSession', () => { userUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(fileUploader.finishUploadSession).not.toHaveBeenCalled() @@ -57,6 +69,7 @@ describe('FinishUploadSession', () => { userUuid: 'invalid', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(result.isFailed()).toBeTruthy() @@ -74,6 +87,7 @@ describe('FinishUploadSession', () => { userUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(result.getError()).toEqual('Could not finish upload session') @@ -88,6 +102,7 @@ describe('FinishUploadSession', () => { userUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(fileUploader.finishUploadSession).toHaveBeenCalledWith('123', '00000000-0000-0000-0000-000000000000/2-3-4', [ @@ -103,6 +118,7 @@ describe('FinishUploadSession', () => { sharedVaultUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(fileUploader.finishUploadSession).toHaveBeenCalledWith('123', '00000000-0000-0000-0000-000000000000/2-3-4', [ @@ -118,6 +134,7 @@ describe('FinishUploadSession', () => { sharedVaultUuid: 'invalid', uploadBytesLimit: 100, uploadBytesUsed: 0, + valetToken: 'valet-token', }) expect(result.isFailed()).toBeTruthy() @@ -137,6 +154,7 @@ describe('FinishUploadSession', () => { userUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: 100, uploadBytesUsed: 20, + valetToken: 'valet-token', }) expect(result.getError()).toEqual('Could not finish upload session. You are out of space.') @@ -156,6 +174,7 @@ describe('FinishUploadSession', () => { userUuid: '00000000-0000-0000-0000-000000000000', uploadBytesLimit: -1, uploadBytesUsed: 20, + valetToken: 'valet-token', }) expect(result.isFailed()).toBeFalsy() diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts index 3d70e6272..2bb8e4325 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSession.ts @@ -5,6 +5,7 @@ import { FinishUploadSessionDTO } from './FinishUploadSessionDTO' import { FileUploaderInterface } from '../../Services/FileUploaderInterface' import { UploadRepositoryInterface } from '../../Upload/UploadRepositoryInterface' import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' export class FinishUploadSession implements UseCaseInterface { constructor( @@ -12,6 +13,7 @@ export class FinishUploadSession implements UseCaseInterface { private uploadRepository: UploadRepositoryInterface, private domainEventPublisher: DomainEventPublisherInterface, private domainEventFactory: DomainEventFactoryInterface, + private valetTokenRepository: ValetTokenRepositoryInterface, ) {} async execute(dto: FinishUploadSessionDTO): Promise> { @@ -74,6 +76,8 @@ export class FinishUploadSession implements UseCaseInterface { ) } + await this.valetTokenRepository.markAsUsed(dto.valetToken) + return Result.ok() } catch (error) { return Result.fail('Could not finish upload session') diff --git a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts index 2c4d1d274..ec29168e3 100644 --- a/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts +++ b/packages/files/src/Domain/UseCase/FinishUploadSession/FinishUploadSessionDTO.ts @@ -4,4 +4,5 @@ export type FinishUploadSessionDTO = { resourceRemoteIdentifier: string uploadBytesUsed: number uploadBytesLimit: number + valetToken: string } diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts index 439d2a27b..b7321bce0 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.spec.ts @@ -10,16 +10,22 @@ import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInter import { RemoveFile } from './RemoveFile' import { FileRemoverInterface } from '../../Services/FileRemoverInterface' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' describe('RemoveFile', () => { let fileRemover: FileRemoverInterface let domainEventPublisher: DomainEventPublisherInterface let domainEventFactory: DomainEventFactoryInterface + let valetTokenRepository: ValetTokenRepositoryInterface let logger: Logger - const createUseCase = () => new RemoveFile(fileRemover, domainEventPublisher, domainEventFactory, logger) + const createUseCase = () => + new RemoveFile(fileRemover, domainEventPublisher, domainEventFactory, valetTokenRepository, logger) beforeEach(() => { + valetTokenRepository = {} as jest.Mocked + valetTokenRepository.markAsUsed = jest.fn() + fileRemover = {} as jest.Mocked fileRemover.remove = jest.fn().mockReturnValue(413) @@ -49,6 +55,7 @@ describe('RemoveFile', () => { userUuid: '1-2-3', regularSubscriptionUuid: '3-4-5', }, + valetToken: 'valet-token', }) expect(result.isFailed()).toEqual(true) @@ -56,7 +63,7 @@ describe('RemoveFile', () => { }) it('should indicate of an error of no proper input', async () => { - const result = await createUseCase().execute({}) + const result = await createUseCase().execute({ valetToken: 'valet-token' }) expect(result.isFailed()).toEqual(true) expect(domainEventPublisher.publish).not.toHaveBeenCalled() @@ -69,6 +76,7 @@ describe('RemoveFile', () => { userUuid: '1-2-3', regularSubscriptionUuid: '3-4-5', }, + valetToken: 'valet-token', }) expect(fileRemover.remove).toHaveBeenCalledWith('1-2-3/2-3-4') @@ -82,6 +90,7 @@ describe('RemoveFile', () => { sharedVaultUuid: '1-2-3', vaultOwnerUuid: '3-4-5', }, + valetToken: 'valet-token', }) expect(fileRemover.remove).toHaveBeenCalledWith('1-2-3/2-3-4') diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts index 3d0595c0f..aca344b83 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFile.ts @@ -7,6 +7,7 @@ import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInter import { FileRemoverInterface } from '../../Services/FileRemoverInterface' import { RemoveFileDTO } from './RemoveFileDTO' import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' @injectable() export class RemoveFile implements UseCaseInterface { @@ -14,6 +15,7 @@ export class RemoveFile implements UseCaseInterface { @inject(TYPES.Files_FileRemover) private fileRemover: FileRemoverInterface, @inject(TYPES.Files_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, @inject(TYPES.Files_DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.Files_ValetTokenRepository) private valetTokenRepository: ValetTokenRepositoryInterface, @inject(TYPES.Files_Logger) private logger: Logger, ) {} @@ -52,6 +54,8 @@ export class RemoveFile implements UseCaseInterface { return Result.fail('Could not remove file') } + await this.valetTokenRepository.markAsUsed(dto.valetToken) + return Result.ok() } catch (error) { this.logger.error(`Could not remove resource: ${resourceUuid} - ${(error as Error).message}`) diff --git a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts index 4ff45f4e6..6a5e75743 100644 --- a/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts +++ b/packages/files/src/Domain/UseCase/RemoveFile/RemoveFileDTO.ts @@ -9,4 +9,5 @@ export interface RemoveFileDTO { vaultOwnerUuid: string resourceRemoteIdentifier: string } + valetToken: string } diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts index f5a0719c6..f686d7b88 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.spec.ts @@ -5,14 +5,20 @@ import { Logger } from 'winston' import { FileDownloaderInterface } from '../../Services/FileDownloaderInterface' import { StreamDownloadFile } from './StreamDownloadFile' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' describe('StreamDownloadFile', () => { let fileDownloader: FileDownloaderInterface let logger: Logger + const valetToken = 'valet-token' + let valetTokenRepository: ValetTokenRepositoryInterface - const createUseCase = () => new StreamDownloadFile(fileDownloader, logger) + const createUseCase = () => new StreamDownloadFile(fileDownloader, valetTokenRepository, logger) beforeEach(() => { + valetTokenRepository = {} as jest.Mocked + valetTokenRepository.markAsUsed = jest.fn() + fileDownloader = {} as jest.Mocked fileDownloader.createDownloadStream = jest.fn().mockReturnValue(new Readable()) @@ -26,11 +32,28 @@ describe('StreamDownloadFile', () => { resourceRemoteIdentifier: '1-2-3', startRange: 0, endRange: 200, + endRangeOfFile: 300, + valetToken, }) expect(result.success).toBeTruthy() }) + it('should mark valet token as used if the last chunk is being streamed', async () => { + const result = await createUseCase().execute({ + ownerUuid: '2-3-4', + resourceRemoteIdentifier: '1-2-3', + startRange: 0, + endRange: 200, + endRangeOfFile: 200, + valetToken, + }) + + expect(result.success).toBeTruthy() + + expect(valetTokenRepository.markAsUsed).toHaveBeenCalledWith(valetToken) + }) + it('should not stream download file contents from S3 if it fails', async () => { fileDownloader.createDownloadStream = jest.fn().mockImplementation(() => { throw new Error('oops') @@ -41,6 +64,8 @@ describe('StreamDownloadFile', () => { resourceRemoteIdentifier: '1-2-3', startRange: 0, endRange: 200, + endRangeOfFile: 200, + valetToken, }) expect(result.success).toBeFalsy() diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts index 7b57b0f5b..065465586 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFile.ts @@ -5,11 +5,13 @@ import { FileDownloaderInterface } from '../../Services/FileDownloaderInterface' import { UseCaseInterface } from '../UseCaseInterface' import { StreamDownloadFileDTO } from './StreamDownloadFileDTO' import { StreamDownloadFileResponse } from './StreamDownloadFileResponse' +import { ValetTokenRepositoryInterface } from '../../ValetToken/ValetTokenRepositoryInterface' @injectable() export class StreamDownloadFile implements UseCaseInterface { constructor( @inject(TYPES.Files_FileDownloader) private fileDownloader: FileDownloaderInterface, + @inject(TYPES.Files_ValetTokenRepository) private valetTokenRepository: ValetTokenRepositoryInterface, @inject(TYPES.Files_Logger) private logger: Logger, ) {} @@ -21,6 +23,10 @@ export class StreamDownloadFile implements UseCaseInterface { dto.endRange, ) + if (dto.endRange === dto.endRangeOfFile) { + await this.valetTokenRepository.markAsUsed(dto.valetToken) + } + return { success: true, readStream, diff --git a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts index 664fa5bf9..317041d9c 100644 --- a/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts +++ b/packages/files/src/Domain/UseCase/StreamDownloadFile/StreamDownloadFileDTO.ts @@ -3,4 +3,6 @@ export type StreamDownloadFileDTO = { resourceRemoteIdentifier: string startRange: number endRange: number + endRangeOfFile: number + valetToken: string } diff --git a/packages/files/src/Domain/ValetToken/ValetTokenRepositoryInterface.ts b/packages/files/src/Domain/ValetToken/ValetTokenRepositoryInterface.ts new file mode 100644 index 000000000..5b022ae0d --- /dev/null +++ b/packages/files/src/Domain/ValetToken/ValetTokenRepositoryInterface.ts @@ -0,0 +1,4 @@ +export interface ValetTokenRepositoryInterface { + markAsUsed(valetToken: string): Promise + isUsed(valetToken: string): Promise +} diff --git a/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.spec.ts b/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.spec.ts index 511f7245d..e17ea9c74 100644 --- a/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.spec.ts +++ b/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.spec.ts @@ -93,7 +93,7 @@ describe('AnnotatedFilesController', () => { expect(response.writeHead).toHaveBeenCalledWith(206, { 'Accept-Ranges': 'bytes', 'Content-Length': 100000, - 'Content-Range': 'bytes 0-99999/555555', + 'Content-Range': 'bytes 0-99999/555554', 'Content-Type': 'application/octet-stream', }) @@ -122,14 +122,14 @@ describe('AnnotatedFilesController', () => { expect(response.writeHead).toHaveBeenNthCalledWith(1, 206, { 'Accept-Ranges': 'bytes', 'Content-Length': 100000, - 'Content-Range': 'bytes 0-99999/555555', + 'Content-Range': 'bytes 0-99999/555554', 'Content-Type': 'application/octet-stream', }) expect(response.writeHead).toHaveBeenNthCalledWith(2, 206, { 'Accept-Ranges': 'bytes', 'Content-Length': 100000, - 'Content-Range': 'bytes 100000-199999/555555', + 'Content-Range': 'bytes 100000-199999/555554', 'Content-Type': 'application/octet-stream', }) }) @@ -145,7 +145,7 @@ describe('AnnotatedFilesController', () => { expect(response.writeHead).toHaveBeenCalledWith(206, { 'Accept-Ranges': 'bytes', 'Content-Length': 50000, - 'Content-Range': 'bytes 0-49999/555555', + 'Content-Range': 'bytes 0-49999/555554', 'Content-Type': 'application/octet-stream', }) @@ -163,7 +163,7 @@ describe('AnnotatedFilesController', () => { expect(response.writeHead).toHaveBeenCalledWith(206, { 'Accept-Ranges': 'bytes', 'Content-Length': 100000, - 'Content-Range': 'bytes 0-99999/555555', + 'Content-Range': 'bytes 0-99999/555554', 'Content-Type': 'application/octet-stream', }) diff --git a/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.ts b/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.ts index 305f17ff3..ccaf62a16 100644 --- a/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.ts +++ b/packages/files/src/Infra/InversifyExpress/AnnotatedFilesController.ts @@ -12,6 +12,7 @@ import { CreateUploadSession } from '../../Domain/UseCase/CreateUploadSession/Cr import { FinishUploadSession } from '../../Domain/UseCase/FinishUploadSession/FinishUploadSession' import { GetFileMetadata } from '../../Domain/UseCase/GetFileMetadata/GetFileMetadata' import { RemoveFile } from '../../Domain/UseCase/RemoveFile/RemoveFile' +import { ValetTokenResponseLocals } from './Middleware/ValetTokenResponseLocals' @controller('/v1/files', TYPES.Files_ValetTokenAuthMiddleware) export class AnnotatedFilesController extends BaseHttpController { @@ -83,15 +84,17 @@ export class AnnotatedFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - if (response.locals.permittedOperation !== ValetTokenOperation.Write) { + const locals = response.locals as ValetTokenResponseLocals + if (locals.permittedOperation !== ValetTokenOperation.Write) { return this.badRequest('Not permitted for this operation') } const result = await this.finishUploadSession.execute({ - userUuid: response.locals.userUuid, - resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, - uploadBytesLimit: response.locals.uploadBytesLimit, - uploadBytesUsed: response.locals.uploadBytesUsed, + userUuid: locals.userUuid, + resourceRemoteIdentifier: locals.permittedResources[0].remoteIdentifier, + uploadBytesLimit: locals.uploadBytesLimit, + uploadBytesUsed: locals.uploadBytesUsed, + valetToken: locals.valetToken, }) if (result.isFailed()) { @@ -108,16 +111,18 @@ export class AnnotatedFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - if (response.locals.permittedOperation !== ValetTokenOperation.Delete) { + const locals = response.locals as ValetTokenResponseLocals + if (locals.permittedOperation !== ValetTokenOperation.Delete) { return this.badRequest('Not permitted for this operation') } const result = await this.removeFile.execute({ userInput: { - userUuid: response.locals.userUuid, - resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, - regularSubscriptionUuid: response.locals.regularSubscriptionUuid, + userUuid: locals.userUuid, + resourceRemoteIdentifier: locals.permittedResources[0].remoteIdentifier, + regularSubscriptionUuid: locals.regularSubscriptionUuid, }, + valetToken: locals.valetToken, }) if (result.isFailed()) { @@ -132,7 +137,8 @@ export class AnnotatedFilesController extends BaseHttpController { request: Request, response: Response, ): Promise Writable)> { - if (response.locals.permittedOperation !== ValetTokenOperation.Read) { + const locals = response.locals as ValetTokenResponseLocals + if (locals.permittedOperation !== ValetTokenOperation.Read) { return this.badRequest('Not permitted for this operation') } @@ -147,20 +153,21 @@ export class AnnotatedFilesController extends BaseHttpController { } const fileMetadataOrError = await this.getFileMetadata.execute({ - ownerUuid: response.locals.userUuid, - resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, + ownerUuid: locals.userUuid, + resourceRemoteIdentifier: locals.permittedResources[0].remoteIdentifier, }) if (fileMetadataOrError.isFailed()) { return this.badRequest(fileMetadataOrError.getError()) } const fileSize = fileMetadataOrError.getValue() + const endRangeOfFile = fileSize - 1 const startRange = Number(range.replace(/\D/g, '')) - const endRange = Math.min(startRange + chunkSize - 1, fileSize - 1) + const endRange = Math.min(startRange + chunkSize - 1, endRangeOfFile) const headers = { - 'Content-Range': `bytes ${startRange}-${endRange}/${fileSize}`, + 'Content-Range': `bytes ${startRange}-${endRange}/${endRangeOfFile}`, 'Accept-Ranges': 'bytes', 'Content-Length': endRange - startRange + 1, 'Content-Type': 'application/octet-stream', @@ -169,10 +176,12 @@ export class AnnotatedFilesController extends BaseHttpController { response.writeHead(206, headers) const result = await this.streamDownloadFile.execute({ - ownerUuid: response.locals.userUuid, - resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier, + ownerUuid: locals.userUuid, + resourceRemoteIdentifier: locals.permittedResources[0].remoteIdentifier, startRange, endRange, + endRangeOfFile, + valetToken: locals.valetToken, }) if (!result.success) { diff --git a/packages/files/src/Infra/InversifyExpress/AnnotatedSharedVaultFilesController.ts b/packages/files/src/Infra/InversifyExpress/AnnotatedSharedVaultFilesController.ts index c8c1914db..7d8d1b9da 100644 --- a/packages/files/src/Infra/InversifyExpress/AnnotatedSharedVaultFilesController.ts +++ b/packages/files/src/Infra/InversifyExpress/AnnotatedSharedVaultFilesController.ts @@ -2,7 +2,7 @@ import { BaseHttpController, controller, httpDelete, httpGet, httpPost, results import { Request, Response } from 'express' import { inject } from 'inversify' import { Writable } from 'stream' -import { SharedVaultValetTokenData, ValetTokenOperation } from '@standardnotes/security' +import { ValetTokenOperation } from '@standardnotes/security' import { Logger } from 'winston' import TYPES from '../../Bootstrap/Types' @@ -13,6 +13,7 @@ import { MoveFile } from '../../Domain/UseCase/MoveFile/MoveFile' import { RemoveFile } from '../../Domain/UseCase/RemoveFile/RemoveFile' import { StreamDownloadFile } from '../../Domain/UseCase/StreamDownloadFile/StreamDownloadFile' import { UploadFileChunk } from '../../Domain/UseCase/UploadFileChunk/UploadFileChunk' +import { SharedVaultValetTokenResponseLocals } from './Middleware/SharedVaultValetTokenResponseLocals' @controller('/v1/shared-vault/files', TYPES.Files_SharedVaultValetTokenAuthMiddleware) export class AnnotatedSharedVaultFilesController extends BaseHttpController { @@ -35,12 +36,12 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Move) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Move) { return this.badRequest('Not permitted for this operation') } - const moveOperation = locals.moveOperation + const moveOperation = locals.valetTokenData.moveOperation if (!moveOperation) { return this.badRequest('Missing move operation data') } @@ -49,7 +50,7 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { moveType: moveOperation.type, from: moveOperation.from, to: moveOperation.to, - resourceRemoteIdentifier: locals.remoteIdentifier, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, }) if (result.isFailed()) { @@ -64,14 +65,14 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Write) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Write) { return this.badRequest('Not permitted for this operation') } const result = await this.createUploadSession.execute({ - ownerUuid: locals.sharedVaultUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, + ownerUuid: locals.valetTokenData.sharedVaultUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, }) if (!result.success) { @@ -86,8 +87,8 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { request: Request, response: Response, ): Promise { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Write) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Write) { return this.badRequest('Not permitted for this operation') } @@ -97,9 +98,9 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { } const result = await this.uploadFileChunk.execute({ - ownerUuid: locals.sharedVaultUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, - resourceUnencryptedFileSize: locals.unencryptedFileSize as number, + ownerUuid: locals.valetTokenData.sharedVaultUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, + resourceUnencryptedFileSize: locals.valetTokenData.unencryptedFileSize as number, chunkId, data: request.body, }) @@ -116,21 +117,22 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Write) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Write) { return this.badRequest('Not permitted for this operation') } - if (locals.uploadBytesLimit === undefined) { + if (locals.valetTokenData.uploadBytesLimit === undefined) { return this.badRequest('Missing upload bytes limit') } const result = await this.finishUploadSession.execute({ - userUuid: locals.vaultOwnerUuid, - sharedVaultUuid: locals.sharedVaultUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, - uploadBytesLimit: locals.uploadBytesLimit, - uploadBytesUsed: locals.uploadBytesUsed, + userUuid: locals.valetTokenData.vaultOwnerUuid, + sharedVaultUuid: locals.valetTokenData.sharedVaultUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, + uploadBytesLimit: locals.valetTokenData.uploadBytesLimit, + uploadBytesUsed: locals.valetTokenData.uploadBytesUsed, + valetToken: locals.valetToken, }) if (result.isFailed()) { @@ -147,17 +149,18 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { _request: Request, response: Response, ): Promise { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Delete) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Delete) { return this.badRequest('Not permitted for this operation') } const result = await this.removeFile.execute({ vaultInput: { - sharedVaultUuid: locals.sharedVaultUuid, - vaultOwnerUuid: locals.vaultOwnerUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, + sharedVaultUuid: locals.valetTokenData.sharedVaultUuid, + vaultOwnerUuid: locals.valetTokenData.vaultOwnerUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, }, + valetToken: locals.valetToken, }) if (result.isFailed()) { @@ -172,8 +175,8 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { request: Request, response: Response, ): Promise Writable)> { - const locals = response.locals as SharedVaultValetTokenData - if (locals.permittedOperation !== ValetTokenOperation.Read) { + const locals = response.locals as SharedVaultValetTokenResponseLocals + if (locals.valetTokenData.permittedOperation !== ValetTokenOperation.Read) { return this.badRequest('Not permitted for this operation') } @@ -188,20 +191,21 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { } const fileMetadataOrError = await this.getFileMetadata.execute({ - ownerUuid: locals.sharedVaultUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, + ownerUuid: locals.valetTokenData.sharedVaultUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, }) if (fileMetadataOrError.isFailed()) { return this.badRequest(fileMetadataOrError.getError()) } const fileSize = fileMetadataOrError.getValue() + const endRangeOfFile = fileSize - 1 const startRange = Number(range.replace(/\D/g, '')) - const endRange = Math.min(startRange + chunkSize - 1, fileSize - 1) + const endRange = Math.min(startRange + chunkSize - 1, endRangeOfFile) const headers = { - 'Content-Range': `bytes ${startRange}-${endRange}/${fileSize}`, + 'Content-Range': `bytes ${startRange}-${endRange}/${endRangeOfFile}`, 'Accept-Ranges': 'bytes', 'Content-Length': endRange - startRange + 1, 'Content-Type': 'application/octet-stream', @@ -210,10 +214,12 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController { response.writeHead(206, headers) const result = await this.streamDownloadFile.execute({ - ownerUuid: locals.sharedVaultUuid, - resourceRemoteIdentifier: locals.remoteIdentifier, + ownerUuid: locals.valetTokenData.sharedVaultUuid, + resourceRemoteIdentifier: locals.valetTokenData.remoteIdentifier, startRange, endRange, + valetToken: locals.valetToken, + endRangeOfFile, }) if (!result.success) { diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts index 4be3e9f98..108fc58a9 100644 --- a/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts +++ b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware.ts @@ -6,11 +6,14 @@ import { BaseMiddleware } from 'inversify-express-utils' import { Logger } from 'winston' import TYPES from '../../../Bootstrap/Types' +import { SharedVaultValetTokenResponseLocals } from './SharedVaultValetTokenResponseLocals' +import { ValetTokenRepositoryInterface } from '../../../Domain/ValetToken/ValetTokenRepositoryInterface' @injectable() export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware { constructor( @inject(TYPES.Files_ValetTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Files_ValetTokenRepository) private valetTokenRepository: ValetTokenRepositoryInterface, @inject(TYPES.Files_Logger) private logger: Logger, ) { super() @@ -32,6 +35,22 @@ export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware { return } + if (await this.valetTokenRepository.isUsed(valetToken)) { + this.logger.debug('Already used valet token.', { + valetToken, + codeTag: 'SharedVaultValetTokenAuthMiddleware', + }) + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid valet token.', + }, + }) + + return + } + const valetTokenData = this.tokenDecoder.decodeToken(valetToken) if (valetTokenData === undefined) { @@ -72,15 +91,18 @@ export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware { return } - const whitelistedData: SharedVaultValetTokenData = { - sharedVaultUuid: valetTokenData.sharedVaultUuid, - vaultOwnerUuid: valetTokenData.vaultOwnerUuid, - remoteIdentifier: valetTokenData.remoteIdentifier, - permittedOperation: valetTokenData.permittedOperation, - uploadBytesUsed: valetTokenData.uploadBytesUsed, - uploadBytesLimit: valetTokenData.uploadBytesLimit, - unencryptedFileSize: valetTokenData.unencryptedFileSize, - moveOperation: valetTokenData.moveOperation, + const whitelistedData: SharedVaultValetTokenResponseLocals = { + valetToken, + valetTokenData: { + sharedVaultUuid: valetTokenData.sharedVaultUuid, + vaultOwnerUuid: valetTokenData.vaultOwnerUuid, + remoteIdentifier: valetTokenData.remoteIdentifier, + permittedOperation: valetTokenData.permittedOperation, + uploadBytesUsed: valetTokenData.uploadBytesUsed, + uploadBytesLimit: valetTokenData.uploadBytesLimit, + unencryptedFileSize: valetTokenData.unencryptedFileSize, + moveOperation: valetTokenData.moveOperation, + }, } Object.assign(response.locals, whitelistedData) diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenResponseLocals.ts b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenResponseLocals.ts new file mode 100644 index 000000000..7666072b5 --- /dev/null +++ b/packages/files/src/Infra/InversifyExpress/Middleware/SharedVaultValetTokenResponseLocals.ts @@ -0,0 +1,6 @@ +import { SharedVaultValetTokenData } from '@standardnotes/security' + +export interface SharedVaultValetTokenResponseLocals { + valetToken: string + valetTokenData: SharedVaultValetTokenData +} diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.spec.ts b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.spec.ts index 4b1e2648b..6c165d4a3 100644 --- a/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.spec.ts +++ b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.spec.ts @@ -4,9 +4,11 @@ import { ValetTokenAuthMiddleware } from './ValetTokenAuthMiddleware' import { NextFunction, Request, Response } from 'express' import { Logger } from 'winston' import { TokenDecoderInterface, ValetTokenData } from '@standardnotes/security' +import { ValetTokenRepositoryInterface } from '../../../Domain/ValetToken/ValetTokenRepositoryInterface' describe('ValetTokenAuthMiddleware', () => { let tokenDecoder: TokenDecoderInterface + let valetTokenRepository: ValetTokenRepositoryInterface let request: Request let response: Response let next: NextFunction @@ -16,9 +18,12 @@ describe('ValetTokenAuthMiddleware', () => { error: jest.fn(), } as unknown as jest.Mocked - const createMiddleware = () => new ValetTokenAuthMiddleware(tokenDecoder, logger) + const createMiddleware = () => new ValetTokenAuthMiddleware(tokenDecoder, valetTokenRepository, logger) beforeEach(() => { + valetTokenRepository = {} as jest.Mocked + valetTokenRepository.isUsed = jest.fn().mockResolvedValue(false) + tokenDecoder = {} as jest.Mocked> tokenDecoder.decodeToken = jest.fn().mockReturnValue({ userUuid: '1-2-3', @@ -75,6 +80,7 @@ describe('ValetTokenAuthMiddleware', () => { ], uploadBytesLimit: -1, uploadBytesUsed: 80, + valetToken: 'valet-token', }) expect(next).toHaveBeenCalled() @@ -108,6 +114,7 @@ describe('ValetTokenAuthMiddleware', () => { ], uploadBytesLimit: -1, uploadBytesUsed: 80, + valetToken: 'valet-token', }) expect(next).toHaveBeenCalled() @@ -134,6 +141,16 @@ describe('ValetTokenAuthMiddleware', () => { expect(next).not.toHaveBeenCalled() }) + it('should not authorize user with valet token already used', async () => { + request.headers['x-valet-token'] = 'valet-token' + valetTokenRepository.isUsed = jest.fn().mockResolvedValue(true) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + it('should authorize user with no space left for upload for download operations', async () => { request.headers['x-valet-token'] = 'valet-token' @@ -163,6 +180,7 @@ describe('ValetTokenAuthMiddleware', () => { ], uploadBytesLimit: 100, uploadBytesUsed: 80, + valetToken: 'valet-token', }) expect(next).toHaveBeenCalled() diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.ts b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.ts index 8cb5c842d..e4dea0075 100644 --- a/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.ts +++ b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenAuthMiddleware.ts @@ -5,11 +5,14 @@ import { inject, injectable } from 'inversify' import { BaseMiddleware } from 'inversify-express-utils' import { Logger } from 'winston' import TYPES from '../../../Bootstrap/Types' +import { ValetTokenResponseLocals } from './ValetTokenResponseLocals' +import { ValetTokenRepositoryInterface } from '../../../Domain/ValetToken/ValetTokenRepositoryInterface' @injectable() export class ValetTokenAuthMiddleware extends BaseMiddleware { constructor( @inject(TYPES.Files_ValetTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Files_ValetTokenRepository) private valetTokenRepository: ValetTokenRepositoryInterface, @inject(TYPES.Files_Logger) private logger: Logger, ) { super() @@ -31,6 +34,22 @@ export class ValetTokenAuthMiddleware extends BaseMiddleware { return } + if (await this.valetTokenRepository.isUsed(valetToken)) { + this.logger.debug('Already used valet token.', { + valetToken, + codeTag: 'ValetTokenAuthMiddleware', + }) + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid valet token.', + }, + }) + + return + } + const valetTokenData = this.tokenDecoder.decodeToken(valetToken) if (valetTokenData === undefined) { @@ -73,12 +92,15 @@ export class ValetTokenAuthMiddleware extends BaseMiddleware { return } - response.locals.userUuid = valetTokenData.userUuid - response.locals.permittedResources = valetTokenData.permittedResources - response.locals.permittedOperation = valetTokenData.permittedOperation - response.locals.uploadBytesUsed = valetTokenData.uploadBytesUsed - response.locals.uploadBytesLimit = valetTokenData.uploadBytesLimit - response.locals.regularSubscriptionUuid = valetTokenData.regularSubscriptionUuid + Object.assign(response.locals, { + userUuid: valetTokenData.userUuid, + permittedResources: valetTokenData.permittedResources, + permittedOperation: valetTokenData.permittedOperation, + uploadBytesUsed: valetTokenData.uploadBytesUsed, + uploadBytesLimit: valetTokenData.uploadBytesLimit, + regularSubscriptionUuid: valetTokenData.regularSubscriptionUuid, + valetToken, + } as ValetTokenResponseLocals) return next() } catch (error) { diff --git a/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenResponseLocals.ts b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenResponseLocals.ts new file mode 100644 index 000000000..e5f256455 --- /dev/null +++ b/packages/files/src/Infra/InversifyExpress/Middleware/ValetTokenResponseLocals.ts @@ -0,0 +1,14 @@ +import { ValetTokenOperation } from '@standardnotes/security' + +export interface ValetTokenResponseLocals { + valetToken: string + userUuid: string + permittedResources: Array<{ + remoteIdentifier: string + unencryptedFileSize?: number + }> + permittedOperation: ValetTokenOperation + uploadBytesUsed: number + uploadBytesLimit: number + regularSubscriptionUuid: string +} diff --git a/packages/files/src/Infra/Redis/RedisValetTokenRepository.ts b/packages/files/src/Infra/Redis/RedisValetTokenRepository.ts new file mode 100644 index 000000000..1a41d8bc5 --- /dev/null +++ b/packages/files/src/Infra/Redis/RedisValetTokenRepository.ts @@ -0,0 +1,19 @@ +import * as IORedis from 'ioredis' + +import { ValetTokenRepositoryInterface } from '../../Domain/ValetToken/ValetTokenRepositoryInterface' + +export class RedisValetTokenRepository implements ValetTokenRepositoryInterface { + private readonly VALET_TOKEN_PREFIX = 'vt' + + constructor(private redisClient: IORedis.Redis) {} + + async markAsUsed(valetToken: string): Promise { + const dayInSeconds = 60 * 60 * 24 + + await this.redisClient.setex(`${this.VALET_TOKEN_PREFIX}:${valetToken}`, dayInSeconds, 'used') + } + + async isUsed(valetToken: string): Promise { + return (await this.redisClient.get(`${this.VALET_TOKEN_PREFIX}:${valetToken}`)) === 'used' + } +} diff --git a/packages/grpc/lib/auth_grpc_pb.d.ts b/packages/grpc/lib/auth_grpc_pb.d.ts index d525d9520..b2f58e58d 100644 --- a/packages/grpc/lib/auth_grpc_pb.d.ts +++ b/packages/grpc/lib/auth_grpc_pb.d.ts @@ -12,12 +12,12 @@ interface IAuthService extends grpc.ServiceDefinition { +interface IAuthService_Ivalidate extends grpc.MethodDefinition { path: "/auth.Auth/validate"; requestStream: false; responseStream: false; - requestSerialize: grpc.serialize; - requestDeserialize: grpc.deserialize; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; responseSerialize: grpc.serialize; responseDeserialize: grpc.deserialize; } @@ -34,14 +34,14 @@ interface IAuthService_IvalidateWebsocket extends grpc.MethodDefinition; + validate: grpc.handleUnaryCall; validateWebsocket: grpc.handleUnaryCall; } export interface IAuthClient { - validate(request: auth_pb.AuthorizationHeader, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; - validate(request: auth_pb.AuthorizationHeader, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; - validate(request: auth_pb.AuthorizationHeader, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + validate(request: auth_pb.RequestValidationOptions, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + validate(request: auth_pb.RequestValidationOptions, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + validate(request: auth_pb.RequestValidationOptions, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; @@ -49,9 +49,9 @@ export interface IAuthClient { export class AuthClient extends grpc.Client implements IAuthClient { constructor(address: string, credentials: grpc.ChannelCredentials, options?: object); - public validate(request: auth_pb.AuthorizationHeader, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; - public validate(request: auth_pb.AuthorizationHeader, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; - public validate(request: auth_pb.AuthorizationHeader, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + public validate(request: auth_pb.RequestValidationOptions, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + public validate(request: auth_pb.RequestValidationOptions, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; + public validate(request: auth_pb.RequestValidationOptions, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.SessionValidationResponse) => void): grpc.ClientUnaryCall; public validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; public validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; public validateWebsocket(request: auth_pb.WebsocketConnectionAuthorizationHeader, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: auth_pb.ConnectionValidationResponse) => void): grpc.ClientUnaryCall; diff --git a/packages/grpc/lib/auth_grpc_pb.js b/packages/grpc/lib/auth_grpc_pb.js index a56d9ea4f..2bc408b81 100644 --- a/packages/grpc/lib/auth_grpc_pb.js +++ b/packages/grpc/lib/auth_grpc_pb.js @@ -4,17 +4,6 @@ var grpc = require('@grpc/grpc-js'); var auth_pb = require('./auth_pb.js'); -function serialize_auth_AuthorizationHeader(arg) { - if (!(arg instanceof auth_pb.AuthorizationHeader)) { - throw new Error('Expected argument of type auth.AuthorizationHeader'); - } - return Buffer.from(arg.serializeBinary()); -} - -function deserialize_auth_AuthorizationHeader(buffer_arg) { - return auth_pb.AuthorizationHeader.deserializeBinary(new Uint8Array(buffer_arg)); -} - function serialize_auth_ConnectionValidationResponse(arg) { if (!(arg instanceof auth_pb.ConnectionValidationResponse)) { throw new Error('Expected argument of type auth.ConnectionValidationResponse'); @@ -26,6 +15,17 @@ function deserialize_auth_ConnectionValidationResponse(buffer_arg) { return auth_pb.ConnectionValidationResponse.deserializeBinary(new Uint8Array(buffer_arg)); } +function serialize_auth_RequestValidationOptions(arg) { + if (!(arg instanceof auth_pb.RequestValidationOptions)) { + throw new Error('Expected argument of type auth.RequestValidationOptions'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_auth_RequestValidationOptions(buffer_arg) { + return auth_pb.RequestValidationOptions.deserializeBinary(new Uint8Array(buffer_arg)); +} + function serialize_auth_SessionValidationResponse(arg) { if (!(arg instanceof auth_pb.SessionValidationResponse)) { throw new Error('Expected argument of type auth.SessionValidationResponse'); @@ -54,10 +54,10 @@ var AuthService = exports.AuthService = { path: '/auth.Auth/validate', requestStream: false, responseStream: false, - requestType: auth_pb.AuthorizationHeader, + requestType: auth_pb.RequestValidationOptions, responseType: auth_pb.SessionValidationResponse, - requestSerialize: serialize_auth_AuthorizationHeader, - requestDeserialize: deserialize_auth_AuthorizationHeader, + requestSerialize: serialize_auth_RequestValidationOptions, + requestDeserialize: deserialize_auth_RequestValidationOptions, responseSerialize: serialize_auth_SessionValidationResponse, responseDeserialize: deserialize_auth_SessionValidationResponse, }, diff --git a/packages/grpc/lib/auth_pb.d.ts b/packages/grpc/lib/auth_pb.d.ts index 7bb7b0f9b..57f2e9610 100644 --- a/packages/grpc/lib/auth_pb.d.ts +++ b/packages/grpc/lib/auth_pb.d.ts @@ -6,23 +6,57 @@ import * as jspb from "google-protobuf"; -export class AuthorizationHeader extends jspb.Message { - getBearerToken(): string; - setBearerToken(value: string): AuthorizationHeader; +export class Cookie extends jspb.Message { + getName(): string; + setName(value: string): Cookie; + getValue(): string; + setValue(value: string): Cookie; serializeBinary(): Uint8Array; - toObject(includeInstance?: boolean): AuthorizationHeader.AsObject; - static toObject(includeInstance: boolean, msg: AuthorizationHeader): AuthorizationHeader.AsObject; + toObject(includeInstance?: boolean): Cookie.AsObject; + static toObject(includeInstance: boolean, msg: Cookie): Cookie.AsObject; static extensions: {[key: number]: jspb.ExtensionFieldInfo}; static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; - static serializeBinaryToWriter(message: AuthorizationHeader, writer: jspb.BinaryWriter): void; - static deserializeBinary(bytes: Uint8Array): AuthorizationHeader; - static deserializeBinaryFromReader(message: AuthorizationHeader, reader: jspb.BinaryReader): AuthorizationHeader; + static serializeBinaryToWriter(message: Cookie, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): Cookie; + static deserializeBinaryFromReader(message: Cookie, reader: jspb.BinaryReader): Cookie; } -export namespace AuthorizationHeader { +export namespace Cookie { + export type AsObject = { + name: string, + value: string, + } +} + +export class RequestValidationOptions extends jspb.Message { + getBearerToken(): string; + setBearerToken(value: string): RequestValidationOptions; + clearCookieList(): void; + getCookieList(): Array; + setCookieList(value: Array): RequestValidationOptions; + addCookie(value?: Cookie, index?: number): Cookie; + + hasSharedVaultOwnerContext(): boolean; + clearSharedVaultOwnerContext(): void; + getSharedVaultOwnerContext(): string | undefined; + setSharedVaultOwnerContext(value: string): RequestValidationOptions; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): RequestValidationOptions.AsObject; + static toObject(includeInstance: boolean, msg: RequestValidationOptions): RequestValidationOptions.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: RequestValidationOptions, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): RequestValidationOptions; + static deserializeBinaryFromReader(message: RequestValidationOptions, reader: jspb.BinaryReader): RequestValidationOptions; +} + +export namespace RequestValidationOptions { export type AsObject = { bearerToken: string, + cookieList: Array, + sharedVaultOwnerContext?: string, } } diff --git a/packages/grpc/lib/auth_pb.js b/packages/grpc/lib/auth_pb.js index 1e34c4e2b..fb49ebe06 100644 --- a/packages/grpc/lib/auth_pb.js +++ b/packages/grpc/lib/auth_pb.js @@ -21,8 +21,9 @@ var global = (function() { return Function('return this')(); }.call(null)); -goog.exportSymbol('proto.auth.AuthorizationHeader', null, global); goog.exportSymbol('proto.auth.ConnectionValidationResponse', null, global); +goog.exportSymbol('proto.auth.Cookie', null, global); +goog.exportSymbol('proto.auth.RequestValidationOptions', null, global); goog.exportSymbol('proto.auth.SessionValidationResponse', null, global); goog.exportSymbol('proto.auth.WebsocketConnectionAuthorizationHeader', null, global); /** @@ -35,16 +36,37 @@ goog.exportSymbol('proto.auth.WebsocketConnectionAuthorizationHeader', null, glo * @extends {jspb.Message} * @constructor */ -proto.auth.AuthorizationHeader = function(opt_data) { +proto.auth.Cookie = function(opt_data) { jspb.Message.initialize(this, opt_data, 0, -1, null, null); }; -goog.inherits(proto.auth.AuthorizationHeader, jspb.Message); +goog.inherits(proto.auth.Cookie, jspb.Message); if (goog.DEBUG && !COMPILED) { /** * @public * @override */ - proto.auth.AuthorizationHeader.displayName = 'proto.auth.AuthorizationHeader'; + proto.auth.Cookie.displayName = 'proto.auth.Cookie'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.auth.RequestValidationOptions = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.auth.RequestValidationOptions.repeatedFields_, null); +}; +goog.inherits(proto.auth.RequestValidationOptions, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.auth.RequestValidationOptions.displayName = 'proto.auth.RequestValidationOptions'; } /** * Generated by JsPbCodeGenerator. @@ -125,8 +147,8 @@ if (jspb.Message.GENERATE_TO_OBJECT) { * http://goto/soy-param-migration * @return {!Object} */ -proto.auth.AuthorizationHeader.prototype.toObject = function(opt_includeInstance) { - return proto.auth.AuthorizationHeader.toObject(opt_includeInstance, this); +proto.auth.Cookie.prototype.toObject = function(opt_includeInstance) { + return proto.auth.Cookie.toObject(opt_includeInstance, this); }; @@ -135,13 +157,14 @@ proto.auth.AuthorizationHeader.prototype.toObject = function(opt_includeInstance * @param {boolean|undefined} includeInstance Deprecated. Whether to include * the JSPB instance for transitional soy proto support: * http://goto/soy-param-migration - * @param {!proto.auth.AuthorizationHeader} msg The msg instance to transform. + * @param {!proto.auth.Cookie} msg The msg instance to transform. * @return {!Object} * @suppress {unusedLocalVariables} f is only used for nested messages */ -proto.auth.AuthorizationHeader.toObject = function(includeInstance, msg) { +proto.auth.Cookie.toObject = function(includeInstance, msg) { var f, obj = { - bearerToken: jspb.Message.getFieldWithDefault(msg, 1, "") + name: jspb.Message.getFieldWithDefault(msg, 1, ""), + value: jspb.Message.getFieldWithDefault(msg, 2, "") }; if (includeInstance) { @@ -155,23 +178,23 @@ proto.auth.AuthorizationHeader.toObject = function(includeInstance, msg) { /** * Deserializes binary data (in protobuf wire format). * @param {jspb.ByteSource} bytes The bytes to deserialize. - * @return {!proto.auth.AuthorizationHeader} + * @return {!proto.auth.Cookie} */ -proto.auth.AuthorizationHeader.deserializeBinary = function(bytes) { +proto.auth.Cookie.deserializeBinary = function(bytes) { var reader = new jspb.BinaryReader(bytes); - var msg = new proto.auth.AuthorizationHeader; - return proto.auth.AuthorizationHeader.deserializeBinaryFromReader(msg, reader); + var msg = new proto.auth.Cookie; + return proto.auth.Cookie.deserializeBinaryFromReader(msg, reader); }; /** * Deserializes binary data (in protobuf wire format) from the * given reader into the given message object. - * @param {!proto.auth.AuthorizationHeader} msg The message object to deserialize into. + * @param {!proto.auth.Cookie} msg The message object to deserialize into. * @param {!jspb.BinaryReader} reader The BinaryReader to use. - * @return {!proto.auth.AuthorizationHeader} + * @return {!proto.auth.Cookie} */ -proto.auth.AuthorizationHeader.deserializeBinaryFromReader = function(msg, reader) { +proto.auth.Cookie.deserializeBinaryFromReader = function(msg, reader) { while (reader.nextField()) { if (reader.isEndGroup()) { break; @@ -180,7 +203,11 @@ proto.auth.AuthorizationHeader.deserializeBinaryFromReader = function(msg, reade switch (field) { case 1: var value = /** @type {string} */ (reader.readString()); - msg.setBearerToken(value); + msg.setName(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setValue(value); break; default: reader.skipField(); @@ -195,9 +222,9 @@ proto.auth.AuthorizationHeader.deserializeBinaryFromReader = function(msg, reade * Serializes the message to binary data (in protobuf wire format). * @return {!Uint8Array} */ -proto.auth.AuthorizationHeader.prototype.serializeBinary = function() { +proto.auth.Cookie.prototype.serializeBinary = function() { var writer = new jspb.BinaryWriter(); - proto.auth.AuthorizationHeader.serializeBinaryToWriter(this, writer); + proto.auth.Cookie.serializeBinaryToWriter(this, writer); return writer.getResultBuffer(); }; @@ -205,11 +232,185 @@ proto.auth.AuthorizationHeader.prototype.serializeBinary = function() { /** * Serializes the given message to binary data (in protobuf wire * format), writing to the given BinaryWriter. - * @param {!proto.auth.AuthorizationHeader} message + * @param {!proto.auth.Cookie} message * @param {!jspb.BinaryWriter} writer * @suppress {unusedLocalVariables} f is only used for nested messages */ -proto.auth.AuthorizationHeader.serializeBinaryToWriter = function(message, writer) { +proto.auth.Cookie.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getName(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getValue(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } +}; + + +/** + * optional string name = 1; + * @return {string} + */ +proto.auth.Cookie.prototype.getName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.auth.Cookie} returns this + */ +proto.auth.Cookie.prototype.setName = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional string value = 2; + * @return {string} + */ +proto.auth.Cookie.prototype.getValue = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.auth.Cookie} returns this + */ +proto.auth.Cookie.prototype.setValue = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.auth.RequestValidationOptions.repeatedFields_ = [2]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.auth.RequestValidationOptions.prototype.toObject = function(opt_includeInstance) { + return proto.auth.RequestValidationOptions.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.auth.RequestValidationOptions} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.auth.RequestValidationOptions.toObject = function(includeInstance, msg) { + var f, obj = { + bearerToken: jspb.Message.getFieldWithDefault(msg, 1, ""), + cookieList: jspb.Message.toObjectList(msg.getCookieList(), + proto.auth.Cookie.toObject, includeInstance), + sharedVaultOwnerContext: jspb.Message.getFieldWithDefault(msg, 3, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.auth.RequestValidationOptions} + */ +proto.auth.RequestValidationOptions.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.auth.RequestValidationOptions; + return proto.auth.RequestValidationOptions.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.auth.RequestValidationOptions} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.auth.RequestValidationOptions} + */ +proto.auth.RequestValidationOptions.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setBearerToken(value); + break; + case 2: + var value = new proto.auth.Cookie; + reader.readMessage(value,proto.auth.Cookie.deserializeBinaryFromReader); + msg.addCookie(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.setSharedVaultOwnerContext(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.auth.RequestValidationOptions.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.auth.RequestValidationOptions.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.auth.RequestValidationOptions} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.auth.RequestValidationOptions.serializeBinaryToWriter = function(message, writer) { var f = undefined; f = message.getBearerToken(); if (f.length > 0) { @@ -218,6 +419,21 @@ proto.auth.AuthorizationHeader.serializeBinaryToWriter = function(message, write f ); } + f = message.getCookieList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 2, + f, + proto.auth.Cookie.serializeBinaryToWriter + ); + } + f = /** @type {string} */ (jspb.Message.getField(message, 3)); + if (f != null) { + writer.writeString( + 3, + f + ); + } }; @@ -225,20 +441,94 @@ proto.auth.AuthorizationHeader.serializeBinaryToWriter = function(message, write * optional string bearer_token = 1; * @return {string} */ -proto.auth.AuthorizationHeader.prototype.getBearerToken = function() { +proto.auth.RequestValidationOptions.prototype.getBearerToken = function() { return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); }; /** * @param {string} value - * @return {!proto.auth.AuthorizationHeader} returns this + * @return {!proto.auth.RequestValidationOptions} returns this */ -proto.auth.AuthorizationHeader.prototype.setBearerToken = function(value) { +proto.auth.RequestValidationOptions.prototype.setBearerToken = function(value) { return jspb.Message.setProto3StringField(this, 1, value); }; +/** + * repeated Cookie cookie = 2; + * @return {!Array} + */ +proto.auth.RequestValidationOptions.prototype.getCookieList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.auth.Cookie, 2)); +}; + + +/** + * @param {!Array} value + * @return {!proto.auth.RequestValidationOptions} returns this +*/ +proto.auth.RequestValidationOptions.prototype.setCookieList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 2, value); +}; + + +/** + * @param {!proto.auth.Cookie=} opt_value + * @param {number=} opt_index + * @return {!proto.auth.Cookie} + */ +proto.auth.RequestValidationOptions.prototype.addCookie = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 2, opt_value, proto.auth.Cookie, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.auth.RequestValidationOptions} returns this + */ +proto.auth.RequestValidationOptions.prototype.clearCookieList = function() { + return this.setCookieList([]); +}; + + +/** + * optional string shared_vault_owner_context = 3; + * @return {string} + */ +proto.auth.RequestValidationOptions.prototype.getSharedVaultOwnerContext = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * @param {string} value + * @return {!proto.auth.RequestValidationOptions} returns this + */ +proto.auth.RequestValidationOptions.prototype.setSharedVaultOwnerContext = function(value) { + return jspb.Message.setField(this, 3, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.auth.RequestValidationOptions} returns this + */ +proto.auth.RequestValidationOptions.prototype.clearSharedVaultOwnerContext = function() { + return jspb.Message.setField(this, 3, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.auth.RequestValidationOptions.prototype.hasSharedVaultOwnerContext = function() { + return jspb.Message.getField(this, 3) != null; +}; + + diff --git a/packages/grpc/proto/auth.proto b/packages/grpc/proto/auth.proto index 6ee183dfd..ef36e6f1c 100644 --- a/packages/grpc/proto/auth.proto +++ b/packages/grpc/proto/auth.proto @@ -2,8 +2,15 @@ syntax = "proto3"; package auth; -message AuthorizationHeader { +message Cookie { + string name = 1; + string value = 2; +} + +message RequestValidationOptions { string bearer_token = 1; + repeated Cookie cookie = 2; + optional string shared_vault_owner_context = 3; } message SessionValidationResponse { @@ -19,6 +26,6 @@ message ConnectionValidationResponse { } service Auth { - rpc validate(AuthorizationHeader) returns (SessionValidationResponse) {} + rpc validate(RequestValidationOptions) returns (SessionValidationResponse) {} rpc validateWebsocket(WebsocketConnectionAuthorizationHeader) returns (ConnectionValidationResponse) {} } diff --git a/packages/home-server/.env.sample b/packages/home-server/.env.sample index 33bc977b0..0ae57cfc1 100644 --- a/packages/home-server/.env.sample +++ b/packages/home-server/.env.sample @@ -9,3 +9,7 @@ PSEUDO_KEY_PARAMS_KEY= VALET_TOKEN_SECRET= FILES_SERVER_URL= + +COOKIE_DOMAIN=localhost +COOKIE_SECURE=false +COOKIE_PARTITIONED=false diff --git a/packages/home-server/CHANGELOG.md b/packages/home-server/CHANGELOG.md index 3eb34f526..a4daf83f2 100644 --- a/packages/home-server/CHANGELOG.md +++ b/packages/home-server/CHANGELOG.md @@ -3,34 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [1.23.0](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.68...@standardnotes/home-server@1.23.0) (2024-03-20) - -### Features - -* add CORS_ORIGIN_STRICT_MODE_ENABLED env var to determine if CORS origin should be restricted ([5c02435](https://github.com/standardnotes/server/commit/5c02435ee478b893747d3f9e41062aae12d7ff10)) - -## [1.22.68](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.67...@standardnotes/home-server@1.22.68) (2024-03-18) - -**Note:** Version bump only for package @standardnotes/home-server - -## [1.22.67](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.66...@standardnotes/home-server@1.22.67) (2024-03-18) - -### Bug Fixes - -* cors issues on clients - fixes [#1046](https://github.com/standardnotes/server/issues/1046) ([#1049](https://github.com/standardnotes/server/issues/1049)) ([6d7ca1b](https://github.com/standardnotes/server/commit/6d7ca1b926fd45d744275bd3c1f4c05b010f76c8)) - -## [1.22.66](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.65...@standardnotes/home-server@1.22.66) (2024-03-18) - -**Note:** Version bump only for package @standardnotes/home-server - -## [1.22.65](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.64...@standardnotes/home-server@1.22.65) (2024-03-15) - -**Note:** Version bump only for package @standardnotes/home-server - -## [1.22.64](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.63...@standardnotes/home-server@1.22.64) (2024-02-09) - -**Note:** Version bump only for package @standardnotes/home-server - ## [1.22.63](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.22.62...@standardnotes/home-server@1.22.63) (2024-01-19) **Note:** Version bump only for package @standardnotes/home-server diff --git a/packages/home-server/package.json b/packages/home-server/package.json index 2c90ab310..8ff53e6bb 100644 --- a/packages/home-server/package.json +++ b/packages/home-server/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/home-server", - "version": "1.23.0", + "version": "1.22.63", "engines": { "node": ">=18.0.0 <21.0.0" }, @@ -33,6 +33,7 @@ "@standardnotes/files-server": "workspace:^", "@standardnotes/revisions-server": "workspace:^", "@standardnotes/syncing-server": "workspace:^", + "cookie-parser": "^1.4.6", "cors": "2.8.5", "dotenv": "^16.0.1", "express": "^4.18.2", @@ -44,6 +45,7 @@ "winston": "^3.8.1" }, "devDependencies": { + "@types/cookie-parser": "^1", "@types/cors": "^2.8.9", "@types/express": "^4.17.14", "@typescript-eslint/eslint-plugin": "^6.5.0", diff --git a/packages/home-server/src/Server/HomeServer.ts b/packages/home-server/src/Server/HomeServer.ts index e01d54f50..c724bb942 100644 --- a/packages/home-server/src/Server/HomeServer.ts +++ b/packages/home-server/src/Server/HomeServer.ts @@ -11,6 +11,7 @@ import { Container } from 'inversify' import { InversifyExpressServer } from 'inversify-express-utils' import helmet from 'helmet' import * as cors from 'cors' +import * as cookieParser from 'cookie-parser' import * as http from 'http' import { text, json, Request, Response, NextFunction, raw } from 'express' import * as winston from 'winston' @@ -129,6 +130,9 @@ export class HomeServer implements HomeServerInterface { ], }), ) + + app.use(cookieParser()) + const corsAllowedOrigins = env.get('CORS_ALLOWED_ORIGINS', true) ? env.get('CORS_ALLOWED_ORIGINS', true).split(',') : [] diff --git a/packages/predicates/package.json b/packages/predicates/package.json index ab583f740..41dfb20bb 100644 --- a/packages/predicates/package.json +++ b/packages/predicates/package.json @@ -28,7 +28,7 @@ "start": "tsc -p tsconfig.json --watch", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest --coverage --no-cache --passWithNoTests" + "test": "jest --coverage --no-cache --passWithNoTests --maxWorkers=2" }, "devDependencies": { "@types/jest": "^29.5.1", diff --git a/packages/revisions/Dockerfile b/packages/revisions/Dockerfile index a76805ee8..185e30399 100644 --- a/packages/revisions/Dockerfile +++ b/packages/revisions/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/revisions ENTRYPOINT [ "/workspace/packages/revisions/docker/entrypoint.sh" ] diff --git a/packages/revisions/package.json b/packages/revisions/package.json index 02e2d7560..129200ff2 100644 --- a/packages/revisions/package.json +++ b/packages/revisions/package.json @@ -24,8 +24,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "start": "yarn node dist/bin/server.js", "worker": "yarn node dist/bin/worker.js" }, @@ -47,7 +46,7 @@ "inversify": "^6.0.1", "inversify-express-utils": "^6.4.3", "ioredis": "^5.3.2", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "reflect-metadata": "^0.2.1", "sqlite3": "^5.1.6", "typeorm": "^0.3.17", diff --git a/packages/scheduler/Dockerfile b/packages/scheduler/Dockerfile index f17c88332..90982b25d 100644 --- a/packages/scheduler/Dockerfile +++ b/packages/scheduler/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/scheduler ENTRYPOINT [ "/workspace/packages/scheduler/docker/entrypoint.sh" ] diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json index c049b292b..1c0484293 100644 --- a/packages/scheduler/package.json +++ b/packages/scheduler/package.json @@ -20,8 +20,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "worker": "yarn node dist/bin/worker.js", "verify:jobs": "yarn node dist/bin/verify.js", "setup:env": "cp .env.sample .env", @@ -39,7 +38,7 @@ "dotenv": "^16.0.1", "inversify": "^6.0.1", "ioredis": "^5.2.4", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "reflect-metadata": "^0.2.1", "typeorm": "^0.3.17", "winston": "^3.8.1" diff --git a/packages/security/package.json b/packages/security/package.json index 76ba31016..2875e78c0 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -28,7 +28,7 @@ "start": "tsc -p tsconfig.json --watch", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest --coverage --no-cache" + "test": "jest --coverage --no-cache --maxWorkers=2" }, "dependencies": { "jsonwebtoken": "^9.0.0", diff --git a/packages/security/src/Domain/Token/CrossServiceTokenData.ts b/packages/security/src/Domain/Token/CrossServiceTokenData.ts index 8064c74fa..2eafa4ae7 100644 --- a/packages/security/src/Domain/Token/CrossServiceTokenData.ts +++ b/packages/security/src/Domain/Token/CrossServiceTokenData.ts @@ -24,4 +24,5 @@ export type CrossServiceTokenData = { refresh_expiration: string } extensionKey?: string + hasContentLimit?: boolean } diff --git a/packages/settings/src/Domain/index.ts b/packages/settings/src/Domain/index.ts index 0a29f80aa..483a0223c 100644 --- a/packages/settings/src/Domain/index.ts +++ b/packages/settings/src/Domain/index.ts @@ -1,6 +1,5 @@ export * from './EmailBackupFrequency/EmailBackupFrequency' export * from './ListedAuthorSecretsData/ListedAuthorSecretsData' export * from './LogSessionUserAgent/LogSessionUserAgentOption' -export * from './MuteFailedBackupsEmails/MuteFailedBackupsEmailsOption' export * from './MuteMarketingEmails/MuteMarketingEmailsOption' export * from './MuteSignInEmails/MuteSignInEmailsOption' diff --git a/packages/sncrypto-node/package.json b/packages/sncrypto-node/package.json index 9b5607dbe..b6b32cc23 100644 --- a/packages/sncrypto-node/package.json +++ b/packages/sncrypto-node/package.json @@ -26,7 +26,7 @@ "clean": "rm -fr dist", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest spec" + "test": "jest --coverage --no-cache --maxWorkers=2" }, "dependencies": { "@standardnotes/sncrypto-common": "^1.13.4", diff --git a/packages/syncing-server/CHANGELOG.md b/packages/syncing-server/CHANGELOG.md index c7e3f74fb..4f58a6176 100644 --- a/packages/syncing-server/CHANGELOG.md +++ b/packages/syncing-server/CHANGELOG.md @@ -3,12 +3,6 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## [1.136.2](https://github.com/standardnotes/server/compare/@standardnotes/syncing-server@1.136.1...@standardnotes/syncing-server@1.136.2) (2024-03-15) - -### Bug Fixes - -* allow handling of new api version ([9d49764](https://github.com/standardnotes/server/commit/9d49764b841e73655e19523eddf10498addc9fb4)) - ## [1.136.1](https://github.com/standardnotes/server/compare/@standardnotes/syncing-server@1.136.0...@standardnotes/syncing-server@1.136.1) (2024-01-19) **Note:** Version bump only for package @standardnotes/syncing-server diff --git a/packages/syncing-server/Dockerfile b/packages/syncing-server/Dockerfile index f17a3eae3..ab16d812f 100644 --- a/packages/syncing-server/Dockerfile +++ b/packages/syncing-server/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/syncing-server ENTRYPOINT [ "/workspace/packages/syncing-server/docker/entrypoint.sh" ] diff --git a/packages/syncing-server/package.json b/packages/syncing-server/package.json index 596d15dbd..76179fdf6 100644 --- a/packages/syncing-server/package.json +++ b/packages/syncing-server/package.json @@ -1,6 +1,6 @@ { "name": "@standardnotes/syncing-server", - "version": "1.136.2", + "version": "1.136.1", "engines": { "node": ">=18.0.0 <21.0.0" }, @@ -24,8 +24,7 @@ "build": "tsc --build", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "start": "yarn node dist/bin/server.js", "worker": "yarn node dist/bin/worker.js", "content-size": "yarn node dist/bin/content.js", @@ -56,7 +55,7 @@ "inversify-express-utils": "^6.4.3", "ioredis": "^5.3.2", "jsonwebtoken": "^9.0.0", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "prettyjson": "^1.2.5", "reflect-metadata": "^0.2.1", "semver": "^7.5.4", diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index dc93e4a12..9d20a8ce4 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/packages/syncing-server/src/Bootstrap/Container.ts @@ -169,8 +169,10 @@ import { DummyMetricStore } from '../Infra/Dummy/DummyMetricStore' import { CheckForTrafficAbuse } from '../Domain/UseCase/Syncing/CheckForTrafficAbuse/CheckForTrafficAbuse' import { FixContentSizes } from '../Domain/UseCase/Syncing/FixContentSizes/FixContentSizes' import { ContentSizesFixRequestedEventHandler } from '../Domain/Handler/ContentSizesFixRequestedEventHandler' +import { CheckForContentLimit } from '../Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit' export class ContainerConfigLoader { + private readonly DEFAULT_FREE_USER_CONTENT_LIMIT_BYTES = 100_000_000 private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000 private readonly DEFAULT_MAX_ITEMS_LIMIT = 300 private readonly DEFAULT_FILE_UPLOAD_PATH = `${__dirname}/../../uploads` @@ -538,6 +540,13 @@ export class ContainerConfigLoader { .toConstantValue( env.get('MAX_ITEMS_LIMIT', true) ? +env.get('MAX_ITEMS_LIMIT', true) : this.DEFAULT_MAX_ITEMS_LIMIT, ) + container + .bind(TYPES.Sync_FREE_USER_CONTENT_LIMIT_BYTES) + .toConstantValue( + env.get('FREE_USER_CONTENT_LIMIT_BYTES', true) + ? +env.get('FREE_USER_CONTENT_LIMIT_BYTES', true) + : this.DEFAULT_FREE_USER_CONTENT_LIMIT_BYTES, + ) container.bind(TYPES.Sync_VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true)) container .bind(TYPES.Sync_VALET_TOKEN_TTL) @@ -691,6 +700,14 @@ export class ContainerConfigLoader { container.get(TYPES.Sync_MetricsStore), ), ) + container + .bind(TYPES.Sync_CheckForContentLimit) + .toConstantValue( + new CheckForContentLimit( + container.get(TYPES.Sync_SQLItemRepository), + container.get(TYPES.Sync_FREE_USER_CONTENT_LIMIT_BYTES), + ), + ) container .bind(TYPES.Sync_SaveItems) .toConstantValue( @@ -703,6 +720,7 @@ export class ContainerConfigLoader { container.get(TYPES.Sync_SendEventToClient), container.get(TYPES.Sync_SendEventToClients), container.get(TYPES.Sync_DomainEventFactory), + container.get(TYPES.Sync_CheckForContentLimit), container.get(TYPES.Sync_Logger), ), ) diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index 7d71b9a33..1c2abbebb 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/packages/syncing-server/src/Bootstrap/Types.ts @@ -38,6 +38,7 @@ const TYPES = { Sync_VERSION: Symbol.for('Sync_VERSION'), Sync_CONTENT_SIZE_TRANSFER_LIMIT: Symbol.for('Sync_CONTENT_SIZE_TRANSFER_LIMIT'), Sync_MAX_ITEMS_LIMIT: Symbol.for('Sync_MAX_ITEMS_LIMIT'), + Sync_FREE_USER_CONTENT_LIMIT_BYTES: Symbol.for('Sync_FREE_USER_CONTENT_LIMIT_BYTES'), Sync_FILE_UPLOAD_PATH: Symbol.for('Sync_FILE_UPLOAD_PATH'), Sync_VALET_TOKEN_SECRET: Symbol.for('Sync_VALET_TOKEN_SECRET'), Sync_VALET_TOKEN_TTL: Symbol.for('Sync_VALET_TOKEN_TTL'), @@ -84,6 +85,7 @@ const TYPES = { Sync_UpdateExistingItem: Symbol.for('Sync_UpdateExistingItem'), Sync_GetItems: Symbol.for('Sync_GetItems'), Sync_SaveItems: Symbol.for('Sync_SaveItems'), + Sync_CheckForContentLimit: Symbol.for('Sync_CheckForContentLimit'), Sync_GetUserNotifications: Symbol.for('Sync_GetUserNotifications'), Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'), Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'), diff --git a/packages/syncing-server/src/Domain/Item/ItemHash.ts b/packages/syncing-server/src/Domain/Item/ItemHash.ts index 447e8a8b6..95351be32 100644 --- a/packages/syncing-server/src/Domain/Item/ItemHash.ts +++ b/packages/syncing-server/src/Domain/Item/ItemHash.ts @@ -22,6 +22,10 @@ export class ItemHash extends ValueObject { return this.props.shared_vault_uuid !== null } + calculateContentSize(): number { + return Buffer.byteLength(JSON.stringify(this)) + } + get sharedVaultUuid(): Uuid | null { if (!this.representsASharedVaultItem()) { return null diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts new file mode 100644 index 000000000..476b2e89a --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.spec.ts @@ -0,0 +1,95 @@ +import { ContentType } from '@standardnotes/domain-core' +import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor' +import { ItemHash } from '../../../Item/ItemHash' +import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface' +import { CheckForContentLimit } from './CheckForContentLimit' + +describe('CheckForContentLimit', () => { + let itemRepository: ItemRepositoryInterface + let freeUserContentLimitInBytes: number + let itemHash: ItemHash + + const createUseCase = () => new CheckForContentLimit(itemRepository, freeUserContentLimitInBytes) + + beforeEach(() => { + itemRepository = {} as ItemRepositoryInterface + + itemHash = ItemHash.create({ + uuid: '00000000-0000-0000-0000-000000000000', + content: 'test content', + content_type: ContentType.TYPES.Note, + user_uuid: '00000000-0000-0000-0000-000000000000', + key_system_identifier: null, + shared_vault_uuid: null, + }).getValue() + + freeUserContentLimitInBytes = 100 + }) + + it('should return a failure result if user uuid is invalid', async () => { + const useCase = createUseCase() + const result = await useCase.execute({ userUuid: 'invalid-uuid', itemsBeingModified: [itemHash] }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return a failure result if user has exceeded their content limit', async () => { + itemRepository.findContentSizeForComputingTransferLimit = jest + .fn() + .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 101).getValue()]) + + const useCase = createUseCase() + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + itemsBeingModified: [itemHash], + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should return a success result if user has not exceeded their content limit', async () => { + itemRepository.findContentSizeForComputingTransferLimit = jest + .fn() + .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 99).getValue()]) + + const useCase = createUseCase() + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + itemsBeingModified: [itemHash], + }) + + expect(result.isFailed()).toBe(false) + }) + + it('should return a success result if user has exceeded their content limit but user modifications are not increasing content size', async () => { + itemHash.calculateContentSize = jest.fn().mockReturnValue(99) + + itemRepository.findContentSizeForComputingTransferLimit = jest + .fn() + .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 101).getValue()]) + + const useCase = createUseCase() + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + itemsBeingModified: [itemHash], + }) + + expect(result.isFailed()).toBe(false) + }) + + it('should treat items with no content size defined as 0', async () => { + itemHash.calculateContentSize = jest.fn().mockReturnValue(99) + + itemRepository.findContentSizeForComputingTransferLimit = jest + .fn() + .mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', null).getValue()]) + + const useCase = createUseCase() + const result = await useCase.execute({ + userUuid: '00000000-0000-0000-0000-000000000000', + itemsBeingModified: [itemHash], + }) + + expect(result.isFailed()).toBe(false) + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts new file mode 100644 index 000000000..3bc49778d --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimit.ts @@ -0,0 +1,66 @@ +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { CheckForContentLimitDTO } from './CheckForContentLimitDTO' +import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface' +import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor' +import { ItemHash } from '../../../Item/ItemHash' + +export class CheckForContentLimit implements UseCaseInterface { + constructor( + private itemRepository: ItemRepositoryInterface, + private freeUserContentLimitInBytes: number, + ) {} + + async execute(dto: CheckForContentLimitDTO): Promise> { + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(userUuidOrError.getError()) + } + const userUuid = userUuidOrError.getValue() + + const contentSizeDescriptors = await this.itemRepository.findContentSizeForComputingTransferLimit({ + userUuid: userUuid.value, + }) + + const isContentLimitExceeded = await this.isContentLimitExceeded(contentSizeDescriptors) + const isUserModificationsIncreasingContentSize = this.userModificationsAreIncreasingContentSize( + contentSizeDescriptors, + dto.itemsBeingModified, + ) + + if (isContentLimitExceeded && isUserModificationsIncreasingContentSize) { + return Result.fail('You have exceeded your content limit. Please upgrade your account.') + } + + return Result.ok() + } + + private userModificationsAreIncreasingContentSize( + contentSizeDescriptors: ItemContentSizeDescriptor[], + itemHashes: ItemHash[], + ): boolean { + for (const itemHash of itemHashes) { + const contentSizeDescriptor = contentSizeDescriptors.find( + (descriptor) => descriptor.props.uuid.value === itemHash.props.uuid, + ) + if (contentSizeDescriptor) { + const afterModificationSize = itemHash.calculateContentSize() + const beforeModificationSize = contentSizeDescriptor.props.contentSize ?? 0 + if (afterModificationSize > beforeModificationSize) { + return true + } + } + } + + return false + } + + private async isContentLimitExceeded(contentSizeDescriptors: ItemContentSizeDescriptor[]): Promise { + const totalContentSize = contentSizeDescriptors.reduce( + (acc, descriptor) => acc + (descriptor.props.contentSize ? +descriptor.props.contentSize : 0), + 0, + ) + + return totalContentSize > this.freeUserContentLimitInBytes + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts new file mode 100644 index 000000000..41220e9c9 --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/CheckForContentLimit/CheckForContentLimitDTO.ts @@ -0,0 +1,6 @@ +import { ItemHash } from '../../../Item/ItemHash' + +export interface CheckForContentLimitDTO { + userUuid: string + itemsBeingModified: ItemHash[] +} diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts index 5a74dc780..33c42398a 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.spec.ts @@ -13,6 +13,7 @@ import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryIn import { ItemsChangedOnServerEvent } from '@standardnotes/domain-events' import { SendEventToClients } from '../SendEventToClients/SendEventToClients' import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation' +import { CheckForContentLimit } from '../CheckForContentLimit/CheckForContentLimit' describe('SaveItems', () => { let itemSaveValidator: ItemSaveValidatorInterface @@ -26,6 +27,7 @@ describe('SaveItems', () => { let sendEventToClient: SendEventToClient let sendEventToClients: SendEventToClients let domainEventFactory: DomainEventFactoryInterface + let checkForContentLimit: CheckForContentLimit const createUseCase = () => new SaveItems( @@ -37,10 +39,14 @@ describe('SaveItems', () => { sendEventToClient, sendEventToClients, domainEventFactory, + checkForContentLimit, logger, ) beforeEach(() => { + checkForContentLimit = {} as jest.Mocked + checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.ok()) + sendEventToClient = {} as jest.Mocked sendEventToClient.execute = jest.fn().mockReturnValue(Result.ok()) @@ -84,6 +90,7 @@ describe('SaveItems', () => { logger = {} as jest.Mocked logger.debug = jest.fn() logger.error = jest.fn() + logger.warn = jest.fn() itemHash1 = ItemHash.create({ uuid: '00000000-0000-0000-0000-000000000000', @@ -114,6 +121,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -139,6 +147,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -164,6 +173,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -186,6 +196,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -209,6 +220,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -228,6 +240,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -247,6 +260,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -293,6 +307,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -321,6 +336,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -346,6 +362,7 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() @@ -392,9 +409,64 @@ describe('SaveItems', () => { sessionUuid: 'session-uuid', snjsVersion: '2.200.0', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeFalsy() expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2') }) + + it('should succeed if a free user has no content limit', async () => { + checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.fail('exceeded')) + + const useCase = createUseCase() + const result = await useCase.execute({ + itemHashes: [itemHash1], + userUuid: '00000000-0000-0000-0000-000000000000', + apiVersion: '1', + readOnlyAccess: false, + sessionUuid: 'session-uuid', + snjsVersion: '2.200.0', + isFreeUser: true, + hasContentLimit: false, + }) + + expect(result.isFailed()).toBeFalsy() + }) + + it('should return a failure result if a free user has exceeded their content limit', async () => { + checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.fail('exceeded')) + + const useCase = createUseCase() + const result = await useCase.execute({ + itemHashes: [itemHash1], + userUuid: '00000000-0000-0000-0000-000000000000', + apiVersion: '1', + readOnlyAccess: false, + sessionUuid: 'session-uuid', + snjsVersion: '2.200.0', + isFreeUser: true, + hasContentLimit: true, + }) + + expect(result.isFailed()).toBeTruthy() + }) + + it('should succeed if a free user has not exceeded their content limit', async () => { + checkForContentLimit.execute = jest.fn().mockResolvedValue(Result.ok()) + + const useCase = createUseCase() + const result = await useCase.execute({ + itemHashes: [itemHash1], + userUuid: '00000000-0000-0000-0000-000000000000', + apiVersion: '1', + readOnlyAccess: false, + sessionUuid: 'session-uuid', + snjsVersion: '2.200.0', + isFreeUser: true, + hasContentLimit: false, + }) + + expect(result.isFailed()).toBeFalsy() + }) }) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts index ec81d1059..9acd7fb18 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts @@ -14,6 +14,7 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface' import { SendEventToClient } from '../SendEventToClient/SendEventToClient' import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface' import { SendEventToClients } from '../SendEventToClients/SendEventToClients' +import { CheckForContentLimit } from '../CheckForContentLimit/CheckForContentLimit' export class SaveItems implements UseCaseInterface { private readonly SYNC_TOKEN_VERSION = 2 @@ -27,6 +28,7 @@ export class SaveItems implements UseCaseInterface { private sendEventToClient: SendEventToClient, private sendEventToClients: SendEventToClients, private domainEventFactory: DomainEventFactoryInterface, + private checkForContentLimit: CheckForContentLimit, private logger: Logger, ) {} @@ -34,6 +36,20 @@ export class SaveItems implements UseCaseInterface { const savedItems: Array = [] const conflicts: Array = [] + if (dto.hasContentLimit) { + const checkForContentLimitResult = await this.checkForContentLimit.execute({ + userUuid: dto.userUuid, + itemsBeingModified: dto.itemHashes, + }) + if (checkForContentLimitResult.isFailed()) { + this.logger.warn(`Checking for content limit failed. Error: ${checkForContentLimitResult.getError()}`, { + userId: dto.userUuid, + }) + + return Result.fail(checkForContentLimitResult.getError()) + } + } + const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds() for (const itemHash of dto.itemHashes) { diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts index 205e11d60..ea088fb1b 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItemsDTO.ts @@ -8,4 +8,5 @@ export interface SaveItemsDTO { sessionUuid: string | null snjsVersion: string isFreeUser: boolean + hasContentLimit: boolean } diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts index 254ad5f7d..a419084b3 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.spec.ts @@ -157,6 +157,7 @@ describe('SyncItems', () => { sessionUuid: null, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.getValue()).toEqual({ conflicts: [], @@ -185,6 +186,7 @@ describe('SyncItems', () => { isFreeUser: false, readOnlyAccess: false, sessionUuid: null, + hasContentLimit: false, }) }) @@ -208,6 +210,7 @@ describe('SyncItems', () => { sessionUuid: null, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) } catch (error) { caughtError = error @@ -228,6 +231,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.getValue()).toEqual({ conflicts: [], @@ -255,6 +259,7 @@ describe('SyncItems', () => { snjsVersion: '1.2.3', isFreeUser: false, sharedVaultUuids: ['00000000-0000-0000-0000-000000000000'], + hasContentLimit: false, }) expect(result.getValue()).toEqual({ conflicts: [], @@ -307,6 +312,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.getValue()).toEqual({ @@ -347,6 +353,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() @@ -368,6 +375,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() @@ -389,6 +397,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() @@ -410,6 +419,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() @@ -431,6 +441,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() @@ -452,6 +463,7 @@ describe('SyncItems', () => { apiVersion: ApiVersion.v20200115, snjsVersion: '1.2.3', isFreeUser: false, + hasContentLimit: false, }) expect(result.isFailed()).toBeTruthy() diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts index 320d2dfb4..c0082a752 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts @@ -48,6 +48,7 @@ export class SyncItems implements UseCaseInterface { sessionUuid: dto.sessionUuid, snjsVersion: dto.snjsVersion, isFreeUser: dto.isFreeUser, + hasContentLimit: dto.hasContentLimit, }) if (saveItemsResultOrError.isFailed()) { return Result.fail(saveItemsResultOrError.getError()) diff --git a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts index d81da6c1c..c5a7d42d1 100644 --- a/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts +++ b/packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts @@ -14,4 +14,5 @@ export type SyncItemsDTO = { readOnlyAccess: boolean sessionUuid: string | null isFreeUser: boolean + hasContentLimit: boolean } diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts index f83b918d7..d2ae68f44 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts +++ b/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseItemsController.ts @@ -132,6 +132,7 @@ export class BaseItemsController extends BaseHttpController { sessionUuid: locals.session ? locals.session.uuid : null, sharedVaultUuids, isFreeUser: locals.isFreeUser, + hasContentLimit: !!locals.hasContentLimit, }) if (syncResult.isFailed()) { return this.json({ error: { message: syncResult.getError() } }, HttpStatusCode.BadRequest) diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts index 78c362b23..f09ebaa2b 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts +++ b/packages/syncing-server/src/Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware.ts @@ -33,6 +33,7 @@ export class InversifyExpressAuthMiddleware extends BaseMiddleware { session: decodedToken.session, readOnlyAccess: decodedToken.session?.readonly_access ?? false, sharedVaultOwnerContext: decodedToken.shared_vault_owner_context, + hasContentLimit: decodedToken.hasContentLimit, } as ResponseLocals) return next() diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/ResponseLocals.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/ResponseLocals.ts index 32fda11d8..8dcb2f439 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/ResponseLocals.ts +++ b/packages/syncing-server/src/Infra/InversifyExpressUtils/ResponseLocals.ts @@ -21,4 +21,5 @@ export interface ResponseLocals { sharedVaultOwnerContext?: { upload_bytes_limit: number } + hasContentLimit?: boolean } diff --git a/packages/syncing-server/src/Infra/gRPC/SyncingServer.ts b/packages/syncing-server/src/Infra/gRPC/SyncingServer.ts index fe8471b82..97919a33f 100644 --- a/packages/syncing-server/src/Infra/gRPC/SyncingServer.ts +++ b/packages/syncing-server/src/Infra/gRPC/SyncingServer.ts @@ -35,6 +35,7 @@ export class SyncingServer implements ISyncingServer { try { const userUuid = call.metadata.get('x-user-uuid').pop() as string const isFreeUser = call.metadata.get('x-is-free-user').pop() === 'true' + const hasContentLimit = call.metadata.get('x-has-content-limit').pop() === 'true' const checkForItemOperationsAbuseResult = await this.checkForTrafficAbuse.execute({ metricToCheck: Metric.NAMES.ItemOperation, @@ -168,6 +169,7 @@ export class SyncingServer implements ISyncingServer { sessionUuid: call.metadata.get('x-session-uuid').pop() as string, sharedVaultUuids, isFreeUser, + hasContentLimit, }) if (syncResult.isFailed()) { const metadata = new grpc.Metadata() diff --git a/packages/time/package.json b/packages/time/package.json index 80197eaf4..732abcdb1 100644 --- a/packages/time/package.json +++ b/packages/time/package.json @@ -26,7 +26,7 @@ "clean": "rm -fr dist", "build": "tsc --build", "lint": "eslint . --ext .ts", - "test": "jest --coverage --no-cache" + "test": "jest --coverage --no-cache --maxWorkers=2" }, "dependencies": { "dayjs": "^1.11.6", diff --git a/packages/websockets/Dockerfile b/packages/websockets/Dockerfile index 64b221126..ca5345330 100644 --- a/packages/websockets/Dockerfile +++ b/packages/websockets/Dockerfile @@ -10,6 +10,12 @@ RUN corepack enable COPY ./ /workspace +WORKDIR /workspace + +RUN yarn install --immutable + +RUN yarn build + WORKDIR /workspace/packages/websockets ENTRYPOINT [ "/workspace/packages/websockets/docker/entrypoint.sh" ] diff --git a/packages/websockets/package.json b/packages/websockets/package.json index 9652531d8..fc6a170b6 100644 --- a/packages/websockets/package.json +++ b/packages/websockets/package.json @@ -20,8 +20,7 @@ "setup:env": "cp .env.sample .env", "build": "tsc --build", "lint": "eslint . --ext .ts", - "pretest": "yarn lint && yarn build", - "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=50%", + "test": "jest --coverage --no-cache --config=./jest.config.js --maxWorkers=2", "start": "yarn node dist/bin/server.js", "worker": "yarn node dist/bin/worker.js", "typeorm": "typeorm-ts-node-commonjs" @@ -42,7 +41,7 @@ "inversify": "^6.0.1", "inversify-express-utils": "^6.4.3", "ioredis": "^5.2.4", - "mysql2": "^3.0.1", + "mysql2": "^3.9.7", "reflect-metadata": "^0.2.1", "typeorm": "^0.3.17", "winston": "^3.8.1" diff --git a/yarn.lock b/yarn.lock index 5eacd8718..69d4fd6f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5731,7 +5731,7 @@ __metadata: ioredis: "npm:^5.2.4" jest: "npm:^29.5.0" mixpanel: "npm:^0.17.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" prettier: "npm:^3.0.3" reflect-metadata: "npm:^0.2.1" ts-jest: "npm:^29.1.0" @@ -5753,6 +5753,7 @@ __metadata: "@standardnotes/grpc": "workspace:^" "@standardnotes/security": "workspace:*" "@standardnotes/time": "workspace:*" + "@types/cookie-parser": "npm:^1" "@types/cors": "npm:^2.8.9" "@types/express": "npm:^4.17.14" "@types/ioredis": "npm:^5.0.0" @@ -5764,6 +5765,7 @@ __metadata: "@typescript-eslint/parser": "npm:^6.5.0" agentkeepalive: "npm:^4.5.0" axios: "npm:^1.6.1" + cookie-parser: "npm:^1.4.6" cors: "npm:2.8.5" dotenv: "npm:^16.0.1" eslint: "npm:^8.39.0" @@ -5827,6 +5829,7 @@ __metadata: "@standardnotes/sncrypto-node": "workspace:*" "@standardnotes/time": "workspace:*" "@types/bcryptjs": "npm:^2.4.2" + "@types/cookie-parser": "npm:^1" "@types/cors": "npm:^2.8.9" "@types/express": "npm:^4.17.14" "@types/ioredis": "npm:^5.0.0" @@ -5838,7 +5841,10 @@ __metadata: "@types/uuid": "npm:^9.0.3" "@typescript-eslint/eslint-plugin": "npm:^6.5.0" "@typescript-eslint/parser": "npm:^6.5.0" + agentkeepalive: "npm:^4.5.0" + axios: "npm:^1.6.7" bcryptjs: "npm:2.4.3" + cookie-parser: "npm:^1.4.6" cors: "npm:2.8.5" dayjs: "npm:^1.11.6" dotenv: "npm:^16.0.1" @@ -5849,7 +5855,7 @@ __metadata: inversify-express-utils: "npm:^6.4.3" ioredis: "npm:^5.2.4" jest: "npm:^29.5.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" otplib: "npm:12.0.1" prettier: "npm:^3.0.3" prettyjson: "npm:^1.2.5" @@ -6044,10 +6050,12 @@ __metadata: "@standardnotes/files-server": "workspace:^" "@standardnotes/revisions-server": "workspace:^" "@standardnotes/syncing-server": "workspace:^" + "@types/cookie-parser": "npm:^1" "@types/cors": "npm:^2.8.9" "@types/express": "npm:^4.17.14" "@typescript-eslint/eslint-plugin": "npm:^6.5.0" "@typescript-eslint/parser": "npm:^6.5.0" + cookie-parser: "npm:^1.4.6" cors: "npm:2.8.5" dotenv: "npm:^16.0.1" eslint: "npm:^8.39.0" @@ -6139,7 +6147,7 @@ __metadata: inversify-express-utils: "npm:^6.4.3" ioredis: "npm:^5.3.2" jest: "npm:^29.5.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" prettier: "npm:^3.0.3" reflect-metadata: "npm:^0.2.1" sqlite3: "npm:^5.1.6" @@ -6173,7 +6181,7 @@ __metadata: inversify: "npm:^6.0.1" ioredis: "npm:^5.2.4" jest: "npm:^29.5.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" prettier: "npm:^3.0.3" reflect-metadata: "npm:^0.2.1" ts-jest: "npm:^29.1.0" @@ -6318,7 +6326,7 @@ __metadata: ioredis: "npm:^5.3.2" jest: "npm:^29.5.0" jsonwebtoken: "npm:^9.0.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" prettier: "npm:^3.0.3" prettyjson: "npm:^1.2.5" reflect-metadata: "npm:^0.2.1" @@ -6393,7 +6401,7 @@ __metadata: inversify-express-utils: "npm:^6.4.3" ioredis: "npm:^5.2.4" jest: "npm:^29.5.0" - mysql2: "npm:^3.0.1" + mysql2: "npm:^3.9.7" prettier: "npm:^3.0.3" reflect-metadata: "npm:^0.2.1" ts-jest: "npm:^29.1.0" @@ -6549,6 +6557,15 @@ __metadata: languageName: node linkType: hard +"@types/cookie-parser@npm:^1": + version: 1.4.6 + resolution: "@types/cookie-parser@npm:1.4.6" + dependencies: + "@types/express": "npm:*" + checksum: b1bbb17bc4189c0e953d4996b3b58bfa20161c27db21f98353e237032e7559aec733735d8902c283300e0a4cded20e62b1a5086af608608ef30a45387e080360 + languageName: node + linkType: hard + "@types/cors@npm:^2.8.9": version: 2.8.13 resolution: "@types/cors@npm:2.8.13" @@ -7552,6 +7569,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.7": + version: 1.6.7 + resolution: "axios@npm:1.6.7" + dependencies: + follow-redirects: "npm:^1.15.4" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: a1932b089ece759cd261f175d9ebf4d41c8994cf0c0767cda86055c7a19bcfdade8ae3464bf4cec4c8b142f4a657dc664fb77a41855e8376cf38b86d7a86518f + languageName: node + linkType: hard + "babel-jest@npm:^29.5.0": version: 29.5.0 resolution: "babel-jest@npm:29.5.0" @@ -8521,6 +8549,16 @@ __metadata: languageName: node linkType: hard +"cookie-parser@npm:^1.4.6": + version: 1.4.6 + resolution: "cookie-parser@npm:1.4.6" + dependencies: + cookie: "npm:0.4.1" + cookie-signature: "npm:1.0.6" + checksum: 1e5a63aa82e8eb4e02d2977c6902983dee87b02e87ec5ec43ac3cb1e72da354003716570cd5190c0ad9e8a454c9d3237f4ad6e2f16d0902205a96a1c72b77ba5 + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -8528,6 +8566,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.4.1": + version: 0.4.1 + resolution: "cookie@npm:0.4.1" + checksum: 0f2defd60ac93645ee31e82d11da695080435eb4fe5bed9b14d2fc4e0621a66f4c5c60f3eb05761df08a9d6279366e8646edfd1654f359d0b5afc25304fc4ddc + languageName: node + linkType: hard + "cookie@npm:0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" @@ -9552,6 +9597,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.4": + version: 1.15.5 + resolution: "follow-redirects@npm:1.15.5" + peerDependenciesMeta: + debug: + optional: true + checksum: d467f13c1c6aa734599b8b369cd7a625b20081af358f6204ff515f6f4116eb440de9c4e0c49f10798eeb0df26c95dd05d5e0d9ddc5786ab1a8a8abefe92929b4 + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.1.1 resolution: "foreground-child@npm:3.1.1" @@ -12165,9 +12220,9 @@ __metadata: languageName: node linkType: hard -"mysql2@npm:^3.0.1": - version: 3.3.3 - resolution: "mysql2@npm:3.3.3" +"mysql2@npm:^3.9.7": + version: 3.9.7 + resolution: "mysql2@npm:3.9.7" dependencies: denque: "npm:^2.1.0" generate-function: "npm:^2.3.1" @@ -12177,7 +12232,7 @@ __metadata: named-placeholders: "npm:^1.1.3" seq-queue: "npm:^0.0.5" sqlstring: "npm:^2.3.2" - checksum: 4bf7ace8f13a54e3117d9259222c629ec32337562cc5c5baf0404410d2c65f4d86038101ed07aa16857f25246ff29d5f5ec9cbea22eaa4703250d834d7d9c8ad + checksum: 7f43b17cc0acdec30791c9b29a97c75f7e4512bbf41c2baa383ce76b50d0a92300083a8dba3cc019423ee3b7710ed7c756baf805449f0c9650e08e5d48454b07 languageName: node linkType: hard