diff --git a/Dockerfile b/Dockerfile index 27dc1b1a..ba3893ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN npm run build # Stage 3: Backend dependencies FROM node:20-alpine AS backend-dependencies +RUN apk add --no-cache python3 WORKDIR /opt/app COPY backend/package.json backend/package-lock.json ./ RUN npm ci diff --git a/README.md b/README.md index e36ef7fa..974d1a23 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ 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). +#### OAuth 2 Login + +View the [OAuth 2 guide](/docs/oauth2-guide.md) for more information. + ### 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 7b2424e6..11775939 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "pingvin-share-backend", "version": "0.18.2", "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", @@ -28,6 +30,8 @@ "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", @@ -52,6 +56,7 @@ "@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-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", @@ -622,6 +627,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", @@ -1438,6 +1455,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", @@ -2525,6 +2552,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", @@ -5248,6 +5292,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", @@ -5572,6 +5621,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 +5793,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" }, @@ -7833,7 +7893,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": { @@ -8235,7 +8295,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 +8365,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", @@ -8951,6 +9011,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", @@ -9542,6 +9608,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", @@ -10386,6 +10462,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", @@ -12394,6 +12486,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", @@ -12636,6 +12733,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 +12869,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" } @@ -14306,7 +14408,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": { @@ -14586,7 +14688,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 +14737,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 f3a32dbd..bb50e967 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", @@ -33,6 +35,8 @@ "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", @@ -57,6 +61,7 @@ "@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-jwt": "^3.0.9", "@types/qrcode-svg": "^1.1.1", diff --git a/backend/prisma/migrations/20231021165436_oauth/migration.sql b/backend/prisma/migrations/20231021165436_oauth/migration.sql new file mode 100644 index 00000000..4887b726 --- /dev/null +++ b/backend/prisma/migrations/20231021165436_oauth/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "OAuthUser" ( + "id" TEXT NOT NULL PRIMARY KEY, + "provider" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerUsername" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "OAuthUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "totpEnabled" BOOLEAN NOT NULL DEFAULT false, + "totpVerified" BOOLEAN NOT NULL DEFAULT false, + "totpSecret" TEXT +); +INSERT INTO "new_User" ("createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username") SELECT "createdAt", "email", "id", "isAdmin", "password", "totpEnabled", "totpSecret", "totpVerified", "updatedAt", "username" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index de898d35..c83a9f04 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[] @@ -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 b7a78f4b..e48b591d 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -119,6 +119,89 @@ const configVariables: ConfigVariables = { obscured: true, }, }, + oauth: { + "allowRegistration": { + type: "boolean", + defaultValue: "true", + }, + "ignoreTotp": { + type: "boolean", + defaultValue: "true", + }, + "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, + }, + "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", + }, + "oidc-discoveryUri": { + type: "string", + defaultValue: "", + }, + "oidc-clientId": { + type: "string", + defaultValue: "", + }, + "oidc-clientSecret": { + type: "string", + defaultValue: "", + obscured: true, + }, + } }; type ConfigVariables = { @@ -175,7 +258,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 985d7954..94942457 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,8 @@ import { UserModule } from "./user/user.module"; import { ClamScanModule } from "./clamscan/clamscan.module"; import { ReverseShareModule } from "./reverseShare/reverseShare.module"; import { AppController } from "./app.controller"; +import { OAuthModule } from "./oauth/oauth.module"; +import { CacheModule } from "@nestjs/cache-manager"; @Module({ imports: [ @@ -33,10 +35,12 @@ import { AppController } from "./app.controller"; ScheduleModule.forRoot(), ClamScanModule, ReverseShareModule, + OAuthModule, + CacheModule.register({ + isGlobal: true, + }), ], - controllers:[ - AppController, - ], + controllers: [AppController], providers: [ { provide: APP_GUARD, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 9f37f18b..1816e7bf 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( + 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 = this.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -85,7 +85,7 @@ export class AuthController { ) { const result = await this.authTotpService.signInTotp(dto); - response = this.addTokensToResponse( + this.authService.addTokensToResponse( response, result.refreshToken, result.accessToken, @@ -117,11 +117,11 @@ export class AuthController { ) { const result = await this.authService.updatePassword( user, - dto.oldPassword, dto.password, + dto.oldPassword, ); - response = this.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 = this.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); } - - private 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.module.ts b/backend/src/auth/auth.module.ts index 56204d13..a96ab2fa 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/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 27d2edc0..d46c14c8 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -8,6 +8,7 @@ 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"; @@ -27,7 +28,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: { @@ -43,7 +44,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 +70,16 @@ 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 }; @@ -129,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); @@ -210,4 +220,38 @@ 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, null otherwise + */ + async getIdOfCurrentUser(request: Request): Promise { + if (!request.cookies.access_token) return null; + try { + const payload = await this.jwtService.verifyAsync( + request.cookies.access_token, + { + secret: this.config.get("internal.jwtSecret"), + }, + ); + return payload.sub; + } catch { + return null; + } + } } diff --git a/backend/src/auth/authTotp.service.ts b/backend/src/auth/authTotp.service.ts index 3760ab8a..1b4f0d42 100644 --- a/backend/src/auth/authTotp.service.ts +++ b/backend/src/auth/authTotp.service.ts @@ -22,43 +22,29 @@ export class AuthTotpService { ) {} async signInTotp(dto: AuthSignInTotpDTO) { - if (!dto.email && !dto.username) - throw new BadRequestException("Email or username is required"); - - const user = await this.prisma.user.findFirst({ - where: { - OR: [{ email: dto.email }, { username: dto.username }], - }, - }); - - if (!user || !(await argon.verify(user.password, dto.password))) - throw new UnauthorizedException("Wrong email or password"); - const token = await this.prisma.loginToken.findFirst({ where: { token: dto.loginToken, }, + include: { + user: true, + }, }); - if (!token || token.userId != user.id || token.used) + if (!token || token.used) throw new UnauthorizedException("Invalid login token"); if (token.expiresAt < new Date()) throw new UnauthorizedException("Login token expired", "token_expired"); // Check the TOTP code - const { totpSecret } = await this.prisma.user.findUnique({ - where: { id: user.id }, - select: { totpSecret: true }, - }); + const { totpSecret } = token.user; if (!totpSecret) { throw new BadRequestException("TOTP is not enabled"); } - const expected = authenticator.generate(totpSecret); - - if (dto.totp !== expected) { + if (!authenticator.check(dto.totp, totpSecret)) { throw new BadRequestException("Invalid code"); } @@ -69,9 +55,9 @@ export class AuthTotpService { }); const { refreshToken, refreshTokenId } = - await this.authService.createRefreshToken(user.id); + await this.authService.createRefreshToken(token.user.id); const accessToken = await this.authService.createAccessToken( - user, + token.user, refreshTokenId, ); diff --git a/backend/src/auth/dto/authSignInTotp.dto.ts b/backend/src/auth/dto/authSignInTotp.dto.ts index 835b5913..5ba96db6 100644 --- a/backend/src/auth/dto/authSignInTotp.dto.ts +++ b/backend/src/auth/dto/authSignInTotp.dto.ts @@ -1,7 +1,7 @@ import { IsString } from "class-validator"; import { AuthSignInDTO } from "./authSignIn.dto"; -export class AuthSignInTotpDTO extends AuthSignInDTO { +export class AuthSignInTotpDTO { @IsString() totp: string; diff --git a/backend/src/auth/dto/updatePassword.dto.ts b/backend/src/auth/dto/updatePassword.dto.ts index ee6b0e07..c154785a 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/config/config.service.ts b/backend/src/config/config.service.ts index 431a127e..a5e02a76 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/config/logo.service.ts b/backend/src/config/logo.service.ts index 0b81937e..e975d68b 100644 --- a/backend/src/config/logo.service.ts +++ b/backend/src/config/logo.service.ts @@ -26,7 +26,7 @@ export class LogoService { fs.promises.writeFile( `${IMAGES_PATH}/icons/icon-${size}x${size}.png`, resized, - "binary" + "binary", ); } } diff --git a/backend/src/oauth/dto/oauthCallback.dto.ts b/backend/src/oauth/dto/oauthCallback.dto.ts new file mode 100644 index 00000000..656b63a3 --- /dev/null +++ b/backend/src/oauth/dto/oauthCallback.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from "class-validator"; + +export class OAuthCallbackDto { + @IsString() + code: string; + + @IsString() + state: string; +} diff --git a/backend/src/oauth/dto/oauthSignIn.dto.ts b/backend/src/oauth/dto/oauthSignIn.dto.ts new file mode 100644 index 00000000..75a64ad3 --- /dev/null +++ b/backend/src/oauth/dto/oauthSignIn.dto.ts @@ -0,0 +1,6 @@ +export interface OAuthSignInDto { + provider: "github" | "google" | "microsoft" | "discord" | "oidc"; + providerId: string; + providerUsername: string; + email: string; +} diff --git a/backend/src/oauth/exceptions/errorPage.exception.ts b/backend/src/oauth/exceptions/errorPage.exception.ts new file mode 100644 index 00000000..6ebb10a6 --- /dev/null +++ b/backend/src/oauth/exceptions/errorPage.exception.ts @@ -0,0 +1,15 @@ +export class ErrorPageException extends Error { + /** + * Exception for redirecting to error page (all i18n key should omit `error.msg` and `error.param` prefix) + * @param key i18n key of message + * @param redirect redirect url + * @param params message params (key) + */ + constructor( + public readonly key: string = "default", + public readonly redirect: string = "/", + public readonly params?: string[], + ) { + super("error"); + } +} diff --git a/backend/src/oauth/filter/errorPageException.filter.ts b/backend/src/oauth/filter/errorPageException.filter.ts new file mode 100644 index 00000000..bdbbcf53 --- /dev/null +++ b/backend/src/oauth/filter/errorPageException.filter.ts @@ -0,0 +1,22 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; +import { ErrorPageException } from "../exceptions/errorPage.exception"; + +@Catch(ErrorPageException) +export class ErrorPageExceptionFilter implements ExceptionFilter { + constructor(private config: ConfigService) {} + + catch(exception: ErrorPageException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const url = new URL(`${this.config.get("general.appUrl")}/error`); + url.searchParams.set("redirect", exception.redirect); + url.searchParams.set("error", exception.key); + if (exception.params) { + url.searchParams.set("params", exception.params.join(",")); + } + + response.redirect(url.toString()); + } +} diff --git a/backend/src/oauth/filter/oauthException.filter.ts b/backend/src/oauth/filter/oauthException.filter.ts new file mode 100644 index 00000000..42f78d77 --- /dev/null +++ b/backend/src/oauth/filter/oauthException.filter.ts @@ -0,0 +1,31 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, +} from "@nestjs/common"; +import { ConfigService } from "../../config/config.service"; + +@Catch(HttpException) +export class OAuthExceptionFilter implements ExceptionFilter { + private errorKeys: Record = { + access_denied: "access_denied", + expired_token: "expired_token", + }; + + constructor(private config: ConfigService) {} + + catch(_exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const key = this.errorKeys[request.query.error] || "default"; + + const url = new URL(`${this.config.get("general.appUrl")}/error`); + url.searchParams.set("redirect", "/account"); + url.searchParams.set("error", key); + + response.redirect(url.toString()); + } +} diff --git a/backend/src/oauth/guard/oauth.guard.ts b/backend/src/oauth/guard/oauth.guard.ts new file mode 100644 index 00000000..32d444f6 --- /dev/null +++ b/backend/src/oauth/guard/oauth.guard.ts @@ -0,0 +1,12 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; + +@Injectable() +export class OAuthGuard implements CanActivate { + constructor() {} + + canActivate(context: ExecutionContext): boolean { + 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 00000000..a115c735 --- /dev/null +++ b/backend/src/oauth/guard/provider.guard.ts @@ -0,0 +1,24 @@ +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 new file mode 100644 index 00000000..63220d94 --- /dev/null +++ b/backend/src/oauth/oauth.controller.ts @@ -0,0 +1,110 @@ +import { + Controller, + Get, + Inject, + Param, + Post, + Query, + Req, + Res, + UseFilters, + UseGuards, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { Request, Response } from "express"; +import { nanoid } from "nanoid"; +import { AuthService } from "../auth/auth.service"; +import { GetUser } from "../auth/decorator/getUser.decorator"; +import { JwtGuard } from "../auth/guard/jwt.guard"; +import { ConfigService } from "../config/config.service"; +import { OAuthCallbackDto } from "./dto/oauthCallback.dto"; +import { ErrorPageExceptionFilter } from "./filter/errorPageException.filter"; +import { OAuthGuard } from "./guard/oauth.guard"; +import { ProviderGuard } from "./guard/provider.guard"; +import { OAuthService } from "./oauth.service"; +import { OAuthProvider } from "./provider/oauthProvider.interface"; +import { OAuthExceptionFilter } from "./filter/oauthException.filter"; + +@Controller("oauth") +export class OAuthController { + constructor( + private authService: AuthService, + private oauthService: OAuthService, + private config: ConfigService, + @Inject("OAUTH_PROVIDERS") + private providers: Record>, + ) {} + + @Get("available") + available() { + return this.oauthService.available(); + } + + @Get("status") + @UseGuards(JwtGuard) + async status(@GetUser() user: User) { + return this.oauthService.status(user); + } + + @Get("auth/:provider") + @UseGuards(ProviderGuard) + @UseFilters(ErrorPageExceptionFilter) + async auth( + @Param("provider") provider: string, + @Res({ passthrough: true }) response: Response, + ) { + const state = nanoid(16); + const url = await this.providers[provider].getAuthEndpoint(state); + response.cookie(`oauth_${provider}_state`, state, { sameSite: "lax" }); + response.redirect(url); + } + + @Get("callback/:provider") + @UseGuards(ProviderGuard, OAuthGuard) + @UseFilters(ErrorPageExceptionFilter, OAuthExceptionFilter) + async callback( + @Param("provider") provider: string, + @Query() query: OAuthCallbackDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const oauthToken = await this.providers[provider].getToken(query); + const user = await this.providers[provider].getUserInfo(oauthToken, query); + const id = await this.authService.getIdOfCurrentUser(request); + + if (id) { + await this.oauthService.link( + id, + provider, + user.providerId, + user.providerUsername, + ); + response.redirect(this.config.get("general.appUrl") + "/account"); + } else { + const token: { + accessToken?: string; + refreshToken?: string; + loginToken?: string; + } = await this.oauthService.signIn(user); + if (token.accessToken) { + this.authService.addTokensToResponse( + response, + token.refreshToken, + token.accessToken, + ); + response.redirect(this.config.get("general.appUrl")); + } else { + response.redirect( + this.config.get("general.appUrl") + `/auth/totp/${token.loginToken}`, + ); + } + } + } + + @Post("unlink/:provider") + @UseGuards(JwtGuard, ProviderGuard) + @UseFilters(ErrorPageExceptionFilter) + 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 new file mode 100644 index 00000000..bdb22b1e --- /dev/null +++ b/backend/src/oauth/oauth.module.ts @@ -0,0 +1,56 @@ +import { Module } from "@nestjs/common"; +import { OAuthController } from "./oauth.controller"; +import { OAuthService } from "./oauth.service"; +import { AuthModule } from "../auth/auth.module"; +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], + providers: [ + OAuthService, + GitHubProvider, + GoogleProvider, + MicrosoftProvider, + DiscordProvider, + OidcProvider, + { + provide: "OAUTH_PROVIDERS", + useFactory( + github: GitHubProvider, + google: GoogleProvider, + microsoft: MicrosoftProvider, + discord: DiscordProvider, + oidc: OidcProvider, + ): Record> { + return { + github, + google, + microsoft, + discord, + oidc, + }; + }, + inject: [ + GitHubProvider, + GoogleProvider, + MicrosoftProvider, + DiscordProvider, + OidcProvider, + ], + }, + { + provide: "OAUTH_PLATFORMS", + useFactory(providers: Record>): string[] { + return Object.keys(providers); + }, + inject: ["OAUTH_PROVIDERS"], + }, + ], + 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 00000000..0a374ec2 --- /dev/null +++ b/backend/src/oauth/oauth.service.ts @@ -0,0 +1,171 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; +import { nanoid } from "nanoid"; +import { AuthService } from "../auth/auth.service"; +import { ConfigService } from "../config/config.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { OAuthSignInDto } from "./dto/oauthSignIn.dto"; +import { ErrorPageException } from "./exceptions/errorPage.exception"; + +@Injectable() +export class OAuthService { + constructor( + private prisma: PrismaService, + private config: ConfigService, + private auth: AuthService, + @Inject("OAUTH_PLATFORMS") private platforms: string[], + ) {} + + available(): string[] { + return this.platforms + .map((platform) => [ + platform, + this.config.get(`oauth.${platform}-enabled`), + ]) + .filter(([_, enabled]) => enabled) + .map(([platform, _]) => platform); + } + + 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 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); + } + + async link( + userId: string, + provider: string, + providerUserId: string, + providerUsername: string, + ) { + const oauthUser = await this.prisma.oAuthUser.findFirst({ + where: { + provider, + providerUserId, + }, + }); + if (oauthUser) { + throw new ErrorPageException("already_linked", "/account", [ + `provider_${provider}`, + ]); + } + + 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 ErrorPageException("not_linked", "/account", [provider]); + } + } + + 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")) { + throw new ErrorPageException("no_user", "/auth/signIn", [ + `provider_${user.provider}`, + ]); + } + + if (!user.email) { + throw new ErrorPageException("no_email", "/auth/signIn", [ + `provider_${user.provider}`, + ]); + } + + 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); + } + + const result = await this.auth.signUp({ + email: user.email, + username: await this.getAvailableUsername(user.email), + password: null, + }); + + await this.prisma.oAuthUser.create({ + data: { + provider: user.provider, + providerUserId: user.providerId.toString(), + providerUsername: user.providerUsername, + userId: result.user.id, + }, + }); + + return result; + } +} diff --git a/backend/src/oauth/provider/discord.provider.ts b/backend/src/oauth/provider/discord.provider.ts new file mode 100644 index 00000000..b14e5d94 --- /dev/null +++ b/backend/src/oauth/provider/discord.provider.ts @@ -0,0 +1,98 @@ +import { OAuthProvider, OAuthToken } 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(query: OAuthCallbackDto): 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: query.code, + grant_type: "authorization_code", + redirect_uri: + this.config.get("general.appUrl") + "/api/oauth/callback/discord", + }), + }); + const token: DiscordToken = await res.json(); + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresIn: token.expires_in, + scope: token.scope, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo(token: OAuthToken): Promise { + const res = await fetch("https://discord.com/api/v10/user/@me", { + method: "post", + headers: { + Accept: "application/json", + Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`, + }, + }); + 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, + }; + } +} + +export interface DiscordToken { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +export 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 new file mode 100644 index 00000000..72a1f4b1 --- /dev/null +++ b/backend/src/oauth/provider/genericOidc.provider.ts @@ -0,0 +1,206 @@ +import { BadRequestException } from "@nestjs/common"; +import fetch from "node-fetch"; +import { ConfigService } from "../../config/config.service"; +import { JwtService } from "@nestjs/jwt"; +import { Cache } from "cache-manager"; +import { nanoid } from "nanoid"; +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthProvider, OAuthToken } 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; + + protected constructor( + protected name: 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) => { + if (this.keyOfConfigUpdateEvents.includes(key)) { + this.deinit(); + this.discoveryUri = this.getDiscoveryUri(); + } + }); + } + + 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; + + 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() + ); + } + + async getToken(query: OAuthCallbackDto): 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: query.code, + redirect_uri: this.redirectUri, + }).toString(), + }); + const token: OidcToken = await res.json(); + return { + accessToken: token.access_token, + expiresIn: token.expires_in, + idToken: token.id_token, + refreshToken: token.refresh_token, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo( + token: OAuthToken, + query: OAuthCallbackDto, + ): Promise { + const idTokenData = this.decodeIdToken(token.idToken); + // 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, + providerId: idTokenData.sub, + providerUsername: idTokenData.name, + }; + } + + 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; + 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 { + 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; + nonce: string; +} diff --git a/backend/src/oauth/provider/github.provider.ts b/backend/src/oauth/provider/github.provider.ts new file mode 100644 index 00000000..bf1ae622 --- /dev/null +++ b/backend/src/oauth/provider/github.provider.ts @@ -0,0 +1,110 @@ +import { OAuthProvider, OAuthToken } 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(query: OAuthCallbackDto): 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: query.code, + }).toString(), + { + method: "post", + headers: { + Accept: "application/json", + }, + }, + ); + const token: GitHubToken = await res.json(); + return { + accessToken: token.access_token, + tokenType: token.token_type, + rawToken: token, + }; + } + + async getUserInfo(token: OAuthToken): Promise { + 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.name ?? user.login, + email, + }; + } + + private async getGitHubUser( + token: OAuthToken, + ): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`, + }, + }); + return (await res.json()) as GitHubUser; + } + + private async getGitHubEmail( + token: OAuthToken, + ): Promise { + const res = await fetch("https://api.github.com/user/public_emails", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `${token.tokenType ?? "Bearer"} ${token.accessToken}`, + }, + }); + const emails = (await res.json()) as GitHubEmail[]; + return emails.find((e) => e.primary && e.verified)?.email; + } +} + +export interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +export interface GitHubUser { + login: string; + id: number; + name?: string; + email?: string; // this filed seems only return null +} + +export interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} diff --git a/backend/src/oauth/provider/google.provider.ts b/backend/src/oauth/provider/google.provider.ts new file mode 100644 index 00000000..5c24dff3 --- /dev/null +++ b/backend/src/oauth/provider/google.provider.ts @@ -0,0 +1,21 @@ +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", ["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/microsoft.provider.ts b/backend/src/oauth/provider/microsoft.provider.ts new file mode 100644 index 00000000..42262b32 --- /dev/null +++ b/backend/src/oauth/provider/microsoft.provider.ts @@ -0,0 +1,29 @@ +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`; + } +} diff --git a/backend/src/oauth/provider/oauthProvider.interface.ts b/backend/src/oauth/provider/oauthProvider.interface.ts new file mode 100644 index 00000000..4ede0995 --- /dev/null +++ b/backend/src/oauth/provider/oauthProvider.interface.ts @@ -0,0 +1,24 @@ +import { OAuthCallbackDto } from "../dto/oauthCallback.dto"; +import { OAuthSignInDto } from "../dto/oauthSignIn.dto"; + +/** + * @typeParam T - type of token + * @typeParam C - type of callback query + */ +export interface OAuthProvider { + getAuthEndpoint(state: string): Promise; + + getToken(query: C): Promise>; + + getUserInfo(token: OAuthToken, query: C): Promise; +} + +export interface OAuthToken { + accessToken: string; + expiresIn?: number; + refreshToken?: string; + tokenType?: string; + scope?: string; + idToken?: string; + rawToken: T; +} diff --git a/backend/src/oauth/provider/oidc.provider.ts b/backend/src/oauth/provider/oidc.provider.ts new file mode 100644 index 00000000..8a123381 --- /dev/null +++ b/backend/src/oauth/provider/oidc.provider.ts @@ -0,0 +1,27 @@ +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", + ["oauth.oidc-enabled", "oauth.oidc-discoveryUri"], + config, + jwtService, + cache, + ); + } + + protected getDiscoveryUri(): string { + return this.config.get("oauth.oidc-discoveryUri"); + } +} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index 5207f552..b11d5d7f 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 1256120f..b55ea665 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -28,7 +28,9 @@ 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") diff --git a/backend/tsconfig.json b/backend/tsconfig.json index adb614ca..46ac521d 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/docs/oauth2-guide.md b/docs/oauth2-guide.md new file mode 100644 index 00000000..bcc99ea8 --- /dev/null +++ b/docs/oauth2-guide.md @@ -0,0 +1,168 @@ +# OAuth 2 Login Guide + +## Config Built-in OAuth 2 Providers + +- [GitHub](#github) +- [Google](#google) +- [Microsoft](#microsoft) +- [Discord](#discord) +- [OpenID Connect](#openid-connect) + +### 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 + +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). + +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 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. + +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 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. + +### 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' | 'microsoft' | 'discord' | 'oidc' /* your provider*/ + ; + 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. 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/components/admin/configuration/ConfigurationNavBar.tsx b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx index c749581c..9df1a19c 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 756a99dc..e5bd96b6 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, @@ -18,19 +20,47 @@ 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 { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util"; import toast from "../../utils/toast.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")), @@ -44,7 +74,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { initialValues: { emailOrUsername: "", password: "", - totp: "", }, validate: yupResolver(validationSchema), }); @@ -55,7 +84,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { .then(async (response) => { if (response.data["loginToken"]) { // Prompt the user to enter their totp code - setShowTotp(true); showNotification({ icon: , color: "blue", @@ -63,7 +91,11 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { title: t("signIn.notify.totp-required.title"), message: t("signIn.notify.totp-required.description"), }); - setLoginToken(response.data["loginToken"]); + router.push( + `/auth/totp/${ + response.data["loginToken"] + }?redirect=${encodeURIComponent(redirectPath)}`, + ); } else { await refreshUser(); router.replace(redirectPath); @@ -72,25 +104,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { .catch(toast.axiosError); }; - const signInTotp = (email: string, password: string, totp: string) => { - authService - .signInTotp(email, password, totp, loginToken) - .then(async () => { - await refreshUser(); - router.replace(redirectPath); - }) - .catch((error) => { - if (error?.response?.data?.error == "share_password_required") { - toast.axiosError(error); - // Refresh the page to start over - window.location.reload(); - } - - toast.axiosError(error); - form.setValues({ totp: "" }); - }); + const getAvailableOAuth = async () => { + const oauth = await authService.getAvailableOAuth(); + setOAuth(oauth.data); }; + React.useEffect(() => { + getAvailableOAuth().catch(toast.axiosError); + }, []); + return ( @@ -107,9 +129,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { <Paper withBorder shadow="md" p={30} mt={30} radius="md"> <form onSubmit={form.onSubmit((values) => { - if (showTotp) - signInTotp(values.emailOrUsername, values.password, values.totp); - else signIn(values.emailOrUsername, values.password); + signIn(values.emailOrUsername, values.password); })} > <TextInput @@ -123,15 +143,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { mt="md" {...form.getInputProps("password")} /> - {showTotp && ( - <TextInput - variant="filled" - label={t("account.modal.totp.code")} - placeholder="******" - mt="md" - {...form.getInputProps("totp")} - /> - )} {config.get("smtp.enabled") && ( <Group position="right" mt="xs"> <Anchor component={Link} href="/auth/resetPassword" size="xs"> @@ -143,6 +154,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 + key={provider} + 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/components/auth/TotpForm.tsx b/frontend/src/components/auth/TotpForm.tsx new file mode 100644 index 00000000..7fdf2edf --- /dev/null +++ b/frontend/src/components/auth/TotpForm.tsx @@ -0,0 +1,84 @@ +import { + Button, + Container, + Group, + Paper, + PinInput, + Title, +} from "@mantine/core"; +import { FormattedMessage } from "react-intl"; +import * as yup from "yup"; +import useTranslate from "../../hooks/useTranslate.hook"; +import { useForm, yupResolver } from "@mantine/form"; +import { useState } from "react"; +import authService from "../../services/auth.service"; +import toast from "../../utils/toast.util"; +import { useRouter } from "next/router"; +import useUser from "../../hooks/user.hook"; + +function TotpForm({ redirectPath }: { redirectPath: string }) { + const t = useTranslate(); + const router = useRouter(); + const { refreshUser } = useUser(); + + const [loading, setLoading] = useState(false); + + const validationSchema = yup.object().shape({ + code: yup + .string() + .min(6, t("common.error.too-short", { length: 6 })) + .required(t("common.error.field-required")), + }); + + const form = useForm({ + initialValues: { + code: "", + }, + validate: yupResolver(validationSchema), + }); + + const onSubmit = async () => { + if (loading) return; + setLoading(true); + try { + await authService.signInTotp( + form.values.code, + router.query.loginToken as string, + ); + await refreshUser(); + await router.replace(redirectPath); + } catch (e) { + toast.axiosError(e); + form.setFieldError("code", "error"); + } finally { + setLoading(false); + } + }; + + return ( + <Container size={420} my={40}> + <Title order={2} align="center" weight={900}> + <FormattedMessage id="totp.title" /> + + +
+ + + + +
+
+
+ ); +} + +export default TotpForm; diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index f2e4bce1..1c834fd2 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 65cda508..1ffa1cc1 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 = ({