From f0360a3e237887834ffc6a101714a6b18829ed87 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 5 Oct 2023 16:44:05 +0800 Subject: [PATCH 01/50] feat(auth): add OAuth2 login with GitHub and Google --- README.md | 17 ++ backend/package-lock.json | 206 +++++++++++++++++- backend/package.json | 5 + backend/prisma/schema.prisma | 13 +- backend/prisma/seed/config.seed.ts | 48 ++++ backend/src/app.module.ts | 2 + backend/src/auth/auth.controller.ts | 12 +- backend/src/auth/auth.service.ts | 8 +- backend/src/oauth/dto/github.dto.ts | 8 + backend/src/oauth/dto/oauthSignIn.dto.ts | 6 + backend/src/oauth/guard/google.guard.ts | 5 + backend/src/oauth/oauth.controller.ts | 84 +++++++ backend/src/oauth/oauth.module.ts | 21 ++ backend/src/oauth/oauth.service.ts | 175 +++++++++++++++ backend/src/oauth/strategy/google.strategy.ts | 39 ++++ backend/tsconfig.json | 5 +- .../configuration/ConfigurationNavBar.tsx | 3 +- frontend/src/components/auth/SignInForm.tsx | 60 +++++ frontend/src/i18n/translations/en-US.ts | 26 +++ frontend/src/pages/account/index.tsx | 62 ++++++ .../src/pages/admin/config/[category].tsx | 7 +- frontend/src/services/auth.service.ts | 10 + frontend/src/utils/oauth.util.tsx | 25 +++ 23 files changed, 819 insertions(+), 28 deletions(-) create mode 100644 backend/src/oauth/dto/github.dto.ts create mode 100644 backend/src/oauth/dto/oauthSignIn.dto.ts create mode 100644 backend/src/oauth/guard/google.guard.ts create mode 100644 backend/src/oauth/oauth.controller.ts create mode 100644 backend/src/oauth/oauth.module.ts create mode 100644 backend/src/oauth/oauth.service.ts create mode 100644 backend/src/oauth/strategy/google.strategy.ts create mode 100644 frontend/src/utils/oauth.util.tsx diff --git a/README.md b/README.md index 672a80af3..2d091af37 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,23 @@ ClamAV is used to scan shares for malicious files and remove them if found. Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements). +#### Social Login + +To enable social login, you need to create an OAuth app for each provider you want to use. + +##### GitHub + +Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). + +Redirect URL: `http(s):///api/oauth/github/callback` + + +##### Google + +Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to complete the prerequisites part. + +Redirect URL: `http(s):///api/oauth/google/callback` + ### Additional resources - [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) diff --git a/backend/package-lock.json b/backend/package-lock.json index f18b79d54..dd5396aac 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,9 +28,12 @@ "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", + "nanoid": "^3.3.6", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "qrcode-svg": "^1.1.0", @@ -52,7 +55,9 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^20.4.5", + "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", + "@types/passport-google-oauth20": "^2.0.12", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", "@types/sharp": "^0.31.1", @@ -1438,6 +1443,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "node_modules/@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz", @@ -1447,6 +1462,15 @@ "@types/node": "*" } }, + "node_modules/@types/oauth": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@types/oauth/-/oauth-0.9.2.tgz", + "integrity": "sha512-Nu3/abQ6yR9VlsCdX3aiGsWFkj6OJvJqDvg/36t8Gwf2mFXdBZXPDN3K+2yfeA6Lo2m1Q12F8Qil9TZ48nWhOQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -1462,6 +1486,17 @@ "@types/express": "*" } }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.12", + "resolved": "https://registry.npmmirror.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.12.tgz", + "integrity": "sha512-+MBVB8uYd8mMZYvTwYChCa2LBGVK9FMwdK5TtcNHMeTL6qBZ3QW0HeUtZiAlwgkw2LYM0Btlzyb19EA8ysm13g==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.9.tgz", @@ -1473,6 +1508,17 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.4.13", + "resolved": "https://registry.npmmirror.com/@types/passport-oauth2/-/passport-oauth2-1.4.13.tgz", + "integrity": "sha512-SKjbAFSgV2ys7Vf8+BbQ2Fq09CZGi72xaHqbWPEKhi7czPSC0ff4gXuQEY3XXAuTynPjwj6dlL3YAta9M2K0AQ==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.35", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", @@ -2288,6 +2334,14 @@ } ] }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -5572,6 +5626,17 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -5733,9 +5798,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -5816,6 +5881,11 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmmirror.com/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -6062,6 +6132,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -6082,6 +6163,21 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz", + "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -7833,7 +7929,7 @@ }, "node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/tree-kill": { @@ -8045,6 +8141,11 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmmirror.com/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/underscore": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", @@ -8235,7 +8336,7 @@ }, "node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { @@ -8305,7 +8406,7 @@ }, "node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", @@ -9542,6 +9643,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "@types/nodemailer": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz", @@ -9551,6 +9662,15 @@ "@types/node": "*" } }, + "@types/oauth": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@types/oauth/-/oauth-0.9.2.tgz", + "integrity": "sha512-Nu3/abQ6yR9VlsCdX3aiGsWFkj6OJvJqDvg/36t8Gwf2mFXdBZXPDN3K+2yfeA6Lo2m1Q12F8Qil9TZ48nWhOQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -9566,6 +9686,17 @@ "@types/express": "*" } }, + "@types/passport-google-oauth20": { + "version": "2.0.12", + "resolved": "https://registry.npmmirror.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.12.tgz", + "integrity": "sha512-+MBVB8uYd8mMZYvTwYChCa2LBGVK9FMwdK5TtcNHMeTL6qBZ3QW0HeUtZiAlwgkw2LYM0Btlzyb19EA8ysm13g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "@types/passport-jwt": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.9.tgz", @@ -9577,6 +9708,17 @@ "@types/passport-strategy": "*" } }, + "@types/passport-oauth2": { + "version": "1.4.13", + "resolved": "https://registry.npmmirror.com/@types/passport-oauth2/-/passport-oauth2-1.4.13.tgz", + "integrity": "sha512-SKjbAFSgV2ys7Vf8+BbQ2Fq09CZGi72xaHqbWPEKhi7czPSC0ff4gXuQEY3XXAuTynPjwj6dlL3YAta9M2K0AQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "@types/passport-strategy": { "version": "0.2.35", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", @@ -10209,6 +10351,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -12636,6 +12783,11 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + }, "napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -12767,9 +12919,9 @@ } }, "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "requires": { "whatwg-url": "^5.0.0" } @@ -12824,6 +12976,11 @@ "set-blocking": "^2.0.0" } }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmmirror.com/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -12994,6 +13151,14 @@ "utils-merge": "^1.0.1" } }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, "passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -13011,6 +13176,18 @@ "passport-strategy": "1.x.x" } }, + "passport-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz", + "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -14306,7 +14483,7 @@ }, "tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "tree-kill": { @@ -14444,6 +14621,11 @@ "@lukeed/csprng": "^1.0.0" } }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmmirror.com/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "underscore": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", @@ -14586,7 +14768,7 @@ }, "webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { @@ -14635,7 +14817,7 @@ }, "whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "requires": { "tr46": "~0.0.3", diff --git a/backend/package.json b/backend/package.json index 843d30d77..49539b8d0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,9 +33,12 @@ "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", + "nanoid": "^3.3.6", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "qrcode-svg": "^1.1.0", @@ -57,7 +60,9 @@ "@types/mime-types": "^2.1.1", "@types/multer": "^1.4.7", "@types/node": "^20.4.5", + "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", + "@types/passport-google-oauth20": "^2.0.12", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", "@types/sharp": "^0.31.1", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index de898d354..74a76197e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -26,6 +26,8 @@ model User { totpVerified Boolean @default(false) totpSecret String? resetPasswordToken ResetPasswordToken? + + oAuthUsers OAuthUser[] } model RefreshToken { @@ -60,6 +62,15 @@ model ResetPasswordToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model OAuthUser { + id String @id @default(uuid()) + provider String + providerUserId String + providerUsername String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Share { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -134,7 +145,7 @@ model Config { name String category String type String - defaultValue String @default("") + defaultValue String @default("") value String? obscured Boolean @default(false) secret Boolean @default(true) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index b7a78f4bb..6f1d72831 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -119,6 +119,54 @@ const configVariables: ConfigVariables = { obscured: true, }, }, + oauth: { + "allowRegistration": { + type: "boolean", + defaultValue: "true", + }, + "ignoreTotp": { + type: "boolean", + defaultValue: "false", + }, + "github-enabled": { + type: "boolean", + defaultValue: "false", + }, + "github-clientId": { + type: "string", + defaultValue: "", + }, + "github-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "google-enabled": { + type: "boolean", + defaultValue: "false", + }, + "google-clientId": { + type: "string", + defaultValue: "", + }, + "google-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + // "oidc-enabled": { + // type: "boolean", + // defaultValue: "false", + // }, + // "oidc-clientId": { + // type: "string", + // defaultValue: "", + // }, + // "oidc-clientSecret": { + // type: "string", + // defaultValue: "", + // }, + } }; type ConfigVariables = { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7f47e08a5..4c013a99a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { ShareModule } from "./share/share.module"; import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; import { ReverseShareModule } from "./reverseShare/reverseShare.module"; +import { OAuthModule } from './oauth/oauth.module'; @Module({ imports: [ @@ -32,6 +33,7 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module"; ScheduleModule.forRoot(), ClamScanModule, ReverseShareModule, + OAuthModule, ], providers: [ { diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 9f37f18bb..8cbeaa4ef 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -47,7 +47,7 @@ export class AuthController { const result = await this.authService.signUp(dto); - response = this.addTokensToResponse( + response = AuthController.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -66,7 +66,7 @@ export class AuthController { const result = await this.authService.signIn(dto); if (result.accessToken && result.refreshToken) { - response = this.addTokensToResponse( + response = AuthController.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -85,7 +85,7 @@ export class AuthController { ) { const result = await this.authTotpService.signInTotp(dto); - response = this.addTokensToResponse( + response = AuthController.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -121,7 +121,7 @@ export class AuthController { dto.password, ); - response = this.addTokensToResponse(response, result.refreshToken); + response = AuthController.addTokensToResponse(response, result.refreshToken); return new TokenDTO().from(result); } @@ -136,7 +136,7 @@ export class AuthController { const accessToken = await this.authService.refreshAccessToken( request.cookies.refresh_token, ); - response = this.addTokensToResponse(response, undefined, accessToken); + response = AuthController.addTokensToResponse(response, undefined, accessToken); return new TokenDTO().from({ accessToken }); } @@ -173,7 +173,7 @@ export class AuthController { return this.authTotpService.disableTotp(user, body.password, body.code); } - private addTokensToResponse( + static addTokensToResponse( response: Response, refreshToken?: string, accessToken?: string, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 27d2edc00..c4b050db2 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -43,7 +43,7 @@ export class AuthService { ); const accessToken = await this.createAccessToken(user, refreshTokenId); - return { accessToken, refreshToken }; + return { accessToken, refreshToken, user }; } catch (e) { if (e instanceof PrismaClientKnownRequestError) { if (e.code == "P2002") { @@ -69,9 +69,13 @@ export class AuthService { if (!user || !(await argon.verify(user.password, dto.password))) throw new UnauthorizedException("Wrong email or password"); + return this.generateToken(user); + } + + async generateToken(user: User, isOAuth = false) { // TODO: Make all old loginTokens invalid when a new one is created // Check if the user has TOTP enabled - if (user.totpVerified) { + if (user.totpVerified && !(isOAuth && this.config.get('oauth.ignoreTotp'))) { const loginToken = await this.createLoginToken(user.id); return { loginToken }; diff --git a/backend/src/oauth/dto/github.dto.ts b/backend/src/oauth/dto/github.dto.ts new file mode 100644 index 000000000..da54c10ec --- /dev/null +++ b/backend/src/oauth/dto/github.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from "class-validator"; + +export class GithubDto { + @IsString() + code: string; + @IsString() + state: string; +} \ No newline at end of file diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts new file mode 100644 index 000000000..48f0069b8 --- /dev/null +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -0,0 +1,6 @@ +interface OAuthSignInDto { + provider: 'github' | 'google'; + providerId: string; + providerUsername: string; + email: string; +} \ No newline at end of file diff --git a/backend/src/oauth/guard/google.guard.ts b/backend/src/oauth/guard/google.guard.ts new file mode 100644 index 000000000..1dc9447bb --- /dev/null +++ b/backend/src/oauth/guard/google.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleOAuthGuard extends AuthGuard('google') {} diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts new file mode 100644 index 000000000..afa9134e0 --- /dev/null +++ b/backend/src/oauth/oauth.controller.ts @@ -0,0 +1,84 @@ +import { BadRequestException, Controller, Get, NotFoundException, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { OAuthService } from "./oauth.service"; +import { Request, Response } from "express"; +import { GithubDto } from "./dto/github.dto"; +import { JwtGuard } from "../auth/guard/jwt.guard"; +import { ConfigService } from "../config/config.service"; +import { GoogleOAuthGuard } from "./guard/google.guard"; +import { nanoid } from "nanoid"; +import { AuthController } from "../auth/auth.controller"; +import { GetUser } from "../auth/decorator/getUser.decorator"; +import { User } from "@prisma/client"; + +@Controller('oauth') +export class OAuthController { + constructor( + private oauthService: OAuthService, + private config: ConfigService, + ) { + } + + @Get("available") + getAvailable(@Res({ passthrough: true }) response: Response, @Req() request: Request) { + return this.oauthService.getAvailable(); + } + + @Get("status") + @UseGuards(JwtGuard) + async status(@GetUser() user: User) { + return this.oauthService.status(user); + } + + @Get("github") + github(@Res({ passthrough: true }) response: Response) { + const state = nanoid(10); + response.cookie("github_oauth_state", state, { sameSite: "strict" }); + const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({ + client_id: this.config.get("oauth.github-clientId"), + redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback", + state: state, + scope: "user:email", + }).toString(); + response.redirect(url); + // return ``; + } + + @Get("github/callback") + async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { + if (!this.config.get("oauth.github-enabled")) { + throw new NotFoundException(); + } + + const { state, code } = query; + + if (state !== request.cookies.github_oauth_state) { + throw new BadRequestException("Invalid state"); + } + + const token = await this.oauthService.github(code); + AuthController.addTokensToResponse( + response, + token.refreshToken, + token.accessToken, + ); + response.redirect(this.config.get("general.appUrl")); + } + + @Get("google") + @UseGuards(GoogleOAuthGuard) + async google() { + } + + @Get("google/callback") + @UseGuards(GoogleOAuthGuard) + async googleCallback(@Req() request: Request, @Res({ passthrough: true }) response: Response) { + const user = request.user as OAuthSignInDto; + const token = await this.oauthService.signIn(user); + AuthController.addTokensToResponse( + response, + token.refreshToken, + token.accessToken, + ); + response.redirect(this.config.get("general.appUrl")); + } +} diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts new file mode 100644 index 000000000..f56ff1914 --- /dev/null +++ b/backend/src/oauth/oauth.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { OAuthController } from './oauth.controller'; +import { OAuthService } from './oauth.service'; +import { AuthService } from "../auth/auth.service"; +import { AuthModule } from "../auth/auth.module"; +import { GoogleStrategy } from "./strategy/google.strategy"; + +@Module({ + controllers: [OAuthController], + providers: [ + OAuthService, + GoogleStrategy, + { + provide: "OAUTH_PLATFORMS", + useValue: ["github", "google"], + }, + ], + imports: [AuthModule], +}) +export class OAuthModule { +} diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts new file mode 100644 index 000000000..3b22f1891 --- /dev/null +++ b/backend/src/oauth/oauth.service.ts @@ -0,0 +1,175 @@ +import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from "../prisma/prisma.service"; +import { ConfigService } from "../config/config.service"; +import { AuthService } from "../auth/auth.service"; +import { User } from "@prisma/client"; +import { nanoid } from "nanoid"; +import fetch from "node-fetch"; + + +@Injectable() +export class OAuthService { + constructor( + private prisma: PrismaService, + private config: ConfigService, + private auth: AuthService, + @Inject("OAUTH_PLATFORMS") private platforms: string[], + ) { + } + + getAvailable(): string[] { + return this.platforms + .map(platform => [platform, this.config.get(`oauth.${platform}-enabled`)]) + .filter(([_, enabled]) => enabled) + .map(([platform, _]) => platform); + } + + private async getGitHubToken(code: string): Promise { + const qs = new URLSearchParams(); + qs.append("client_id", this.config.get("oauth.github-clientId")); + qs.append("client_secret", this.config.get("oauth.github-clientSecret")); + qs.append("code", code); + + const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), { + method: "post", + headers: { + "Accept": "application/json", + } + }); + return await res.json() as GitHubToken; + } + + private async getGitHubUser(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + return await res.json() as GitHubUser; + } + + private async getGitHubEmails(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user/public_emails", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + const emails = await res.json() as GitHubEmail[]; + return emails.find(e => e.primary && e.verified)?.email; + } + + async signIn(user: OAuthSignInDto) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider: user.provider, + providerUserId: user.providerId, + }, + include: { + user: true + }, + }); + if (oauthUser) { + return this.auth.generateToken(oauthUser.user, true); + } + + return this.signUp(user); + } + + private async signUp(user: OAuthSignInDto) { + // register + if (!this.config.get("oauth.allowRegistration")) { + throw new UnauthorizedException("No such user"); + } + + if (!user.email) { + throw new BadRequestException("No email found"); + } + + const existingUser: User = await this.prisma.user.findFirst({ + where: { + email: user.email, + } + }); + + if (existingUser) { + await this.prisma.oAuthUser.create({ + data: { + provider: user.provider, + providerUserId: user.providerId.toString(), + providerUsername: user.providerUsername, + userId: existingUser.id, + }, + }); + return this.auth.generateToken(existingUser, true); + } + + // TODO user registered by oauth will hava a random password and username + const result = await this.auth.signUp({ + email: user.email, + username: nanoid().replaceAll("-", ''), + password: nanoid(), + }); + + await this.prisma.oAuthUser.create({ + data: { + provider: user.provider, + providerUserId: user.providerId.toString(), + providerUsername: user.providerUsername, + userId: result.user.id, + }, + }); + + return result; + } + + async github(code: string) { + const ghToken = await this.getGitHubToken(code); + const ghUser = await this.getGitHubUser(ghToken); + if (!ghToken.scope.includes("user:email")) { + throw new BadRequestException("No email permission granted"); + } + const email = await this.getGitHubEmails(ghToken); + return this.signIn({ + provider: "github", + providerId: ghUser.id.toString(), + providerUsername: ghUser.login, + email, + }); + } + + async status(user: User) { + const oauthUsers = await this.prisma.oAuthUser.findMany({ + select: { + provider: true, + providerUsername: true, + }, + where: { + userId: user.id, + }, + }); + return Object.fromEntries(oauthUsers.map(u => [u.provider, u])); + } +} + + +interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +interface GitHubUser { + login: string; + id: number; + name?: string; + email?: string; // this filed seems only return null +} + +interface GitHubEmail { + email: string; + primary: boolean, + verified: boolean, + visibility: string | null +} \ No newline at end of file diff --git a/backend/src/oauth/strategy/google.strategy.ts b/backend/src/oauth/strategy/google.strategy.ts new file mode 100644 index 000000000..de0ebcbb7 --- /dev/null +++ b/backend/src/oauth/strategy/google.strategy.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from "../../config/config.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy) { + constructor( + config: ConfigService, + private prisma: PrismaService, + ) { + super({ + callbackURL: config.get('general.appUrl') + '/api/oauth/google/callback', + clientID: config.get('oauth.google-clientId'), + clientSecret: config.get('oauth.google-clientSecret'), + scope: ['profile', 'email'], + }); + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + console.log(profile); + const { id, displayName, emails } = profile; + + const user = { + provider: 'google', + providerId: id, + providerUsername: displayName, + email: emails.find((v: { verified: boolean; }) => v.verified).value, + }; + + done(null, user); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index adb614cab..feb2ce67e 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,7 +6,10 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2021", + "lib": [ + "ES2021", + ], "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx index c749581c2..9df1a19c2 100644 --- a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx +++ b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import Link from "next/link"; import { Dispatch, SetStateAction } from "react"; -import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb"; +import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; const categories = [ @@ -19,6 +19,7 @@ const categories = [ { name: "Email", icon: }, { name: "Share", icon: }, { name: "SMTP", icon: }, + { name: "OAuth", icon: }, ]; const useStyles = createStyles((theme) => ({ diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 756a99dc3..9352517f8 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -2,9 +2,11 @@ import { Anchor, Button, Container, + createStyles, Group, Paper, PasswordInput, + Stack, Text, TextInput, Title, @@ -22,15 +24,43 @@ import useTranslate from "../../hooks/useTranslate.hook"; import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; import toast from "../../utils/toast.util"; +import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util"; + +const useStyles = createStyles((theme) => ({ + or: { + "&:before": { + content: "''", + flex: 1, + display: 'block', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderColor: theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + "&:after": { + content: "''", + flex: 1, + display: 'block', + borderTopWidth: 1, + borderTopStyle: 'solid', + borderColor: theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], + }, + }, +})); const SignInForm = ({ redirectPath }: { redirectPath: string }) => { const config = useConfig(); const router = useRouter(); const t = useTranslate(); const { refreshUser } = useUser(); + const { classes } = useStyles(); const [showTotp, setShowTotp] = React.useState(false); const [loginToken, setLoginToken] = React.useState(""); + const [oauth, setOAuth] = React.useState([]); const validationSchema = yup.object().shape({ emailOrUsername: yup.string().required(t("common.error.field-required")), @@ -91,6 +121,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { }); }; + const getAvailableOAuth = async () => { + const oauth = await authService.getAvailableOAuth(); + setOAuth(oauth.data); + } + + React.useEffect(() => { + getAvailableOAuth().catch(toast.axiosError); + }, []); + return ( @@ -143,6 +182,27 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { <FormattedMessage id="signin.button.submit" /> </Button> </form> + {oauth.length > 0 && ( + <Stack mt="xl"> + <Group align="center" className={classes.or}> + <Text>{t('signIn.oauth.or')}</Text> + </Group> + <Group position="center"> + { + oauth.map((provider) => + <Button + component="a" + target="_blank" + title={t(`signIn.oauth.${provider}`)} + href={getOAuthUrl(config.get('general.appUrl'), provider)} + variant="light"> + {getOAuthIcon(provider)} + </Button> + ) + } + </Group> + </Stack> + )} </Paper> </Container> ); diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index b2feeec29..700dffffd 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -43,6 +43,9 @@ export default { "signIn.notify.totp-required.title": "Two-factor authentication required", "signIn.notify.totp-required.description": "Please enter your two-factor authentication code", + "signIn.oauth.or": "OR", + "signIn.oauth.github": "GitHub", + "signIn.oauth.google": "Google", // END /auth/signin @@ -83,6 +86,13 @@ export default { "account.card.password.new": "New password", "account.notify.password.success": "Password changed successfully", + "account.card.oauth.title": "Social login", + "account.card.oauth.github": "GitHub", + "account.card.oauth.google": "google", + "account.card.oauth.link": "Link", + "account.card.oauth.unlink": "Unlink", + "account.card.oauth.unlinked": "Unlinked", + "account.card.security.title": "Security", "account.card.security.totp.enable.description": "Enter your current password to start enabling TOTP", @@ -336,6 +346,7 @@ export default { "admin.config.category.share": "Share", "admin.config.category.email": "Email", "admin.config.category.smtp": "SMTP", + "admin.config.category.oauth": "Social Login", "admin.config.general.app-name": "App name", "admin.config.general.app-name.description": "Name of the application", @@ -407,6 +418,21 @@ export default { "admin.config.smtp.password.description": "Password of the SMTP server", "admin.config.smtp.button.test": "Send test email", + "admin.config.oauth.allow-registration": "Allow registration", + "admin.config.oauth.allow-registration.description": "Allow users to register via social login", + "admin.config.oauth.github-enabled": "GitHub", + "admin.config.oauth.github-enabled.description": "Whether GitHub login is enabled", + "admin.config.oauth.github-client-id": "GitHub Client ID", + "admin.config.oauth.github-client-id.description": "Client ID of the GitHub OAuth app", + "admin.config.oauth.github-client-secret": "GitHub Client secret", + "admin.config.oauth.github-client-secret.description": "Client secret of the GitHub OAuth app", + "admin.config.oauth.google-enabled": "Google", + "admin.config.oauth.google-enabled.description": "Whether Google login is enabled", + "admin.config.oauth.google-client-id": "Google Client ID", + "admin.config.oauth.google-client-id.description": "Client ID of the Google OAuth app", + "admin.config.oauth.google-client-secret": "Google Client secret", + "admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app", + // 404 "404.description": "Oops this page doesn't exist.", "404.button.home": "Bring me back home", diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index d5bda976e..a0a2d2fe7 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -25,11 +25,21 @@ import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; import userService from "../../services/user.service"; import toast from "../../utils/toast.util"; +import { useEffect, useState } from "react"; +import useConfig from "../../hooks/config.hook"; +import { getOAuthIcon, getOAuthUrl, revokeOAuth } from "../../utils/oauth.util"; const Account = () => { + const [oauth, setOAuth] = useState<string[]>([]); + const [oauthStatus, setOAuthStatus] = useState<Record<string, { + provider: string; + providerUsername: string; + }> | null>(null); + const { user, refreshUser } = useUser(); const modals = useModals(); const t = useTranslate(); + const config = useConfig(); const accountForm = useForm({ initialValues: { @@ -96,6 +106,15 @@ const Account = () => { ), }); + useEffect(() => { + authService.getAvailableOAuth().then(data => { + setOAuth(data.data); + }).catch(toast.axiosError); + authService.getOAuthStatus().then(data => { + setOAuthStatus(data.data); + }).catch(toast.axiosError); + }, []); + return ( <> <Meta title={t("account.title")} /> @@ -167,7 +186,50 @@ const Account = () => { </Stack> </form> </Paper> + {oauth.length > 0 && ( + <Paper withBorder p="xl" mt="lg"> + <Title order={5} mb="xs"> + <FormattedMessage id="account.card.oauth.title" /> + + + + { + oauth.map(provider => + + {t(`account.card.oauth.${provider}`)} + + ) + } + + { + oauth.map(provider => + + + { + oauthStatus?.[provider] + ? oauthStatus[provider].providerUsername + : t('account.card.oauth.unlinked') + } + { + oauthStatus?.[provider] + ? + : + } + + + ) + } + + + )} <FormattedMessage id="account.card.security.title" /> diff --git a/frontend/src/pages/admin/config/[category].tsx b/frontend/src/pages/admin/config/[category].tsx index 747d08bfc..9502d00e4 100644 --- a/frontend/src/pages/admin/config/[category].tsx +++ b/frontend/src/pages/admin/config/[category].tsx @@ -24,10 +24,7 @@ import CenterLoader from "../../../components/core/CenterLoader"; import useConfig from "../../../hooks/config.hook"; import configService from "../../../services/config.service"; import { AdminConfig, UpdateConfig } from "../../../types/config.type"; -import { - camelToKebab, - capitalizeFirstLetter, -} from "../../../utils/string.util"; +import { camelToKebab, } from "../../../utils/string.util"; import toast from "../../../utils/toast.util"; import useTranslate from "../../../hooks/useTranslate.hook"; @@ -128,7 +125,7 @@ export default function AppShellDemo() { <> <Stack> <Title mb="md" order={3}> - {capitalizeFirstLetter(categoryId)} + {t("admin.config.category." + categoryId)} {configVariables.map((configVariable) => ( diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 5935b9491..97292d774 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -96,6 +96,14 @@ const disableTOTP = async (totpCode: string, password: string) => { }); }; +const getAvailableOAuth = async () => { + return api.get("/oauth/available"); +} + +const getOAuthStatus = () => { + return api.get("/oauth/status"); +} + export default { signIn, signInTotp, @@ -108,4 +116,6 @@ export default { enableTOTP, verifyTOTP, disableTOTP, + getAvailableOAuth, + getOAuthStatus, }; diff --git a/frontend/src/utils/oauth.util.tsx b/frontend/src/utils/oauth.util.tsx new file mode 100644 index 000000000..5b9f20594 --- /dev/null +++ b/frontend/src/utils/oauth.util.tsx @@ -0,0 +1,25 @@ +import { SiGithub, SiGoogle, SiOpenid } from "react-icons/si"; +import React from "react"; +import toast from "./toast.util"; + +const getOAuthUrl = (appUrl: string, provider: string, isRevoke = false) => { + return appUrl + '/api/oauth/' + provider + (isRevoke ? '/revoke' : ''); +} + +const getOAuthIcon = (provider: string) => { + return { + 'google': , + 'github': , + 'oidc': , + }[provider]; +} + +const revokeOAuth = (_appUrl: string, _provider: string) => { + toast.error("Not implemented yet"); +} + +export { + getOAuthUrl, + getOAuthIcon, + revokeOAuth, +} \ No newline at end of file From f15a8dc277ad96dc3593b3d6f64c5da2cc33e467 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 6 Oct 2023 09:21:14 +0200 Subject: [PATCH 02/50] chore(translations): add files for Japanese --- frontend/src/i18n/locales.ts | 6 + frontend/src/i18n/translations/ja-JP.ts | 439 ++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 frontend/src/i18n/translations/ja-JP.ts diff --git a/frontend/src/i18n/locales.ts b/frontend/src/i18n/locales.ts index 6bf6abb6a..0f02726c1 100644 --- a/frontend/src/i18n/locales.ts +++ b/frontend/src/i18n/locales.ts @@ -4,6 +4,7 @@ import english from "./translations/en-US"; import spanish from "./translations/es-ES"; import finnish from "./translations/fi-FI"; import french from "./translations/fr-FR"; +import japanese from "./translations/ja-JP"; import dutch from "./translations/nl-BE"; import portuguese from "./translations/pt-BR"; import russian from "./translations/ru-RU"; @@ -72,4 +73,9 @@ export const LOCALES = { code: "nl-BE", messages: dutch, }, + JAPANESE: { + name: "日本語", + code: "ja-JP", + messages: japanese, + }, }; diff --git a/frontend/src/i18n/translations/ja-JP.ts b/frontend/src/i18n/translations/ja-JP.ts new file mode 100644 index 000000000..b2feeec29 --- /dev/null +++ b/frontend/src/i18n/translations/ja-JP.ts @@ -0,0 +1,439 @@ +export default { + // Navbar + "navbar.upload": "Upload", + "navbar.signin": "Sign in", + "navbar.home": "Home", + "navbar.signup": "Sign Up", + + "navbar.links.shares": "My shares", + "navbar.links.reverse": "Reverse shares", + + "navbar.avatar.account": "My account", + "navbar.avatar.admin": "Administration", + "navbar.avatar.signout": "Sign out", + // END navbar + + // / + "home.title": "A self-hosted file sharing platform.", + + "home.description": + "Do you really want to give your personal files in the hand of third parties like WeTransfer?", + "home.bullet.a.name": "Self-Hosted", + "home.bullet.a.description": "Host Pingvin Share on your own machine.", + "home.bullet.b.name": "Privacy", + "home.bullet.b.description": + "Your files are your files and should never get into the hands of third parties.", + "home.bullet.c.name": "No annoying file size limit", + "home.bullet.c.description": + "Upload as big files as you want. Only your hard drive will be your limit.", + + "home.button.start": "Get started", + "home.button.source": "Source code", + // END / + + // /auth/signin + "signin.title": "Welcome back", + "signin.description": "You don't have an account yet?", + "signin.button.signup": "Sign up", + "signin.input.email-or-username": "Email or username", + "signin.input.email-or-username.placeholder": "Your email or username", + "signin.input.password": "Password", + "signin.input.password.placeholder": "Your password", + "signin.button.submit": "Sign in", + "signIn.notify.totp-required.title": "Two-factor authentication required", + "signIn.notify.totp-required.description": + "Please enter your two-factor authentication code", + + // END /auth/signin + + // /auth/signup + "signup.title": "Create an account", + "signup.description": "Already have an account?", + "signup.button.signin": "Sign in", + "signup.input.username": "Username", + "signup.input.username.placeholder": "Your username", + "signup.input.email": "Email", + "signup.input.email.placeholder": "Your email", + "signup.button.submit": "Let's get started", + + // END /auth/signup + + // /auth/reset-password + "resetPassword.title": "Forgot your password?", + "resetPassword.description": "Enter your email to reset your password.", + "resetPassword.notify.success": + "An email has been sent with a link to reset your password.", + "resetPassword.button.back": "Back to sign in page", + "resetPassword.text.resetPassword": "Reset password", + "resetPassword.text.enterNewPassword": "Enter your new password", + "resetPassword.input.password": "New password", + "resetPassword.notify.passwordReset": + "Your password has been reset successfully.", + + // /account + "account.title": "My account", + + "account.card.info.title": "Account info", + "account.card.info.username": "Username", + "account.card.info.email": "Email", + "account.notify.info.success": "Account updated successfully", + + "account.card.password.title": "Password", + "account.card.password.old": "Old password", + "account.card.password.new": "New password", + "account.notify.password.success": "Password changed successfully", + + "account.card.security.title": "Security", + "account.card.security.totp.enable.description": + "Enter your current password to start enabling TOTP", + "account.card.security.totp.disable.description": + "Enter your current password to disable TOTP", + "account.card.security.totp.button.start": "Start", + "account.modal.totp.title": "Enable TOTP", + "account.modal.totp.step1": "Step 1: Add your authenticator", + "account.modal.totp.step2": "Step 2: Validate your code", + "account.modal.totp.enterManually": "Enter manually", + "account.modal.totp.code": "Code", + "account.modal.totp.clickToCopy": "Click to copy", + "account.modal.totp.verify": "Verify", + "account.notify.totp.disable": "TOTP disabled successfully", + "account.notify.totp.enable": "TOTP enabled successfully", + + "account.card.language.title": "Language", + "account.card.language.description": + "The project is translated by the community. Some languages might be incomplete.", + "account.card.color.title": "Color scheme", + + // ThemeSwitcher.tsx + "account.theme.dark": "Dark", + "account.theme.light": "Light", + "account.theme.system": "System", + + "account.button.delete": "Delete Account", + "account.modal.delete.title": "Delete Account", + "account.modal.delete.description": + "Do you really want to delete your account including all your active shares?", + // END /account + + // /account/shares + "account.shares.title": "My shares", + "account.shares.title.empty": "It's empty here 👀", + "account.shares.description.empty": "You don't have any shares.", + "account.shares.button.create": "Create one", + + "account.shares.info.title": "Share informations", + "account.shares.table.id": "ID", + "account.shares.table.name": "Name", + "account.shares.table.description": "Description", + "account.shares.table.visitors": "Visitors", + "account.shares.table.expiresAt": "Expires at", + "account.shares.table.createdAt": "Created at", + "account.shares.table.size": "Size", + + "account.shares.modal.share-informations": "Share informations", + "account.shares.modal.share-link": "Share link", + + "account.shares.modal.delete.title": "Delete share {share}", + "account.shares.modal.delete.description": + "Do you really want to delete this share?", + + // END /account/shares + + // /account/reverseShares + "account.reverseShares.title": "Reverse shares", + "account.reverseShares.description": + "A reverse share allows you to generate a unique URL that allows external users to create a share.", + + "account.reverseShares.title.empty": "It's empty here 👀", + "account.reverseShares.description.empty": + "You don't have any reverse shares.", + + // showCreateReverseShareModal.tsx + "account.reverseShares.modal.title": "Create reverse share", + "account.reverseShares.modal.expiration.label": "Expiration", + "account.reverseShares.modal.expiration.minute-singular": "Minute", + "account.reverseShares.modal.expiration.minute-plural": "Minutes", + "account.reverseShares.modal.expiration.hour-singular": "Hour", + "account.reverseShares.modal.expiration.hour-plural": "Hours", + "account.reverseShares.modal.expiration.day-singular": "Day", + "account.reverseShares.modal.expiration.day-plural": "Days", + "account.reverseShares.modal.expiration.week-singular": "Week", + "account.reverseShares.modal.expiration.week-plural": "Weeks", + "account.reverseShares.modal.expiration.month-singular": "Month", + "account.reverseShares.modal.expiration.month-plural": "Months", + "account.reverseShares.modal.expiration.year-singular": "Year", + "account.reverseShares.modal.expiration.year-plural": "Years", + + "account.reverseShares.modal.max-size.label": "Max share size", + + "account.reverseShares.modal.send-email": "Send email notification", + "account.reverseShares.modal.send-email.description": + "Send an email notification when a share is created with this reverse share link.", + + "account.reverseShares.modal.max-use.label": "Max uses", + "account.reverseShares.modal.max-use.description": + "The maximum amount of times this URL can be used to create a share.", + "account.reverseShare.never-expires": "This reverse share will never expire.", + "account.reverseShare.expires-on": + "This reverse share will expire on {expiration}.", + + "account.reverseShares.table.no-shares": "No shares created yet", + "account.reverseShares.table.count.singular": "share", + "account.reverseShares.table.count.plural": "shares", + "account.reverseShares.table.shares": "Shares", + "account.reverseShares.table.remaining": "Remaining uses", + "account.reverseShares.table.max-size": "Max share size", + "account.reverseShares.table.expires": "Expires at", + + "account.reverseShares.modal.reverse-share-link": "Reverse share link", + + "account.reverseShares.modal.delete.title": "Delete reverse share", + "account.reverseShares.modal.delete.description": + "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.", + + // END /account/reverseShares + + // /admin + "admin.title": "Administration", + "admin.button.users": "User management", + "admin.button.config": "Configuration", + "admin.version": "Version", + // END /admin + + // /admin/users + "admin.users.title": "User management", + "admin.users.table.username": "Username", + "admin.users.table.email": "Email", + "admin.users.table.admin": "Admin", + + "admin.users.edit.update.title": "Update user {username}", + "admin.users.edit.update.admin-privileges": "Admin privileges", + "admin.users.edit.update.change-password.title": "Change password", + "admin.users.edit.update.change-password.field": "New password", + "admin.users.edit.update.change-password.button": "Save new password", + "admin.users.edit.update.notify.password.success": + "Password changed successfully", + + "admin.users.edit.delete.title": "Delete user {username}", + "admin.users.edit.delete.description": + "Do you really want to delete this user and all his shares?", + + // showCreateUserModal.tsx + "admin.users.modal.create.title": "Create user", + "admin.users.modal.create.username": "Username", + "admin.users.modal.create.email": "Email", + "admin.users.modal.create.password": "Password", + "admin.users.modal.create.manual-password": "Set password manually", + "admin.users.modal.create.manual-password.description": + "If not checked, the user will receive an email with a link to set their password.", + "admin.users.modal.create.admin": "Admin privileges", + "admin.users.modal.create.admin.description": + "If checked, the user will be able to access the admin panel.", + + // END /admin/users + + // /upload + "upload.title": "Upload", + + "upload.notify.generic-error": + "An error occurred while finishing your share.", + "upload.notify.count-failed": "{count} files failed to upload. Trying again.", + + // Dropzone.tsx + "upload.dropzone.title": "Upload files", + "upload.dropzone.description": + "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.", + "upload.dropzone.notify.file-too-big": + "Your files exceed the maximum share size of {maxSize}.", + + // FileList.tsx + "upload.filelist.name": "Name", + "upload.filelist.size": "Size", + + // showCreateUploadModal.tsx + "upload.modal.title": "Create Share", + "upload.modal.link.error.invalid": + "Can only contain letters, numbers, underscores, and hyphens", + "upload.modal.link.error.taken": "This link is already in use", + "upload.modal.not-signed-in": "You're not signed in", + "upload.modal.not-signed-in-description": + "You will be unable to delete your share manually and view the visitor count.", + + "upload.modal.expires.never": "never", + "upload.modal.expires.never-long": "Never Expires", + + "upload.modal.link.label": "Link", + "upload.modal.expires.label": "Expiration", + "upload.modal.expires.minute-singular": "Minute", + "upload.modal.expires.minute-plural": "Minutes", + "upload.modal.expires.hour-singular": "Hour", + "upload.modal.expires.hour-plural": "Hours", + "upload.modal.expires.day-singular": "Day", + "upload.modal.expires.day-plural": "Days", + "upload.modal.expires.week-singular": "Week", + "upload.modal.expires.week-plural": "Weeks", + "upload.modal.expires.month-singular": "Month", + "upload.modal.expires.month-plural": "Months", + "upload.modal.expires.year-singular": "Year", + "upload.modal.expires.year-plural": "Years", + + "upload.modal.accordion.description.title": "Description", + "upload.modal.accordion.description.placeholder": + "Note for the recipients of this share", + + "upload.modal.accordion.email.title": "Email recipients", + "upload.modal.accordion.email.placeholder": "Enter email recipients", + "upload.modal.accordion.email.invalid-email": "Invalid email address", + + "upload.modal.accordion.security.title": "Security options", + "upload.modal.accordion.security.password.label": "Password protection", + "upload.modal.accordion.security.password.placeholder": "No password", + "upload.modal.accordion.security.max-views.label": "Maximum views", + "upload.modal.accordion.security.max-views.placeholder": "No limit", + + // showCompletedUploadModal.tsx + "upload.modal.completed.never-expires": "This share will never expire.", + "upload.modal.completed.expires-on": + "This share will expire on {expiration}.", + "upload.modal.completed.share-ready": "Share ready", + + // END /upload + + // /share/[id] + "share.title": "Share {shareId}", + "share.description": "Look what I've shared with you!", + "share.error.visitor-limit-exceeded.title": "Visitor limit exceeded", + "share.error.visitor-limit-exceeded.description": + "The visitor limit from this share has been exceeded.", + "share.error.removed.title": "Share removed", + "share.error.not-found.title": "Share not found", + "share.error.not-found.description": + "The share you're looking for doesn't exist.", + + "share.modal.password.title": "Password required", + "share.modal.password.description": + "To access this share please enter the password for the share.", + "share.modal.password": "Password", + "share.modal.error.invalid-password": "Invalid password", + + "share.button.download-all": "Download all", + "share.notify.download-all-preparing": + "The share is preparing. Try again in a few minutes.", + + "share.modal.file-link": "File link", + "share.table.name": "Name", + "share.table.size": "Size", + + "share.modal.file-preview.error.not-supported.title": "Preview not supported", + "share.modal.file-preview.error.not-supported.description": + "A preview for thise file type is unsupported. Please download the file to view it.", + + // END /share/[id] + + // /admin/config + "admin.config.title": "Configuration", + "admin.config.category.general": "General", + "admin.config.category.share": "Share", + "admin.config.category.email": "Email", + "admin.config.category.smtp": "SMTP", + + "admin.config.general.app-name": "App name", + "admin.config.general.app-name.description": "Name of the application", + "admin.config.general.app-url": "App URL", + "admin.config.general.app-url.description": + "On which URL Pingvin Share is available", + "admin.config.general.show-home-page": "Show home page", + "admin.config.general.show-home-page.description": + "Whether to show the home page", + "admin.config.general.logo": "Logo", + "admin.config.general.logo.description": + "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.", + "admin.config.general.logo.placeholder": "Pick image", + + "admin.config.email.enable-share-email-recipients": + "Enable share email recipients", + "admin.config.email.enable-share-email-recipients.description": + "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", + "admin.config.email.share-recipients-subject": "Share recipients subject", + "admin.config.email.share-recipients-subject.description": + "Subject of the email which gets sent to the share recipients.", + "admin.config.email.share-recipients-message": "Share recipients message", + "admin.config.email.share-recipients-message.description": + "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.", + "admin.config.email.reverse-share-subject": "Reverse share subject", + "admin.config.email.reverse-share-subject.description": + "Subject of the email which gets sent when someone created a share with your reverse share link.", + "admin.config.email.reverse-share-message": "Reverse share message", + "admin.config.email.reverse-share-message.description": + "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", + "admin.config.email.reset-password-subject": "Reset password subject", + "admin.config.email.reset-password-subject.description": + "Subject of the email which gets sent when a user requests a password reset.", + "admin.config.email.reset-password-message": "Reset password message", + "admin.config.email.reset-password-message.description": + "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", + "admin.config.email.invite-subject": "Invite subject", + "admin.config.email.invite-subject.description": + "Subject of the email which gets sent when an admin invites a user.", + "admin.config.email.invite-message": "Invite message", + "admin.config.email.invite-message.description": + "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.", + "admin.config.share.allow-registration": "Allow registration", + "admin.config.share.allow-registration.description": + "Whether registration is allowed", + "admin.config.share.allow-unauthenticated-shares": + "Allow unauthenticated shares", + "admin.config.share.allow-unauthenticated-shares.description": + "Whether unauthenticated users can create shares", + "admin.config.share.max-size": "Max size", + "admin.config.share.max-size.description": "Maximum share size in bytes", + "admin.config.share.zip-compression-level": "Zip compression level", + "admin.config.share.zip-compression-level.description": + "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ", + + "admin.config.smtp.enabled": "Enabled", + "admin.config.smtp.enabled.description": + "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + "admin.config.smtp.host": "Host", + "admin.config.smtp.host.description": "Host of the SMTP server", + "admin.config.smtp.port": "Port", + "admin.config.smtp.port.description": "Port of the SMTP server", + "admin.config.smtp.email": "Email", + "admin.config.smtp.email.description": + "Email address which the emails get sent from", + "admin.config.smtp.username": "Username", + "admin.config.smtp.username.description": "Username of the SMTP server", + "admin.config.smtp.password": "Password", + "admin.config.smtp.password.description": "Password of the SMTP server", + "admin.config.smtp.button.test": "Send test email", + + // 404 + "404.description": "Oops this page doesn't exist.", + "404.button.home": "Bring me back home", + + // Common translations + "common.button.save": "Save", + "common.button.create": "Create", + "common.button.submit": "Submit", + "common.button.delete": "Delete", + "common.button.cancel": "Cancel", + "common.button.confirm": "Confirm", + "common.button.disable": "Disable", + "common.button.share": "Share", + "common.button.generate": "Generate", + "common.button.done": "Done", + "common.text.link": "Link", + "common.text.or": "or", + "common.button.go-back": "Go back", + "common.notify.copied": "Your link was copied to the clipboard", + "common.success": "Success", + + "common.error": "Error", + "common.error.unknown": "An unknown error occurred", + "common.error.invalid-email": "Invalid email address", + "common.error.too-short": "Must be at least {length} characters", + "common.error.too-long": "Must be at most {length} characters", + "common.error.exact-length": "Must be exactly {length} characters", + "common.error.invalid-number": "Must be a number", + "common.error.field-required": "This field is required", +}; From c85eaade6a08e278941a7165e55e34923623ccde Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Fri, 6 Oct 2023 19:33:57 +0800 Subject: [PATCH 03/50] fix(auth): fix link function for GitHub --- backend/src/oauth/oauth.controller.ts | 37 ++++--- backend/src/oauth/oauth.module.ts | 2 + backend/src/oauth/oauth.service.ts | 123 +++++++++------------- backend/src/oauth/oauthRequest.service.ts | 66 ++++++++++++ frontend/src/pages/account/index.tsx | 3 +- frontend/src/utils/oauth.util.tsx | 4 +- 6 files changed, 147 insertions(+), 88 deletions(-) create mode 100644 backend/src/oauth/oauthRequest.service.ts diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts index afa9134e0..03e888cf3 100644 --- a/backend/src/oauth/oauth.controller.ts +++ b/backend/src/oauth/oauth.controller.ts @@ -19,8 +19,8 @@ export class OAuthController { } @Get("available") - getAvailable(@Res({ passthrough: true }) response: Response, @Req() request: Request) { - return this.oauthService.getAvailable(); + available(@Res({ passthrough: true }) response: Response, @Req() request: Request) { + return this.oauthService.available(); } @Get("status") @@ -30,14 +30,14 @@ export class OAuthController { } @Get("github") - github(@Res({ passthrough: true }) response: Response) { + github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) { const state = nanoid(10); response.cookie("github_oauth_state", state, { sameSite: "strict" }); const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({ client_id: this.config.get("oauth.github-clientId"), - redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback", + redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback" + (link ? "/link" : ""), state: state, - scope: "user:email", + scope: link ? "" : "user:email", // linking account doesn't need email }).toString(); response.redirect(url); // return ``; @@ -45,15 +45,8 @@ export class OAuthController { @Get("github/callback") async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { - if (!this.config.get("oauth.github-enabled")) { - throw new NotFoundException(); - } - const { state, code } = query; - - if (state !== request.cookies.github_oauth_state) { - throw new BadRequestException("Invalid state"); - } + this.oauthService.validate("github", request.cookies, state); const token = await this.oauthService.github(code); AuthController.addTokensToResponse( @@ -64,6 +57,24 @@ export class OAuthController { response.redirect(this.config.get("general.appUrl")); } + @Get("github/callback/link") + @UseGuards(JwtGuard) + async githubLink(@Req() request: Request, + @Res({ passthrough: true }) response: Response, + @Query() query: GithubDto, + @GetUser() user: User) { + const { state, code } = query; + this.oauthService.validate("github", request.cookies, state); + + try { + await this.oauthService.githubLink(code, user); + response.redirect(this.config.get("general.appUrl") + '/account'); + } catch (e) { + // TODO error page + throw e; + } + } + @Get("google") @UseGuards(GoogleOAuthGuard) async google() { diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index f56ff1914..5286a77a3 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -4,11 +4,13 @@ import { OAuthService } from './oauth.service'; import { AuthService } from "../auth/auth.service"; import { AuthModule } from "../auth/auth.module"; import { GoogleStrategy } from "./strategy/google.strategy"; +import { OAuthRequestService } from "./oauthRequest.service"; @Module({ controllers: [OAuthController], providers: [ OAuthService, + OAuthRequestService, GoogleStrategy, { provide: "OAUTH_PLATFORMS", diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index 3b22f1891..cdbbd4c5e 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -1,10 +1,10 @@ -import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from "../prisma/prisma.service"; import { ConfigService } from "../config/config.service"; import { AuthService } from "../auth/auth.service"; import { User } from "@prisma/client"; import { nanoid } from "nanoid"; -import fetch from "node-fetch"; +import { OAuthRequestService } from "./oauthRequest.service"; @Injectable() @@ -13,51 +13,39 @@ export class OAuthService { private prisma: PrismaService, private config: ConfigService, private auth: AuthService, + private request: OAuthRequestService, @Inject("OAUTH_PLATFORMS") private platforms: string[], ) { } - getAvailable(): string[] { + validate(provider: string, cookies: Record, state: string) { + if (!this.config.get(`oauth.${provider}-enabled`)) { + throw new NotFoundException(); + } + + if (cookies[`${provider}_oauth_state`] !== state) { + throw new BadRequestException("Invalid state"); + } + } + + available(): string[] { return this.platforms .map(platform => [platform, this.config.get(`oauth.${platform}-enabled`)]) .filter(([_, enabled]) => enabled) .map(([platform, _]) => platform); } - private async getGitHubToken(code: string): Promise { - const qs = new URLSearchParams(); - qs.append("client_id", this.config.get("oauth.github-clientId")); - qs.append("client_secret", this.config.get("oauth.github-clientSecret")); - qs.append("code", code); - - const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), { - method: "post", - headers: { - "Accept": "application/json", - } - }); - return await res.json() as GitHubToken; - } - - private async getGitHubUser(token: GitHubToken): Promise { - const res = await fetch("https://api.github.com/user", { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `${token.token_type} ${token.access_token}`, - } - }); - return await res.json() as GitHubUser; - } - - private async getGitHubEmails(token: GitHubToken): Promise { - const res = await fetch("https://api.github.com/user/public_emails", { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `${token.token_type} ${token.access_token}`, - } + async status(user: User) { + const oauthUsers = await this.prisma.oAuthUser.findMany({ + select: { + provider: true, + providerUsername: true, + }, + where: { + userId: user.id, + }, }); - const emails = await res.json() as GitHubEmail[]; - return emails.find(e => e.primary && e.verified)?.email; + return Object.fromEntries(oauthUsers.map(u => [u.provider, u])); } async signIn(user: OAuthSignInDto) { @@ -124,13 +112,34 @@ export class OAuthService { return result; } + async link(userId: string, provider: string, providerUserId: string, providerUsername: string) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider, + providerUserId, + } + }); + if (oauthUser) { + throw new BadRequestException(`This ${provider} account has been linked to another account`); + } + + await this.prisma.oAuthUser.create({ + data: { + userId, + provider, + providerUsername, + providerUserId, + } + }); + } + async github(code: string) { - const ghToken = await this.getGitHubToken(code); - const ghUser = await this.getGitHubUser(ghToken); + const ghToken = await this.request.getGitHubToken(code); + const ghUser = await this.request.getGitHubUser(ghToken); if (!ghToken.scope.includes("user:email")) { throw new BadRequestException("No email permission granted"); } - const email = await this.getGitHubEmails(ghToken); + const email = await this.request.getGitHubEmail(ghToken); return this.signIn({ provider: "github", providerId: ghUser.id.toString(), @@ -139,37 +148,9 @@ export class OAuthService { }); } - async status(user: User) { - const oauthUsers = await this.prisma.oAuthUser.findMany({ - select: { - provider: true, - providerUsername: true, - }, - where: { - userId: user.id, - }, - }); - return Object.fromEntries(oauthUsers.map(u => [u.provider, u])); + async githubLink(code: string, user: User) { + const ghToken = await this.request.getGitHubToken(code); + const ghUser = await this.request.getGitHubUser(ghToken); + await this.link(user.id, 'github', ghUser.id.toString(), ghUser.name); } } - - -interface GitHubToken { - access_token: string; - token_type: string; - scope: string; -} - -interface GitHubUser { - login: string; - id: number; - name?: string; - email?: string; // this filed seems only return null -} - -interface GitHubEmail { - email: string; - primary: boolean, - verified: boolean, - visibility: string | null -} \ No newline at end of file diff --git a/backend/src/oauth/oauthRequest.service.ts b/backend/src/oauth/oauthRequest.service.ts new file mode 100644 index 000000000..cc47cc017 --- /dev/null +++ b/backend/src/oauth/oauthRequest.service.ts @@ -0,0 +1,66 @@ +import fetch from "node-fetch"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "../config/config.service"; + +@Injectable() +export class OAuthRequestService { + constructor(private config: ConfigService) { + } + + async getGitHubToken(code: string): Promise { + const qs = new URLSearchParams(); + qs.append("client_id", this.config.get("oauth.github-clientId")); + qs.append("client_secret", this.config.get("oauth.github-clientSecret")); + qs.append("code", code); + + const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), { + method: "post", + headers: { + "Accept": "application/json", + } + }); + return await res.json() as GitHubToken; + } + + async getGitHubUser(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + return await res.json() as GitHubUser; + } + + async getGitHubEmail(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user/public_emails", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + const emails = await res.json() as GitHubEmail[]; + return emails.find(e => e.primary && e.verified)?.email; + } +} + + +interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +interface GitHubUser { + login: string; + id: number; + name?: string; + email?: string; // this filed seems only return null +} + +interface GitHubEmail { + email: string; + primary: boolean, + verified: boolean, + visibility: string | null +} \ No newline at end of file diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index a0a2d2fe7..f927074dd 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -219,8 +219,7 @@ const Account = () => { } : } diff --git a/frontend/src/utils/oauth.util.tsx b/frontend/src/utils/oauth.util.tsx index 5b9f20594..847fb08de 100644 --- a/frontend/src/utils/oauth.util.tsx +++ b/frontend/src/utils/oauth.util.tsx @@ -2,8 +2,8 @@ import { SiGithub, SiGoogle, SiOpenid } from "react-icons/si"; import React from "react"; import toast from "./toast.util"; -const getOAuthUrl = (appUrl: string, provider: string, isRevoke = false) => { - return appUrl + '/api/oauth/' + provider + (isRevoke ? '/revoke' : ''); +const getOAuthUrl = (appUrl: string, provider: string) => { + return `${appUrl}/api/oauth/${provider}`; } const getOAuthIcon = (provider: string) => { From 85a9ba9414be40c4c2315b7e893982839bdf6838 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Sun, 8 Oct 2023 01:41:33 +0800 Subject: [PATCH 04/50] feat(oauth): basic oidc implementation --- backend/package-lock.json | 63 +++++++ backend/package.json | 2 + backend/prisma/seed/config.seed.ts | 31 ++-- backend/src/app.module.ts | 4 + backend/src/auth/auth.module.ts | 7 +- backend/src/config/config.service.ts | 13 +- .../decorator/oauthProvider.decorator.ts | 3 + backend/src/oauth/dto/oauthSignIn.dto.ts | 2 +- backend/src/oauth/dto/oidcCallback.dto.ts | 9 + backend/src/oauth/guard/google.guard.ts | 5 - backend/src/oauth/guard/oauth.guard.ts | 18 ++ backend/src/oauth/oauth.controller.ts | 136 +++++++++------ backend/src/oauth/oauth.module.ts | 18 +- backend/src/oauth/oidc.service.ts | 162 ++++++++++++++++++ backend/src/oauth/strategy/google.strategy.ts | 39 ----- frontend/src/i18n/translations/en-US.ts | 2 +- 16 files changed, 394 insertions(+), 120 deletions(-) create mode 100644 backend/src/oauth/decorator/oauthProvider.decorator.ts create mode 100644 backend/src/oauth/dto/oidcCallback.dto.ts delete mode 100644 backend/src/oauth/guard/google.guard.ts create mode 100644 backend/src/oauth/guard/oauth.guard.ts create mode 100644 backend/src/oauth/oidc.service.ts delete mode 100644 backend/src/oauth/strategy/google.strategy.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index dd5396aac..02ba2dffa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "pingvin-share-backend", "version": "0.18.1", "dependencies": { + "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.1.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.1.2", @@ -21,6 +22,7 @@ "archiver": "^5.3.1", "argon2": "^0.30.3", "body-parser": "^1.20.2", + "cache-manager": "^5.2.4", "clamscan": "^2.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -627,6 +629,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", + "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.1.10", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz", @@ -2579,6 +2593,23 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz", + "integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.1" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -5302,6 +5333,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -9052,6 +9088,12 @@ } } }, + "@nestjs/cache-manager": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", + "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "requires": {} + }, "@nestjs/cli": { "version": "10.1.10", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz", @@ -10533,6 +10575,22 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, + "cache-manager": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/cache-manager/-/cache-manager-5.2.4.tgz", + "integrity": "sha512-gkuCjug16NdGvKm/sydxGVx17uffrSWcEe2xraBtwRCgdYcFxwJAla4OYpASAZT2yhSoxgDiWL9XH6IAChcZJA==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.0.1" + }, + "dependencies": { + "lru-cache": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.0.1.tgz", + "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==" + } + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -12541,6 +12599,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 49539b8d0..80424f883 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "seed": "ts-node prisma/seed/config.seed.ts" }, "dependencies": { + "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.1.2", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.1.2", @@ -26,6 +27,7 @@ "archiver": "^5.3.1", "argon2": "^0.30.3", "body-parser": "^1.20.2", + "cache-manager": "^5.2.4", "clamscan": "^2.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 6f1d72831..86b966507 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -154,18 +154,23 @@ const configVariables: ConfigVariables = { defaultValue: "", obscured: true, }, - // "oidc-enabled": { - // type: "boolean", - // defaultValue: "false", - // }, - // "oidc-clientId": { - // type: "string", - // defaultValue: "", - // }, - // "oidc-clientSecret": { - // type: "string", - // defaultValue: "", - // }, + "oidc-enabled": { + type: "boolean", + defaultValue: "false", + }, + "oidc-discoveryUri": { + type: "string", + defaultValue: "", + }, + "oidc-clientId": { + type: "string", + defaultValue: "", + }, + "oidc-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, } }; @@ -223,7 +228,7 @@ async function migrateConfigVariables() { const configVariable = configVariables[existingConfigVariable.category]?.[ existingConfigVariable.name - ]; + ]; if (!configVariable) { await prisma.config.delete({ where: { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4c013a99a..5c0d00e6f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; import { ReverseShareModule } from "./reverseShare/reverseShare.module"; import { OAuthModule } from './oauth/oauth.module'; +import { CacheModule } from "@nestjs/cache-manager"; @Module({ imports: [ @@ -34,6 +35,9 @@ import { OAuthModule } from './oauth/oauth.module'; ClamScanModule, ReverseShareModule, OAuthModule, + CacheModule.register({ + isGlobal: true, + }), ], providers: [ { diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 56204d134..f5404bc82 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service"; import { JwtStrategy } from "./strategy/jwt.strategy"; @Module({ - imports: [JwtModule.register({}), EmailModule], + imports: [ + JwtModule.register({ + global: true, + }), + EmailModule + ], controllers: [AuthController], providers: [AuthService, AuthTotpService, JwtStrategy], exports: [AuthService], diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 431a127e1..a5e02a761 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -6,13 +6,20 @@ import { } from "@nestjs/common"; import { Config } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; +import { EventEmitter } from "events"; +/** + * ConfigService extends EventEmitter to allow listening for config updates, + * now only `update` event will be emitted. + */ @Injectable() -export class ConfigService { +export class ConfigService extends EventEmitter { constructor( @Inject("CONFIG_VARIABLES") private configVariables: Config[], private prisma: PrismaService, - ) {} + ) { + super(); + } get(key: `${string}.${string}`): any { const configVariable = this.configVariables.filter( @@ -105,6 +112,8 @@ export class ConfigService { this.configVariables = await this.prisma.config.findMany(); + this.emit("update", key, value); + return updatedVariable; } } diff --git a/backend/src/oauth/decorator/oauthProvider.decorator.ts b/backend/src/oauth/decorator/oauthProvider.decorator.ts new file mode 100644 index 000000000..0a4cbfa39 --- /dev/null +++ b/backend/src/oauth/decorator/oauthProvider.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; + +export const OAuthProvider = (provider: string) => SetMetadata('oauthProvider', provider); \ No newline at end of file diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts index 48f0069b8..12bb03b3d 100644 --- a/backend/src/oauth/dto/oauthSignIn.dto.ts +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -1,5 +1,5 @@ interface OAuthSignInDto { - provider: 'github' | 'google'; + provider: 'github' | 'google' | 'oidc'; providerId: string; providerUsername: string; email: string; diff --git a/backend/src/oauth/dto/oidcCallback.dto.ts b/backend/src/oauth/dto/oidcCallback.dto.ts new file mode 100644 index 000000000..b37022627 --- /dev/null +++ b/backend/src/oauth/dto/oidcCallback.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from "class-validator"; + +export class OidcCallbackDto { + @IsString() + code: string; + + @IsString() + state: string; +} \ No newline at end of file diff --git a/backend/src/oauth/guard/google.guard.ts b/backend/src/oauth/guard/google.guard.ts deleted file mode 100644 index 1dc9447bb..000000000 --- a/backend/src/oauth/guard/google.guard.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -@Injectable() -export class GoogleOAuthGuard extends AuthGuard('google') {} diff --git a/backend/src/oauth/guard/oauth.guard.ts b/backend/src/oauth/guard/oauth.guard.ts new file mode 100644 index 000000000..08f48e69e --- /dev/null +++ b/backend/src/oauth/guard/oauth.guard.ts @@ -0,0 +1,18 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from "@nestjs/core"; + +@Injectable() +export class OAuthGuard implements CanActivate { + constructor(private reflector: Reflector) { + } + + canActivate(context: ExecutionContext): boolean { + const provider = this.reflector.get("oauthProvider", context.getHandler()) + const request = context.switchToHttp().getRequest(); + if (request.query.state !== request.cookies[`oauth_${provider}_state`]) { + return false; + } + // TODO validate nonce + return true; + } +} diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts index 03e888cf3..0fb2dd042 100644 --- a/backend/src/oauth/oauth.controller.ts +++ b/backend/src/oauth/oauth.controller.ts @@ -1,20 +1,24 @@ -import { BadRequestException, Controller, Get, NotFoundException, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, Req, Res, SetMetadata, UseGuards } from '@nestjs/common'; import { OAuthService } from "./oauth.service"; import { Request, Response } from "express"; import { GithubDto } from "./dto/github.dto"; import { JwtGuard } from "../auth/guard/jwt.guard"; import { ConfigService } from "../config/config.service"; -import { GoogleOAuthGuard } from "./guard/google.guard"; import { nanoid } from "nanoid"; import { AuthController } from "../auth/auth.controller"; import { GetUser } from "../auth/decorator/getUser.decorator"; import { User } from "@prisma/client"; +import { OidcService } from "./oidc.service"; +import { OidcCallbackDto } from "./dto/oidcCallback.dto"; +import { OAuthGuard } from "./guard/oauth.guard"; +import { OAuthProvider } from "./decorator/oauthProvider.decorator"; @Controller('oauth') export class OAuthController { constructor( private oauthService: OAuthService, private config: ConfigService, + private oidcService: OidcService, ) { } @@ -29,62 +33,86 @@ export class OAuthController { return this.oauthService.status(user); } - @Get("github") - github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) { - const state = nanoid(10); - response.cookie("github_oauth_state", state, { sameSite: "strict" }); - const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({ - client_id: this.config.get("oauth.github-clientId"), - redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback" + (link ? "/link" : ""), - state: state, - scope: link ? "" : "user:email", // linking account doesn't need email - }).toString(); - response.redirect(url); - // return ``; - } - - @Get("github/callback") - async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { - const { state, code } = query; - this.oauthService.validate("github", request.cookies, state); - - const token = await this.oauthService.github(code); - AuthController.addTokensToResponse( - response, - token.refreshToken, - token.accessToken, - ); - response.redirect(this.config.get("general.appUrl")); - } - - @Get("github/callback/link") - @UseGuards(JwtGuard) - async githubLink(@Req() request: Request, - @Res({ passthrough: true }) response: Response, - @Query() query: GithubDto, - @GetUser() user: User) { - const { state, code } = query; - this.oauthService.validate("github", request.cookies, state); - - try { - await this.oauthService.githubLink(code, user); - response.redirect(this.config.get("general.appUrl") + '/account'); - } catch (e) { - // TODO error page - throw e; - } - } + // @Get("github") + // github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) { + // const state = nanoid(10); + // response.cookie("github_oauth_state", state, { sameSite: "strict" }); + // const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({ + // client_id: this.config.get("oauth.github-clientId"), + // redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback" + (link ? "/link" : ""), + // state: state, + // scope: link ? "" : "user:email", // linking account doesn't need email + // }).toString(); + // response.redirect(url); + // // return ``; + // } + // + // @Get("github/callback") + // async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { + // const { state, code } = query; + // this.oauthService.validate("github", request.cookies, state); + // + // const token = await this.oauthService.github(code); + // AuthController.addTokensToResponse( + // response, + // token.refreshToken, + // token.accessToken, + // ); + // response.redirect(this.config.get("general.appUrl")); + // } + // + // @Get("github/callback/link") + // @UseGuards(JwtGuard) + // async githubLink(@Req() request: Request, + // @Res({ passthrough: true }) response: Response, + // @Query() query: GithubDto, + // @GetUser() user: User) { + // const { state, code } = query; + // this.oauthService.validate("github", request.cookies, state); + // + // try { + // await this.oauthService.githubLink(code, user); + // response.redirect(this.config.get("general.appUrl") + '/account'); + // } catch (e) { + // // TODO error page + // throw e; + // } + // } + // + // @Get("google") + // google() { + // } + // + // @Get("google/callback") + // async googleCallback(@Req() request: Request, @Res({ passthrough: true }) response: Response) { + // const user = request.user as OAuthSignInDto; + // const token = await this.oauthService.signIn(user); + // AuthController.addTokensToResponse( + // response, + // token.refreshToken, + // token.accessToken, + // ); + // response.redirect(this.config.get("general.appUrl")); + // } - @Get("google") - @UseGuards(GoogleOAuthGuard) - async google() { + @Get("oidc") + async oidc(@Res({ passthrough: true }) response: Response) { + const state = nanoid(16); + const url = await this.oidcService.getAuthEndpoint(state); + response.cookie("oauth_oidc_state", state, { sameSite: "lax" }); + response.redirect(url); } - @Get("google/callback") - @UseGuards(GoogleOAuthGuard) - async googleCallback(@Req() request: Request, @Res({ passthrough: true }) response: Response) { - const user = request.user as OAuthSignInDto; + @Get("oidc/callback") + @OAuthProvider("oidc") + @UseGuards(OAuthGuard) + async oidcCallback(@Query() query: OidcCallbackDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response) { + const user = await this.oidcService.getUserInfo(query.code); + // TODO check is login const token = await this.oauthService.signIn(user); + // TODO totp AuthController.addTokensToResponse( response, token.refreshToken, diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index 5286a77a3..e786c2430 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -1,21 +1,31 @@ import { Module } from '@nestjs/common'; import { OAuthController } from './oauth.controller'; import { OAuthService } from './oauth.service'; -import { AuthService } from "../auth/auth.service"; import { AuthModule } from "../auth/auth.module"; -import { GoogleStrategy } from "./strategy/google.strategy"; import { OAuthRequestService } from "./oauthRequest.service"; +import { OidcService } from "./oidc.service"; +import { ConfigService } from "../config/config.service"; @Module({ controllers: [OAuthController], providers: [ OAuthService, OAuthRequestService, - GoogleStrategy, { provide: "OAUTH_PLATFORMS", - useValue: ["github", "google"], + useValue: ["oidc"], }, + + { + provide: "OIDC_NAME", + useValue: "oidc", + }, + { + provide: "OIDC_DISCOVERY_URI", + useFactory: (config: ConfigService) => config.get("oauth.oidc-discoveryUri"), + inject: [ConfigService], + }, + OidcService, ], imports: [AuthModule], }) diff --git a/backend/src/oauth/oidc.service.ts b/backend/src/oauth/oidc.service.ts new file mode 100644 index 000000000..0ef88e369 --- /dev/null +++ b/backend/src/oauth/oidc.service.ts @@ -0,0 +1,162 @@ +import { Inject, Injectable } from "@nestjs/common"; +import fetch from "node-fetch"; +import { ConfigService } from "../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class OidcService { + private configuration: OidcConfigurationCache; + private jwk: OidcJwkCache; + private redirectUri: string; + + constructor(@Inject("OIDC_NAME") private name: string, + @Inject("OIDC_DISCOVERY_URI") private discoveryUri: string, + private config: ConfigService, + private jwtService: JwtService, + @Inject(CACHE_MANAGER) private cache: Cache) { + this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/${name}/callback`; + this.config.addListener("update", (key: string, _: unknown) => { + if (key === `oauth.${name}-enabled` || key === `oauth.${name}-discoveryUri`) { + this.discoveryUri = this.config.get(`oauth.${name}-discoveryUri`); + this.deinit(); + } + }); + } + + private async fetchConfiguration(): Promise { + const res = await fetch(this.discoveryUri); + const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; + this.configuration = { + expires, + data: await res.json(), + }; + } + + private async fetchJwk(): Promise { + const configuration = await this.getConfiguration(); + const res = await fetch(configuration.jwks_uri); + const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; + this.jwk = { + expires, + data: (await res.json())['keys'], + }; + } + + private deinit() { + this.discoveryUri = undefined; + this.configuration = undefined; + this.jwk = undefined; + } + + async getConfiguration(): Promise { + if (!this.configuration || this.configuration.expires < Date.now()) { + await this.fetchConfiguration(); + } + return this.configuration.data; + } + + async getJwk(): Promise { + if (!this.jwk || this.jwk.expires < Date.now()) { + await this.fetchJwk(); + } + return this.jwk.data; + } + + async getAuthEndpoint(state: string) { + const configuration = await this.getConfiguration(); + const endpoint = configuration.authorization_endpoint; + // TODO nonce + return endpoint + "?" + new URLSearchParams({ + client_id: this.config.get(`oauth.${this.name}-clientId`), + response_type: "code", + scope: "openid profile email", + redirect_uri: this.redirectUri, + state, + }).toString(); + } + + async getToken(code: string): Promise { + const configuration = await this.getConfiguration(); + const endpoint = configuration.token_endpoint; + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: this.config.get(`oauth.${this.name}-clientId`), + client_secret: this.config.get(`oauth.${this.name}-clientSecret`), + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + }).toString(), + }); + return await res.json(); + } + + private decodeIdToken(idToken: string): OidcIdToken { + return this.jwtService.decode(idToken) as OidcIdToken; + } + + async getUserInfo(code: string): Promise { + const token = await this.getToken(code); + const idTokenData = this.decodeIdToken(token.id_token); + // TODO verify id token + return { + provider: this.name as any, + email: idTokenData.email, + providerId: idTokenData.sub, + providerUsername: idTokenData.name, + } + } + +} + +export interface OidcCache { + expires: number, + data: T, +} + +export interface OidcConfiguration { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; + response_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + scopes_supported?: string[]; + claims_supported?: string[]; +} + +export interface OidcJwk { + e: string; + alg: string; + kid: string; + use: string; + kty: string; + n: string; +} + +export type OidcConfigurationCache = OidcCache; + +export type OidcJwkCache = OidcCache; + +export interface OidcToken { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + id_token: string; +} + +export interface OidcIdToken { + iss: string; + sub: string; + exp: number; + iat: number; + email: string; + name: string; +} \ No newline at end of file diff --git a/backend/src/oauth/strategy/google.strategy.ts b/backend/src/oauth/strategy/google.strategy.ts deleted file mode 100644 index de0ebcbb7..000000000 --- a/backend/src/oauth/strategy/google.strategy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Strategy, VerifyCallback } from 'passport-google-oauth20'; -import { ConfigService } from "../../config/config.service"; -import { PrismaService } from "../../prisma/prisma.service"; - -@Injectable() -export class GoogleStrategy extends PassportStrategy(Strategy) { - constructor( - config: ConfigService, - private prisma: PrismaService, - ) { - super({ - callbackURL: config.get('general.appUrl') + '/api/oauth/google/callback', - clientID: config.get('oauth.google-clientId'), - clientSecret: config.get('oauth.google-clientSecret'), - scope: ['profile', 'email'], - }); - } - - async validate( - _accessToken: string, - _refreshToken: string, - profile: any, - done: VerifyCallback, - ): Promise { - console.log(profile); - const { id, displayName, emails } = profile; - - const user = { - provider: 'google', - providerId: id, - providerUsername: displayName, - email: emails.find((v: { verified: boolean; }) => v.verified).value, - }; - - done(null, user); - } -} diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 700dffffd..104864b1e 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -88,7 +88,7 @@ export default { "account.card.oauth.title": "Social login", "account.card.oauth.github": "GitHub", - "account.card.oauth.google": "google", + "account.card.oauth.google": "Google", "account.card.oauth.link": "Link", "account.card.oauth.unlink": "Unlink", "account.card.oauth.unlinked": "Unlinked", From 87b52dfddbb8da8928de6798de64ccf6cb648778 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Sun, 8 Oct 2023 15:07:49 +0800 Subject: [PATCH 05/50] feat(oauth): oauth guard --- backend/src/auth/auth.controller.ts | 28 +++------------- backend/src/auth/auth.service.ts | 32 +++++++++++++++++++ .../decorator/oauthProvider.decorator.ts | 8 ++++- backend/src/oauth/guard/oauth.guard.ts | 21 ++++++++---- backend/src/oauth/oauth.controller.ts | 32 +++++++++++-------- backend/src/oauth/oidc.service.ts | 16 +++++++--- 6 files changed, 87 insertions(+), 50 deletions(-) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 8cbeaa4ef..427543e76 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -47,7 +47,7 @@ export class AuthController { const result = await this.authService.signUp(dto); - response = AuthController.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -66,7 +66,7 @@ export class AuthController { const result = await this.authService.signIn(dto); if (result.accessToken && result.refreshToken) { - response = AuthController.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -85,7 +85,7 @@ export class AuthController { ) { const result = await this.authTotpService.signInTotp(dto); - response = AuthController.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -121,7 +121,7 @@ export class AuthController { dto.password, ); - response = AuthController.addTokensToResponse(response, result.refreshToken); + this.authService.addTokensToResponse(response, result.refreshToken); return new TokenDTO().from(result); } @@ -136,7 +136,7 @@ export class AuthController { const accessToken = await this.authService.refreshAccessToken( request.cookies.refresh_token, ); - response = AuthController.addTokensToResponse(response, undefined, accessToken); + this.authService.addTokensToResponse(response, undefined, accessToken); return new TokenDTO().from({ accessToken }); } @@ -172,22 +172,4 @@ export class AuthController { // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code return this.authTotpService.disableTotp(user, body.password, body.code); } - - static addTokensToResponse( - response: Response, - refreshToken?: string, - accessToken?: string, - ) { - if (accessToken) - response.cookie("access_token", accessToken, { sameSite: "lax" }); - if (refreshToken) - response.cookie("refresh_token", refreshToken, { - path: "/api/auth/token", - httpOnly: true, - sameSite: "strict", - maxAge: 1000 * 60 * 60 * 24 * 30 * 3, - }); - - return response; - } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c4b050db2..e328e1e4f 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -14,6 +14,7 @@ import { EmailService } from "src/email/email.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; +import { Request, Response } from "express"; @Injectable() export class AuthService { @@ -214,4 +215,35 @@ export class AuthService { return loginToken; } + + addTokensToResponse( + response: Response, + refreshToken?: string, + accessToken?: string, + ) { + if (accessToken) + response.cookie("access_token", accessToken, { sameSite: "lax" }); + if (refreshToken) + response.cookie("refresh_token", refreshToken, { + path: "/api/auth/token", + httpOnly: true, + sameSite: "strict", + maxAge: 1000 * 60 * 60 * 24 * 30 * 3, + }); + } + + /** + * Returns the user id if the user is logged in, false otherwise + */ + async getIdIfLogin(request: Request): Promise { + if (!request.cookies.access_token) return false; + try { + const payload = await this.jwtService.verifyAsync(request.cookies.access_token, { + secret: this.config.get("internal.jwtSecret"), + }); + return payload.sub; + } catch (e) { + return false; + } + } } diff --git a/backend/src/oauth/decorator/oauthProvider.decorator.ts b/backend/src/oauth/decorator/oauthProvider.decorator.ts index 0a4cbfa39..f599fb874 100644 --- a/backend/src/oauth/decorator/oauthProvider.decorator.ts +++ b/backend/src/oauth/decorator/oauthProvider.decorator.ts @@ -1,3 +1,9 @@ import { SetMetadata } from '@nestjs/common'; -export const OAuthProvider = (provider: string) => SetMetadata('oauthProvider', provider); \ No newline at end of file +export const OAuthProvider = (provider: string, type: "auth" | "callback") => SetMetadata( + 'oauth', + { + provider, + type + } +); \ No newline at end of file diff --git a/backend/src/oauth/guard/oauth.guard.ts b/backend/src/oauth/guard/oauth.guard.ts index 08f48e69e..ece10e1b8 100644 --- a/backend/src/oauth/guard/oauth.guard.ts +++ b/backend/src/oauth/guard/oauth.guard.ts @@ -1,18 +1,25 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from "@nestjs/core"; +import { ConfigService } from "../../config/config.service"; @Injectable() export class OAuthGuard implements CanActivate { - constructor(private reflector: Reflector) { + constructor( + private reflector: Reflector, + private config: ConfigService, + ) { } canActivate(context: ExecutionContext): boolean { - const provider = this.reflector.get("oauthProvider", context.getHandler()) - const request = context.switchToHttp().getRequest(); - if (request.query.state !== request.cookies[`oauth_${provider}_state`]) { - return false; + const oauth = this.reflector.get<{ + provider: string; + type: "endpoint" | "callback"; + }>("oauth", context.getHandler()) + if (oauth.type === "callback") { + const request = context.switchToHttp().getRequest(); + return request.query.state === request.cookies[`oauth_${oauth.provider}_state`]; + } else { + return this.config.get(`oauth.${oauth.provider}-enabled`) } - // TODO validate nonce - return true; } } diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts index 0fb2dd042..40032b0cf 100644 --- a/backend/src/oauth/oauth.controller.ts +++ b/backend/src/oauth/oauth.controller.ts @@ -1,21 +1,21 @@ -import { Controller, Get, Query, Req, Res, SetMetadata, UseGuards } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Query, Req, Res, UseGuards } from '@nestjs/common'; import { OAuthService } from "./oauth.service"; import { Request, Response } from "express"; -import { GithubDto } from "./dto/github.dto"; import { JwtGuard } from "../auth/guard/jwt.guard"; import { ConfigService } from "../config/config.service"; import { nanoid } from "nanoid"; -import { AuthController } from "../auth/auth.controller"; import { GetUser } from "../auth/decorator/getUser.decorator"; import { User } from "@prisma/client"; import { OidcService } from "./oidc.service"; import { OidcCallbackDto } from "./dto/oidcCallback.dto"; import { OAuthGuard } from "./guard/oauth.guard"; import { OAuthProvider } from "./decorator/oauthProvider.decorator"; +import { AuthService } from "../auth/auth.service"; @Controller('oauth') export class OAuthController { constructor( + private authService: AuthService, private oauthService: OAuthService, private config: ConfigService, private oidcService: OidcService, @@ -96,6 +96,8 @@ export class OAuthController { // } @Get("oidc") + @OAuthProvider("oidc", "auth") + @UseGuards(OAuthGuard) async oidc(@Res({ passthrough: true }) response: Response) { const state = nanoid(16); const url = await this.oidcService.getAuthEndpoint(state); @@ -104,20 +106,22 @@ export class OAuthController { } @Get("oidc/callback") - @OAuthProvider("oidc") + @OAuthProvider("oidc", "callback") @UseGuards(OAuthGuard) async oidcCallback(@Query() query: OidcCallbackDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { - const user = await this.oidcService.getUserInfo(query.code); - // TODO check is login - const token = await this.oauthService.signIn(user); - // TODO totp - AuthController.addTokensToResponse( - response, - token.refreshToken, - token.accessToken, - ); - response.redirect(this.config.get("general.appUrl")); + const user = await this.oidcService.getUserInfo(query); + const id = await this.authService.getIdIfLogin(request); + + if (id) { + await this.oauthService.link(id, "oidc", user.providerId, user.providerUsername); + response.redirect(this.config.get("general.appUrl") + '/account'); + } else { + const token = await this.oauthService.signIn(user); + // TODO totp + this.authService.addTokensToResponse(response, token.refreshToken, token.accessToken); + response.redirect(this.config.get("general.appUrl")); + } } } diff --git a/backend/src/oauth/oidc.service.ts b/backend/src/oauth/oidc.service.ts index 0ef88e369..345f703c3 100644 --- a/backend/src/oauth/oidc.service.ts +++ b/backend/src/oauth/oidc.service.ts @@ -4,6 +4,8 @@ import { ConfigService } from "../config/config.service"; import { JwtService } from "@nestjs/jwt"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Cache } from "cache-manager"; +import { nanoid } from "nanoid"; +import { OidcCallbackDto } from "./dto/oidcCallback.dto"; @Injectable() export class OidcService { @@ -19,8 +21,8 @@ export class OidcService { this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/${name}/callback`; this.config.addListener("update", (key: string, _: unknown) => { if (key === `oauth.${name}-enabled` || key === `oauth.${name}-discoveryUri`) { - this.discoveryUri = this.config.get(`oauth.${name}-discoveryUri`); this.deinit(); + this.discoveryUri = this.config.get(`oauth.${name}-discoveryUri`); } }); } @@ -67,13 +69,17 @@ export class OidcService { async getAuthEndpoint(state: string) { const configuration = await this.getConfiguration(); const endpoint = configuration.authorization_endpoint; - // TODO nonce + + const nonce = nanoid(); + await this.cache.set(`oauth-${this.name}-nonce-${state}`, nonce, 1000 * 60 * 5); + return endpoint + "?" + new URLSearchParams({ client_id: this.config.get(`oauth.${this.name}-clientId`), response_type: "code", scope: "openid profile email", redirect_uri: this.redirectUri, state, + nonce, }).toString(); } @@ -100,10 +106,10 @@ export class OidcService { return this.jwtService.decode(idToken) as OidcIdToken; } - async getUserInfo(code: string): Promise { - const token = await this.getToken(code); + async getUserInfo(query: OidcCallbackDto): Promise { + const token = await this.getToken(query.code); const idTokenData = this.decodeIdToken(token.id_token); - // TODO verify id token + // TODO verify id token and nonce return { provider: this.name as any, email: idTokenData.email, From 38919003e9091203b507d0f0b061f4a1835ff4f4 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 9 Oct 2023 10:40:55 +0200 Subject: [PATCH 06/50] fix: disable image optimizations for logo to prevent caching issues with custom logos --- backend/src/config/logo.service.ts | 5 +++-- frontend/.eslintrc.json | 3 ++- frontend/public/img/logo.png | Bin 32632 -> 87662 bytes frontend/src/components/Logo.tsx | 4 +--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/config/logo.service.ts b/backend/src/config/logo.service.ts index 1fdadcaf5..0b81937e1 100644 --- a/backend/src/config/logo.service.ts +++ b/backend/src/config/logo.service.ts @@ -7,7 +7,8 @@ const IMAGES_PATH = "../frontend/public/img"; @Injectable() export class LogoService { async create(file: Buffer) { - fs.writeFileSync(`${IMAGES_PATH}/logo.png`, file, "binary"); + const resized = await sharp(file).resize(900).toBuffer(); + fs.writeFileSync(`${IMAGES_PATH}/logo.png`, resized, "binary"); this.createFavicon(file); this.createPWAIcons(file); } @@ -25,7 +26,7 @@ export class LogoService { fs.promises.writeFile( `${IMAGES_PATH}/icons/icon-${size}x${size}.png`, resized, - "binary", + "binary" ); } } diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bfea7a17e..00b0a541a 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -11,6 +11,7 @@ "react-hooks/exhaustive-deps": ["off"], "import/no-anonymous-default-export": ["off"], "no-unused-vars": ["warn"], - "react/no-unescaped-entities": ["off"] + "react/no-unescaped-entities": ["off"], + "@next/next/no-img-element": ["off"] } } diff --git a/frontend/public/img/logo.png b/frontend/public/img/logo.png index 340efd290f092786a25d539d89b2ff9f6d228905..bb7ea122ebb1ac1dacc5355d706201c0eca1035d 100644 GIT binary patch literal 87662 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

fyu$1KP`warnHx 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%0ADcTU)B^ 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 MyN{F#@0k4ef0$Ip*Z=?k diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index c69c50c04..44fe2e44e 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -1,6 +1,4 @@ -import Image from "next/image"; - const Logo = ({ height, width }: { height: number; width: number }) => { - return logo; + return logo; }; export default Logo; From 97e7d7190dfe219caf441dffcd7830c304c3c939 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 9 Oct 2023 11:14:51 +0200 Subject: [PATCH 07/50] fix: memory leak while downloading large files --- Dockerfile | 6 ++++-- README.md | 2 ++ backend/src/app.controller.ts | 19 +++++++++++++++++++ backend/src/app.module.ts | 4 ++++ frontend/src/pages/api/[...all].tsx | 2 ++ frontend/src/pages/api/health.tsx | 14 -------------- nginx/nginx.conf | 22 ++++++++++++++++++++++ 7 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 backend/src/app.controller.ts delete mode 100644 frontend/src/pages/api/health.tsx create mode 100644 nginx/nginx.conf diff --git a/Dockerfile b/Dockerfile index 60f3f1432..27dc1b1a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,9 @@ ENV NODE_ENV=docker # Alpine specific dependencies RUN apk update --no-cache RUN apk upgrade --no-cache -RUN apk add --no-cache curl +RUN apk add --no-cache curl nginx + +COPY ./nginx/nginx.conf /etc/nginx/nginx.conf WORKDIR /opt/app/frontend COPY --from=frontend-builder /opt/app/public ./public @@ -55,4 +57,4 @@ HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/he # Application startup # HOSTNAME=0.0.0.0 fixes https://github.com/vercel/next.js/issues/51684. It can be removed as soon as the issue is fixed -CMD cp -rn /tmp/img /opt/app/frontend/public && HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod \ No newline at end of file +CMD cp -rn /tmp/img /opt/app/frontend/public && nginx && PORT=3333 HOSTNAME=0.0.0.0 node frontend/server.js & cd backend && npm run prod \ No newline at end of file diff --git a/README.md b/README.md index 672a80af3..65fb3a88e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ npm run build pm2 start --name="pingvin-share-frontend" npm -- run start ``` +**Uploading Large Files**: By default, Pingvin Share uses a built-in reverse proxy to reduce the installation steps. However, this reverse proxy is not optimized for uploading large files. If you wish to upload larger files, you can either use the Docker installation or set up your own reverse proxy. An example configuration for Nginx can be found in `/nginx/nginx.conf`. + The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧! ### Integrations diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 000000000..f7b3bc9c4 --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Res } from "@nestjs/common"; +import { Response } from "express"; +import { PrismaService } from "./prisma/prisma.service"; + +@Controller("/") +export class AppController { + constructor(private prismaService: PrismaService) {} + + @Get("health") + async health(@Res({ passthrough: true }) res: Response) { + try { + await this.prismaService.config.findMany(); + return "OK"; + } catch { + res.statusCode = 500; + return "ERROR"; + } + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7f47e08a5..985d79548 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { ShareModule } from "./share/share.module"; import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; import { ReverseShareModule } from "./reverseShare/reverseShare.module"; +import { AppController } from "./app.controller"; @Module({ imports: [ @@ -33,6 +34,9 @@ import { ReverseShareModule } from "./reverseShare/reverseShare.module"; ClamScanModule, ReverseShareModule, ], + controllers:[ + AppController, + ], providers: [ { provide: APP_GUARD, diff --git a/frontend/src/pages/api/[...all].tsx b/frontend/src/pages/api/[...all].tsx index 8e7e850c9..ff27d57b6 100644 --- a/frontend/src/pages/api/[...all].tsx +++ b/frontend/src/pages/api/[...all].tsx @@ -11,6 +11,8 @@ export const config = { const { apiURL } = getConfig().serverRuntimeConfig; +// A proxy to the API server only used in development. +// In production this route gets overridden by nginx. export default (req: NextApiRequest, res: NextApiResponse) => { httpProxyMiddleware(req, res, { headers: { diff --git a/frontend/src/pages/api/health.tsx b/frontend/src/pages/api/health.tsx deleted file mode 100644 index e015da117..000000000 --- a/frontend/src/pages/api/health.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import axios from "axios"; -import { NextApiRequest, NextApiResponse } from "next"; -import getConfig from "next/config"; - -const { apiURL } = getConfig().serverRuntimeConfig; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - const apiStatus = await axios - .get(`${apiURL}/api/configs`) - .then(() => "OK") - .catch(() => "ERROR"); - - res.status(apiStatus == "OK" ? 200 : 500).send(apiStatus); -}; diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 000000000..7134235c6 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,22 @@ +events {} + +http { + server { + listen 3000; + client_max_body_size 100M; + + location /api { + proxy_pass http://localhost:8080; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://localhost:3333; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} From c502cd58dbf4de0901fe68f2ada0bbb50d5a3aad Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 9 Oct 2023 11:19:48 +0200 Subject: [PATCH 08/50] chore(translations): update translations via Crowdin (#278) * New translations en-us.ts (Japanese) * New translations en-us.ts (Japanese) * New translations en-us.ts (Japanese) --- frontend/src/i18n/translations/ja-JP.ts | 301 ++++++++---------------- 1 file changed, 93 insertions(+), 208 deletions(-) diff --git a/frontend/src/i18n/translations/ja-JP.ts b/frontend/src/i18n/translations/ja-JP.ts index b2feeec29..2c62d2721 100644 --- a/frontend/src/i18n/translations/ja-JP.ts +++ b/frontend/src/i18n/translations/ja-JP.ts @@ -1,93 +1,71 @@ export default { // Navbar - "navbar.upload": "Upload", - "navbar.signin": "Sign in", - "navbar.home": "Home", - "navbar.signup": "Sign Up", - - "navbar.links.shares": "My shares", - "navbar.links.reverse": "Reverse shares", - - "navbar.avatar.account": "My account", - "navbar.avatar.admin": "Administration", - "navbar.avatar.signout": "Sign out", + "navbar.upload": "アップロード", + "navbar.signin": "サインイン", + "navbar.home": "ホーム", + "navbar.signup": "会員登録", + "navbar.links.shares": "自分の共有", + "navbar.links.reverse": "自分と共有", + "navbar.avatar.account": "マイアカウント", + "navbar.avatar.admin": "管理画面", + "navbar.avatar.signout": "サインアウト", // END navbar - // / - "home.title": "A self-hosted file sharing platform.", - - "home.description": - "Do you really want to give your personal files in the hand of third parties like WeTransfer?", - "home.bullet.a.name": "Self-Hosted", - "home.bullet.a.description": "Host Pingvin Share on your own machine.", - "home.bullet.b.name": "Privacy", - "home.bullet.b.description": - "Your files are your files and should never get into the hands of third parties.", - "home.bullet.c.name": "No annoying file size limit", - "home.bullet.c.description": - "Upload as big files as you want. Only your hard drive will be your limit.", - - "home.button.start": "Get started", - "home.button.source": "Source code", + "home.title": "セルフホストのファイル共有プラットフォーム。", + "home.description": "WeTransferのようなサードパーティーサービスに自分のファイルを渡したいですか?", + "home.bullet.a.name": "セルフホスト", + "home.bullet.a.description": "Pingvin Shareをあなたのマシンでホストしましょう。", + "home.bullet.b.name": "プライバシー", + "home.bullet.b.description": "あなたのファイルはあなたのものであり、決して第三者の手に入るべきではありません。", + "home.bullet.c.name": "ファイルサイズ制限に悩まされることはありません", + "home.bullet.c.description": "大きなファイルもアップロード。ストレージのサイズだけが唯一の制限です。", + "home.button.start": "始めましょう", + "home.button.source": "ソースコード", // END / - // /auth/signin - "signin.title": "Welcome back", - "signin.description": "You don't have an account yet?", - "signin.button.signup": "Sign up", - "signin.input.email-or-username": "Email or username", - "signin.input.email-or-username.placeholder": "Your email or username", - "signin.input.password": "Password", - "signin.input.password.placeholder": "Your password", - "signin.button.submit": "Sign in", - "signIn.notify.totp-required.title": "Two-factor authentication required", - "signIn.notify.totp-required.description": - "Please enter your two-factor authentication code", - + "signin.title": "おかえりなさい", + "signin.description": "アカウントをお持ちではありませんか?", + "signin.button.signup": "会員登録", + "signin.input.email-or-username": "メールアドレスまたはユーザー名", + "signin.input.email-or-username.placeholder": "メールアドレスまたはユーザー名", + "signin.input.password": "パスワード", + "signin.input.password.placeholder": "あなたのパスワード", + "signin.button.submit": "サインイン", + "signIn.notify.totp-required.title": "二段階認証が必要です", + "signIn.notify.totp-required.description": "二段階認証コードを入力してください", // END /auth/signin - // /auth/signup - "signup.title": "Create an account", - "signup.description": "Already have an account?", - "signup.button.signin": "Sign in", - "signup.input.username": "Username", - "signup.input.username.placeholder": "Your username", - "signup.input.email": "Email", - "signup.input.email.placeholder": "Your email", - "signup.button.submit": "Let's get started", - + "signup.title": "アカウントを作成", + "signup.description": "既にアカウントをお持ちですか?", + "signup.button.signin": "サインイン", + "signup.input.username": "ユーザー名", + "signup.input.username.placeholder": "あなたのユーザー名", + "signup.input.email": "メールアドレス", + "signup.input.email.placeholder": "あなたのメールアドレス", + "signup.button.submit": "さあ始めましょう", // END /auth/signup - // /auth/reset-password - "resetPassword.title": "Forgot your password?", - "resetPassword.description": "Enter your email to reset your password.", - "resetPassword.notify.success": - "An email has been sent with a link to reset your password.", - "resetPassword.button.back": "Back to sign in page", - "resetPassword.text.resetPassword": "Reset password", - "resetPassword.text.enterNewPassword": "Enter your new password", - "resetPassword.input.password": "New password", - "resetPassword.notify.passwordReset": - "Your password has been reset successfully.", - + "resetPassword.title": "パスワードを忘れてしまいましたか?", + "resetPassword.description": "登録しているメールアドレスを入力してください。", + "resetPassword.notify.success": "パスワードをリセットするためのリンクをメールで送信しました。", + "resetPassword.button.back": "サインインページに戻る", + "resetPassword.text.resetPassword": "パスワードをリセット", + "resetPassword.text.enterNewPassword": "新規パスワードを入力", + "resetPassword.input.password": "新規パスワード", + "resetPassword.notify.passwordReset": "Your password has been reset successfully.", // /account "account.title": "My account", - "account.card.info.title": "Account info", "account.card.info.username": "Username", "account.card.info.email": "Email", "account.notify.info.success": "Account updated successfully", - "account.card.password.title": "Password", "account.card.password.old": "Old password", "account.card.password.new": "New password", "account.notify.password.success": "Password changed successfully", - "account.card.security.title": "Security", - "account.card.security.totp.enable.description": - "Enter your current password to start enabling TOTP", - "account.card.security.totp.disable.description": - "Enter your current password to disable TOTP", + "account.card.security.totp.enable.description": "Enter your current password to start enabling TOTP", + "account.card.security.totp.disable.description": "Enter your current password to disable TOTP", "account.card.security.totp.button.start": "Start", "account.modal.totp.title": "Enable TOTP", "account.modal.totp.step1": "Step 1: Add your authenticator", @@ -98,29 +76,22 @@ export default { "account.modal.totp.verify": "Verify", "account.notify.totp.disable": "TOTP disabled successfully", "account.notify.totp.enable": "TOTP enabled successfully", - "account.card.language.title": "Language", - "account.card.language.description": - "The project is translated by the community. Some languages might be incomplete.", + "account.card.language.description": "The project is translated by the community. Some languages might be incomplete.", "account.card.color.title": "Color scheme", - // ThemeSwitcher.tsx "account.theme.dark": "Dark", "account.theme.light": "Light", "account.theme.system": "System", - "account.button.delete": "Delete Account", "account.modal.delete.title": "Delete Account", - "account.modal.delete.description": - "Do you really want to delete your account including all your active shares?", + "account.modal.delete.description": "Do you really want to delete your account including all your active shares?", // END /account - // /account/shares "account.shares.title": "My shares", "account.shares.title.empty": "It's empty here 👀", "account.shares.description.empty": "You don't have any shares.", "account.shares.button.create": "Create one", - "account.shares.info.title": "Share informations", "account.shares.table.id": "ID", "account.shares.table.name": "Name", @@ -129,25 +100,16 @@ export default { "account.shares.table.expiresAt": "Expires at", "account.shares.table.createdAt": "Created at", "account.shares.table.size": "Size", - "account.shares.modal.share-informations": "Share informations", "account.shares.modal.share-link": "Share link", - "account.shares.modal.delete.title": "Delete share {share}", - "account.shares.modal.delete.description": - "Do you really want to delete this share?", - + "account.shares.modal.delete.description": "Do you really want to delete this share?", // END /account/shares - // /account/reverseShares "account.reverseShares.title": "Reverse shares", - "account.reverseShares.description": - "A reverse share allows you to generate a unique URL that allows external users to create a share.", - + "account.reverseShares.description": "A reverse share allows you to generate a unique URL that allows external users to create a share.", "account.reverseShares.title.empty": "It's empty here 👀", - "account.reverseShares.description.empty": - "You don't have any reverse shares.", - + "account.reverseShares.description.empty": "You don't have any reverse shares.", // showCreateReverseShareModal.tsx "account.reverseShares.modal.title": "Create reverse share", "account.reverseShares.modal.expiration.label": "Expiration", @@ -163,20 +125,13 @@ export default { "account.reverseShares.modal.expiration.month-plural": "Months", "account.reverseShares.modal.expiration.year-singular": "Year", "account.reverseShares.modal.expiration.year-plural": "Years", - "account.reverseShares.modal.max-size.label": "Max share size", - "account.reverseShares.modal.send-email": "Send email notification", - "account.reverseShares.modal.send-email.description": - "Send an email notification when a share is created with this reverse share link.", - + "account.reverseShares.modal.send-email.description": "Send an email notification when a share is created with this reverse share link.", "account.reverseShares.modal.max-use.label": "Max uses", - "account.reverseShares.modal.max-use.description": - "The maximum amount of times this URL can be used to create a share.", + "account.reverseShares.modal.max-use.description": "The maximum amount of times this URL can be used to create a share.", "account.reverseShare.never-expires": "This reverse share will never expire.", - "account.reverseShare.expires-on": - "This reverse share will expire on {expiration}.", - + "account.reverseShare.expires-on": "This reverse share will expire on {expiration}.", "account.reverseShares.table.no-shares": "No shares created yet", "account.reverseShares.table.count.singular": "share", "account.reverseShares.table.count.plural": "shares", @@ -184,84 +139,58 @@ export default { "account.reverseShares.table.remaining": "Remaining uses", "account.reverseShares.table.max-size": "Max share size", "account.reverseShares.table.expires": "Expires at", - "account.reverseShares.modal.reverse-share-link": "Reverse share link", - "account.reverseShares.modal.delete.title": "Delete reverse share", - "account.reverseShares.modal.delete.description": - "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.", - + "account.reverseShares.modal.delete.description": "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.", // END /account/reverseShares - // /admin "admin.title": "Administration", "admin.button.users": "User management", "admin.button.config": "Configuration", "admin.version": "Version", // END /admin - // /admin/users "admin.users.title": "User management", "admin.users.table.username": "Username", "admin.users.table.email": "Email", "admin.users.table.admin": "Admin", - "admin.users.edit.update.title": "Update user {username}", "admin.users.edit.update.admin-privileges": "Admin privileges", "admin.users.edit.update.change-password.title": "Change password", "admin.users.edit.update.change-password.field": "New password", "admin.users.edit.update.change-password.button": "Save new password", - "admin.users.edit.update.notify.password.success": - "Password changed successfully", - + "admin.users.edit.update.notify.password.success": "Password changed successfully", "admin.users.edit.delete.title": "Delete user {username}", - "admin.users.edit.delete.description": - "Do you really want to delete this user and all his shares?", - + "admin.users.edit.delete.description": "Do you really want to delete this user and all his shares?", // showCreateUserModal.tsx "admin.users.modal.create.title": "Create user", "admin.users.modal.create.username": "Username", "admin.users.modal.create.email": "Email", "admin.users.modal.create.password": "Password", "admin.users.modal.create.manual-password": "Set password manually", - "admin.users.modal.create.manual-password.description": - "If not checked, the user will receive an email with a link to set their password.", + "admin.users.modal.create.manual-password.description": "If not checked, the user will receive an email with a link to set their password.", "admin.users.modal.create.admin": "Admin privileges", - "admin.users.modal.create.admin.description": - "If checked, the user will be able to access the admin panel.", - + "admin.users.modal.create.admin.description": "If checked, the user will be able to access the admin panel.", // END /admin/users - // /upload "upload.title": "Upload", - - "upload.notify.generic-error": - "An error occurred while finishing your share.", + "upload.notify.generic-error": "An error occurred while finishing your share.", "upload.notify.count-failed": "{count} files failed to upload. Trying again.", - // Dropzone.tsx "upload.dropzone.title": "Upload files", - "upload.dropzone.description": - "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.", - "upload.dropzone.notify.file-too-big": - "Your files exceed the maximum share size of {maxSize}.", - + "upload.dropzone.description": "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.", + "upload.dropzone.notify.file-too-big": "Your files exceed the maximum share size of {maxSize}.", // FileList.tsx "upload.filelist.name": "Name", "upload.filelist.size": "Size", - // showCreateUploadModal.tsx "upload.modal.title": "Create Share", - "upload.modal.link.error.invalid": - "Can only contain letters, numbers, underscores, and hyphens", + "upload.modal.link.error.invalid": "Can only contain letters, numbers, underscores, and hyphens", "upload.modal.link.error.taken": "This link is already in use", "upload.modal.not-signed-in": "You're not signed in", - "upload.modal.not-signed-in-description": - "You will be unable to delete your share manually and view the visitor count.", - + "upload.modal.not-signed-in-description": "You will be unable to delete your share manually and view the visitor count.", "upload.modal.expires.never": "never", "upload.modal.expires.never-long": "Never Expires", - "upload.modal.link.label": "Link", "upload.modal.expires.label": "Expiration", "upload.modal.expires.minute-singular": "Minute", @@ -276,141 +205,98 @@ export default { "upload.modal.expires.month-plural": "Months", "upload.modal.expires.year-singular": "Year", "upload.modal.expires.year-plural": "Years", - "upload.modal.accordion.description.title": "Description", - "upload.modal.accordion.description.placeholder": - "Note for the recipients of this share", - + "upload.modal.accordion.description.placeholder": "Note for the recipients of this share", "upload.modal.accordion.email.title": "Email recipients", "upload.modal.accordion.email.placeholder": "Enter email recipients", "upload.modal.accordion.email.invalid-email": "Invalid email address", - "upload.modal.accordion.security.title": "Security options", "upload.modal.accordion.security.password.label": "Password protection", "upload.modal.accordion.security.password.placeholder": "No password", "upload.modal.accordion.security.max-views.label": "Maximum views", "upload.modal.accordion.security.max-views.placeholder": "No limit", - // showCompletedUploadModal.tsx "upload.modal.completed.never-expires": "This share will never expire.", - "upload.modal.completed.expires-on": - "This share will expire on {expiration}.", + "upload.modal.completed.expires-on": "This share will expire on {expiration}.", "upload.modal.completed.share-ready": "Share ready", - // END /upload - // /share/[id] "share.title": "Share {shareId}", "share.description": "Look what I've shared with you!", "share.error.visitor-limit-exceeded.title": "Visitor limit exceeded", - "share.error.visitor-limit-exceeded.description": - "The visitor limit from this share has been exceeded.", + "share.error.visitor-limit-exceeded.description": "The visitor limit from this share has been exceeded.", "share.error.removed.title": "Share removed", "share.error.not-found.title": "Share not found", - "share.error.not-found.description": - "The share you're looking for doesn't exist.", - + "share.error.not-found.description": "The share you're looking for doesn't exist.", "share.modal.password.title": "Password required", - "share.modal.password.description": - "To access this share please enter the password for the share.", + "share.modal.password.description": "To access this share please enter the password for the share.", "share.modal.password": "Password", "share.modal.error.invalid-password": "Invalid password", - "share.button.download-all": "Download all", - "share.notify.download-all-preparing": - "The share is preparing. Try again in a few minutes.", - + "share.notify.download-all-preparing": "The share is preparing. Try again in a few minutes.", "share.modal.file-link": "File link", "share.table.name": "Name", "share.table.size": "Size", - "share.modal.file-preview.error.not-supported.title": "Preview not supported", - "share.modal.file-preview.error.not-supported.description": - "A preview for thise file type is unsupported. Please download the file to view it.", - + "share.modal.file-preview.error.not-supported.description": "A preview for thise file type is unsupported. Please download the file to view it.", // END /share/[id] - // /admin/config "admin.config.title": "Configuration", "admin.config.category.general": "General", "admin.config.category.share": "Share", "admin.config.category.email": "Email", "admin.config.category.smtp": "SMTP", - "admin.config.general.app-name": "App name", "admin.config.general.app-name.description": "Name of the application", "admin.config.general.app-url": "App URL", - "admin.config.general.app-url.description": - "On which URL Pingvin Share is available", + "admin.config.general.app-url.description": "On which URL Pingvin Share is available", "admin.config.general.show-home-page": "Show home page", - "admin.config.general.show-home-page.description": - "Whether to show the home page", + "admin.config.general.show-home-page.description": "Whether to show the home page", "admin.config.general.logo": "Logo", - "admin.config.general.logo.description": - "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.", + "admin.config.general.logo.description": "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.", "admin.config.general.logo.placeholder": "Pick image", - - "admin.config.email.enable-share-email-recipients": - "Enable share email recipients", - "admin.config.email.enable-share-email-recipients.description": - "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", + "admin.config.email.enable-share-email-recipients": "Enable share email recipients", + "admin.config.email.enable-share-email-recipients.description": "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", "admin.config.email.share-recipients-subject": "Share recipients subject", - "admin.config.email.share-recipients-subject.description": - "Subject of the email which gets sent to the share recipients.", + "admin.config.email.share-recipients-subject.description": "Subject of the email which gets sent to the share recipients.", "admin.config.email.share-recipients-message": "Share recipients message", - "admin.config.email.share-recipients-message.description": - "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.", + "admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.", "admin.config.email.reverse-share-subject": "Reverse share subject", - "admin.config.email.reverse-share-subject.description": - "Subject of the email which gets sent when someone created a share with your reverse share link.", + "admin.config.email.reverse-share-subject.description": "Subject of the email which gets sent when someone created a share with your reverse share link.", "admin.config.email.reverse-share-message": "Reverse share message", - "admin.config.email.reverse-share-message.description": - "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", + "admin.config.email.reverse-share-message.description": "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", "admin.config.email.reset-password-subject": "Reset password subject", - "admin.config.email.reset-password-subject.description": - "Subject of the email which gets sent when a user requests a password reset.", + "admin.config.email.reset-password-subject.description": "Subject of the email which gets sent when a user requests a password reset.", "admin.config.email.reset-password-message": "Reset password message", - "admin.config.email.reset-password-message.description": - "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", + "admin.config.email.reset-password-message.description": "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", "admin.config.email.invite-subject": "Invite subject", - "admin.config.email.invite-subject.description": - "Subject of the email which gets sent when an admin invites a user.", + "admin.config.email.invite-subject.description": "Subject of the email which gets sent when an admin invites a user.", "admin.config.email.invite-message": "Invite message", - "admin.config.email.invite-message.description": - "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.", + "admin.config.email.invite-message.description": "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.", "admin.config.share.allow-registration": "Allow registration", - "admin.config.share.allow-registration.description": - "Whether registration is allowed", - "admin.config.share.allow-unauthenticated-shares": - "Allow unauthenticated shares", - "admin.config.share.allow-unauthenticated-shares.description": - "Whether unauthenticated users can create shares", + "admin.config.share.allow-registration.description": "Whether registration is allowed", + "admin.config.share.allow-unauthenticated-shares": "Allow unauthenticated shares", + "admin.config.share.allow-unauthenticated-shares.description": "Whether unauthenticated users can create shares", "admin.config.share.max-size": "Max size", "admin.config.share.max-size.description": "Maximum share size in bytes", "admin.config.share.zip-compression-level": "Zip compression level", - "admin.config.share.zip-compression-level.description": - "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ", - + "admin.config.share.zip-compression-level.description": "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ", "admin.config.smtp.enabled": "Enabled", - "admin.config.smtp.enabled.description": - "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + "admin.config.smtp.enabled.description": "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "admin.config.smtp.host": "Host", "admin.config.smtp.host.description": "Host of the SMTP server", "admin.config.smtp.port": "Port", "admin.config.smtp.port.description": "Port of the SMTP server", "admin.config.smtp.email": "Email", - "admin.config.smtp.email.description": - "Email address which the emails get sent from", + "admin.config.smtp.email.description": "Email address which the emails get sent from", "admin.config.smtp.username": "Username", "admin.config.smtp.username.description": "Username of the SMTP server", "admin.config.smtp.password": "Password", "admin.config.smtp.password.description": "Password of the SMTP server", "admin.config.smtp.button.test": "Send test email", - // 404 "404.description": "Oops this page doesn't exist.", "404.button.home": "Bring me back home", - // Common translations "common.button.save": "Save", "common.button.create": "Create", @@ -427,7 +313,6 @@ export default { "common.button.go-back": "Go back", "common.notify.copied": "Your link was copied to the clipboard", "common.success": "Success", - "common.error": "Error", "common.error.unknown": "An unknown error occurred", "common.error.invalid-email": "Invalid email address", @@ -435,5 +320,5 @@ export default { "common.error.too-long": "Must be at most {length} characters", "common.error.exact-length": "Must be exactly {length} characters", "common.error.invalid-number": "Must be a number", - "common.error.field-required": "This field is required", -}; + "common.error.field-required": "This field is required" +}; \ No newline at end of file From b088a5ef2aa790c5ad059ed4f969a4c5fa5c73b3 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 9 Oct 2023 11:20:06 +0200 Subject: [PATCH 09/50] release: 0.18.2 --- CHANGELOG.md | 8 ++++++++ backend/package-lock.json | 4 ++-- backend/package.json | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package.json | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa933593f..cababd58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [0.18.2](https://github.com/stonith404/pingvin-share/compare/v0.18.1...v0.18.2) (2023-10-09) + + +### Bug Fixes + +* disable image optimizations for logo to prevent caching issues with custom logos ([3891900](https://github.com/stonith404/pingvin-share/commit/38919003e9091203b507d0f0b061f4a1835ff4f4)) +* memory leak while downloading large files ([97e7d71](https://github.com/stonith404/pingvin-share/commit/97e7d7190dfe219caf441dffcd7830c304c3c939)) + ## [0.18.1](https://github.com/stonith404/pingvin-share/compare/v0.18.0...v0.18.1) (2023-09-22) diff --git a/backend/package-lock.json b/backend/package-lock.json index f18b79d54..7b2424e66 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "dependencies": { "@nestjs/common": "^10.1.2", "@nestjs/config": "^3.0.0", diff --git a/backend/package.json b/backend/package.json index 843d30d77..f3a32dbd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "pingvin-share-backend", - "version": "0.18.1", + "version": "0.18.2", "scripts": { "build": "nest build", "dev": "cross-env NODE_ENV=development nest start --watch", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fecaf9e22..a9373d579 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/server": "^11.11.0", diff --git a/frontend/package.json b/frontend/package.json index 491ee3763..c1428eb76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "pingvin-share-frontend", - "version": "0.18.1", + "version": "0.18.2", "scripts": { "dev": "next dev", "build": "next build", diff --git a/package.json b/package.json index f2efbee75..6755c399d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pingvin-share", - "version": "0.18.1", + "version": "0.18.2", "scripts": { "format": "cd frontend && npm run format && cd ../backend && npm run format", "lint": "cd frontend && npm run lint && cd ../backend && npm run lint", From 21809843cdfec5a7d1efaa4a4099d16d6463de84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=B5=E3=81=86=E3=81=9B=E3=82=93?= <10260662+fusengum@users.noreply.github.com> Date: Tue, 10 Oct 2023 15:19:28 +0900 Subject: [PATCH 10/50] doc(translations): Add Japanese README (#279) * Added Japanese README. * Added JAPANESE README link to README.md. * Updated Japanese README. * Updated Environment Variable Table. * updated zh-cn README. --- README.md | 2 +- docs/README.es.md | 2 +- docs/README.ja-jp.md | 158 +++++++++++++++++++++++++++++++++++++++++++ docs/README.zh-cn.md | 2 +- 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 docs/README.ja-jp.md diff --git a/README.md b/README.md index 65fb3a88e..e36ef7fa0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ --- -_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md)_ +_Read this in another language: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ --- diff --git a/docs/README.es.md b/docs/README.es.md index abb97ce11..8b32e55d6 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -2,7 +2,7 @@ --- -_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md)_ +_Leer esto en otro idioma: [Inglés](/README.md), [Español](/docs/README.es.md), [Chino Simplificado](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ --- diff --git a/docs/README.ja-jp.md b/docs/README.ja-jp.md new file mode 100644 index 000000000..e50671b19 --- /dev/null +++ b/docs/README.ja-jp.md @@ -0,0 +1,158 @@ +#


Pingvin Share
+ +--- + +_READMEを別の言語で読む: [Spanish](/docs/README.es.md), [English](/README.md), [Simplified Chinese](/docs/README.zh-cn.md), [日本語](/docs/README.ja-jp.md)_ + +--- + +Pingvin Share は、セルフホスト型のファイル共有プラットフォームであり、WeTransfer、ギガファイル便などの代替プラットフォームです。 + +## ✨ 特徴的な機能 + +- リンクを用いたファイル共有 +- ファイルサイズ無制限 (ストレージスペースの範囲内で) +- 共有への有効期限の設定 +- 訪問回数の制限とパスワードの設定により共有を安全に保つ +- メールでリンクを共有 +- ClamAVと連携して、ウイルスチェックが可能 + +## 🐧 Pingvin Shareについて知る + +- [デモ](https://pingvin-share.dev.eliasschneider.com) +- [DB Techによるレビュー](https://www.youtube.com/watch?v=rWwNeZCOPJA) + + + +## ⌨️ セットアップ + +> 注意: Pingvin Shareは、早期段階であり、バグが含まれている場合があります。 + +### Dockerでインストール (おすすめ) + +1. `docker-compose.yml`ファイルをダウンロード +2. `docker-compose up -d`を実行 + +Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧! + +### スタンドアローンインストール + +必要なツール: + +- [Node.js](https://nodejs.org/en/download/) >= 16 +- [Git](https://git-scm.com/downloads) +- [pm2](https://pm2.keymetrics.io/) Pingvin Shareをバックグラウンドで動作させるために必要 + +```bash +git clone https://github.com/stonith404/pingvin-share +cd pingvin-share + +# 最新バージョンをチェックアウト +git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`) + +# バックエンドを開始 +cd backend +npm install +npm run build +pm2 start --name="pingvin-share-backend" npm -- run prod + +#フロントエンドを開始 +cd ../frontend +npm install +npm run build +pm2 start --name="pingvin-share-frontend" npm -- run start +``` + +Webサイトは、`http://localhost:3000`でリッスンされます。これでPingvin Shareをお使い頂けます🐧! + +### 連携機能 + +#### ClamAV (Dockerのみ) + +ClamAVは、共有されたファイルをスキャンし、感染したファイルを見つけた場合に削除するために使用されます。 + +1. ClamAVコンテナをDocker Composeの定義ファイル(`docker-compose.yml`を確認)に追加し、コンテナを開始してください。 +2. Dockerは、Pingvin Shareを開始する前に、ClamAVの準備が整うまで待機します。これには、1分から2分ほどかかります。 +3. Pingvin Shareのログに"ClamAV is active"というログが記録されます。 + +ClamAVは、非常に多くのリソースを必要とします、詳しくは[リソース](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements)をご確認ください。 + +### 追加情報 + +- [Synology NASへのインストール方法](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) + +### 新しいバージョンへのアップグレード + +Pingvin Shareは早期段階のため、アップグレード前に必ずリリースノートを確認して、アップグレードしても問題ないかどうかご確認ください。 + +#### Docker + +```bash +docker compose pull +docker compose up -d +``` + +#### スタンドアローン + +1. アプリを停止する + ```bash + pm2 stop pingvin-share-backend pingvin-share-frontend + ``` +2. `git clone`のステップを除いて、[インストールガイド](#stand-alone-installation)をくり返してください。 + + ```bash + cd pingvin-share + + # 最新バージョンをチェックアウト + git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`) + + # バックエンドを開始 + cd backend + npm run build + pm2 restart pingvin-share-backend + + #フロントエンドを開始 + cd ../frontend + npm run build + pm2 restart pingvin-share-frontend + ``` + +### 設定 + +管理者のダッシュボード内の「設定」ページから、Pingvin Shareをカスタマイズできます。 + +#### 環境変数 + +インストール時の特定の設定で、環境変数を使用できます。次の環境変数が使用可能です: + +##### バックエンド + +| 変数名 | デフォルト値 | 説明 | +| ---------------- | -------------------------------------------------- | -------------------------------------- | +| `PORT` | `8080` | バックエンドがリッスンするポート番号 | +| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | SQLiteのURL | +| `DATA_DIRECTORY` | `./data` | データを保管するディレクトリ | +| `CLAMAV_HOST` | `127.0.0.1` | ClamAVサーバーのIPアドレス | +| `CLAMAV_PORT` | `3310` | ClamAVサーバーのポート番号 | + +##### フロントエンド + +| 変数名 | デフォルト値 | 説明 | +| --------- | ----------------------- | ---------------------------------------- | +| `PORT` | `3000` | フロントエンドがリッスンするポート番号 | +| `API_URL` | `http://localhost:8080` | フロントエンドからアクセスするバックエンドへのURL | + +## 🖤 コントリビュート + +### 翻訳 + +Pingvin Shareをあなたが使用している言語に翻訳するお手伝いを募集しています。 +[Crowdin](https://crowdin.com/project/pingvin-share)上で、簡単にPingvin Shareの翻訳作業への参加が可能です。 + +あなたの言語がありませんか? 気軽に[リクエスト](https://github.com/stonith404/pingvin-share/issues/new?assignees=&labels=language-request&projects=&template=language-request.yml&title=%F0%9F%8C%90+Language+request%3A+%3Clanguage+name+in+english%3E)してください。 + +翻訳中に問題がありましたか? [ローカライズに関するディスカッション](https://github.com/stonith404/pingvin-share/discussions/198)に是非参加してください。 + +### プロジェクト + +Pingvin Shareへのコントリビュートをいつでもお待ちしています! [コントリビューションガイド](/CONTRIBUTING.md)を確認して、是非参加してください。 diff --git a/docs/README.zh-cn.md b/docs/README.zh-cn.md index a0401bb2d..986524529 100644 --- a/docs/README.zh-cn.md +++ b/docs/README.zh-cn.md @@ -2,7 +2,7 @@ --- -_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md)_ +_选择合适的语言阅读: [西班牙语](/docs/README.es.md), [英语](/README.md), [简体中文](/docs/README.zh-cn.md), [日本语](/docs/README.ja-jp.md)_ --- From db42d99487b28284ccbecbb40d154670a3bcad58 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:08:55 +0800 Subject: [PATCH 11/50] feat(oauth): unlink account --- backend/prisma/seed/config.seed.ts | 2 +- backend/src/oauth/oauth.controller.ts | 11 ++++++- backend/src/oauth/oauth.module.ts | 5 ---- backend/src/oauth/oauth.service.ts | 20 +++++++++++++ backend/src/oauth/oidc.service.ts | 38 +++++++++++++++--------- frontend/src/i18n/translations/en-US.ts | 16 ++++++++++ frontend/src/pages/account/index.tsx | 39 ++++++++++++++++++------- frontend/src/utils/oauth.util.tsx | 7 +++-- 8 files changed, 104 insertions(+), 34 deletions(-) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 86b966507..1e3648862 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -126,7 +126,7 @@ const configVariables: ConfigVariables = { }, "ignoreTotp": { type: "boolean", - defaultValue: "false", + defaultValue: "true", }, "github-enabled": { type: "boolean", diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts index 40032b0cf..8cc0cd1aa 100644 --- a/backend/src/oauth/oauth.controller.ts +++ b/backend/src/oauth/oauth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, NotFoundException, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import { OAuthService } from "./oauth.service"; import { Request, Response } from "express"; import { JwtGuard } from "../auth/guard/jwt.guard"; @@ -33,6 +33,15 @@ export class OAuthController { return this.oauthService.status(user); } + @Post("unlink/:provider") + @UseGuards(JwtGuard) + unlink(@GetUser() user: User, @Param("provider") provider: string) { + if (!this.oauthService.available().includes(provider)) { + throw new NotFoundException("No such provider."); + } + return this.oauthService.unlink(user, provider); + } + // @Get("github") // github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) { // const state = nanoid(10); diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index e786c2430..f7f502286 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -15,11 +15,6 @@ import { ConfigService } from "../config/config.service"; provide: "OAUTH_PLATFORMS", useValue: ["oidc"], }, - - { - provide: "OIDC_NAME", - useValue: "oidc", - }, { provide: "OIDC_DISCOVERY_URI", useFactory: (config: ConfigService) => config.get("oauth.oidc-discoveryUri"), diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index cdbbd4c5e..df69706df 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -133,6 +133,25 @@ export class OAuthService { }); } + + async unlink(user: User, provider: string) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + userId: user.id, + provider, + }, + }); + if (oauthUser) { + await this.prisma.oAuthUser.delete({ + where: { + id: oauthUser.id, + }, + }); + } else { + throw new BadRequestException(`You have not linked your account to ${provider} yet.`); + } + } + async github(code: string) { const ghToken = await this.request.getGitHubToken(code); const ghUser = await this.request.getGitHubUser(ghToken); @@ -153,4 +172,5 @@ export class OAuthService { const ghUser = await this.request.getGitHubUser(ghToken); await this.link(user.id, 'github', ghUser.id.toString(), ghUser.name); } + } diff --git a/backend/src/oauth/oidc.service.ts b/backend/src/oauth/oidc.service.ts index 345f703c3..792f2a6c9 100644 --- a/backend/src/oauth/oidc.service.ts +++ b/backend/src/oauth/oidc.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import fetch from "node-fetch"; import { ConfigService } from "../config/config.service"; import { JwtService } from "@nestjs/jwt"; @@ -9,20 +9,21 @@ import { OidcCallbackDto } from "./dto/oidcCallback.dto"; @Injectable() export class OidcService { - private configuration: OidcConfigurationCache; - private jwk: OidcJwkCache; - private redirectUri: string; - - constructor(@Inject("OIDC_NAME") private name: string, - @Inject("OIDC_DISCOVERY_URI") private discoveryUri: string, - private config: ConfigService, - private jwtService: JwtService, - @Inject(CACHE_MANAGER) private cache: Cache) { - this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/${name}/callback`; + protected configuration: OidcConfigurationCache; + protected jwk: OidcJwkCache; + protected redirectUri: string; + protected name: string = "oidc"; + protected keyOfConfigUpdateEvents: string[] = ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"]; + + constructor(@Inject("OIDC_DISCOVERY_URI") protected discoveryUri: string, + protected config: ConfigService, + protected jwtService: JwtService, + @Inject(CACHE_MANAGER) protected cache: Cache) { + this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/${this.name}/callback`; this.config.addListener("update", (key: string, _: unknown) => { - if (key === `oauth.${name}-enabled` || key === `oauth.${name}-discoveryUri`) { + if (this.keyOfConfigUpdateEvents.includes(key)) { this.deinit(); - this.discoveryUri = this.config.get(`oauth.${name}-discoveryUri`); + this.discoveryUri = this.config.get(`oauth.${this.name}-discoveryUri`); } }); } @@ -109,7 +110,15 @@ export class OidcService { async getUserInfo(query: OidcCallbackDto): Promise { const token = await this.getToken(query.code); const idTokenData = this.decodeIdToken(token.id_token); - // TODO verify id token and nonce + // maybe it's not necessary to verify the id token since it's directly obtained from the provider + + const key = `oauth-${this.name}-nonce-${query.state}`; + const nonce = await this.cache.get(key); + await this.cache.del(key); + if (nonce !== idTokenData.nonce) { + throw new BadRequestException("Invalid token"); + } + return { provider: this.name as any, email: idTokenData.email, @@ -165,4 +174,5 @@ export interface OidcIdToken { iat: number; email: string; name: string; + nonce: string; } \ No newline at end of file diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 104864b1e..696c1bc24 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -46,6 +46,7 @@ export default { "signIn.oauth.or": "OR", "signIn.oauth.github": "GitHub", "signIn.oauth.google": "Google", + "signIn.oauth.oidc": "OpenID", // END /auth/signin @@ -89,9 +90,14 @@ export default { "account.card.oauth.title": "Social login", "account.card.oauth.github": "GitHub", "account.card.oauth.google": "Google", + "account.card.oauth.oidc": "OpenID", "account.card.oauth.link": "Link", "account.card.oauth.unlink": "Unlink", "account.card.oauth.unlinked": "Unlinked", + "account.modal.unlink.title": "Unlink account", + "account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your username and password.", + "account.notify.oauth.unlinked.success": "Unlinked successfully", + "account.card.security.title": "Security", "account.card.security.totp.enable.description": @@ -420,6 +426,8 @@ export default { "admin.config.oauth.allow-registration": "Allow registration", "admin.config.oauth.allow-registration.description": "Allow users to register via social login", + "admin.config.oauth.ignore-totp": "Ignore TOTP", + "admin.config.oauth.ignore-totp.description": "Whether to ignore TOTP when user using social login", "admin.config.oauth.github-enabled": "GitHub", "admin.config.oauth.github-enabled.description": "Whether GitHub login is enabled", "admin.config.oauth.github-client-id": "GitHub Client ID", @@ -432,6 +440,14 @@ export default { "admin.config.oauth.google-client-id.description": "Client ID of the Google OAuth app", "admin.config.oauth.google-client-secret": "Google Client secret", "admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app", + "admin.config.oauth.oidc-enabled": "OpenID", + "admin.config.oauth.oidc-enabled.description": "Whether OpenID login is enabled", + "admin.config.oauth.oidc-discovery-uri": "OpenID Discovery URI", + "admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID OAuth app", + "admin.config.oauth.oidc-client-id": "OpenID Client ID", + "admin.config.oauth.oidc-client-id.description": "Client ID of the OpenID OAuth app", + "admin.config.oauth.oidc-client-secret": "OpenID Client secret", + "admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID OAuth app", // 404 "404.description": "Oops this page doesn't exist.", diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index f927074dd..6a1d5fb3a 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -27,7 +27,7 @@ import userService from "../../services/user.service"; import toast from "../../utils/toast.util"; import { useEffect, useState } from "react"; import useConfig from "../../hooks/config.hook"; -import { getOAuthIcon, getOAuthUrl, revokeOAuth } from "../../utils/oauth.util"; +import { getOAuthIcon, getOAuthUrl, unlinkOAuth } from "../../utils/oauth.util"; const Account = () => { const [oauth, setOAuth] = useState([]); @@ -106,13 +106,17 @@ const Account = () => { ), }); + const refreshOAuthStatus = () => { + authService.getOAuthStatus().then(data => { + setOAuthStatus(data.data); + }).catch(toast.axiosError); + } + useEffect(() => { authService.getAvailableOAuth().then(data => { setOAuth(data.data); }).catch(toast.axiosError); - authService.getOAuthStatus().then(data => { - setOAuthStatus(data.data); - }).catch(toast.axiosError); + refreshOAuthStatus(); }, []); return ( @@ -192,11 +196,11 @@ const Account = () => { - + { oauth.map(provider => - + {t(`account.card.oauth.${provider}`)} ) @@ -204,7 +208,7 @@ const Account = () => { { oauth.map(provider => - + { oauthStatus?.[provider] @@ -213,13 +217,28 @@ const Account = () => { } { oauthStatus?.[provider] - ? : } diff --git a/frontend/src/utils/oauth.util.tsx b/frontend/src/utils/oauth.util.tsx index 847fb08de..519afcee0 100644 --- a/frontend/src/utils/oauth.util.tsx +++ b/frontend/src/utils/oauth.util.tsx @@ -1,6 +1,7 @@ import { SiGithub, SiGoogle, SiOpenid } from "react-icons/si"; import React from "react"; import toast from "./toast.util"; +import api from "../services/api.service"; const getOAuthUrl = (appUrl: string, provider: string) => { return `${appUrl}/api/oauth/${provider}`; @@ -14,12 +15,12 @@ const getOAuthIcon = (provider: string) => { }[provider]; } -const revokeOAuth = (_appUrl: string, _provider: string) => { - toast.error("Not implemented yet"); +const unlinkOAuth = (provider: string) => { + return api.post(`/oauth/unlink/${provider}`); } export { getOAuthUrl, getOAuthIcon, - revokeOAuth, + unlinkOAuth, } \ No newline at end of file From ea42b4fd79cec64273c5f9201f69fd5fccecab94 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Wed, 11 Oct 2023 21:20:04 +0800 Subject: [PATCH 12/50] refactor(oauth): make providers extensible --- backend/package-lock.json | 143 ------------------ backend/package.json | 2 - .../decorator/oauthProvider.decorator.ts | 9 -- backend/src/oauth/dto/github.dto.ts | 8 - ...dcCallback.dto.ts => oauthCallback.dto.ts} | 2 +- backend/src/oauth/dto/oauthSignIn.dto.ts | 2 +- backend/src/oauth/guard/oauth.guard.ts | 21 +-- backend/src/oauth/guard/provider.guard.ts | 15 ++ backend/src/oauth/oauth.controller.ts | 115 +++----------- backend/src/oauth/oauth.module.ts | 31 ++-- backend/src/oauth/oauth.service.ts | 116 +++++--------- backend/src/oauth/oauthRequest.service.ts | 66 -------- .../genericOidc.provider.ts} | 98 ++++++------ backend/src/oauth/provider/github.provider.ts | 96 ++++++++++++ backend/src/oauth/provider/google.provider.ts | 20 +++ .../oauth/provider/oauthProvider.interface.ts | 13 ++ backend/src/oauth/provider/oidc.provider.ts | 23 +++ frontend/src/utils/oauth.util.tsx | 2 +- 18 files changed, 310 insertions(+), 472 deletions(-) delete mode 100644 backend/src/oauth/decorator/oauthProvider.decorator.ts delete mode 100644 backend/src/oauth/dto/github.dto.ts rename backend/src/oauth/dto/{oidcCallback.dto.ts => oauthCallback.dto.ts} (77%) create mode 100644 backend/src/oauth/guard/provider.guard.ts delete mode 100644 backend/src/oauth/oauthRequest.service.ts rename backend/src/oauth/{oidc.service.ts => provider/genericOidc.provider.ts} (83%) create mode 100644 backend/src/oauth/provider/github.provider.ts create mode 100644 backend/src/oauth/provider/google.provider.ts create mode 100644 backend/src/oauth/provider/oauthProvider.interface.ts create mode 100644 backend/src/oauth/provider/oidc.provider.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 02ba2dffa..6ac1f09a4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,7 +35,6 @@ "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", - "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "qrcode-svg": "^1.1.0", @@ -59,7 +58,6 @@ "@types/node": "^20.4.5", "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", - "@types/passport-google-oauth20": "^2.0.12", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", "@types/sharp": "^0.31.1", @@ -1476,15 +1474,6 @@ "@types/node": "*" } }, - "node_modules/@types/oauth": { - "version": "0.9.2", - "resolved": "https://registry.npmmirror.com/@types/oauth/-/oauth-0.9.2.tgz", - "integrity": "sha512-Nu3/abQ6yR9VlsCdX3aiGsWFkj6OJvJqDvg/36t8Gwf2mFXdBZXPDN3K+2yfeA6Lo2m1Q12F8Qil9TZ48nWhOQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -1500,17 +1489,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-google-oauth20": { - "version": "2.0.12", - "resolved": "https://registry.npmmirror.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.12.tgz", - "integrity": "sha512-+MBVB8uYd8mMZYvTwYChCa2LBGVK9FMwdK5TtcNHMeTL6qBZ3QW0HeUtZiAlwgkw2LYM0Btlzyb19EA8ysm13g==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-jwt": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.9.tgz", @@ -1522,17 +1500,6 @@ "@types/passport-strategy": "*" } }, - "node_modules/@types/passport-oauth2": { - "version": "1.4.13", - "resolved": "https://registry.npmmirror.com/@types/passport-oauth2/-/passport-oauth2-1.4.13.tgz", - "integrity": "sha512-SKjbAFSgV2ys7Vf8+BbQ2Fq09CZGi72xaHqbWPEKhi7czPSC0ff4gXuQEY3XXAuTynPjwj6dlL3YAta9M2K0AQ==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" - } - }, "node_modules/@types/passport-strategy": { "version": "0.2.35", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", @@ -2348,14 +2315,6 @@ } ] }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -5917,11 +5876,6 @@ "set-blocking": "^2.0.0" } }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmmirror.com/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" - }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -6168,17 +6122,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -6199,21 +6142,6 @@ "node": ">= 0.4.0" } }, - "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", - "dependencies": { - "base64url": "3.x.x", - "oauth": "0.9.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8177,11 +8105,6 @@ "node": ">=8" } }, - "node_modules/uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmmirror.com/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "node_modules/underscore": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", @@ -9704,15 +9627,6 @@ "@types/node": "*" } }, - "@types/oauth": { - "version": "0.9.2", - "resolved": "https://registry.npmmirror.com/@types/oauth/-/oauth-0.9.2.tgz", - "integrity": "sha512-Nu3/abQ6yR9VlsCdX3aiGsWFkj6OJvJqDvg/36t8Gwf2mFXdBZXPDN3K+2yfeA6Lo2m1Q12F8Qil9TZ48nWhOQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -9728,17 +9642,6 @@ "@types/express": "*" } }, - "@types/passport-google-oauth20": { - "version": "2.0.12", - "resolved": "https://registry.npmmirror.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.12.tgz", - "integrity": "sha512-+MBVB8uYd8mMZYvTwYChCa2LBGVK9FMwdK5TtcNHMeTL6qBZ3QW0HeUtZiAlwgkw2LYM0Btlzyb19EA8ysm13g==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "@types/passport-jwt": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.9.tgz", @@ -9750,17 +9653,6 @@ "@types/passport-strategy": "*" } }, - "@types/passport-oauth2": { - "version": "1.4.13", - "resolved": "https://registry.npmmirror.com/@types/passport-oauth2/-/passport-oauth2-1.4.13.tgz", - "integrity": "sha512-SKjbAFSgV2ys7Vf8+BbQ2Fq09CZGi72xaHqbWPEKhi7czPSC0ff4gXuQEY3XXAuTynPjwj6dlL3YAta9M2K0AQ==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" - } - }, "@types/passport-strategy": { "version": "0.2.35", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", @@ -10393,11 +10285,6 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, - "base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" - }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -13039,11 +12926,6 @@ "set-blocking": "^2.0.0" } }, - "oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmmirror.com/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" - }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -13214,14 +13096,6 @@ "utils-merge": "^1.0.1" } }, - "passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "requires": { - "passport-oauth2": "1.x.x" - } - }, "passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -13239,18 +13113,6 @@ "passport-strategy": "1.x.x" } }, - "passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", - "requires": { - "base64url": "3.x.x", - "oauth": "0.9.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - } - }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -14684,11 +14546,6 @@ "@lukeed/csprng": "^1.0.0" } }, - "uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmmirror.com/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "underscore": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", diff --git a/backend/package.json b/backend/package.json index 80424f883..cd0d13f39 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,7 +40,6 @@ "nodemailer": "^6.9.4", "otplib": "^12.0.1", "passport": "^0.6.0", - "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "qrcode-svg": "^1.1.0", @@ -64,7 +63,6 @@ "@types/node": "^20.4.5", "@types/node-fetch": "^2.6.6", "@types/nodemailer": "^6.4.9", - "@types/passport-google-oauth20": "^2.0.12", "@types/passport-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", "@types/sharp": "^0.31.1", diff --git a/backend/src/oauth/decorator/oauthProvider.decorator.ts b/backend/src/oauth/decorator/oauthProvider.decorator.ts deleted file mode 100644 index f599fb874..000000000 --- a/backend/src/oauth/decorator/oauthProvider.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const OAuthProvider = (provider: string, type: "auth" | "callback") => SetMetadata( - 'oauth', - { - provider, - type - } -); \ No newline at end of file diff --git a/backend/src/oauth/dto/github.dto.ts b/backend/src/oauth/dto/github.dto.ts deleted file mode 100644 index da54c10ec..000000000 --- a/backend/src/oauth/dto/github.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsString } from "class-validator"; - -export class GithubDto { - @IsString() - code: string; - @IsString() - state: string; -} \ No newline at end of file diff --git a/backend/src/oauth/dto/oidcCallback.dto.ts b/backend/src/oauth/dto/oauthCallback.dto.ts similarity index 77% rename from backend/src/oauth/dto/oidcCallback.dto.ts rename to backend/src/oauth/dto/oauthCallback.dto.ts index b37022627..85a731482 100644 --- a/backend/src/oauth/dto/oidcCallback.dto.ts +++ b/backend/src/oauth/dto/oauthCallback.dto.ts @@ -1,6 +1,6 @@ import { IsString } from "class-validator"; -export class OidcCallbackDto { +export class OAuthCallbackDto { @IsString() code: string; diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts index 12bb03b3d..f8c28f454 100644 --- a/backend/src/oauth/dto/oauthSignIn.dto.ts +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -1,4 +1,4 @@ -interface OAuthSignInDto { +export interface OAuthSignInDto { provider: 'github' | 'google' | 'oidc'; providerId: string; providerUsername: string; diff --git a/backend/src/oauth/guard/oauth.guard.ts b/backend/src/oauth/guard/oauth.guard.ts index ece10e1b8..e4d285746 100644 --- a/backend/src/oauth/guard/oauth.guard.ts +++ b/backend/src/oauth/guard/oauth.guard.ts @@ -1,25 +1,10 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { Reflector } from "@nestjs/core"; -import { ConfigService } from "../../config/config.service"; @Injectable() export class OAuthGuard implements CanActivate { - constructor( - private reflector: Reflector, - private config: ConfigService, - ) { - } - canActivate(context: ExecutionContext): boolean { - const oauth = this.reflector.get<{ - provider: string; - type: "endpoint" | "callback"; - }>("oauth", context.getHandler()) - if (oauth.type === "callback") { - const request = context.switchToHttp().getRequest(); - return request.query.state === request.cookies[`oauth_${oauth.provider}_state`]; - } else { - return this.config.get(`oauth.${oauth.provider}-enabled`) - } + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + return request.query.state === request.cookies[`oauth_${provider}_state`]; } } diff --git a/backend/src/oauth/guard/provider.guard.ts b/backend/src/oauth/guard/provider.guard.ts new file mode 100644 index 000000000..d9f5d36c9 --- /dev/null +++ b/backend/src/oauth/guard/provider.guard.ts @@ -0,0 +1,15 @@ +import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; + +@Injectable() +export class ProviderGuard implements CanActivate { + constructor(private config: ConfigService, + @Inject("OAUTH_PLATFORMS") private platforms: string[]) { + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + return this.platforms.includes(provider) && this.config.get(`oauth.${provider}-enabled`); + } +} diff --git a/backend/src/oauth/oauth.controller.ts b/backend/src/oauth/oauth.controller.ts index 8cc0cd1aa..7f646a6d5 100644 --- a/backend/src/oauth/oauth.controller.ts +++ b/backend/src/oauth/oauth.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, NotFoundException, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import { OAuthService } from "./oauth.service"; import { Request, Response } from "express"; import { JwtGuard } from "../auth/guard/jwt.guard"; @@ -6,11 +6,11 @@ import { ConfigService } from "../config/config.service"; import { nanoid } from "nanoid"; import { GetUser } from "../auth/decorator/getUser.decorator"; import { User } from "@prisma/client"; -import { OidcService } from "./oidc.service"; -import { OidcCallbackDto } from "./dto/oidcCallback.dto"; +import { OAuthCallbackDto } from "./dto/oauthCallback.dto"; import { OAuthGuard } from "./guard/oauth.guard"; -import { OAuthProvider } from "./decorator/oauthProvider.decorator"; import { AuthService } from "../auth/auth.service"; +import { ProviderGuard } from "./guard/provider.guard"; +import { OAuthProvider } from "./provider/oauthProvider.interface"; @Controller('oauth') export class OAuthController { @@ -18,7 +18,7 @@ export class OAuthController { private authService: AuthService, private oauthService: OAuthService, private config: ConfigService, - private oidcService: OidcService, + @Inject("OAUTH_PROVIDERS") private providers: Record>, ) { } @@ -33,98 +33,26 @@ export class OAuthController { return this.oauthService.status(user); } - @Post("unlink/:provider") - @UseGuards(JwtGuard) - unlink(@GetUser() user: User, @Param("provider") provider: string) { - if (!this.oauthService.available().includes(provider)) { - throw new NotFoundException("No such provider."); - } - return this.oauthService.unlink(user, provider); - } - - // @Get("github") - // github(@Res({ passthrough: true }) response: Response, @Query('link') link: boolean) { - // const state = nanoid(10); - // response.cookie("github_oauth_state", state, { sameSite: "strict" }); - // const url = "https://github.com/login/oauth/authorize?" + new URLSearchParams({ - // client_id: this.config.get("oauth.github-clientId"), - // redirect_uri: this.config.get("general.appUrl") + "/api/oauth/github/callback" + (link ? "/link" : ""), - // state: state, - // scope: link ? "" : "user:email", // linking account doesn't need email - // }).toString(); - // response.redirect(url); - // // return ``; - // } - // - // @Get("github/callback") - // async githubCallback(@Query() query: GithubDto, @Req() request: Request, @Res({ passthrough: true }) response: Response) { - // const { state, code } = query; - // this.oauthService.validate("github", request.cookies, state); - // - // const token = await this.oauthService.github(code); - // AuthController.addTokensToResponse( - // response, - // token.refreshToken, - // token.accessToken, - // ); - // response.redirect(this.config.get("general.appUrl")); - // } - // - // @Get("github/callback/link") - // @UseGuards(JwtGuard) - // async githubLink(@Req() request: Request, - // @Res({ passthrough: true }) response: Response, - // @Query() query: GithubDto, - // @GetUser() user: User) { - // const { state, code } = query; - // this.oauthService.validate("github", request.cookies, state); - // - // try { - // await this.oauthService.githubLink(code, user); - // response.redirect(this.config.get("general.appUrl") + '/account'); - // } catch (e) { - // // TODO error page - // throw e; - // } - // } - // - // @Get("google") - // google() { - // } - // - // @Get("google/callback") - // async googleCallback(@Req() request: Request, @Res({ passthrough: true }) response: Response) { - // const user = request.user as OAuthSignInDto; - // const token = await this.oauthService.signIn(user); - // AuthController.addTokensToResponse( - // response, - // token.refreshToken, - // token.accessToken, - // ); - // response.redirect(this.config.get("general.appUrl")); - // } - - @Get("oidc") - @OAuthProvider("oidc", "auth") - @UseGuards(OAuthGuard) - async oidc(@Res({ passthrough: true }) response: Response) { + @Get("auth/:provider") + @UseGuards(ProviderGuard) + async auth(@Param("provider") provider: string, @Res({ passthrough: true }) response: Response) { const state = nanoid(16); - const url = await this.oidcService.getAuthEndpoint(state); - response.cookie("oauth_oidc_state", state, { sameSite: "lax" }); + const url = await this.providers[provider].getAuthEndpoint(state); + response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" }); response.redirect(url); } - @Get("oidc/callback") - @OAuthProvider("oidc", "callback") - @UseGuards(OAuthGuard) - async oidcCallback(@Query() query: OidcCallbackDto, - @Req() request: Request, - @Res({ passthrough: true }) response: Response) { - const user = await this.oidcService.getUserInfo(query); + @Get("callback/:provider") + @UseGuards(ProviderGuard, OAuthGuard) + async callback(@Param("provider") provider: string, + @Query() query: OAuthCallbackDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response) { + const user = await this.providers[provider].getUserInfo(query); const id = await this.authService.getIdIfLogin(request); if (id) { - await this.oauthService.link(id, "oidc", user.providerId, user.providerUsername); + await this.oauthService.link(id, provider, user.providerId, user.providerUsername); response.redirect(this.config.get("general.appUrl") + '/account'); } else { const token = await this.oauthService.signIn(user); @@ -133,4 +61,11 @@ export class OAuthController { response.redirect(this.config.get("general.appUrl")); } } + + @Post("unlink/:provider") + @UseGuards(JwtGuard, ProviderGuard) + unlink(@GetUser() user: User, @Param("provider") provider: string) { + return this.oauthService.unlink(user, provider); + } + } diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index f7f502286..45862293f 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -2,25 +2,36 @@ import { Module } from '@nestjs/common'; import { OAuthController } from './oauth.controller'; import { OAuthService } from './oauth.service'; import { AuthModule } from "../auth/auth.module"; -import { OAuthRequestService } from "./oauthRequest.service"; -import { OidcService } from "./oidc.service"; -import { ConfigService } from "../config/config.service"; +import { GitHubProvider } from "./provider/github.provider"; +import { GoogleProvider } from "./provider/google.provider"; +import { OAuthProvider } from "./provider/oauthProvider.interface"; +import { OidcProvider } from "./provider/oidc.provider"; @Module({ controllers: [OAuthController], providers: [ OAuthService, - OAuthRequestService, + GitHubProvider, + GoogleProvider, + OidcProvider, { - provide: "OAUTH_PLATFORMS", - useValue: ["oidc"], + provide: "OAUTH_PROVIDERS", + useFactory(github: GitHubProvider, google: GoogleProvider, oidc: OidcProvider): Record> { + return { + github, + google, + oidc, + }; + }, + inject: [GitHubProvider, GoogleProvider, OidcProvider], }, { - provide: "OIDC_DISCOVERY_URI", - useFactory: (config: ConfigService) => config.get("oauth.oidc-discoveryUri"), - inject: [ConfigService], + provide: "OAUTH_PLATFORMS", + useFactory(providers: Record>): string[] { + return Object.keys(providers); + }, + inject: ["OAUTH_PROVIDERS"], }, - OidcService, ], imports: [AuthModule], }) diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index df69706df..98494fa5a 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -1,10 +1,10 @@ -import { BadRequestException, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from "../prisma/prisma.service"; import { ConfigService } from "../config/config.service"; import { AuthService } from "../auth/auth.service"; import { User } from "@prisma/client"; import { nanoid } from "nanoid"; -import { OAuthRequestService } from "./oauthRequest.service"; +import { OAuthSignInDto } from "./dto/oauthSignIn.dto"; @Injectable() @@ -13,21 +13,10 @@ export class OAuthService { private prisma: PrismaService, private config: ConfigService, private auth: AuthService, - private request: OAuthRequestService, @Inject("OAUTH_PLATFORMS") private platforms: string[], ) { } - validate(provider: string, cookies: Record, state: string) { - if (!this.config.get(`oauth.${provider}-enabled`)) { - throw new NotFoundException(); - } - - if (cookies[`${provider}_oauth_state`] !== state) { - throw new BadRequestException("Invalid state"); - } - } - available(): string[] { return this.platforms .map(platform => [platform, this.config.get(`oauth.${platform}-enabled`)]) @@ -65,6 +54,45 @@ export class OAuthService { return this.signUp(user); } + async link(userId: string, provider: string, providerUserId: string, providerUsername: string) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider, + providerUserId, + } + }); + if (oauthUser) { + throw new BadRequestException(`This ${provider} account has been linked to another account`); + } + + await this.prisma.oAuthUser.create({ + data: { + userId, + provider, + providerUsername, + providerUserId, + } + }); + } + + async unlink(user: User, provider: string) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + userId: user.id, + provider, + }, + }); + if (oauthUser) { + await this.prisma.oAuthUser.delete({ + where: { + id: oauthUser.id, + }, + }); + } else { + throw new BadRequestException(`You have not linked your account to ${provider} yet.`); + } + } + private async signUp(user: OAuthSignInDto) { // register if (!this.config.get("oauth.allowRegistration")) { @@ -111,66 +139,4 @@ export class OAuthService { return result; } - - async link(userId: string, provider: string, providerUserId: string, providerUsername: string) { - const oauthUser = await this.prisma.oAuthUser.findFirst({ - where: { - provider, - providerUserId, - } - }); - if (oauthUser) { - throw new BadRequestException(`This ${provider} account has been linked to another account`); - } - - await this.prisma.oAuthUser.create({ - data: { - userId, - provider, - providerUsername, - providerUserId, - } - }); - } - - - async unlink(user: User, provider: string) { - const oauthUser = await this.prisma.oAuthUser.findFirst({ - where: { - userId: user.id, - provider, - }, - }); - if (oauthUser) { - await this.prisma.oAuthUser.delete({ - where: { - id: oauthUser.id, - }, - }); - } else { - throw new BadRequestException(`You have not linked your account to ${provider} yet.`); - } - } - - async github(code: string) { - const ghToken = await this.request.getGitHubToken(code); - const ghUser = await this.request.getGitHubUser(ghToken); - if (!ghToken.scope.includes("user:email")) { - throw new BadRequestException("No email permission granted"); - } - const email = await this.request.getGitHubEmail(ghToken); - return this.signIn({ - provider: "github", - providerId: ghUser.id.toString(), - providerUsername: ghUser.login, - email, - }); - } - - async githubLink(code: string, user: User) { - const ghToken = await this.request.getGitHubToken(code); - const ghUser = await this.request.getGitHubUser(ghToken); - await this.link(user.id, 'github', ghUser.id.toString(), ghUser.name); - } - } diff --git a/backend/src/oauth/oauthRequest.service.ts b/backend/src/oauth/oauthRequest.service.ts deleted file mode 100644 index cc47cc017..000000000 --- a/backend/src/oauth/oauthRequest.service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import fetch from "node-fetch"; -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "../config/config.service"; - -@Injectable() -export class OAuthRequestService { - constructor(private config: ConfigService) { - } - - async getGitHubToken(code: string): Promise { - const qs = new URLSearchParams(); - qs.append("client_id", this.config.get("oauth.github-clientId")); - qs.append("client_secret", this.config.get("oauth.github-clientSecret")); - qs.append("code", code); - - const res = await fetch("https://github.com/login/oauth/access_token?" + qs.toString(), { - method: "post", - headers: { - "Accept": "application/json", - } - }); - return await res.json() as GitHubToken; - } - - async getGitHubUser(token: GitHubToken): Promise { - const res = await fetch("https://api.github.com/user", { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `${token.token_type} ${token.access_token}`, - } - }); - return await res.json() as GitHubUser; - } - - async getGitHubEmail(token: GitHubToken): Promise { - const res = await fetch("https://api.github.com/user/public_emails", { - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `${token.token_type} ${token.access_token}`, - } - }); - const emails = await res.json() as GitHubEmail[]; - return emails.find(e => e.primary && e.verified)?.email; - } -} - - -interface GitHubToken { - access_token: string; - token_type: string; - scope: string; -} - -interface GitHubUser { - login: string; - id: number; - name?: string; - email?: string; // this filed seems only return null -} - -interface GitHubEmail { - email: string; - primary: boolean, - verified: boolean, - visibility: string | null -} \ No newline at end of file diff --git a/backend/src/oauth/oidc.service.ts b/backend/src/oauth/provider/genericOidc.provider.ts similarity index 83% rename from backend/src/oauth/oidc.service.ts rename to backend/src/oauth/provider/genericOidc.provider.ts index 792f2a6c9..408c8e63f 100644 --- a/backend/src/oauth/oidc.service.ts +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -1,25 +1,27 @@ -import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +import { BadRequestException } from "@nestjs/common"; import fetch from "node-fetch"; -import { ConfigService } from "../config/config.service"; +import { ConfigService } from "../../config/config.service"; import { JwtService } from "@nestjs/jwt"; -import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Cache } from "cache-manager"; import { nanoid } from "nanoid"; -import { OidcCallbackDto } from "./dto/oidcCallback.dto"; - -@Injectable() -export class OidcService { - protected configuration: OidcConfigurationCache; - protected jwk: OidcJwkCache; - protected redirectUri: string; - protected name: string = "oidc"; - protected keyOfConfigUpdateEvents: string[] = ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"]; - - constructor(@Inject("OIDC_DISCOVERY_URI") protected discoveryUri: string, - protected config: ConfigService, - protected jwtService: JwtService, - @Inject(CACHE_MANAGER) protected cache: Cache) { - this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/${this.name}/callback`; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthProvider } from "./oauthProvider.interface"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; + +export abstract class GenericOidcProvider implements OAuthProvider { + private configuration: OidcConfigurationCache; + private jwk: OidcJwkCache; + private redirectUri: string; + + protected constructor( + protected name: string, + protected discoveryUri: string, + protected keyOfConfigUpdateEvents: string[], + protected config: ConfigService, + protected jwtService: JwtService, + protected cache: Cache, + ) { + this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/callback/${this.name}`; this.config.addListener("update", (key: string, _: unknown) => { if (this.keyOfConfigUpdateEvents.includes(key)) { this.deinit(); @@ -28,31 +30,6 @@ export class OidcService { }); } - private async fetchConfiguration(): Promise { - const res = await fetch(this.discoveryUri); - const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; - this.configuration = { - expires, - data: await res.json(), - }; - } - - private async fetchJwk(): Promise { - const configuration = await this.getConfiguration(); - const res = await fetch(configuration.jwks_uri); - const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; - this.jwk = { - expires, - data: (await res.json())['keys'], - }; - } - - private deinit() { - this.discoveryUri = undefined; - this.configuration = undefined; - this.jwk = undefined; - } - async getConfiguration(): Promise { if (!this.configuration || this.configuration.expires < Date.now()) { await this.fetchConfiguration(); @@ -103,11 +80,7 @@ export class OidcService { return await res.json(); } - private decodeIdToken(idToken: string): OidcIdToken { - return this.jwtService.decode(idToken) as OidcIdToken; - } - - async getUserInfo(query: OidcCallbackDto): Promise { + async getUserInfo(query: OAuthCallbackDto): Promise { const token = await this.getToken(query.code); const idTokenData = this.decodeIdToken(token.id_token); // maybe it's not necessary to verify the id token since it's directly obtained from the provider @@ -127,6 +100,35 @@ export class OidcService { } } + private async fetchConfiguration(): Promise { + const res = await fetch(this.discoveryUri); + const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; + this.configuration = { + expires, + data: await res.json(), + }; + } + + private async fetchJwk(): Promise { + const configuration = await this.getConfiguration(); + const res = await fetch(configuration.jwks_uri); + const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; + this.jwk = { + expires, + data: (await res.json())['keys'], + }; + } + + private deinit() { + this.discoveryUri = undefined; + this.configuration = undefined; + this.jwk = undefined; + } + + private decodeIdToken(idToken: string): OidcIdToken { + return this.jwtService.decode(idToken) as OidcIdToken; + } + } export interface OidcCache { diff --git a/backend/src/oauth/provider/github.provider.ts b/backend/src/oauth/provider/github.provider.ts new file mode 100644 index 000000000..c86424e4a --- /dev/null +++ b/backend/src/oauth/provider/github.provider.ts @@ -0,0 +1,96 @@ +import { OAuthProvider } from "./oauthProvider.interface"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; +import { ConfigService } from "../../config/config.service"; +import fetch from "node-fetch"; +import { BadRequestException, Injectable } from "@nestjs/common"; + +@Injectable() +export class GitHubProvider implements OAuthProvider { + constructor(private config: ConfigService) { + } + + getAuthEndpoint(state: string): Promise { + return Promise.resolve("https://github.com/login/oauth/authorize?" + new URLSearchParams({ + client_id: this.config.get("oauth.github-clientId"), + redirect_uri: this.config.get("general.appUrl") + "/api/oauth/callback/github", + state: state, + scope: "user:email", + }).toString()); + } + + async getToken(code: string): Promise { + const res = await fetch("https://github.com/login/oauth/access_token?" + new URLSearchParams({ + client_id: this.config.get("oauth.github-clientId"), + client_secret: this.config.get("oauth.github-clientSecret"), + code, + }).toString(), { + method: "post", + headers: { + "Accept": "application/json", + } + }); + return await res.json(); + } + + async getUserInfo(query: OAuthCallbackDto): Promise { + const token = await this.getToken(query.code); + const user = await this.getGitHubUser(token); + if (!token.scope.includes("user:email")) { + throw new BadRequestException("No email permission granted"); + } + const email = await this.getGitHubEmail(token); + if (!email) { + throw new BadRequestException("No email found"); + } + + return { + provider: "github", + providerId: user.id.toString(), + providerUsername: user.login, + email, + }; + } + + private async getGitHubUser(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + return await res.json() as GitHubUser; + } + + private async getGitHubEmail(token: GitHubToken): Promise { + const res = await fetch("https://api.github.com/user/public_emails", { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `${token.token_type} ${token.access_token}`, + } + }); + const emails = await res.json() as GitHubEmail[]; + return emails.find(e => e.primary && e.verified)?.email; + } +} + + +interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +interface GitHubUser { + login: string; + id: number; + name?: string; + email?: string; // this filed seems only return null +} + +interface GitHubEmail { + email: string; + primary: boolean, + verified: boolean, + visibility: string | null +} \ No newline at end of file diff --git a/backend/src/oauth/provider/google.provider.ts b/backend/src/oauth/provider/google.provider.ts new file mode 100644 index 000000000..ce05fd696 --- /dev/null +++ b/backend/src/oauth/provider/google.provider.ts @@ -0,0 +1,20 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Inject, Injectable } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class GoogleProvider extends GenericOidcProvider { + constructor(config: ConfigService, jwtService: JwtService, @Inject(CACHE_MANAGER) cache: Cache) { + super( + "google", + "https://accounts.google.com/.well-known/openid-configuration", + ["oauth.google-enabled"], + config, + jwtService, + cache, + ); + } +} diff --git a/backend/src/oauth/provider/oauthProvider.interface.ts b/backend/src/oauth/provider/oauthProvider.interface.ts new file mode 100644 index 000000000..8405258e1 --- /dev/null +++ b/backend/src/oauth/provider/oauthProvider.interface.ts @@ -0,0 +1,13 @@ +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; + +/** + * @typeParam T - type of token + */ +export interface OAuthProvider { + getAuthEndpoint(state: string): Promise; + + getToken(code: string): Promise; + + getUserInfo(query: OAuthCallbackDto): Promise; +} \ No newline at end of file diff --git a/backend/src/oauth/provider/oidc.provider.ts b/backend/src/oauth/provider/oidc.provider.ts new file mode 100644 index 000000000..6e0b91990 --- /dev/null +++ b/backend/src/oauth/provider/oidc.provider.ts @@ -0,0 +1,23 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class OidcProvider extends GenericOidcProvider { + constructor(config: ConfigService, + jwtService: JwtService, + @Inject(CACHE_MANAGER) protected cache: Cache) { + super( + "oidc", + config.get("oauth.oidc-discoveryUri"), + ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"], + config, + jwtService, + cache, + ); + } + +} \ No newline at end of file diff --git a/frontend/src/utils/oauth.util.tsx b/frontend/src/utils/oauth.util.tsx index 519afcee0..2c84be74c 100644 --- a/frontend/src/utils/oauth.util.tsx +++ b/frontend/src/utils/oauth.util.tsx @@ -4,7 +4,7 @@ import toast from "./toast.util"; import api from "../services/api.service"; const getOAuthUrl = (appUrl: string, provider: string) => { - return `${appUrl}/api/oauth/${provider}`; + return `${appUrl}/api/oauth/auth/${provider}`; } const getOAuthIcon = (provider: string) => { From 9b5ed7649707859a91249e6ac73535529d1180a5 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 12 Oct 2023 01:17:10 +0800 Subject: [PATCH 13/50] fix(oauth): fix discoveryUri error when toggle google-enabled --- backend/src/oauth/provider/genericOidc.provider.ts | 11 +++++++---- backend/src/oauth/provider/google.provider.ts | 5 ++++- backend/src/oauth/provider/oidc.provider.ts | 5 ++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/oauth/provider/genericOidc.provider.ts b/backend/src/oauth/provider/genericOidc.provider.ts index 408c8e63f..e84868f73 100644 --- a/backend/src/oauth/provider/genericOidc.provider.ts +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -9,23 +9,24 @@ import { OAuthProvider } from "./oauthProvider.interface"; import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; export abstract class GenericOidcProvider implements OAuthProvider { + protected redirectUri: string; + protected discoveryUri: string; private configuration: OidcConfigurationCache; private jwk: OidcJwkCache; - private redirectUri: string; protected constructor( protected name: string, - protected discoveryUri: string, protected keyOfConfigUpdateEvents: string[], protected config: ConfigService, protected jwtService: JwtService, protected cache: Cache, ) { + this.discoveryUri = this.getDiscoveryUri(); this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/callback/${this.name}`; - this.config.addListener("update", (key: string, _: unknown) => { + this.config.addListener("update", (key: string, value: string) => { if (this.keyOfConfigUpdateEvents.includes(key)) { this.deinit(); - this.discoveryUri = this.config.get(`oauth.${this.name}-discoveryUri`); + this.discoveryUri = this.getDiscoveryUri(); } }); } @@ -100,6 +101,8 @@ export abstract class GenericOidcProvider implements OAuthProvider { } } + protected abstract getDiscoveryUri(): string; + private async fetchConfiguration(): Promise { const res = await fetch(this.discoveryUri); const expires = res.headers.has("expires") ? new Date(res.headers.get("expires")).getTime() : Date.now() + 1000 * 60 * 60 * 24; diff --git a/backend/src/oauth/provider/google.provider.ts b/backend/src/oauth/provider/google.provider.ts index ce05fd696..a73421b3a 100644 --- a/backend/src/oauth/provider/google.provider.ts +++ b/backend/src/oauth/provider/google.provider.ts @@ -10,11 +10,14 @@ export class GoogleProvider extends GenericOidcProvider { constructor(config: ConfigService, jwtService: JwtService, @Inject(CACHE_MANAGER) cache: Cache) { super( "google", - "https://accounts.google.com/.well-known/openid-configuration", ["oauth.google-enabled"], config, jwtService, cache, ); } + + protected getDiscoveryUri(): string { + return "https://accounts.google.com/.well-known/openid-configuration"; + } } diff --git a/backend/src/oauth/provider/oidc.provider.ts b/backend/src/oauth/provider/oidc.provider.ts index 6e0b91990..619e95b36 100644 --- a/backend/src/oauth/provider/oidc.provider.ts +++ b/backend/src/oauth/provider/oidc.provider.ts @@ -12,7 +12,6 @@ export class OidcProvider extends GenericOidcProvider { @Inject(CACHE_MANAGER) protected cache: Cache) { super( "oidc", - config.get("oauth.oidc-discoveryUri"), ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"], config, jwtService, @@ -20,4 +19,8 @@ export class OidcProvider extends GenericOidcProvider { ); } + protected getDiscoveryUri(): string { + return this.config.get("oauth.oidc-discoveryUri"); + } + } \ No newline at end of file From 810aded8cb4c57939d012ce41f6577f76afec807 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:22:53 +0800 Subject: [PATCH 14/50] feat(oauth): add microsoft and discord as oauth provider --- README.md | 10 -- backend/prisma/seed/config.seed.ts | 30 ++++ backend/src/oauth/dto/oauthSignIn.dto.ts | 2 +- backend/src/oauth/oauth.module.ts | 16 +- .../src/oauth/provider/discord.provider.ts | 82 +++++++++ .../oauth/provider/genericOidc.provider.ts | 2 +- backend/src/oauth/provider/github.provider.ts | 2 +- .../src/oauth/provider/microsoft.provider.ts | 28 +++ .../oauth/provider/oauthProvider.interface.ts | 5 +- docs/oauth2-provider.md | 160 ++++++++++++++++++ frontend/src/i18n/translations/en-US.ts | 18 ++ frontend/src/utils/oauth.util.tsx | 5 +- 12 files changed, 341 insertions(+), 19 deletions(-) create mode 100644 backend/src/oauth/provider/discord.provider.ts create mode 100644 backend/src/oauth/provider/microsoft.provider.ts create mode 100644 docs/oauth2-provider.md diff --git a/README.md b/README.md index 2d091af37..873529e23 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,9 @@ Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manu To enable social login, you need to create an OAuth app for each provider you want to use. -##### GitHub - -Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). - -Redirect URL: `http(s):///api/oauth/github/callback` - ##### Google -Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to complete the prerequisites part. - -Redirect URL: `http(s):///api/oauth/google/callback` - ### Additional resources - [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/) diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 1e3648862..e48b591d9 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -154,6 +154,36 @@ const configVariables: ConfigVariables = { defaultValue: "", obscured: true, }, + "microsoft-enabled": { + type: "boolean", + defaultValue: "false", + }, + "microsoft-tenant": { + type: "string", + defaultValue: "common", + }, + "microsoft-clientId": { + type: "string", + defaultValue: "", + }, + "microsoft-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + "discord-enabled": { + type: "boolean", + defaultValue: "false", + }, + "discord-clientId": { + type: "string", + defaultValue: "", + }, + "discord-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, "oidc-enabled": { type: "boolean", defaultValue: "false", diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts index f8c28f454..3bacf4772 100644 --- a/backend/src/oauth/dto/oauthSignIn.dto.ts +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -1,5 +1,5 @@ export interface OAuthSignInDto { - provider: 'github' | 'google' | 'oidc'; + provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc'; providerId: string; providerUsername: string; email: string; diff --git a/backend/src/oauth/oauth.module.ts b/backend/src/oauth/oauth.module.ts index 45862293f..bcf76308d 100644 --- a/backend/src/oauth/oauth.module.ts +++ b/backend/src/oauth/oauth.module.ts @@ -6,6 +6,8 @@ import { GitHubProvider } from "./provider/github.provider"; import { GoogleProvider } from "./provider/google.provider"; import { OAuthProvider } from "./provider/oauthProvider.interface"; import { OidcProvider } from "./provider/oidc.provider"; +import { DiscordProvider } from "./provider/discord.provider"; +import { MicrosoftProvider } from "./provider/microsoft.provider"; @Module({ controllers: [OAuthController], @@ -13,17 +15,27 @@ import { OidcProvider } from "./provider/oidc.provider"; OAuthService, GitHubProvider, GoogleProvider, + MicrosoftProvider, + DiscordProvider, OidcProvider, { provide: "OAUTH_PROVIDERS", - useFactory(github: GitHubProvider, google: GoogleProvider, oidc: OidcProvider): Record> { + useFactory( + github: GitHubProvider, + google: GoogleProvider, + microsoft: MicrosoftProvider, + discord: DiscordProvider, + oidc: OidcProvider, + ): Record> { return { github, google, + microsoft, + discord, oidc, }; }, - inject: [GitHubProvider, GoogleProvider, OidcProvider], + inject: [GitHubProvider, GoogleProvider, MicrosoftProvider, DiscordProvider, OidcProvider], }, { provide: "OAUTH_PLATFORMS", diff --git a/backend/src/oauth/provider/discord.provider.ts b/backend/src/oauth/provider/discord.provider.ts new file mode 100644 index 000000000..6e212311e --- /dev/null +++ b/backend/src/oauth/provider/discord.provider.ts @@ -0,0 +1,82 @@ +import { OAuthProvider } from "./oauthProvider.interface"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; +import { ConfigService } from "../../config/config.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import fetch from "node-fetch"; + +@Injectable() +export class DiscordProvider implements OAuthProvider { + + constructor(private config: ConfigService) { + } + + getAuthEndpoint(state: string): Promise { + return Promise.resolve("https://discord.com/api/oauth2/authorize?" + new URLSearchParams({ + client_id: this.config.get("oauth.discord-clientId"), + redirect_uri: this.config.get("general.appUrl") + "/api/oauth/callback/discord", + response_type: "code", + state: state, + scope: "identify email", + }).toString()); + } + + private getAuthorizationHeader() { + return "Basic " + Buffer.from(this.config.get("oauth.discord-clientId") + ":" + this.config.get("oauth.discord-clientSecret")).toString("base64"); + } + + async getToken(code: string): Promise { + const res = await fetch("https://discord.com/api/v10/oauth2/token", { + method: "post", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": this.getAuthorizationHeader(), + }, + body: new URLSearchParams({ + code, + grant_type: "authorization_code", + redirect_uri: this.config.get("general.appUrl") + "/api/oauth/callback/discord", + }), + }); + return await res.json(); + } + + async getUserInfo(query: OAuthCallbackDto): Promise { + const token = await this.getToken(query.code); + const res = await fetch("https://discord.com/api/v10/user/@me", { + method: "post", + headers: { + "Accept": "application/json", + "Authorization": `${token.token_type} ${token.access_token}`, + }, + }); + const user = await res.json() as DiscordUser; + if (user.verified === false) { + throw new BadRequestException("Unverified account."); + } + + return { + provider: "discord", + providerId: user.id, + providerUsername: user.global_name ?? user.username, + email: user.email, + }; + } + +} + +interface DiscordToken { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +interface DiscordUser { + id: string; + username: string; + global_name: string; + email: string; + verified: boolean; +} diff --git a/backend/src/oauth/provider/genericOidc.provider.ts b/backend/src/oauth/provider/genericOidc.provider.ts index e84868f73..5a479b39e 100644 --- a/backend/src/oauth/provider/genericOidc.provider.ts +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -23,7 +23,7 @@ export abstract class GenericOidcProvider implements OAuthProvider { ) { this.discoveryUri = this.getDiscoveryUri(); this.redirectUri = `${this.config.get("general.appUrl")}/api/oauth/callback/${this.name}`; - this.config.addListener("update", (key: string, value: string) => { + this.config.addListener("update", (key: string, _: unknown) => { if (this.keyOfConfigUpdateEvents.includes(key)) { this.deinit(); this.discoveryUri = this.getDiscoveryUri(); diff --git a/backend/src/oauth/provider/github.provider.ts b/backend/src/oauth/provider/github.provider.ts index c86424e4a..381053ef8 100644 --- a/backend/src/oauth/provider/github.provider.ts +++ b/backend/src/oauth/provider/github.provider.ts @@ -47,7 +47,7 @@ export class GitHubProvider implements OAuthProvider { return { provider: "github", providerId: user.id.toString(), - providerUsername: user.login, + providerUsername: user.name ?? user.login, email, }; } diff --git a/backend/src/oauth/provider/microsoft.provider.ts b/backend/src/oauth/provider/microsoft.provider.ts new file mode 100644 index 000000000..32110fc0d --- /dev/null +++ b/backend/src/oauth/provider/microsoft.provider.ts @@ -0,0 +1,28 @@ +import { GenericOidcProvider } from "./genericOidc.provider"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Inject, Injectable } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Cache } from "cache-manager"; + +@Injectable() +export class MicrosoftProvider extends GenericOidcProvider { + + constructor( + config: ConfigService, + jwtService: JwtService, + @Inject(CACHE_MANAGER) cache: Cache, + ) { + super( + "microsoft", + ["oauth.microsoft-enabled", "oauth.microsoft-tenant"], + config, + jwtService, + cache, + ); + } + + protected getDiscoveryUri(): string { + return `https://login.microsoftonline.com/${this.config.get("oauth.microsoft-tenant")}/v2.0/.well-known/openid-configuration`; + } +} \ No newline at end of file diff --git a/backend/src/oauth/provider/oauthProvider.interface.ts b/backend/src/oauth/provider/oauthProvider.interface.ts index 8405258e1..4fd584e58 100644 --- a/backend/src/oauth/provider/oauthProvider.interface.ts +++ b/backend/src/oauth/provider/oauthProvider.interface.ts @@ -3,11 +3,12 @@ import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; /** * @typeParam T - type of token + * @typeParam C - type of callback query */ -export interface OAuthProvider { +export interface OAuthProvider { getAuthEndpoint(state: string): Promise; getToken(code: string): Promise; - getUserInfo(query: OAuthCallbackDto): Promise; + getUserInfo(query: C): Promise; } \ No newline at end of file diff --git a/docs/oauth2-provider.md b/docs/oauth2-provider.md new file mode 100644 index 000000000..743848909 --- /dev/null +++ b/docs/oauth2-provider.md @@ -0,0 +1,160 @@ +# OAuth 2 Login Guide + +## Config Built-in OAuth 2 Providers + +- [GitHub](#github) +- [Google](#google) +- [Microsoft](#microsoft) +- [Discord](#discord) +- [OpenID Connect](#oidc) + +### GitHub + +Please follow the [official guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) +to create an OAuth app. + +Redirect URL: `https:///api/oauth/callback/github` + +### Google + +Please follow the [official guide](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites) to +create an OAuth 2.0 App. + +Redirect URL: `https:///api/oauth/callback/google` + +### Microsoft + +### Discord + +Create an application on [Discord Developer Portal](https://discord.com/developers/applications). + +Redirect URL: `https:///api/oauth/callback/discord` + +### OpenID Connect + +Generic OpenID Connect provider is also supported, we have tested it on Keycloak and Authentik. + +Redirect URL: `https:///api/oauth/callback/oidc` + +## Custom your OAuth 2 Provider + +If our built-in providers don't meet your needs, you can create your own OAuth 2 provider. + +### 1. Create config + +Add your config (client id, client secret, etc.) in [`config.seed.ts`](../backend/prisma/seed/config.seed.ts): + +```ts +const configVariables: ConfigVariables = { + // ... + oauth: { + // ... + "YOUR_PROVIDER_NAME-enabled": { + type: "boolean", + defaultValue: "false", + }, + "YOUR_PROVIDER_NAME-clientId": { + type: "string", + defaultValue: "", + }, + "YOUR_PROVIDER_NAME-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + } +} +``` + +### 2. Create provider class + +#### OpenID Connect + +If your provider support OpenID connect, it's extremely easy to +extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect +provider. + +The [Google provider](../backend/src/oauth/provider/google.provider.ts) +and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples: + +Here are some discovery URIs for popular providers: + +- Microsoft: `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration` +- Google: `https://accounts.google.com/.well-known/openid-configuration` +- Apple: `https://appleid.apple.com/.well-known/openid-configuration` +- Gitlab: `https://gitlab.com/.well-known/openid-configuration` +- Huawei: `https://oauth-login.cloud.huawei.com/.well-known/openid-configuration` +- Paypal: `https://www.paypal.com/.well-known/openid-configuration` +- Yahoo: `https://api.login.yahoo.com/.well-known/openid-configuration` + +#### OAuth 2 + +If your provider only support arbitrary OAuth 2, you can +implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 +provider. + +The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) +and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples: + +### 3. Register provider + +Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts): + +```ts +@Module({ + providers: [ + GitHubProvider, + // your provider + { + provide: "OAUTH_PROVIDERS", + useFactory(github: GitHubProvider, /* your provider */): Record> { + return { + github, + google, + oidc, + }; + }, + inject: [GitHubProvider, /* your provider */], + }, + ], +}) +export class OAuthModule { +} +``` + +```ts +export interface OAuthSignInDto { + provider: 'github' | 'google' | 'oidc' | 'discord'; + providerId: string; + providerUsername: string; + email: string; +} +``` + +### 4. Add frontend icon + +Add an icon in [`oauth.util.tsx`](../frontend/src/utils/oauth.util.tsx). + +```tsx +const getOAuthIcon = (provider: string) => { + return { + 'github': , + /* your provider */ + }[provider]; +} +``` + +### 5. (Optional) Add i18n text + +Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts). + +- `signIn.oauth.YOUR_PROVIDER_NAME` +- `account.card.oauth.YOUR_PROVIDER_NAME` +- `admin.config.oauth.YOUR_PROVIDER_NAME-enabled` +- `admin.config.oauth.YOUR_PROVIDER_NAME-client-id` +- `admin.config.oauth.YOUR_PROVIDER_NAME-client-secret` +- Other config keys you defined in step 1 + +Congratulations! 🎉 You have successfully added a new OAuth 2 provider! Pull requests are welcome if you want to share +your provider with others. + diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 696c1bc24..2b778fc75 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -46,6 +46,8 @@ export default { "signIn.oauth.or": "OR", "signIn.oauth.github": "GitHub", "signIn.oauth.google": "Google", + "signIn.oauth.microsoft": "Microsoft", + "signIn.oauth.discord": "Discord", "signIn.oauth.oidc": "OpenID", // END /auth/signin @@ -90,6 +92,8 @@ export default { "account.card.oauth.title": "Social login", "account.card.oauth.github": "GitHub", "account.card.oauth.google": "Google", + "account.card.oauth.microsoft": "Microsoft", + "account.card.oauth.discord": "Discord", "account.card.oauth.oidc": "OpenID", "account.card.oauth.link": "Link", "account.card.oauth.unlink": "Unlink", @@ -440,6 +444,20 @@ export default { "admin.config.oauth.google-client-id.description": "Client ID of the Google OAuth app", "admin.config.oauth.google-client-secret": "Google Client secret", "admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app", + "admin.config.oauth.microsoft-enabled": "Microsoft", + "admin.config.oauth.microsoft-enabled.description": "Whether Microsoft login is enabled", + "admin.config.oauth.microsoft-tenant": "Microsoft Tenant", + "admin.config.oauth.microsoft-tenant.description": "Tenant ID of the Microsoft OAuth app\ncommon: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.\nconsumers: Only users with a personal Microsoft account can sign in to the application.\ndomain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.", + "admin.config.oauth.microsoft-client-id": "Microsoft Client ID", + "admin.config.oauth.microsoft-client-id.description": "Client ID of the Microsoft OAuth app", + "admin.config.oauth.microsoft-client-secret": "Microsoft Client secret", + "admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app", + "admin.config.oauth.discord-enabled": "Discord", + "admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled", + "admin.config.oauth.discord-client-id": "Discord Client ID", + "admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app", + "admin.config.oauth.discord-client-secret": "Discord Client secret", + "admin.config.oauth.discord-client-secret.description": "Client secret of the Discord OAuth app", "admin.config.oauth.oidc-enabled": "OpenID", "admin.config.oauth.oidc-enabled.description": "Whether OpenID login is enabled", "admin.config.oauth.oidc-discovery-uri": "OpenID Discovery URI", diff --git a/frontend/src/utils/oauth.util.tsx b/frontend/src/utils/oauth.util.tsx index 2c84be74c..b40380e3c 100644 --- a/frontend/src/utils/oauth.util.tsx +++ b/frontend/src/utils/oauth.util.tsx @@ -1,6 +1,5 @@ -import { SiGithub, SiGoogle, SiOpenid } from "react-icons/si"; +import { SiDiscord, SiGithub, SiGoogle, SiMicrosoft, SiOpenid } from "react-icons/si"; import React from "react"; -import toast from "./toast.util"; import api from "../services/api.service"; const getOAuthUrl = (appUrl: string, provider: string) => { @@ -10,7 +9,9 @@ const getOAuthUrl = (appUrl: string, provider: string) => { const getOAuthIcon = (provider: string) => { return { 'google': , + 'microsoft': , 'github': , + 'discord': , 'oidc': , }[provider]; } From 0680bbf4f9fa14eea86afa24af2511876c2ef820 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:28:25 +0800 Subject: [PATCH 15/50] docs(oauth): update README.md --- README.md | 7 ++----- docs/{oauth2-provider.md => oauth2-guide.md} | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) rename docs/{oauth2-provider.md => oauth2-guide.md} (99%) diff --git a/README.md b/README.md index 873529e23..9fcca16e3 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,9 @@ ClamAV is used to scan shares for malicious files and remove them if found. Please note that ClamAV needs a lot of [ressources](https://docs.clamav.net/manual/Installing/Docker.html#memory-ram-requirements). -#### Social Login +#### OAuth 2 Login -To enable social login, you need to create an OAuth app for each provider you want to use. - - -##### Google +View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information. ### Additional resources diff --git a/docs/oauth2-provider.md b/docs/oauth2-guide.md similarity index 99% rename from docs/oauth2-provider.md rename to docs/oauth2-guide.md index 743848909..196c1060a 100644 --- a/docs/oauth2-provider.md +++ b/docs/oauth2-guide.md @@ -6,7 +6,7 @@ - [Google](#google) - [Microsoft](#microsoft) - [Discord](#discord) -- [OpenID Connect](#oidc) +- [OpenID Connect](#openid-connect) ### GitHub From b1afdc17f9b37ba3874be1a7d8c41d101cf77939 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:47:57 +0800 Subject: [PATCH 16/50] docs(oauth): update oauth2-guide.md --- docs/oauth2-guide.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/oauth2-guide.md b/docs/oauth2-guide.md index 196c1060a..bcc99ea8b 100644 --- a/docs/oauth2-guide.md +++ b/docs/oauth2-guide.md @@ -24,6 +24,12 @@ Redirect URL: `https:///api/oauth/callback/google` ### Microsoft +Please follow +the [official guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) +to register an application. + +Redirect URL: `https:///api/oauth/callback/microsoft` + ### Discord Create an application on [Discord Developer Portal](https://discord.com/developers/applications). @@ -70,12 +76,12 @@ const configVariables: ConfigVariables = { #### OpenID Connect -If your provider support OpenID connect, it's extremely easy to +If your provider supports OpenID connect, it's extremely easy to extend [`GenericOidcProvider`](../backend/src/oauth/provider/genericOidc.provider.ts) to add a new OpenID Connect provider. The [Google provider](../backend/src/oauth/provider/google.provider.ts) -and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples: +and [Microsoft provider](../backend/src/oauth/provider/microsoft.provider.ts) are good examples. Here are some discovery URIs for popular providers: @@ -89,16 +95,17 @@ Here are some discovery URIs for popular providers: #### OAuth 2 -If your provider only support arbitrary OAuth 2, you can +If your provider only supports OAuth 2, you can implement [`OAuthProvider`](../backend/src/oauth/provider/oauthProvider.interface.ts) interface to add a new OAuth 2 provider. The [GitHub provider](../backend/src/oauth/provider/github.provider.ts) -and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples: +and [Discord provider](../backend/src/oauth/provider/discord.provider.ts) are good examples. ### 3. Register provider -Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts): +Register your provider in [`OAuthModule`](../backend/src/oauth/oauth.module.ts) +and [`OAuthSignInDto`](../backend/src/oauth/dto/oauthSignIn.dto.ts): ```ts @Module({ @@ -124,7 +131,8 @@ export class OAuthModule { ```ts export interface OAuthSignInDto { - provider: 'github' | 'google' | 'oidc' | 'discord'; + provider: 'github' | 'google' | 'microsoft' | 'discord' | 'oidc' /* your provider*/ + ; providerId: string; providerUsername: string; email: string; @@ -144,7 +152,7 @@ const getOAuthIcon = (provider: string) => { } ``` -### 5. (Optional) Add i18n text +### 5. Add i18n text Add keys below to your i18n text in [locale file](../frontend/src/i18n/translations/en-US.ts). From f3e180d4ed4266a20a1ace37cc823789a27b7537 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 12 Oct 2023 14:21:30 +0200 Subject: [PATCH 17/50] set password to null for new oauth users --- backend/prisma/schema.prisma | 2 +- backend/src/auth/auth.controller.ts | 2 +- backend/src/auth/auth.service.ts | 38 ++-- backend/src/auth/dto/updatePassword.dto.ts | 5 +- backend/src/oauth/oauth.service.ts | 2 +- backend/src/user/dto/user.dto.ts | 3 + backend/src/user/user.controller.ts | 8 +- frontend/src/components/auth/SignInForm.tsx | 54 +++--- frontend/src/i18n/translations/en-US.ts | 1 + frontend/src/pages/account/index.tsx | 187 ++++++++++++-------- frontend/src/types/user.type.ts | 1 + 11 files changed, 178 insertions(+), 125 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 74a76197e..c83a9f04b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,7 +14,7 @@ model User { username String @unique email String @unique - password String + password String? isAdmin Boolean @default(false) shares Share[] diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 427543e76..1816e7bff 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -117,8 +117,8 @@ export class AuthController { ) { const result = await this.authService.updatePassword( user, - dto.oldPassword, dto.password, + dto.oldPassword, ); this.authService.addTokensToResponse(response, result.refreshToken); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index e328e1e4f..38d8c499f 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -8,13 +8,13 @@ import { JwtService } from "@nestjs/jwt"; import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import * as argon from "argon2"; +import { Request, Response } from "express"; import * as moment from "moment"; import { ConfigService } from "src/config/config.service"; import { EmailService } from "src/email/email.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; -import { Request, Response } from "express"; @Injectable() export class AuthService { @@ -22,7 +22,7 @@ export class AuthService { private prisma: PrismaService, private jwtService: JwtService, private config: ConfigService, - private emailService: EmailService, + private emailService: EmailService ) {} async signUp(dto: AuthRegisterDTO) { @@ -40,7 +40,7 @@ export class AuthService { }); const { refreshToken, refreshTokenId } = await this.createRefreshToken( - user.id, + user.id ); const accessToken = await this.createAccessToken(user, refreshTokenId); @@ -50,7 +50,7 @@ export class AuthService { if (e.code == "P2002") { const duplicatedField: string = e.meta.target[0]; throw new BadRequestException( - `A user with this ${duplicatedField} already exists`, + `A user with this ${duplicatedField} already exists` ); } } @@ -76,14 +76,17 @@ export class AuthService { async generateToken(user: User, isOAuth = false) { // TODO: Make all old loginTokens invalid when a new one is created // Check if the user has TOTP enabled - if (user.totpVerified && !(isOAuth && this.config.get('oauth.ignoreTotp'))) { + if ( + user.totpVerified && + !(isOAuth && this.config.get("oauth.ignoreTotp")) + ) { const loginToken = await this.createLoginToken(user.id); return { loginToken }; } const { refreshToken, refreshTokenId } = await this.createRefreshToken( - user.id, + user.id ); const accessToken = await this.createAccessToken(user, refreshTokenId); @@ -134,9 +137,11 @@ export class AuthService { }); } - async updatePassword(user: User, oldPassword: string, newPassword: string) { - if (!(await argon.verify(user.password, oldPassword))) - throw new ForbiddenException("Invalid password"); + async updatePassword(user: User, newPassword: string, oldPassword?: string) { + const isPasswordValid = + !user.password || !(await argon.verify(user.password, oldPassword)); + + if (!isPasswordValid) throw new ForbiddenException("Invalid password"); const hash = await argon.hash(newPassword); @@ -163,7 +168,7 @@ export class AuthService { { expiresIn: "15min", secret: this.config.get("internal.jwtSecret"), - }, + } ); } @@ -194,7 +199,7 @@ export class AuthService { return this.createAccessToken( refreshTokenMetaData.user, - refreshTokenMetaData.id, + refreshTokenMetaData.id ); } @@ -219,7 +224,7 @@ export class AuthService { addTokensToResponse( response: Response, refreshToken?: string, - accessToken?: string, + accessToken?: string ) { if (accessToken) response.cookie("access_token", accessToken, { sameSite: "lax" }); @@ -238,9 +243,12 @@ export class AuthService { async getIdIfLogin(request: Request): Promise { if (!request.cookies.access_token) return false; try { - const payload = await this.jwtService.verifyAsync(request.cookies.access_token, { - secret: this.config.get("internal.jwtSecret"), - }); + const payload = await this.jwtService.verifyAsync( + request.cookies.access_token, + { + secret: this.config.get("internal.jwtSecret"), + } + ); return payload.sub; } catch (e) { return false; diff --git a/backend/src/auth/dto/updatePassword.dto.ts b/backend/src/auth/dto/updatePassword.dto.ts index ee6b0e07f..c154785a3 100644 --- a/backend/src/auth/dto/updatePassword.dto.ts +++ b/backend/src/auth/dto/updatePassword.dto.ts @@ -1,8 +1,9 @@ import { PickType } from "@nestjs/swagger"; -import { IsString } from "class-validator"; +import { IsOptional, IsString } from "class-validator"; import { UserDTO } from "src/user/dto/user.dto"; export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) { @IsString() - oldPassword: string; + @IsOptional() + oldPassword?: string; } diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index 98494fa5a..d915d32e4 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -125,7 +125,7 @@ export class OAuthService { const result = await this.auth.signUp({ email: user.email, username: nanoid().replaceAll("-", ''), - password: nanoid(), + password: null, }); await this.prisma.oAuthUser.create({ diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 5207f5524..b11d5d7f5 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -16,6 +16,9 @@ export class UserDTO { @IsEmail() email: string; + @Expose() + hasPassword: boolean; + @MinLength(8) password: string; diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 1256120fc..2b03ab32c 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -28,14 +28,16 @@ export class UserController { @Get("me") @UseGuards(JwtGuard) async getCurrentUser(@GetUser() user: User) { - return new UserDTO().from(user); + const userDTO = new UserDTO().from(user); + userDTO.hasPassword = !!user.password; + return userDTO; } @Patch("me") @UseGuards(JwtGuard) async updateCurrentUser( @GetUser() user: User, - @Body() data: UpdateOwnUserDTO, + @Body() data: UpdateOwnUserDTO ) { return new UserDTO().from(await this.userService.update(user.id, data)); } @@ -44,7 +46,7 @@ export class UserController { @UseGuards(JwtGuard) async deleteCurrentUser( @GetUser() user: User, - @Res({ passthrough: true }) response: Response, + @Res({ passthrough: true }) response: Response ) { response.cookie("access_token", "accessToken", { maxAge: -1 }); response.cookie("refresh_token", "", { diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 9352517f8..d32f4e61a 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -20,33 +20,35 @@ import { TbInfoCircle } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; import * as yup from "yup"; import useConfig from "../../hooks/config.hook"; -import useTranslate from "../../hooks/useTranslate.hook"; import useUser from "../../hooks/user.hook"; +import useTranslate from "../../hooks/useTranslate.hook"; import authService from "../../services/auth.service"; -import toast from "../../utils/toast.util"; import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util"; +import toast from "../../utils/toast.util"; const useStyles = createStyles((theme) => ({ or: { "&:before": { content: "''", flex: 1, - display: 'block', + display: "block", borderTopWidth: 1, - borderTopStyle: 'solid', - borderColor: theme.colorScheme === "dark" - ? theme.colors.dark[3] - : theme.colors.gray[4], + borderTopStyle: "solid", + borderColor: + theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], }, "&:after": { content: "''", flex: 1, - display: 'block', + display: "block", borderTopWidth: 1, - borderTopStyle: 'solid', - borderColor: theme.colorScheme === "dark" - ? theme.colors.dark[3] - : theme.colors.gray[4], + borderTopStyle: "solid", + borderColor: + theme.colorScheme === "dark" + ? theme.colors.dark[3] + : theme.colors.gray[4], }, }, })); @@ -124,7 +126,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { const getAvailableOAuth = async () => { const oauth = await authService.getAvailableOAuth(); setOAuth(oauth.data); - } + }; React.useEffect(() => { getAvailableOAuth().catch(toast.axiosError); @@ -185,21 +187,21 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { {oauth.length > 0 && ( - {t('signIn.oauth.or')} + {t("signIn.oauth.or")} - { - oauth.map((provider) => - - ) - } + {oauth.map((provider) => ( + + ))} )} diff --git a/frontend/src/i18n/translations/en-US.ts b/frontend/src/i18n/translations/en-US.ts index 2b778fc75..5d7ad7289 100644 --- a/frontend/src/i18n/translations/en-US.ts +++ b/frontend/src/i18n/translations/en-US.ts @@ -87,6 +87,7 @@ export default { "account.card.password.title": "Password", "account.card.password.old": "Old password", "account.card.password.new": "New password", + "account.card.password.noPasswordSet": "You don't have a password set. If you want to sign in with email and password you need to set a password.", "account.notify.password.success": "Password changed successfully", "account.card.oauth.title": "Social login", diff --git a/frontend/src/pages/account/index.tsx b/frontend/src/pages/account/index.tsx index 6a1d5fb3a..e011d5518 100644 --- a/frontend/src/pages/account/index.tsx +++ b/frontend/src/pages/account/index.tsx @@ -13,6 +13,7 @@ import { } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; import { useModals } from "@mantine/modals"; +import { useEffect, useState } from "react"; import { Tb2Fa } from "react-icons/tb"; import { FormattedMessage } from "react-intl"; import * as yup from "yup"; @@ -20,21 +21,23 @@ import Meta from "../../components/Meta"; import LanguagePicker from "../../components/account/LanguagePicker"; import ThemeSwitcher from "../../components/account/ThemeSwitcher"; import showEnableTotpModal from "../../components/account/showEnableTotpModal"; +import useConfig from "../../hooks/config.hook"; import useTranslate from "../../hooks/useTranslate.hook"; import useUser from "../../hooks/user.hook"; import authService from "../../services/auth.service"; import userService from "../../services/user.service"; -import toast from "../../utils/toast.util"; -import { useEffect, useState } from "react"; -import useConfig from "../../hooks/config.hook"; import { getOAuthIcon, getOAuthUrl, unlinkOAuth } from "../../utils/oauth.util"; +import toast from "../../utils/toast.util"; const Account = () => { const [oauth, setOAuth] = useState([]); - const [oauthStatus, setOAuthStatus] = useState | null>(null); + const [oauthStatus, setOAuthStatus] = useState | null>(null); const { user, refreshUser } = useUser(); const modals = useModals(); @@ -52,7 +55,7 @@ const Account = () => { username: yup .string() .min(3, t("common.error.too-short", { length: 3 })), - }), + }) ), }); @@ -63,15 +66,19 @@ const Account = () => { }, validate: yupResolver( yup.object().shape({ - oldPassword: yup - .string() - .min(8, t("common.error.too-short", { length: 8 })) - .required(t("common.error.field-required")), + oldPassword: yup.string().when([], { + is: () => !!user?.hasPassword, + then: (schema) => + schema + .min(8, t("common.error.too-short", { length: 8 })) + .required(t("common.error.field-required")), + otherwise: (schema) => schema.notRequired(), + }), password: yup .string() .min(8, t("common.error.too-short", { length: 8 })) .required(t("common.error.field-required")), - }), + }) ), }); @@ -85,7 +92,7 @@ const Account = () => { .string() .min(8, t("common.error.too-short", { length: 8 })) .required(t("common.error.field-required")), - }), + }) ), }); @@ -102,20 +109,26 @@ const Account = () => { .min(6, t("common.error.exact-length", { length: 6 })) .max(6, t("common.error.exact-length", { length: 6 })) .matches(/^[0-9]+$/, { message: t("common.error.invalid-number") }), - }), + }) ), }); const refreshOAuthStatus = () => { - authService.getOAuthStatus().then(data => { - setOAuthStatus(data.data); - }).catch(toast.axiosError); - } + authService + .getOAuthStatus() + .then((data) => { + setOAuthStatus(data.data); + }) + .catch(toast.axiosError); + }; useEffect(() => { - authService.getAvailableOAuth().then(data => { - setOAuth(data.data); - }).catch(toast.axiosError); + authService + .getAvailableOAuth() + .then((data) => { + setOAuth(data.data); + }) + .catch(toast.axiosError); refreshOAuthStatus(); }, []); @@ -138,7 +151,7 @@ const Account = () => { email: values.email, }) .then(() => toast.success(t("account.notify.info.success"))) - .catch(toast.axiosError), + .catch(toast.axiosError) )} > @@ -166,18 +179,25 @@ const Account = () => { onSubmit={passwordForm.onSubmit((values) => authService .updatePassword(values.oldPassword, values.password) - .then(() => { + .then(async () => { + refreshUser(); toast.success(t("account.notify.password.success")); passwordForm.reset(); }) - .catch(toast.axiosError), + .catch(toast.axiosError) )} > - + {user?.hasPassword ? ( + + ) : ( + + + + )} { - { - oauth.map(provider => - - {t(`account.card.oauth.${provider}`)} - - ) - } + {oauth.map((provider) => ( + + {t(`account.card.oauth.${provider}`)} + + ))} - { - oauth.map(provider => - - - { - oauthStatus?.[provider] - ? oauthStatus[provider].providerUsername - : t('account.card.oauth.unlinked') - } - { - oauthStatus?.[provider] - ? - : - } - - - ) - } + }) + .catch(toast.axiosError); + }, + }); + }} + > + {t("account.card.oauth.unlink")} + + ) : ( + + )} + + + ))}
)} @@ -279,7 +314,7 @@ const Account = () => { { diff --git a/frontend/src/types/user.type.ts b/frontend/src/types/user.type.ts index d6b6e528d..cb3809252 100644 --- a/frontend/src/types/user.type.ts +++ b/frontend/src/types/user.type.ts @@ -4,6 +4,7 @@ type User = { email: string; isAdmin: boolean; totpVerified: boolean; + hasPassword: boolean; }; export type CreateUser = { From 688ae6c86e3ea14e19c9eb2fa77fda2c6bc70582 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 12 Oct 2023 14:28:03 +0200 Subject: [PATCH 18/50] New translations en-us.ts (Japanese) (#281) --- frontend/src/i18n/translations/ja-JP.ts | 488 ++++++++++++------------ 1 file changed, 244 insertions(+), 244 deletions(-) diff --git a/frontend/src/i18n/translations/ja-JP.ts b/frontend/src/i18n/translations/ja-JP.ts index 2c62d2721..163b19dd2 100644 --- a/frontend/src/i18n/translations/ja-JP.ts +++ b/frontend/src/i18n/translations/ja-JP.ts @@ -5,7 +5,7 @@ export default { "navbar.home": "ホーム", "navbar.signup": "会員登録", "navbar.links.shares": "自分の共有", - "navbar.links.reverse": "自分と共有", + "navbar.links.reverse": "ファイルリクエスト", "navbar.avatar.account": "マイアカウント", "navbar.avatar.admin": "管理画面", "navbar.avatar.signout": "サインアウト", @@ -52,273 +52,273 @@ export default { "resetPassword.text.resetPassword": "パスワードをリセット", "resetPassword.text.enterNewPassword": "新規パスワードを入力", "resetPassword.input.password": "新規パスワード", - "resetPassword.notify.passwordReset": "Your password has been reset successfully.", + "resetPassword.notify.passwordReset": "パスワードのリセットに成功しました。", // /account - "account.title": "My account", - "account.card.info.title": "Account info", - "account.card.info.username": "Username", - "account.card.info.email": "Email", - "account.notify.info.success": "Account updated successfully", - "account.card.password.title": "Password", - "account.card.password.old": "Old password", - "account.card.password.new": "New password", - "account.notify.password.success": "Password changed successfully", - "account.card.security.title": "Security", - "account.card.security.totp.enable.description": "Enter your current password to start enabling TOTP", - "account.card.security.totp.disable.description": "Enter your current password to disable TOTP", - "account.card.security.totp.button.start": "Start", - "account.modal.totp.title": "Enable TOTP", - "account.modal.totp.step1": "Step 1: Add your authenticator", - "account.modal.totp.step2": "Step 2: Validate your code", - "account.modal.totp.enterManually": "Enter manually", - "account.modal.totp.code": "Code", - "account.modal.totp.clickToCopy": "Click to copy", - "account.modal.totp.verify": "Verify", - "account.notify.totp.disable": "TOTP disabled successfully", - "account.notify.totp.enable": "TOTP enabled successfully", - "account.card.language.title": "Language", - "account.card.language.description": "The project is translated by the community. Some languages might be incomplete.", - "account.card.color.title": "Color scheme", + "account.title": "マイアカウント", + "account.card.info.title": "アカウント情報", + "account.card.info.username": "ユーザー名", + "account.card.info.email": "メールアドレス", + "account.notify.info.success": "アカウントの更新に成功しました", + "account.card.password.title": "パスワード", + "account.card.password.old": "現在のパスワード", + "account.card.password.new": "新規パスワード", + "account.notify.password.success": "パスワードの変更に成功しました", + "account.card.security.title": "セキュリティ", + "account.card.security.totp.enable.description": "2段階認証を有効にするため、現在のパスワードを入力してください", + "account.card.security.totp.disable.description": "2段階認証を無効にするため、現在のパスワードを入力してください", + "account.card.security.totp.button.start": "開始", + "account.modal.totp.title": "2段階認証を有効にする", + "account.modal.totp.step1": "ステップ1: 認証アプリを追加する", + "account.modal.totp.step2": "ステップ2: コードを検証", + "account.modal.totp.enterManually": "手動で入力", + "account.modal.totp.code": "コピー", + "account.modal.totp.clickToCopy": "ここをクリックしてコピー", + "account.modal.totp.verify": "検証", + "account.notify.totp.disable": "2段階認証の無効化に成功しました", + "account.notify.totp.enable": "2段階認証の有効化に成功しました", + "account.card.language.title": "言語", + "account.card.language.description": "プロジェクトはコミュニティによって翻訳されています。一部の言語の翻訳は不完全の場合があります。", + "account.card.color.title": "カラースキーム", // ThemeSwitcher.tsx - "account.theme.dark": "Dark", - "account.theme.light": "Light", - "account.theme.system": "System", - "account.button.delete": "Delete Account", - "account.modal.delete.title": "Delete Account", - "account.modal.delete.description": "Do you really want to delete your account including all your active shares?", + "account.theme.dark": "ダーク", + "account.theme.light": "ライト", + "account.theme.system": "システムに合わせる", + "account.button.delete": "アカウントを削除", + "account.modal.delete.title": "アカウントを削除", + "account.modal.delete.description": "全ての有効な共有を含め、アカウントに関する全てのデータを完全に削除してもよろしいですか?", // END /account // /account/shares - "account.shares.title": "My shares", - "account.shares.title.empty": "It's empty here 👀", - "account.shares.description.empty": "You don't have any shares.", - "account.shares.button.create": "Create one", - "account.shares.info.title": "Share informations", + "account.shares.title": "自分の共有", + "account.shares.title.empty": "まだ何もありません 👀", + "account.shares.description.empty": "共有しているアイテムがありません。", + "account.shares.button.create": "新規作成", + "account.shares.info.title": "共有情報", "account.shares.table.id": "ID", - "account.shares.table.name": "Name", - "account.shares.table.description": "Description", - "account.shares.table.visitors": "Visitors", - "account.shares.table.expiresAt": "Expires at", - "account.shares.table.createdAt": "Created at", - "account.shares.table.size": "Size", - "account.shares.modal.share-informations": "Share informations", - "account.shares.modal.share-link": "Share link", - "account.shares.modal.delete.title": "Delete share {share}", - "account.shares.modal.delete.description": "Do you really want to delete this share?", + "account.shares.table.name": "名前", + "account.shares.table.description": "説明", + "account.shares.table.visitors": "訪問者", + "account.shares.table.expiresAt": "有効期限:", + "account.shares.table.createdAt": "作成日:", + "account.shares.table.size": "サイズ", + "account.shares.modal.share-informations": "共有情報", + "account.shares.modal.share-link": "共有リンク", + "account.shares.modal.delete.title": "共有を削除 {share}", + "account.shares.modal.delete.description": "この共有を削除してもよろしいですか?", // END /account/shares // /account/reverseShares - "account.reverseShares.title": "Reverse shares", - "account.reverseShares.description": "A reverse share allows you to generate a unique URL that allows external users to create a share.", - "account.reverseShares.title.empty": "It's empty here 👀", - "account.reverseShares.description.empty": "You don't have any reverse shares.", + "account.reverseShares.title": "ファイルリクエスト", + "account.reverseShares.description": "ファイルリクエストは、外部のユーザーにファイルをアップロードしてもらえるユニークなURLを生成できます。", + "account.reverseShares.title.empty": "まだ何もありません 👀", + "account.reverseShares.description.empty": "ファイルリクエストがありません。", // showCreateReverseShareModal.tsx - "account.reverseShares.modal.title": "Create reverse share", - "account.reverseShares.modal.expiration.label": "Expiration", - "account.reverseShares.modal.expiration.minute-singular": "Minute", - "account.reverseShares.modal.expiration.minute-plural": "Minutes", - "account.reverseShares.modal.expiration.hour-singular": "Hour", - "account.reverseShares.modal.expiration.hour-plural": "Hours", - "account.reverseShares.modal.expiration.day-singular": "Day", - "account.reverseShares.modal.expiration.day-plural": "Days", - "account.reverseShares.modal.expiration.week-singular": "Week", - "account.reverseShares.modal.expiration.week-plural": "Weeks", - "account.reverseShares.modal.expiration.month-singular": "Month", - "account.reverseShares.modal.expiration.month-plural": "Months", - "account.reverseShares.modal.expiration.year-singular": "Year", - "account.reverseShares.modal.expiration.year-plural": "Years", - "account.reverseShares.modal.max-size.label": "Max share size", - "account.reverseShares.modal.send-email": "Send email notification", - "account.reverseShares.modal.send-email.description": "Send an email notification when a share is created with this reverse share link.", - "account.reverseShares.modal.max-use.label": "Max uses", - "account.reverseShares.modal.max-use.description": "The maximum amount of times this URL can be used to create a share.", - "account.reverseShare.never-expires": "This reverse share will never expire.", - "account.reverseShare.expires-on": "This reverse share will expire on {expiration}.", - "account.reverseShares.table.no-shares": "No shares created yet", - "account.reverseShares.table.count.singular": "share", - "account.reverseShares.table.count.plural": "shares", - "account.reverseShares.table.shares": "Shares", - "account.reverseShares.table.remaining": "Remaining uses", - "account.reverseShares.table.max-size": "Max share size", - "account.reverseShares.table.expires": "Expires at", - "account.reverseShares.modal.reverse-share-link": "Reverse share link", - "account.reverseShares.modal.delete.title": "Delete reverse share", - "account.reverseShares.modal.delete.description": "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.", + "account.reverseShares.modal.title": "ファイルリクエストを作成", + "account.reverseShares.modal.expiration.label": "有効期限", + "account.reverseShares.modal.expiration.minute-singular": "分間", + "account.reverseShares.modal.expiration.minute-plural": "分間", + "account.reverseShares.modal.expiration.hour-singular": "時間", + "account.reverseShares.modal.expiration.hour-plural": "時間", + "account.reverseShares.modal.expiration.day-singular": "日間", + "account.reverseShares.modal.expiration.day-plural": "日間", + "account.reverseShares.modal.expiration.week-singular": "週間", + "account.reverseShares.modal.expiration.week-plural": "週間", + "account.reverseShares.modal.expiration.month-singular": "ヶ月", + "account.reverseShares.modal.expiration.month-plural": "ヶ月", + "account.reverseShares.modal.expiration.year-singular": "年間", + "account.reverseShares.modal.expiration.year-plural": "年間", + "account.reverseShares.modal.max-size.label": "最大ファイルサイズ", + "account.reverseShares.modal.send-email": "メール通知を送信", + "account.reverseShares.modal.send-email.description": "このファイルリクエストリンクを使用して、ファイルがアップロードされた場合にメールで通知します。", + "account.reverseShares.modal.max-use.label": "最大回数", + "account.reverseShares.modal.max-use.description": "このURLを使用してファイルをアップロードできる最大回数です。", + "account.reverseShare.never-expires": "このファイルリクエストリンクは期限切れになりません。", + "account.reverseShare.expires-on": "このファイルリクエストリンクは、{expiration} に期限切れとなります。", + "account.reverseShares.table.no-shares": "まだファイルがアップロードされていません", + "account.reverseShares.table.count.singular": "共有", + "account.reverseShares.table.count.plural": "共有", + "account.reverseShares.table.shares": "共有", + "account.reverseShares.table.remaining": "残り使用回数", + "account.reverseShares.table.max-size": "最大ファイルサイズ", + "account.reverseShares.table.expires": "有効期限", + "account.reverseShares.modal.reverse-share-link": "ファイルリクエストリンク", + "account.reverseShares.modal.delete.title": "ファイルリクエストを削除", + "account.reverseShares.modal.delete.description": "本当にこのファイルリクエストを削除しますか?削除すると、関連するファイルアップロードも削除されます。", // END /account/reverseShares // /admin - "admin.title": "Administration", - "admin.button.users": "User management", - "admin.button.config": "Configuration", - "admin.version": "Version", + "admin.title": "管理画面", + "admin.button.users": "ユーザー管理", + "admin.button.config": "設定", + "admin.version": "バージョン", // END /admin // /admin/users - "admin.users.title": "User management", - "admin.users.table.username": "Username", - "admin.users.table.email": "Email", - "admin.users.table.admin": "Admin", - "admin.users.edit.update.title": "Update user {username}", - "admin.users.edit.update.admin-privileges": "Admin privileges", - "admin.users.edit.update.change-password.title": "Change password", - "admin.users.edit.update.change-password.field": "New password", - "admin.users.edit.update.change-password.button": "Save new password", - "admin.users.edit.update.notify.password.success": "Password changed successfully", - "admin.users.edit.delete.title": "Delete user {username}", - "admin.users.edit.delete.description": "Do you really want to delete this user and all his shares?", + "admin.users.title": "ユーザー管理", + "admin.users.table.username": "ユーザー名", + "admin.users.table.email": "メールアドレス", + "admin.users.table.admin": "管理画面", + "admin.users.edit.update.title": "ユーザー「{username}」を更新", + "admin.users.edit.update.admin-privileges": "管理者権限", + "admin.users.edit.update.change-password.title": "パスワードを変更", + "admin.users.edit.update.change-password.field": "新規パスワード", + "admin.users.edit.update.change-password.button": "新しいパスワードを保存", + "admin.users.edit.update.notify.password.success": "パスワードの変更に成功しました", + "admin.users.edit.delete.title": "ユーザーを削除 {username}", + "admin.users.edit.delete.description": "このユーザーとこのユーザーに関連する全てのファイルアップロードを削除してもよろしいですか?", // showCreateUserModal.tsx - "admin.users.modal.create.title": "Create user", - "admin.users.modal.create.username": "Username", - "admin.users.modal.create.email": "Email", - "admin.users.modal.create.password": "Password", - "admin.users.modal.create.manual-password": "Set password manually", - "admin.users.modal.create.manual-password.description": "If not checked, the user will receive an email with a link to set their password.", - "admin.users.modal.create.admin": "Admin privileges", - "admin.users.modal.create.admin.description": "If checked, the user will be able to access the admin panel.", + "admin.users.modal.create.title": "ユーザーを作成", + "admin.users.modal.create.username": "ユーザー名", + "admin.users.modal.create.email": "メールアドレス", + "admin.users.modal.create.password": "パスワード", + "admin.users.modal.create.manual-password": "パスワードを手動で設定", + "admin.users.modal.create.manual-password.description": "チェックされていない場合、ユーザーにパスワードを設定する為のリンクが記載されたメールを送信します。", + "admin.users.modal.create.admin": "管理者権限", + "admin.users.modal.create.admin.description": "チェックされている場合、ユーザーは管理画面にアクセスできるようになります。", // END /admin/users // /upload - "upload.title": "Upload", - "upload.notify.generic-error": "An error occurred while finishing your share.", - "upload.notify.count-failed": "{count} files failed to upload. Trying again.", + "upload.title": "アップロード", + "upload.notify.generic-error": "共有を仕上げている最中にエラーが発生しました。", + "upload.notify.count-failed": "{count} ファイルがアップロードに失敗しました。再度お試しください。", // Dropzone.tsx - "upload.dropzone.title": "Upload files", - "upload.dropzone.description": "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.", - "upload.dropzone.notify.file-too-big": "Your files exceed the maximum share size of {maxSize}.", + "upload.dropzone.title": "ファイルをアップロード", + "upload.dropzone.description": "ここにファイルをドラッグしてアップロードを開始しましょう。{maxSize} 以下のファイルがアップロードできます。", + "upload.dropzone.notify.file-too-big": "アップロードしようとしたファイルは、最大ファイルサイズの{maxSize} を超えています。", // FileList.tsx - "upload.filelist.name": "Name", - "upload.filelist.size": "Size", + "upload.filelist.name": "ファイル名", + "upload.filelist.size": "サイズ", // showCreateUploadModal.tsx - "upload.modal.title": "Create Share", - "upload.modal.link.error.invalid": "Can only contain letters, numbers, underscores, and hyphens", - "upload.modal.link.error.taken": "This link is already in use", - "upload.modal.not-signed-in": "You're not signed in", - "upload.modal.not-signed-in-description": "You will be unable to delete your share manually and view the visitor count.", - "upload.modal.expires.never": "never", - "upload.modal.expires.never-long": "Never Expires", - "upload.modal.link.label": "Link", - "upload.modal.expires.label": "Expiration", - "upload.modal.expires.minute-singular": "Minute", - "upload.modal.expires.minute-plural": "Minutes", - "upload.modal.expires.hour-singular": "Hour", - "upload.modal.expires.hour-plural": "Hours", - "upload.modal.expires.day-singular": "Day", - "upload.modal.expires.day-plural": "Days", - "upload.modal.expires.week-singular": "Week", - "upload.modal.expires.week-plural": "Weeks", - "upload.modal.expires.month-singular": "Month", - "upload.modal.expires.month-plural": "Months", - "upload.modal.expires.year-singular": "Year", - "upload.modal.expires.year-plural": "Years", - "upload.modal.accordion.description.title": "Description", - "upload.modal.accordion.description.placeholder": "Note for the recipients of this share", - "upload.modal.accordion.email.title": "Email recipients", - "upload.modal.accordion.email.placeholder": "Enter email recipients", - "upload.modal.accordion.email.invalid-email": "Invalid email address", - "upload.modal.accordion.security.title": "Security options", - "upload.modal.accordion.security.password.label": "Password protection", - "upload.modal.accordion.security.password.placeholder": "No password", - "upload.modal.accordion.security.max-views.label": "Maximum views", - "upload.modal.accordion.security.max-views.placeholder": "No limit", + "upload.modal.title": "共有を作成", + "upload.modal.link.error.invalid": "文字、数字、アンダースコア、ハイフンのみ使用できます", + "upload.modal.link.error.taken": "このリンクは既に使用されています", + "upload.modal.not-signed-in": "サインインしていません", + "upload.modal.not-signed-in-description": "共有の手動削除と訪問者カウンターは表示できません。", + "upload.modal.expires.never": "永久", + "upload.modal.expires.never-long": "期限切れにさせない", + "upload.modal.link.label": "リンク", + "upload.modal.expires.label": "有効期限", + "upload.modal.expires.minute-singular": "分間", + "upload.modal.expires.minute-plural": "分間", + "upload.modal.expires.hour-singular": "時間", + "upload.modal.expires.hour-plural": "時間", + "upload.modal.expires.day-singular": "日間", + "upload.modal.expires.day-plural": "日間", + "upload.modal.expires.week-singular": "週間", + "upload.modal.expires.week-plural": "週間", + "upload.modal.expires.month-singular": "ヶ月間", + "upload.modal.expires.month-plural": "ヶ月間", + "upload.modal.expires.year-singular": "年間", + "upload.modal.expires.year-plural": "年間", + "upload.modal.accordion.description.title": "説明", + "upload.modal.accordion.description.placeholder": "この共有に関する受信者へのメモ", + "upload.modal.accordion.email.title": "メールで受け取る相手", + "upload.modal.accordion.email.placeholder": "メールの宛先を入力", + "upload.modal.accordion.email.invalid-email": "無効なメールアドレスです", + "upload.modal.accordion.security.title": "セキュリティオプション", + "upload.modal.accordion.security.password.label": "パスワード保護", + "upload.modal.accordion.security.password.placeholder": "パスワードなし", + "upload.modal.accordion.security.max-views.label": "最大閲覧回数", + "upload.modal.accordion.security.max-views.placeholder": "制限なし", // showCompletedUploadModal.tsx - "upload.modal.completed.never-expires": "This share will never expire.", - "upload.modal.completed.expires-on": "This share will expire on {expiration}.", - "upload.modal.completed.share-ready": "Share ready", + "upload.modal.completed.never-expires": "この共有は期限切れになりません。", + "upload.modal.completed.expires-on": "この共有は、{expiration} に期限切れとなります。", + "upload.modal.completed.share-ready": "共有の準備ができました", // END /upload // /share/[id] - "share.title": "Share {shareId}", - "share.description": "Look what I've shared with you!", - "share.error.visitor-limit-exceeded.title": "Visitor limit exceeded", - "share.error.visitor-limit-exceeded.description": "The visitor limit from this share has been exceeded.", - "share.error.removed.title": "Share removed", - "share.error.not-found.title": "Share not found", - "share.error.not-found.description": "The share you're looking for doesn't exist.", - "share.modal.password.title": "Password required", - "share.modal.password.description": "To access this share please enter the password for the share.", - "share.modal.password": "Password", - "share.modal.error.invalid-password": "Invalid password", - "share.button.download-all": "Download all", - "share.notify.download-all-preparing": "The share is preparing. Try again in a few minutes.", - "share.modal.file-link": "File link", - "share.table.name": "Name", - "share.table.size": "Size", - "share.modal.file-preview.error.not-supported.title": "Preview not supported", - "share.modal.file-preview.error.not-supported.description": "A preview for thise file type is unsupported. Please download the file to view it.", + "share.title": "「{shareId}」が共有されました", + "share.description": "あなたと共有したファイルをご確認ください!", + "share.error.visitor-limit-exceeded.title": "訪問者の上限を超えました", + "share.error.visitor-limit-exceeded.description": "この共有からの訪問者の回数が制限を超えています。", + "share.error.removed.title": "共有が削除されました", + "share.error.not-found.title": "共有が見つかりません", + "share.error.not-found.description": "お探しの共有が見つかりません。", + "share.modal.password.title": "パスワードが必要です", + "share.modal.password.description": "この共有にアクセスするには、共有相手から伝えられているパスワードを入力してください。", + "share.modal.password": "パスワード", + "share.modal.error.invalid-password": "パスワードが間違っています", + "share.button.download-all": "全てダウンロード", + "share.notify.download-all-preparing": "共有は準備中です。数分後に再度お試しください。", + "share.modal.file-link": "ファイルリンク", + "share.table.name": "ファイル名", + "share.table.size": "サイズ", + "share.modal.file-preview.error.not-supported.title": "プレビューに対応していません", + "share.modal.file-preview.error.not-supported.description": "これらのファイルのプレビューには対応していません。ファイルをダウンロードして、直接確認してください。", // END /share/[id] // /admin/config - "admin.config.title": "Configuration", - "admin.config.category.general": "General", - "admin.config.category.share": "Share", - "admin.config.category.email": "Email", + "admin.config.title": "設定", + "admin.config.category.general": "一般", + "admin.config.category.share": "共有", + "admin.config.category.email": "メール", "admin.config.category.smtp": "SMTP", - "admin.config.general.app-name": "App name", - "admin.config.general.app-name.description": "Name of the application", - "admin.config.general.app-url": "App URL", - "admin.config.general.app-url.description": "On which URL Pingvin Share is available", - "admin.config.general.show-home-page": "Show home page", - "admin.config.general.show-home-page.description": "Whether to show the home page", - "admin.config.general.logo": "Logo", - "admin.config.general.logo.description": "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.", - "admin.config.general.logo.placeholder": "Pick image", - "admin.config.email.enable-share-email-recipients": "Enable share email recipients", - "admin.config.email.enable-share-email-recipients.description": "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", - "admin.config.email.share-recipients-subject": "Share recipients subject", - "admin.config.email.share-recipients-subject.description": "Subject of the email which gets sent to the share recipients.", - "admin.config.email.share-recipients-message": "Share recipients message", - "admin.config.email.share-recipients-message.description": "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.", - "admin.config.email.reverse-share-subject": "Reverse share subject", - "admin.config.email.reverse-share-subject.description": "Subject of the email which gets sent when someone created a share with your reverse share link.", - "admin.config.email.reverse-share-message": "Reverse share message", - "admin.config.email.reverse-share-message.description": "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", - "admin.config.email.reset-password-subject": "Reset password subject", - "admin.config.email.reset-password-subject.description": "Subject of the email which gets sent when a user requests a password reset.", - "admin.config.email.reset-password-message": "Reset password message", - "admin.config.email.reset-password-message.description": "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", - "admin.config.email.invite-subject": "Invite subject", - "admin.config.email.invite-subject.description": "Subject of the email which gets sent when an admin invites a user.", - "admin.config.email.invite-message": "Invite message", - "admin.config.email.invite-message.description": "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.", - "admin.config.share.allow-registration": "Allow registration", - "admin.config.share.allow-registration.description": "Whether registration is allowed", - "admin.config.share.allow-unauthenticated-shares": "Allow unauthenticated shares", - "admin.config.share.allow-unauthenticated-shares.description": "Whether unauthenticated users can create shares", - "admin.config.share.max-size": "Max size", - "admin.config.share.max-size.description": "Maximum share size in bytes", - "admin.config.share.zip-compression-level": "Zip compression level", - "admin.config.share.zip-compression-level.description": "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ", - "admin.config.smtp.enabled": "Enabled", - "admin.config.smtp.enabled.description": "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", - "admin.config.smtp.host": "Host", - "admin.config.smtp.host.description": "Host of the SMTP server", - "admin.config.smtp.port": "Port", - "admin.config.smtp.port.description": "Port of the SMTP server", - "admin.config.smtp.email": "Email", - "admin.config.smtp.email.description": "Email address which the emails get sent from", - "admin.config.smtp.username": "Username", - "admin.config.smtp.username.description": "Username of the SMTP server", - "admin.config.smtp.password": "Password", - "admin.config.smtp.password.description": "Password of the SMTP server", - "admin.config.smtp.button.test": "Send test email", + "admin.config.general.app-name": "アプリ名", + "admin.config.general.app-name.description": "アプリの名前", + "admin.config.general.app-url": "アプリ名", + "admin.config.general.app-url.description": "Pingvin Shareで利用できるURL", + "admin.config.general.show-home-page": "ホームページを表示する", + "admin.config.general.show-home-page.description": "ホームページを表示するかどうか選択", + "admin.config.general.logo": "ロゴ", + "admin.config.general.logo.description": "新しい画像をアップロードしてロゴを変更できます。画像は、PNG形式でアスペクト比が1:1である必要があります。", + "admin.config.general.logo.placeholder": "画像を選択", + "admin.config.email.enable-share-email-recipients": "メールでの共有を有効にする", + "admin.config.email.enable-share-email-recipients.description": "メールで共有を送信できるようにするか選択してください。SMTPが有効になっている場合にのみ機能します。", + "admin.config.email.share-recipients-subject": "宛先への件名", + "admin.config.email.share-recipients-subject.description": "メールで共有された相手に送信メールの件名です。", + "admin.config.email.share-recipients-message": "宛先への本文", + "admin.config.email.share-recipients-message.description": "メールで共有された相手に送信されるメールの本文です。次の変数が利用できます:\n{creator} - 共有作成者のユーザー名\n{shareUrl} - 共有のURL\n{desc} - 共有の説明\n{expires} - 共有の有効期限\n変数は、実際の値によって置き換えられます。", + "admin.config.email.reverse-share-subject": "ファイルリクエストの件名", + "admin.config.email.reverse-share-subject.description": "あなたが作成したファイルリクエストリンクからファイルがアップロードされた場合に送信されるメールの件名です。", + "admin.config.email.reverse-share-message": "ファイルリクエストの本文", + "admin.config.email.reverse-share-message.description": "あなたが作成したファイルリクエストリンクからファイルがアップロードされた場合に送信されるメールの本文です。{shareUrl} は、作成者の名前とURLにより置き換えられます。", + "admin.config.email.reset-password-subject": "パスワードリセットの件名", + "admin.config.email.reset-password-subject.description": "パスワードリセットのリクエストがされた時に送信されるメールの件名です。", + "admin.config.email.reset-password-message": "パスワードリセットの本文", + "admin.config.email.reset-password-message.description": "あなたが作成したファイルリクエストリンクからファイルがアップロードされた場合に送信されるメールの本文です。{url} は、実際のパスワードリセットURLによって置き換えられます。", + "admin.config.email.invite-subject": "無効な件名", + "admin.config.email.invite-subject.description": "管理者がユーザーを招待したときに送信されるメールの件名です。", + "admin.config.email.invite-message": "無効な本文", + "admin.config.email.invite-message.description": "管理者がユーザーを招待したときに送信されるメールの本文です。{url} は、招待URLに、{password} は、パスワードに置き換えられます。", + "admin.config.share.allow-registration": "登録を許可する", + "admin.config.share.allow-registration.description": "登録を許可するかどうかを選択してください。", + "admin.config.share.allow-unauthenticated-shares": "ログインしていない状態での共有を許可する", + "admin.config.share.allow-unauthenticated-shares.description": "ログインしていないユーザーに共有の作成を許可するかどうかを選択してください。", + "admin.config.share.max-size": "最大ファイルサイズ", + "admin.config.share.max-size.description": "最大ファイルサイズ(byte単位)", + "admin.config.share.zip-compression-level": "Zip圧縮レベル", + "admin.config.share.zip-compression-level.description": "ファイルサイズと圧縮速度のバランスを取るように、レベルを調整できます。有効な値は0~9の間で、0が無圧縮、9で最大限の圧縮となります。 ", + "admin.config.smtp.enabled": "有効", + "admin.config.smtp.enabled.description": "SMTPを有効にするかどうかを選択してください。SMTPサーバーのホスト名、ポート番号、電子メールアドレス、ユーザー名、パスワードが入力されている場合にのみ、有効にしてください。", + "admin.config.smtp.host": "ホスト名", + "admin.config.smtp.host.description": "SMTPサーバーのホスト名", + "admin.config.smtp.port": "ポート番号", + "admin.config.smtp.port.description": "SMTPサーバーのポート番号", + "admin.config.smtp.email": "メールアドレス", + "admin.config.smtp.email.description": "送信メールに設定するメールアドレス", + "admin.config.smtp.username": "ユーザー名", + "admin.config.smtp.username.description": "SMTPサーバーのユーザー名", + "admin.config.smtp.password": "パスワード", + "admin.config.smtp.password.description": "SMTPサーバーのパスワード", + "admin.config.smtp.button.test": "テストメールを送信", // 404 - "404.description": "Oops this page doesn't exist.", - "404.button.home": "Bring me back home", + "404.description": "ページが見つかりません。", + "404.button.home": "ホームに戻る", // Common translations - "common.button.save": "Save", - "common.button.create": "Create", - "common.button.submit": "Submit", - "common.button.delete": "Delete", - "common.button.cancel": "Cancel", - "common.button.confirm": "Confirm", - "common.button.disable": "Disable", - "common.button.share": "Share", - "common.button.generate": "Generate", - "common.button.done": "Done", - "common.text.link": "Link", - "common.text.or": "or", - "common.button.go-back": "Go back", - "common.notify.copied": "Your link was copied to the clipboard", - "common.success": "Success", - "common.error": "Error", - "common.error.unknown": "An unknown error occurred", - "common.error.invalid-email": "Invalid email address", - "common.error.too-short": "Must be at least {length} characters", - "common.error.too-long": "Must be at most {length} characters", - "common.error.exact-length": "Must be exactly {length} characters", - "common.error.invalid-number": "Must be a number", - "common.error.field-required": "This field is required" + "common.button.save": "保存", + "common.button.create": "作成", + "common.button.submit": "送信", + "common.button.delete": "削除", + "common.button.cancel": "キャンセル", + "common.button.confirm": "確認", + "common.button.disable": "無効", + "common.button.share": "共有", + "common.button.generate": "生成", + "common.button.done": "完了", + "common.text.link": "リンク", + "common.text.or": "または", + "common.button.go-back": "戻る", + "common.notify.copied": "リンクをクリップボードにコピーしました", + "common.success": "成功", + "common.error": "エラー", + "common.error.unknown": "不明なエラーが発生しました", + "common.error.invalid-email": "無効なメールアドレス", + "common.error.too-short": "最低{length} 文字である必要があります", + "common.error.too-long": "最大{length} 文字である必要があります", + "common.error.exact-length": "{length} 文字である必要があります", + "common.error.invalid-number": "数字でなければなりません", + "common.error.field-required": "これは必須項目です" }; \ No newline at end of file From 1d8dc8fe5becfa9112d6b6dda26cfd3a11f27b1d Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 12 Oct 2023 14:30:04 +0200 Subject: [PATCH 19/50] chore(translations): add Polish files --- frontend/src/i18n/locales.ts | 6 + frontend/src/i18n/translations/pl-PL.ts | 439 ++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 frontend/src/i18n/translations/pl-PL.ts diff --git a/frontend/src/i18n/locales.ts b/frontend/src/i18n/locales.ts index 0f02726c1..2609d068b 100644 --- a/frontend/src/i18n/locales.ts +++ b/frontend/src/i18n/locales.ts @@ -11,6 +11,7 @@ import russian from "./translations/ru-RU"; import serbian from "./translations/sr-SP"; import thai from "./translations/th-TH"; import chineseSimplified from "./translations/zh-CN"; +import polish from "./translations/pl-PL"; export const LOCALES = { ENGLISH: { @@ -78,4 +79,9 @@ export const LOCALES = { code: "ja-JP", messages: japanese, }, + POLISH: { + name: "Polski", + code: "pl-PL", + messages: polish, + }, }; diff --git a/frontend/src/i18n/translations/pl-PL.ts b/frontend/src/i18n/translations/pl-PL.ts new file mode 100644 index 000000000..b2feeec29 --- /dev/null +++ b/frontend/src/i18n/translations/pl-PL.ts @@ -0,0 +1,439 @@ +export default { + // Navbar + "navbar.upload": "Upload", + "navbar.signin": "Sign in", + "navbar.home": "Home", + "navbar.signup": "Sign Up", + + "navbar.links.shares": "My shares", + "navbar.links.reverse": "Reverse shares", + + "navbar.avatar.account": "My account", + "navbar.avatar.admin": "Administration", + "navbar.avatar.signout": "Sign out", + // END navbar + + // / + "home.title": "A self-hosted file sharing platform.", + + "home.description": + "Do you really want to give your personal files in the hand of third parties like WeTransfer?", + "home.bullet.a.name": "Self-Hosted", + "home.bullet.a.description": "Host Pingvin Share on your own machine.", + "home.bullet.b.name": "Privacy", + "home.bullet.b.description": + "Your files are your files and should never get into the hands of third parties.", + "home.bullet.c.name": "No annoying file size limit", + "home.bullet.c.description": + "Upload as big files as you want. Only your hard drive will be your limit.", + + "home.button.start": "Get started", + "home.button.source": "Source code", + // END / + + // /auth/signin + "signin.title": "Welcome back", + "signin.description": "You don't have an account yet?", + "signin.button.signup": "Sign up", + "signin.input.email-or-username": "Email or username", + "signin.input.email-or-username.placeholder": "Your email or username", + "signin.input.password": "Password", + "signin.input.password.placeholder": "Your password", + "signin.button.submit": "Sign in", + "signIn.notify.totp-required.title": "Two-factor authentication required", + "signIn.notify.totp-required.description": + "Please enter your two-factor authentication code", + + // END /auth/signin + + // /auth/signup + "signup.title": "Create an account", + "signup.description": "Already have an account?", + "signup.button.signin": "Sign in", + "signup.input.username": "Username", + "signup.input.username.placeholder": "Your username", + "signup.input.email": "Email", + "signup.input.email.placeholder": "Your email", + "signup.button.submit": "Let's get started", + + // END /auth/signup + + // /auth/reset-password + "resetPassword.title": "Forgot your password?", + "resetPassword.description": "Enter your email to reset your password.", + "resetPassword.notify.success": + "An email has been sent with a link to reset your password.", + "resetPassword.button.back": "Back to sign in page", + "resetPassword.text.resetPassword": "Reset password", + "resetPassword.text.enterNewPassword": "Enter your new password", + "resetPassword.input.password": "New password", + "resetPassword.notify.passwordReset": + "Your password has been reset successfully.", + + // /account + "account.title": "My account", + + "account.card.info.title": "Account info", + "account.card.info.username": "Username", + "account.card.info.email": "Email", + "account.notify.info.success": "Account updated successfully", + + "account.card.password.title": "Password", + "account.card.password.old": "Old password", + "account.card.password.new": "New password", + "account.notify.password.success": "Password changed successfully", + + "account.card.security.title": "Security", + "account.card.security.totp.enable.description": + "Enter your current password to start enabling TOTP", + "account.card.security.totp.disable.description": + "Enter your current password to disable TOTP", + "account.card.security.totp.button.start": "Start", + "account.modal.totp.title": "Enable TOTP", + "account.modal.totp.step1": "Step 1: Add your authenticator", + "account.modal.totp.step2": "Step 2: Validate your code", + "account.modal.totp.enterManually": "Enter manually", + "account.modal.totp.code": "Code", + "account.modal.totp.clickToCopy": "Click to copy", + "account.modal.totp.verify": "Verify", + "account.notify.totp.disable": "TOTP disabled successfully", + "account.notify.totp.enable": "TOTP enabled successfully", + + "account.card.language.title": "Language", + "account.card.language.description": + "The project is translated by the community. Some languages might be incomplete.", + "account.card.color.title": "Color scheme", + + // ThemeSwitcher.tsx + "account.theme.dark": "Dark", + "account.theme.light": "Light", + "account.theme.system": "System", + + "account.button.delete": "Delete Account", + "account.modal.delete.title": "Delete Account", + "account.modal.delete.description": + "Do you really want to delete your account including all your active shares?", + // END /account + + // /account/shares + "account.shares.title": "My shares", + "account.shares.title.empty": "It's empty here 👀", + "account.shares.description.empty": "You don't have any shares.", + "account.shares.button.create": "Create one", + + "account.shares.info.title": "Share informations", + "account.shares.table.id": "ID", + "account.shares.table.name": "Name", + "account.shares.table.description": "Description", + "account.shares.table.visitors": "Visitors", + "account.shares.table.expiresAt": "Expires at", + "account.shares.table.createdAt": "Created at", + "account.shares.table.size": "Size", + + "account.shares.modal.share-informations": "Share informations", + "account.shares.modal.share-link": "Share link", + + "account.shares.modal.delete.title": "Delete share {share}", + "account.shares.modal.delete.description": + "Do you really want to delete this share?", + + // END /account/shares + + // /account/reverseShares + "account.reverseShares.title": "Reverse shares", + "account.reverseShares.description": + "A reverse share allows you to generate a unique URL that allows external users to create a share.", + + "account.reverseShares.title.empty": "It's empty here 👀", + "account.reverseShares.description.empty": + "You don't have any reverse shares.", + + // showCreateReverseShareModal.tsx + "account.reverseShares.modal.title": "Create reverse share", + "account.reverseShares.modal.expiration.label": "Expiration", + "account.reverseShares.modal.expiration.minute-singular": "Minute", + "account.reverseShares.modal.expiration.minute-plural": "Minutes", + "account.reverseShares.modal.expiration.hour-singular": "Hour", + "account.reverseShares.modal.expiration.hour-plural": "Hours", + "account.reverseShares.modal.expiration.day-singular": "Day", + "account.reverseShares.modal.expiration.day-plural": "Days", + "account.reverseShares.modal.expiration.week-singular": "Week", + "account.reverseShares.modal.expiration.week-plural": "Weeks", + "account.reverseShares.modal.expiration.month-singular": "Month", + "account.reverseShares.modal.expiration.month-plural": "Months", + "account.reverseShares.modal.expiration.year-singular": "Year", + "account.reverseShares.modal.expiration.year-plural": "Years", + + "account.reverseShares.modal.max-size.label": "Max share size", + + "account.reverseShares.modal.send-email": "Send email notification", + "account.reverseShares.modal.send-email.description": + "Send an email notification when a share is created with this reverse share link.", + + "account.reverseShares.modal.max-use.label": "Max uses", + "account.reverseShares.modal.max-use.description": + "The maximum amount of times this URL can be used to create a share.", + "account.reverseShare.never-expires": "This reverse share will never expire.", + "account.reverseShare.expires-on": + "This reverse share will expire on {expiration}.", + + "account.reverseShares.table.no-shares": "No shares created yet", + "account.reverseShares.table.count.singular": "share", + "account.reverseShares.table.count.plural": "shares", + "account.reverseShares.table.shares": "Shares", + "account.reverseShares.table.remaining": "Remaining uses", + "account.reverseShares.table.max-size": "Max share size", + "account.reverseShares.table.expires": "Expires at", + + "account.reverseShares.modal.reverse-share-link": "Reverse share link", + + "account.reverseShares.modal.delete.title": "Delete reverse share", + "account.reverseShares.modal.delete.description": + "Do you really want to delete this reverse share? If you do, the associated shares will be deleted as well.", + + // END /account/reverseShares + + // /admin + "admin.title": "Administration", + "admin.button.users": "User management", + "admin.button.config": "Configuration", + "admin.version": "Version", + // END /admin + + // /admin/users + "admin.users.title": "User management", + "admin.users.table.username": "Username", + "admin.users.table.email": "Email", + "admin.users.table.admin": "Admin", + + "admin.users.edit.update.title": "Update user {username}", + "admin.users.edit.update.admin-privileges": "Admin privileges", + "admin.users.edit.update.change-password.title": "Change password", + "admin.users.edit.update.change-password.field": "New password", + "admin.users.edit.update.change-password.button": "Save new password", + "admin.users.edit.update.notify.password.success": + "Password changed successfully", + + "admin.users.edit.delete.title": "Delete user {username}", + "admin.users.edit.delete.description": + "Do you really want to delete this user and all his shares?", + + // showCreateUserModal.tsx + "admin.users.modal.create.title": "Create user", + "admin.users.modal.create.username": "Username", + "admin.users.modal.create.email": "Email", + "admin.users.modal.create.password": "Password", + "admin.users.modal.create.manual-password": "Set password manually", + "admin.users.modal.create.manual-password.description": + "If not checked, the user will receive an email with a link to set their password.", + "admin.users.modal.create.admin": "Admin privileges", + "admin.users.modal.create.admin.description": + "If checked, the user will be able to access the admin panel.", + + // END /admin/users + + // /upload + "upload.title": "Upload", + + "upload.notify.generic-error": + "An error occurred while finishing your share.", + "upload.notify.count-failed": "{count} files failed to upload. Trying again.", + + // Dropzone.tsx + "upload.dropzone.title": "Upload files", + "upload.dropzone.description": + "Drag'n'drop files here to start your share. We can accept only files that are less than {maxSize} in total.", + "upload.dropzone.notify.file-too-big": + "Your files exceed the maximum share size of {maxSize}.", + + // FileList.tsx + "upload.filelist.name": "Name", + "upload.filelist.size": "Size", + + // showCreateUploadModal.tsx + "upload.modal.title": "Create Share", + "upload.modal.link.error.invalid": + "Can only contain letters, numbers, underscores, and hyphens", + "upload.modal.link.error.taken": "This link is already in use", + "upload.modal.not-signed-in": "You're not signed in", + "upload.modal.not-signed-in-description": + "You will be unable to delete your share manually and view the visitor count.", + + "upload.modal.expires.never": "never", + "upload.modal.expires.never-long": "Never Expires", + + "upload.modal.link.label": "Link", + "upload.modal.expires.label": "Expiration", + "upload.modal.expires.minute-singular": "Minute", + "upload.modal.expires.minute-plural": "Minutes", + "upload.modal.expires.hour-singular": "Hour", + "upload.modal.expires.hour-plural": "Hours", + "upload.modal.expires.day-singular": "Day", + "upload.modal.expires.day-plural": "Days", + "upload.modal.expires.week-singular": "Week", + "upload.modal.expires.week-plural": "Weeks", + "upload.modal.expires.month-singular": "Month", + "upload.modal.expires.month-plural": "Months", + "upload.modal.expires.year-singular": "Year", + "upload.modal.expires.year-plural": "Years", + + "upload.modal.accordion.description.title": "Description", + "upload.modal.accordion.description.placeholder": + "Note for the recipients of this share", + + "upload.modal.accordion.email.title": "Email recipients", + "upload.modal.accordion.email.placeholder": "Enter email recipients", + "upload.modal.accordion.email.invalid-email": "Invalid email address", + + "upload.modal.accordion.security.title": "Security options", + "upload.modal.accordion.security.password.label": "Password protection", + "upload.modal.accordion.security.password.placeholder": "No password", + "upload.modal.accordion.security.max-views.label": "Maximum views", + "upload.modal.accordion.security.max-views.placeholder": "No limit", + + // showCompletedUploadModal.tsx + "upload.modal.completed.never-expires": "This share will never expire.", + "upload.modal.completed.expires-on": + "This share will expire on {expiration}.", + "upload.modal.completed.share-ready": "Share ready", + + // END /upload + + // /share/[id] + "share.title": "Share {shareId}", + "share.description": "Look what I've shared with you!", + "share.error.visitor-limit-exceeded.title": "Visitor limit exceeded", + "share.error.visitor-limit-exceeded.description": + "The visitor limit from this share has been exceeded.", + "share.error.removed.title": "Share removed", + "share.error.not-found.title": "Share not found", + "share.error.not-found.description": + "The share you're looking for doesn't exist.", + + "share.modal.password.title": "Password required", + "share.modal.password.description": + "To access this share please enter the password for the share.", + "share.modal.password": "Password", + "share.modal.error.invalid-password": "Invalid password", + + "share.button.download-all": "Download all", + "share.notify.download-all-preparing": + "The share is preparing. Try again in a few minutes.", + + "share.modal.file-link": "File link", + "share.table.name": "Name", + "share.table.size": "Size", + + "share.modal.file-preview.error.not-supported.title": "Preview not supported", + "share.modal.file-preview.error.not-supported.description": + "A preview for thise file type is unsupported. Please download the file to view it.", + + // END /share/[id] + + // /admin/config + "admin.config.title": "Configuration", + "admin.config.category.general": "General", + "admin.config.category.share": "Share", + "admin.config.category.email": "Email", + "admin.config.category.smtp": "SMTP", + + "admin.config.general.app-name": "App name", + "admin.config.general.app-name.description": "Name of the application", + "admin.config.general.app-url": "App URL", + "admin.config.general.app-url.description": + "On which URL Pingvin Share is available", + "admin.config.general.show-home-page": "Show home page", + "admin.config.general.show-home-page.description": + "Whether to show the home page", + "admin.config.general.logo": "Logo", + "admin.config.general.logo.description": + "Change your logo by uploading a new image. The image must be a PNG and should have the format 1:1.", + "admin.config.general.logo.placeholder": "Pick image", + + "admin.config.email.enable-share-email-recipients": + "Enable share email recipients", + "admin.config.email.enable-share-email-recipients.description": + "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", + "admin.config.email.share-recipients-subject": "Share recipients subject", + "admin.config.email.share-recipients-subject.description": + "Subject of the email which gets sent to the share recipients.", + "admin.config.email.share-recipients-message": "Share recipients message", + "admin.config.email.share-recipients-message.description": + "Message which gets sent to the share recipients. Available variables:\n {creator} - The username of the creator of the share\n {shareUrl} - The URL of the share\n {desc} - The description of the share\n {expires} - The expiration date of the share\n The variables will be replaced with the actual value.", + "admin.config.email.reverse-share-subject": "Reverse share subject", + "admin.config.email.reverse-share-subject.description": + "Subject of the email which gets sent when someone created a share with your reverse share link.", + "admin.config.email.reverse-share-message": "Reverse share message", + "admin.config.email.reverse-share-message.description": + "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", + "admin.config.email.reset-password-subject": "Reset password subject", + "admin.config.email.reset-password-subject.description": + "Subject of the email which gets sent when a user requests a password reset.", + "admin.config.email.reset-password-message": "Reset password message", + "admin.config.email.reset-password-message.description": + "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", + "admin.config.email.invite-subject": "Invite subject", + "admin.config.email.invite-subject.description": + "Subject of the email which gets sent when an admin invites a user.", + "admin.config.email.invite-message": "Invite message", + "admin.config.email.invite-message.description": + "Message which gets sent when an admin invites a user. {url} will be replaced with the invite URL and {password} with the password.", + "admin.config.share.allow-registration": "Allow registration", + "admin.config.share.allow-registration.description": + "Whether registration is allowed", + "admin.config.share.allow-unauthenticated-shares": + "Allow unauthenticated shares", + "admin.config.share.allow-unauthenticated-shares.description": + "Whether unauthenticated users can create shares", + "admin.config.share.max-size": "Max size", + "admin.config.share.max-size.description": "Maximum share size in bytes", + "admin.config.share.zip-compression-level": "Zip compression level", + "admin.config.share.zip-compression-level.description": + "Adjust the level to balance between file size and compression speed. Valid values range from 0 to 9, with 0 being no compression and 9 being maximum compression. ", + + "admin.config.smtp.enabled": "Enabled", + "admin.config.smtp.enabled.description": + "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + "admin.config.smtp.host": "Host", + "admin.config.smtp.host.description": "Host of the SMTP server", + "admin.config.smtp.port": "Port", + "admin.config.smtp.port.description": "Port of the SMTP server", + "admin.config.smtp.email": "Email", + "admin.config.smtp.email.description": + "Email address which the emails get sent from", + "admin.config.smtp.username": "Username", + "admin.config.smtp.username.description": "Username of the SMTP server", + "admin.config.smtp.password": "Password", + "admin.config.smtp.password.description": "Password of the SMTP server", + "admin.config.smtp.button.test": "Send test email", + + // 404 + "404.description": "Oops this page doesn't exist.", + "404.button.home": "Bring me back home", + + // Common translations + "common.button.save": "Save", + "common.button.create": "Create", + "common.button.submit": "Submit", + "common.button.delete": "Delete", + "common.button.cancel": "Cancel", + "common.button.confirm": "Confirm", + "common.button.disable": "Disable", + "common.button.share": "Share", + "common.button.generate": "Generate", + "common.button.done": "Done", + "common.text.link": "Link", + "common.text.or": "or", + "common.button.go-back": "Go back", + "common.notify.copied": "Your link was copied to the clipboard", + "common.success": "Success", + + "common.error": "Error", + "common.error.unknown": "An unknown error occurred", + "common.error.invalid-email": "Invalid email address", + "common.error.too-short": "Must be at least {length} characters", + "common.error.too-long": "Must be at most {length} characters", + "common.error.exact-length": "Must be exactly {length} characters", + "common.error.invalid-number": "Must be a number", + "common.error.field-required": "This field is required", +}; From 8827ab622fb8647dce6a065e7929607eaa5fe6f2 Mon Sep 17 00:00:00 2001 From: Qing Fu <16662647+zz5840@users.noreply.github.com> Date: Thu, 12 Oct 2023 20:59:44 +0800 Subject: [PATCH 20/50] fix(oauth): fix random username and password --- backend/src/auth/auth.service.ts | 9 ++---- backend/src/oauth/oauth.service.ts | 22 ++++++++++++-- frontend/src/components/upload/Dropzone.tsx | 2 +- .../upload/modals/showCreateUploadModal.tsx | 16 +++++----- frontend/src/pages/account/index.tsx | 20 ++++++------- .../src/pages/admin/config/[category].tsx | 2 +- frontend/src/pages/upload/index.tsx | 14 ++++----- frontend/src/services/auth.service.ts | 4 +-- frontend/src/utils/oauth.util.tsx | 30 ++++++++++--------- 9 files changed, 66 insertions(+), 53 deletions(-) diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 38d8c499f..cb805bb98 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; +import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -28,7 +23,7 @@ export class AuthService { async signUp(dto: AuthRegisterDTO) { const isFirstUser = (await this.prisma.user.count()) == 0; - const hash = await argon.hash(dto.password); + const hash = dto.password ? await argon.hash(dto.password) : null; try { const user = await this.prisma.user.create({ data: { diff --git a/backend/src/oauth/oauth.service.ts b/backend/src/oauth/oauth.service.ts index d915d32e4..26081331b 100644 --- a/backend/src/oauth/oauth.service.ts +++ b/backend/src/oauth/oauth.service.ts @@ -3,8 +3,8 @@ import { PrismaService } from "../prisma/prisma.service"; import { ConfigService } from "../config/config.service"; import { AuthService } from "../auth/auth.service"; import { User } from "@prisma/client"; -import { nanoid } from "nanoid"; import { OAuthSignInDto } from "./dto/oauthSignIn.dto"; +import { nanoid } from "nanoid"; @Injectable() @@ -93,6 +93,23 @@ export class OAuthService { } } + private async getAvailableUsername(email: string) { + // only remove + and - from email for now (maybe not enough) + let username = email.split("@")[0].replace(/[+-]/g, "").substring(0, 20); + while (true) { + const user = await this.prisma.user.findFirst({ + where: { + username: username, + }, + }); + if (user) { + username = username + "_" + nanoid(10).replaceAll("-", ""); + } else { + return username; + } + } + } + private async signUp(user: OAuthSignInDto) { // register if (!this.config.get("oauth.allowRegistration")) { @@ -121,10 +138,9 @@ export class OAuthService { return this.auth.generateToken(existingUser, true); } - // TODO user registered by oauth will hava a random password and username const result = await this.auth.signUp({ email: user.email, - username: nanoid().replaceAll("-", ''), + username: await this.getAvailableUsername(user.email), password: null, }); diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index f2e4bce1e..1c834fd20 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -60,7 +60,7 @@ const Dropzone = ({ toast.error( t("upload.dropzone.notify.file-too-big", { maxSize: byteToHumanSizeString(maxShareSize), - }) + }), ); } else { files = files.map((newFile) => { diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index 65cda5080..1ffa1cc1e 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -40,7 +40,7 @@ const showCreateUploadModal = ( enableEmailRecepients: boolean; }, files: FileUpload[], - uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void + uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void, ) => { const t = translateOutsideContext(); @@ -137,7 +137,7 @@ const CreateUploadModalBody = ({ maxViews: values.maxViews, }, }, - files + files, ); modals.closeAll(); } @@ -160,7 +160,7 @@ const CreateUploadModalBody = ({ "link", Buffer.from(Math.random().toString(), "utf8") .toString("base64") - .substr(10, 7) + .substr(10, 7), ) } > @@ -259,7 +259,7 @@ const CreateUploadModalBody = ({ neverExpires: t("upload.modal.completed.never-expires"), expiresOn: t("upload.modal.completed.expires-on"), }, - form + form, )} @@ -274,7 +274,7 @@ const CreateUploadModalBody = ({