mirror of
https://github.com/stonith404/pingvin-share.git
synced 2024-11-05 15:30:14 +01:00
feat(auth): add OAuth2 login (#276)
* feat(auth): add OAuth2 login with GitHub and Google * chore(translations): add files for Japanese * fix(auth): fix link function for GitHub * feat(oauth): basic oidc implementation * feat(oauth): oauth guard * fix: disable image optimizations for logo to prevent caching issues with custom logos * fix: memory leak while downloading large files * 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) * release: 0.18.2 * 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. * feat(oauth): unlink account * refactor(oauth): make providers extensible * fix(oauth): fix discoveryUri error when toggle google-enabled * feat(oauth): add microsoft and discord as oauth provider * docs(oauth): update README.md * docs(oauth): update oauth2-guide.md * set password to null for new oauth users * New translations en-us.ts (Japanese) (#281) * chore(translations): add Polish files * fix(oauth): fix random username and password * feat(oauth): add totp * fix(oauth): fix totp throttle * fix(oauth): fix qrcode and remove comment * feat(oauth): add error page * fix(oauth): i18n of error page * feat(auth): add OAuth2 login * fix(auth): fix link function for GitHub * feat(oauth): basic oidc implementation * feat(oauth): oauth guard * feat(oauth): unlink account * refactor(oauth): make providers extensible * fix(oauth): fix discoveryUri error when toggle google-enabled * feat(oauth): add microsoft and discord as oauth provider * docs(oauth): update README.md * docs(oauth): update oauth2-guide.md * set password to null for new oauth users * fix(oauth): fix random username and password * feat(oauth): add totp * fix(oauth): fix totp throttle * fix(oauth): fix qrcode and remove comment * feat(oauth): add error page * fix(oauth): i18n of error page * refactor: return null instead of `false` in `getIdOfCurrentUser` functiom * feat: show original oauth error if available * refactor: run formatter * refactor(oauth): error message i18n * refactor(oauth): make OAuth token available someone may use it (to revoke token or get other info etc.) also improved the i18n message * chore(oauth): remove unused import * chore: add database migration * fix: missing python installation for nanoid --------- Co-authored-by: Elias Schneider <login@eliasschneider.com> Co-authored-by: ふうせん <10260662+fusengum@users.noreply.github.com>
This commit is contained in:
parent
d327bc355c
commit
02cd98fa9c
@ -13,6 +13,7 @@ RUN npm run build
|
|||||||
|
|
||||||
# Stage 3: Backend dependencies
|
# Stage 3: Backend dependencies
|
||||||
FROM node:20-alpine AS backend-dependencies
|
FROM node:20-alpine AS backend-dependencies
|
||||||
|
RUN apk add --no-cache python3
|
||||||
WORKDIR /opt/app
|
WORKDIR /opt/app
|
||||||
COPY backend/package.json backend/package-lock.json ./
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
@ -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).
|
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
|
### Additional resources
|
||||||
|
|
||||||
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
- [Synology NAS installation](https://mariushosting.com/how-to-install-pingvin-share-on-your-synology-nas/)
|
||||||
|
126
backend/package-lock.json
generated
126
backend/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.18.2",
|
"version": "0.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^2.1.0",
|
||||||
"@nestjs/common": "^10.1.2",
|
"@nestjs/common": "^10.1.2",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.1.2",
|
"@nestjs/core": "^10.1.2",
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"argon2": "^0.30.3",
|
"argon2": "^0.30.3",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"cache-manager": "^5.2.4",
|
||||||
"clamscan": "^2.1.2",
|
"clamscan": "^2.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
@ -28,6 +30,8 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
"nanoid": "^3.3.6",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.9.4",
|
"nodemailer": "^6.9.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
@ -52,6 +56,7 @@
|
|||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.4.5",
|
"@types/node": "^20.4.5",
|
||||||
|
"@types/node-fetch": "^2.6.6",
|
||||||
"@types/nodemailer": "^6.4.9",
|
"@types/nodemailer": "^6.4.9",
|
||||||
"@types/passport-jwt": "^3.0.9",
|
"@types/passport-jwt": "^3.0.9",
|
||||||
"@types/qrcode-svg": "^1.1.1",
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
@ -622,6 +627,18 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "10.1.10",
|
"version": "10.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
||||||
"integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg=="
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.9",
|
"version": "6.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
||||||
@ -2525,6 +2552,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
"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": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
@ -5572,6 +5621,17 @@
|
|||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||||
@ -5733,9 +5793,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.6.7",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
|
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"whatwg-url": "^5.0.0"
|
"whatwg-url": "^5.0.0"
|
||||||
},
|
},
|
||||||
@ -7833,7 +7893,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"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=="
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||||
},
|
},
|
||||||
"node_modules/tree-kill": {
|
"node_modules/tree-kill": {
|
||||||
@ -8235,7 +8295,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"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=="
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
},
|
},
|
||||||
"node_modules/webpack": {
|
"node_modules/webpack": {
|
||||||
@ -8305,7 +8365,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"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==",
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tr46": "~0.0.3",
|
"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": {
|
"@nestjs/cli": {
|
||||||
"version": "10.1.10",
|
"version": "10.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.10.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
||||||
"integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg=="
|
"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": {
|
"@types/nodemailer": {
|
||||||
"version": "6.4.9",
|
"version": "6.4.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
|
"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": {
|
"call-bind": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
"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": {
|
"lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
@ -12636,6 +12733,11 @@
|
|||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||||
"dev": true
|
"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": {
|
"napi-build-utils": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||||
@ -12767,9 +12869,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node-fetch": {
|
"node-fetch": {
|
||||||
"version": "2.6.7",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
|
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"whatwg-url": "^5.0.0"
|
"whatwg-url": "^5.0.0"
|
||||||
}
|
}
|
||||||
@ -14306,7 +14408,7 @@
|
|||||||
},
|
},
|
||||||
"tr46": {
|
"tr46": {
|
||||||
"version": "0.0.3",
|
"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=="
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||||
},
|
},
|
||||||
"tree-kill": {
|
"tree-kill": {
|
||||||
@ -14586,7 +14688,7 @@
|
|||||||
},
|
},
|
||||||
"webidl-conversions": {
|
"webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"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=="
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
},
|
},
|
||||||
"webpack": {
|
"webpack": {
|
||||||
@ -14635,7 +14737,7 @@
|
|||||||
},
|
},
|
||||||
"whatwg-url": {
|
"whatwg-url": {
|
||||||
"version": "5.0.0",
|
"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==",
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"tr46": "~0.0.3",
|
"tr46": "~0.0.3",
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^2.1.0",
|
||||||
"@nestjs/common": "^10.1.2",
|
"@nestjs/common": "^10.1.2",
|
||||||
"@nestjs/config": "^3.0.0",
|
"@nestjs/config": "^3.0.0",
|
||||||
"@nestjs/core": "^10.1.2",
|
"@nestjs/core": "^10.1.2",
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"argon2": "^0.30.3",
|
"argon2": "^0.30.3",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"cache-manager": "^5.2.4",
|
||||||
"clamscan": "^2.1.2",
|
"clamscan": "^2.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
@ -33,6 +35,8 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
"nanoid": "^3.3.6",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.9.4",
|
"nodemailer": "^6.9.4",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
@ -57,6 +61,7 @@
|
|||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^20.4.5",
|
"@types/node": "^20.4.5",
|
||||||
|
"@types/node-fetch": "^2.6.6",
|
||||||
"@types/nodemailer": "^6.4.9",
|
"@types/nodemailer": "^6.4.9",
|
||||||
"@types/passport-jwt": "^3.0.9",
|
"@types/passport-jwt": "^3.0.9",
|
||||||
"@types/qrcode-svg": "^1.1.1",
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
|
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
31
backend/prisma/migrations/20231021165436_oauth/migration.sql
Normal file
@ -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;
|
@ -14,7 +14,7 @@ model User {
|
|||||||
|
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
|
|
||||||
shares Share[]
|
shares Share[]
|
||||||
@ -26,6 +26,8 @@ model User {
|
|||||||
totpVerified Boolean @default(false)
|
totpVerified Boolean @default(false)
|
||||||
totpSecret String?
|
totpSecret String?
|
||||||
resetPasswordToken ResetPasswordToken?
|
resetPasswordToken ResetPasswordToken?
|
||||||
|
|
||||||
|
oAuthUsers OAuthUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@ -60,6 +62,15 @@ model ResetPasswordToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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 {
|
model Share {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
@ -119,6 +119,89 @@ const configVariables: ConfigVariables = {
|
|||||||
obscured: true,
|
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 = {
|
type ConfigVariables = {
|
||||||
|
@ -15,6 +15,8 @@ import { UserModule } from "./user/user.module";
|
|||||||
import { ClamScanModule } from "./clamscan/clamscan.module";
|
import { ClamScanModule } from "./clamscan/clamscan.module";
|
||||||
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
import { ReverseShareModule } from "./reverseShare/reverseShare.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
import { OAuthModule } from "./oauth/oauth.module";
|
||||||
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -33,10 +35,12 @@ import { AppController } from "./app.controller";
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ClamScanModule,
|
ClamScanModule,
|
||||||
ReverseShareModule,
|
ReverseShareModule,
|
||||||
|
OAuthModule,
|
||||||
|
CacheModule.register({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers:[
|
controllers: [AppController],
|
||||||
AppController,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
|
@ -47,7 +47,7 @@ export class AuthController {
|
|||||||
|
|
||||||
const result = await this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken,
|
||||||
@ -66,7 +66,7 @@ export class AuthController {
|
|||||||
const result = await this.authService.signIn(dto);
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
if (result.accessToken && result.refreshToken) {
|
if (result.accessToken && result.refreshToken) {
|
||||||
response = this.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken,
|
||||||
@ -85,7 +85,7 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
const result = await this.authTotpService.signInTotp(dto);
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
response = this.addTokensToResponse(
|
this.authService.addTokensToResponse(
|
||||||
response,
|
response,
|
||||||
result.refreshToken,
|
result.refreshToken,
|
||||||
result.accessToken,
|
result.accessToken,
|
||||||
@ -117,11 +117,11 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
const result = await this.authService.updatePassword(
|
const result = await this.authService.updatePassword(
|
||||||
user,
|
user,
|
||||||
dto.oldPassword,
|
|
||||||
dto.password,
|
dto.password,
|
||||||
|
dto.oldPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
response = this.addTokensToResponse(response, result.refreshToken);
|
this.authService.addTokensToResponse(response, result.refreshToken);
|
||||||
return new TokenDTO().from(result);
|
return new TokenDTO().from(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ export class AuthController {
|
|||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
request.cookies.refresh_token,
|
request.cookies.refresh_token,
|
||||||
);
|
);
|
||||||
response = this.addTokensToResponse(response, undefined, accessToken);
|
this.authService.addTokensToResponse(response, undefined, accessToken);
|
||||||
return new TokenDTO().from({ 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
|
// 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);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service";
|
|||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), EmailModule],
|
imports: [
|
||||||
|
JwtModule.register({
|
||||||
|
global: true,
|
||||||
|
}),
|
||||||
|
EmailModule,
|
||||||
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
@ -8,6 +8,7 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
import * as argon from "argon2";
|
import * as argon from "argon2";
|
||||||
|
import { Request, Response } from "express";
|
||||||
import * as moment from "moment";
|
import * as moment from "moment";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { EmailService } from "src/email/email.service";
|
import { EmailService } from "src/email/email.service";
|
||||||
@ -27,7 +28,7 @@ export class AuthService {
|
|||||||
async signUp(dto: AuthRegisterDTO) {
|
async signUp(dto: AuthRegisterDTO) {
|
||||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
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 {
|
try {
|
||||||
const user = await this.prisma.user.create({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@ -43,7 +44,7 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken, user };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PrismaClientKnownRequestError) {
|
if (e instanceof PrismaClientKnownRequestError) {
|
||||||
if (e.code == "P2002") {
|
if (e.code == "P2002") {
|
||||||
@ -69,9 +70,16 @@ export class AuthService {
|
|||||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||||
throw new UnauthorizedException("Wrong email or 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
|
// TODO: Make all old loginTokens invalid when a new one is created
|
||||||
// Check if the user has TOTP enabled
|
// 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);
|
const loginToken = await this.createLoginToken(user.id);
|
||||||
|
|
||||||
return { loginToken };
|
return { loginToken };
|
||||||
@ -129,9 +137,11 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
|
||||||
if (!(await argon.verify(user.password, oldPassword)))
|
const isPasswordValid =
|
||||||
throw new ForbiddenException("Invalid password");
|
!user.password || !(await argon.verify(user.password, oldPassword));
|
||||||
|
|
||||||
|
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
const hash = await argon.hash(newPassword);
|
const hash = await argon.hash(newPassword);
|
||||||
|
|
||||||
@ -210,4 +220,38 @@ export class AuthService {
|
|||||||
|
|
||||||
return loginToken;
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,43 +22,29 @@ export class AuthTotpService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
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({
|
const token = await this.prisma.loginToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token: dto.loginToken,
|
token: dto.loginToken,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!token || token.userId != user.id || token.used)
|
if (!token || token.used)
|
||||||
throw new UnauthorizedException("Invalid login token");
|
throw new UnauthorizedException("Invalid login token");
|
||||||
|
|
||||||
if (token.expiresAt < new Date())
|
if (token.expiresAt < new Date())
|
||||||
throw new UnauthorizedException("Login token expired", "token_expired");
|
throw new UnauthorizedException("Login token expired", "token_expired");
|
||||||
|
|
||||||
// Check the TOTP code
|
// Check the TOTP code
|
||||||
const { totpSecret } = await this.prisma.user.findUnique({
|
const { totpSecret } = token.user;
|
||||||
where: { id: user.id },
|
|
||||||
select: { totpSecret: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!totpSecret) {
|
if (!totpSecret) {
|
||||||
throw new BadRequestException("TOTP is not enabled");
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const expected = authenticator.generate(totpSecret);
|
if (!authenticator.check(dto.totp, totpSecret)) {
|
||||||
|
|
||||||
if (dto.totp !== expected) {
|
|
||||||
throw new BadRequestException("Invalid code");
|
throw new BadRequestException("Invalid code");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,9 +55,9 @@ export class AuthTotpService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { refreshToken, refreshTokenId } =
|
const { refreshToken, refreshTokenId } =
|
||||||
await this.authService.createRefreshToken(user.id);
|
await this.authService.createRefreshToken(token.user.id);
|
||||||
const accessToken = await this.authService.createAccessToken(
|
const accessToken = await this.authService.createAccessToken(
|
||||||
user,
|
token.user,
|
||||||
refreshTokenId,
|
refreshTokenId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { AuthSignInDTO } from "./authSignIn.dto";
|
import { AuthSignInDTO } from "./authSignIn.dto";
|
||||||
|
|
||||||
export class AuthSignInTotpDTO extends AuthSignInDTO {
|
export class AuthSignInTotpDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
totp: string;
|
totp: string;
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { PickType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { IsString } from "class-validator";
|
import { IsOptional, IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||||
@IsString()
|
@IsString()
|
||||||
oldPassword: string;
|
@IsOptional()
|
||||||
|
oldPassword?: string;
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,20 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Config } from "@prisma/client";
|
import { Config } from "@prisma/client";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
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()
|
@Injectable()
|
||||||
export class ConfigService {
|
export class ConfigService extends EventEmitter {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
) {}
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
get(key: `${string}.${string}`): any {
|
get(key: `${string}.${string}`): any {
|
||||||
const configVariable = this.configVariables.filter(
|
const configVariable = this.configVariables.filter(
|
||||||
@ -105,6 +112,8 @@ export class ConfigService {
|
|||||||
|
|
||||||
this.configVariables = await this.prisma.config.findMany();
|
this.configVariables = await this.prisma.config.findMany();
|
||||||
|
|
||||||
|
this.emit("update", key, value);
|
||||||
|
|
||||||
return updatedVariable;
|
return updatedVariable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ export class LogoService {
|
|||||||
fs.promises.writeFile(
|
fs.promises.writeFile(
|
||||||
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
`${IMAGES_PATH}/icons/icon-${size}x${size}.png`,
|
||||||
resized,
|
resized,
|
||||||
"binary"
|
"binary",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
9
backend/src/oauth/dto/oauthCallback.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class OAuthCallbackDto {
|
||||||
|
@IsString()
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
state: string;
|
||||||
|
}
|
6
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
6
backend/src/oauth/dto/oauthSignIn.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface OAuthSignInDto {
|
||||||
|
provider: "github" | "google" | "microsoft" | "discord" | "oidc";
|
||||||
|
providerId: string;
|
||||||
|
providerUsername: string;
|
||||||
|
email: string;
|
||||||
|
}
|
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
15
backend/src/oauth/exceptions/errorPage.exception.ts
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
22
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
22
backend/src/oauth/filter/errorPageException.filter.ts
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
31
backend/src/oauth/filter/oauthException.filter.ts
Normal file
31
backend/src/oauth/filter/oauthException.filter.ts
Normal file
@ -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<string, string> = {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
12
backend/src/oauth/guard/oauth.guard.ts
Normal file
@ -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`];
|
||||||
|
}
|
||||||
|
}
|
24
backend/src/oauth/guard/provider.guard.ts
Normal file
24
backend/src/oauth/guard/provider.guard.ts
Normal file
@ -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`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
110
backend/src/oauth/oauth.controller.ts
Normal file
110
backend/src/oauth/oauth.controller.ts
Normal file
@ -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<string, OAuthProvider<unknown>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
56
backend/src/oauth/oauth.module.ts
Normal file
56
backend/src/oauth/oauth.module.ts
Normal file
@ -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<string, OAuthProvider<unknown>> {
|
||||||
|
return {
|
||||||
|
github,
|
||||||
|
google,
|
||||||
|
microsoft,
|
||||||
|
discord,
|
||||||
|
oidc,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [
|
||||||
|
GitHubProvider,
|
||||||
|
GoogleProvider,
|
||||||
|
MicrosoftProvider,
|
||||||
|
DiscordProvider,
|
||||||
|
OidcProvider,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: "OAUTH_PLATFORMS",
|
||||||
|
useFactory(providers: Record<string, OAuthProvider<unknown>>): string[] {
|
||||||
|
return Object.keys(providers);
|
||||||
|
},
|
||||||
|
inject: ["OAUTH_PROVIDERS"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imports: [AuthModule],
|
||||||
|
})
|
||||||
|
export class OAuthModule {}
|
171
backend/src/oauth/oauth.service.ts
Normal file
171
backend/src/oauth/oauth.service.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
98
backend/src/oauth/provider/discord.provider.ts
Normal file
98
backend/src/oauth/provider/discord.provider.ts
Normal file
@ -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<DiscordToken> {
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
getAuthEndpoint(state: string): Promise<string> {
|
||||||
|
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<OAuthToken<DiscordToken>> {
|
||||||
|
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<DiscordToken>): Promise<OAuthSignInDto> {
|
||||||
|
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;
|
||||||
|
}
|
206
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
206
backend/src/oauth/provider/genericOidc.provider.ts
Normal file
@ -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<OidcToken> {
|
||||||
|
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<OidcConfiguration> {
|
||||||
|
if (!this.configuration || this.configuration.expires < Date.now()) {
|
||||||
|
await this.fetchConfiguration();
|
||||||
|
}
|
||||||
|
return this.configuration.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJwk(): Promise<OidcJwk[]> {
|
||||||
|
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<OAuthToken<OidcToken>> {
|
||||||
|
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<OidcToken>,
|
||||||
|
query: OAuthCallbackDto,
|
||||||
|
): Promise<OAuthSignInDto> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<T> {
|
||||||
|
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<OidcConfiguration>;
|
||||||
|
|
||||||
|
export type OidcJwkCache = OidcCache<OidcJwk[]>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
110
backend/src/oauth/provider/github.provider.ts
Normal file
110
backend/src/oauth/provider/github.provider.ts
Normal file
@ -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<GitHubToken> {
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
getAuthEndpoint(state: string): Promise<string> {
|
||||||
|
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<OAuthToken<GitHubToken>> {
|
||||||
|
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<GitHubToken>): Promise<OAuthSignInDto> {
|
||||||
|
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<GitHubToken>,
|
||||||
|
): Promise<GitHubUser> {
|
||||||
|
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<GitHubToken>,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
21
backend/src/oauth/provider/google.provider.ts
Normal file
21
backend/src/oauth/provider/google.provider.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
29
backend/src/oauth/provider/microsoft.provider.ts
Normal file
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
24
backend/src/oauth/provider/oauthProvider.interface.ts
Normal file
@ -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<T, C = OAuthCallbackDto> {
|
||||||
|
getAuthEndpoint(state: string): Promise<string>;
|
||||||
|
|
||||||
|
getToken(query: C): Promise<OAuthToken<T>>;
|
||||||
|
|
||||||
|
getUserInfo(token: OAuthToken<T>, query: C): Promise<OAuthSignInDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthToken<T> {
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
scope?: string;
|
||||||
|
idToken?: string;
|
||||||
|
rawToken: T;
|
||||||
|
}
|
27
backend/src/oauth/provider/oidc.provider.ts
Normal file
27
backend/src/oauth/provider/oidc.provider.ts
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,9 @@ export class UserDTO {
|
|||||||
@IsEmail()
|
@IsEmail()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
hasPassword: boolean;
|
||||||
|
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
|
@ -28,7 +28,9 @@ export class UserController {
|
|||||||
@Get("me")
|
@Get("me")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async getCurrentUser(@GetUser() user: User) {
|
async getCurrentUser(@GetUser() user: User) {
|
||||||
return new UserDTO().from(user);
|
const userDTO = new UserDTO().from(user);
|
||||||
|
userDTO.hasPassword = !!user.password;
|
||||||
|
return userDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("me")
|
@Patch("me")
|
||||||
|
@ -6,7 +6,10 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "es2017",
|
"target": "es2021",
|
||||||
|
"lib": [
|
||||||
|
"ES2021"
|
||||||
|
],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
168
docs/oauth2-guide.md
Normal file
168
docs/oauth2-guide.md
Normal file
@ -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://<your-domain>/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://<your-domain>/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://<your-domain>/api/oauth/callback/microsoft`
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
Create an application on [Discord Developer Portal](https://discord.com/developers/applications).
|
||||||
|
|
||||||
|
Redirect URL: `https://<your-domain>/api/oauth/callback/discord`
|
||||||
|
|
||||||
|
### OpenID Connect
|
||||||
|
|
||||||
|
Generic OpenID Connect provider is also supported, we have tested it on Keycloak and Authentik.
|
||||||
|
|
||||||
|
Redirect URL: `https://<your-domain>/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<string, OAuthProvider<unknown>> {
|
||||||
|
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': <SiGithub />,
|
||||||
|
/* 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.
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
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";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
@ -19,6 +19,7 @@ const categories = [
|
|||||||
{ name: "Email", icon: <TbMail /> },
|
{ name: "Email", icon: <TbMail /> },
|
||||||
{ name: "Share", icon: <TbShare /> },
|
{ name: "Share", icon: <TbShare /> },
|
||||||
{ name: "SMTP", icon: <TbAt /> },
|
{ name: "SMTP", icon: <TbAt /> },
|
||||||
|
{ name: "OAuth", icon: <TbSocial /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
|
@ -2,9 +2,11 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
|
createStyles,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
@ -18,19 +20,47 @@ import { TbInfoCircle } from "react-icons/tb";
|
|||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useTranslate from "../../hooks/useTranslate.hook";
|
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
|
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
||||||
import toast from "../../utils/toast.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 SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const { refreshUser } = useUser();
|
const { refreshUser } = useUser();
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
const [showTotp, setShowTotp] = React.useState(false);
|
const [oauth, setOAuth] = React.useState<string[]>([]);
|
||||||
const [loginToken, setLoginToken] = React.useState("");
|
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||||
@ -44,7 +74,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
initialValues: {
|
initialValues: {
|
||||||
emailOrUsername: "",
|
emailOrUsername: "",
|
||||||
password: "",
|
password: "",
|
||||||
totp: "",
|
|
||||||
},
|
},
|
||||||
validate: yupResolver(validationSchema),
|
validate: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
@ -55,7 +84,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (response.data["loginToken"]) {
|
if (response.data["loginToken"]) {
|
||||||
// Prompt the user to enter their totp code
|
// Prompt the user to enter their totp code
|
||||||
setShowTotp(true);
|
|
||||||
showNotification({
|
showNotification({
|
||||||
icon: <TbInfoCircle />,
|
icon: <TbInfoCircle />,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
@ -63,7 +91,11 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
title: t("signIn.notify.totp-required.title"),
|
title: t("signIn.notify.totp-required.title"),
|
||||||
message: t("signIn.notify.totp-required.description"),
|
message: t("signIn.notify.totp-required.description"),
|
||||||
});
|
});
|
||||||
setLoginToken(response.data["loginToken"]);
|
router.push(
|
||||||
|
`/auth/totp/${
|
||||||
|
response.data["loginToken"]
|
||||||
|
}?redirect=${encodeURIComponent(redirectPath)}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
router.replace(redirectPath);
|
router.replace(redirectPath);
|
||||||
@ -72,25 +104,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const signInTotp = (email: string, password: string, totp: string) => {
|
const getAvailableOAuth = async () => {
|
||||||
authService
|
const oauth = await authService.getAvailableOAuth();
|
||||||
.signInTotp(email, password, totp, loginToken)
|
setOAuth(oauth.data);
|
||||||
.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: "" });
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
getAvailableOAuth().catch(toast.axiosError);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title order={2} align="center" weight={900}>
|
<Title order={2} align="center" weight={900}>
|
||||||
@ -107,9 +129,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
if (showTotp)
|
signIn(values.emailOrUsername, values.password);
|
||||||
signInTotp(values.emailOrUsername, values.password, values.totp);
|
|
||||||
else signIn(values.emailOrUsername, values.password);
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -123,15 +143,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
{showTotp && (
|
|
||||||
<TextInput
|
|
||||||
variant="filled"
|
|
||||||
label={t("account.modal.totp.code")}
|
|
||||||
placeholder="******"
|
|
||||||
mt="md"
|
|
||||||
{...form.getInputProps("totp")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{config.get("smtp.enabled") && (
|
{config.get("smtp.enabled") && (
|
||||||
<Group position="right" mt="xs">
|
<Group position="right" mt="xs">
|
||||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||||
@ -143,6 +154,27 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||||||
<FormattedMessage id="signin.button.submit" />
|
<FormattedMessage id="signin.button.submit" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
84
frontend/src/components/auth/TotpForm.tsx
Normal file
84
frontend/src/components/auth/TotpForm.tsx
Normal file
@ -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" />
|
||||||
|
</Title>
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<Group position="center">
|
||||||
|
<PinInput
|
||||||
|
length={6}
|
||||||
|
oneTimeCode
|
||||||
|
aria-label="One time code"
|
||||||
|
autoFocus={true}
|
||||||
|
onComplete={onSubmit}
|
||||||
|
{...form.getInputProps("code")}
|
||||||
|
/>
|
||||||
|
<Button mt="md" type="submit" loading={loading}>
|
||||||
|
{t("totp.button.signIn")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TotpForm;
|
@ -60,7 +60,7 @@ const Dropzone = ({
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("upload.dropzone.notify.file-too-big", {
|
t("upload.dropzone.notify.file-too-big", {
|
||||||
maxSize: byteToHumanSizeString(maxShareSize),
|
maxSize: byteToHumanSizeString(maxShareSize),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
files = files.map((newFile) => {
|
files = files.map((newFile) => {
|
||||||
|
@ -40,7 +40,7 @@ const showCreateUploadModal = (
|
|||||||
enableEmailRecepients: boolean;
|
enableEmailRecepients: boolean;
|
||||||
},
|
},
|
||||||
files: FileUpload[],
|
files: FileUpload[],
|
||||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
|
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
||||||
) => {
|
) => {
|
||||||
const t = translateOutsideContext();
|
const t = translateOutsideContext();
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ const CreateUploadModalBody = ({
|
|||||||
maxViews: values.maxViews,
|
maxViews: values.maxViews,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files
|
files,
|
||||||
);
|
);
|
||||||
modals.closeAll();
|
modals.closeAll();
|
||||||
}
|
}
|
||||||
@ -160,7 +160,7 @@ const CreateUploadModalBody = ({
|
|||||||
"link",
|
"link",
|
||||||
Buffer.from(Math.random().toString(), "utf8")
|
Buffer.from(Math.random().toString(), "utf8")
|
||||||
.toString("base64")
|
.toString("base64")
|
||||||
.substr(10, 7)
|
.substr(10, 7),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -259,7 +259,7 @@ const CreateUploadModalBody = ({
|
|||||||
neverExpires: t("upload.modal.completed.never-expires"),
|
neverExpires: t("upload.modal.completed.never-expires"),
|
||||||
expiresOn: t("upload.modal.completed.expires-on"),
|
expiresOn: t("upload.modal.completed.expires-on"),
|
||||||
},
|
},
|
||||||
form
|
form,
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@ -274,7 +274,7 @@ const CreateUploadModalBody = ({
|
|||||||
<Textarea
|
<Textarea
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"upload.modal.accordion.description.placeholder"
|
"upload.modal.accordion.description.placeholder",
|
||||||
)}
|
)}
|
||||||
{...form.getInputProps("description")}
|
{...form.getInputProps("description")}
|
||||||
/>
|
/>
|
||||||
@ -298,7 +298,7 @@ const CreateUploadModalBody = ({
|
|||||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||||
form.setFieldError(
|
form.setFieldError(
|
||||||
"recipients",
|
"recipients",
|
||||||
t("upload.modal.accordion.email.invalid-email")
|
t("upload.modal.accordion.email.invalid-email"),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
form.setFieldError("recipients", null);
|
form.setFieldError("recipients", null);
|
||||||
@ -324,7 +324,7 @@ const CreateUploadModalBody = ({
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"upload.modal.accordion.security.password.placeholder"
|
"upload.modal.accordion.security.password.placeholder",
|
||||||
)}
|
)}
|
||||||
label={t("upload.modal.accordion.security.password.label")}
|
label={t("upload.modal.accordion.security.password.label")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -335,7 +335,7 @@ const CreateUploadModalBody = ({
|
|||||||
type="number"
|
type="number"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"upload.modal.accordion.security.max-views.placeholder"
|
"upload.modal.accordion.security.max-views.placeholder",
|
||||||
)}
|
)}
|
||||||
label={t("upload.modal.accordion.security.max-views.label")}
|
label={t("upload.modal.accordion.security.max-views.label")}
|
||||||
{...form.getInputProps("maxViews")}
|
{...form.getInputProps("maxViews")}
|
||||||
|
@ -43,6 +43,12 @@ export default {
|
|||||||
"signIn.notify.totp-required.title": "Two-factor authentication required",
|
"signIn.notify.totp-required.title": "Two-factor authentication required",
|
||||||
"signIn.notify.totp-required.description":
|
"signIn.notify.totp-required.description":
|
||||||
"Please enter your two-factor authentication code",
|
"Please enter your two-factor authentication code",
|
||||||
|
"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
|
// END /auth/signin
|
||||||
|
|
||||||
@ -58,6 +64,12 @@ export default {
|
|||||||
|
|
||||||
// END /auth/signup
|
// END /auth/signup
|
||||||
|
|
||||||
|
// /auth/totp
|
||||||
|
"totp.title": "TOTP Authentication",
|
||||||
|
"totp.button.signIn": "Sign in",
|
||||||
|
|
||||||
|
// END /auth/totp
|
||||||
|
|
||||||
// /auth/reset-password
|
// /auth/reset-password
|
||||||
"resetPassword.title": "Forgot your password?",
|
"resetPassword.title": "Forgot your password?",
|
||||||
"resetPassword.description": "Enter your email to reset your password.",
|
"resetPassword.description": "Enter your email to reset your password.",
|
||||||
@ -81,8 +93,23 @@ export default {
|
|||||||
"account.card.password.title": "Password",
|
"account.card.password.title": "Password",
|
||||||
"account.card.password.old": "Old password",
|
"account.card.password.old": "Old password",
|
||||||
"account.card.password.new": "New 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.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.microsoft": "Microsoft",
|
||||||
|
"account.card.oauth.discord": "Discord",
|
||||||
|
"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.title": "Security",
|
||||||
"account.card.security.totp.enable.description":
|
"account.card.security.totp.enable.description":
|
||||||
"Enter your current password to start enabling TOTP",
|
"Enter your current password to start enabling TOTP",
|
||||||
@ -336,6 +363,7 @@ export default {
|
|||||||
"admin.config.category.share": "Share",
|
"admin.config.category.share": "Share",
|
||||||
"admin.config.category.email": "Email",
|
"admin.config.category.email": "Email",
|
||||||
"admin.config.category.smtp": "SMTP",
|
"admin.config.category.smtp": "SMTP",
|
||||||
|
"admin.config.category.oauth": "Social Login",
|
||||||
|
|
||||||
"admin.config.general.app-name": "App name",
|
"admin.config.general.app-name": "App name",
|
||||||
"admin.config.general.app-name.description": "Name of the application",
|
"admin.config.general.app-name.description": "Name of the application",
|
||||||
@ -407,10 +435,66 @@ export default {
|
|||||||
"admin.config.smtp.password.description": "Password of the SMTP server",
|
"admin.config.smtp.password.description": "Password of the SMTP server",
|
||||||
"admin.config.smtp.button.test": "Send test email",
|
"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.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",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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
|
||||||
"404.description": "Oops this page doesn't exist.",
|
"404.description": "Oops this page doesn't exist.",
|
||||||
"404.button.home": "Bring me back home",
|
"404.button.home": "Bring me back home",
|
||||||
|
|
||||||
|
// error
|
||||||
|
"error.title": "Error",
|
||||||
|
"error.description": "Oops!",
|
||||||
|
"error.button.back": "Go back",
|
||||||
|
"error.msg.default": "Something went wrong.",
|
||||||
|
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
||||||
|
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
||||||
|
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
||||||
|
"error.msg.no_email": "Can't get email address from this {0} account.",
|
||||||
|
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
||||||
|
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
||||||
|
"error.param.provider_github": "GitHub",
|
||||||
|
"error.param.provider_google": "Google",
|
||||||
|
"error.param.provider_microsoft": "Microsoft",
|
||||||
|
"error.param.provider_discord": "Discord",
|
||||||
|
"error.param.provider_oidc": "OpenID",
|
||||||
|
|
||||||
// Common translations
|
// Common translations
|
||||||
"common.button.save": "Save",
|
"common.button.save": "Save",
|
||||||
"common.button.create": "Create",
|
"common.button.create": "Create",
|
||||||
|
@ -14,7 +14,7 @@ export const config = {
|
|||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const routes = {
|
const routes = {
|
||||||
unauthenticated: new Routes(["/auth/*", "/"]),
|
unauthenticated: new Routes(["/auth/*", "/"]),
|
||||||
public: new Routes(["/share/*", "/s/*", "/upload/*"]),
|
public: new Routes(["/share/*", "/s/*", "/upload/*", "/error"]),
|
||||||
admin: new Routes(["/admin/*"]),
|
admin: new Routes(["/admin/*"]),
|
||||||
account: new Routes(["/account*"]),
|
account: new Routes(["/account*"]),
|
||||||
disabled: new Routes([]),
|
disabled: new Routes([]),
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { useModals } from "@mantine/modals";
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Tb2Fa } from "react-icons/tb";
|
import { Tb2Fa } from "react-icons/tb";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
@ -20,16 +21,28 @@ import Meta from "../../components/Meta";
|
|||||||
import LanguagePicker from "../../components/account/LanguagePicker";
|
import LanguagePicker from "../../components/account/LanguagePicker";
|
||||||
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
||||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useTranslate from "../../hooks/useTranslate.hook";
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../services/user.service";
|
||||||
|
import { getOAuthIcon, getOAuthUrl, unlinkOAuth } from "../../utils/oauth.util";
|
||||||
import toast from "../../utils/toast.util";
|
import toast from "../../utils/toast.util";
|
||||||
|
|
||||||
const Account = () => {
|
const Account = () => {
|
||||||
|
const [oauth, setOAuth] = useState<string[]>([]);
|
||||||
|
const [oauthStatus, setOAuthStatus] = useState<Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
provider: string;
|
||||||
|
providerUsername: string;
|
||||||
|
}
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
const { user, refreshUser } = useUser();
|
const { user, refreshUser } = useUser();
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
const accountForm = useForm({
|
const accountForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@ -53,10 +66,14 @@ const Account = () => {
|
|||||||
},
|
},
|
||||||
validate: yupResolver(
|
validate: yupResolver(
|
||||||
yup.object().shape({
|
yup.object().shape({
|
||||||
oldPassword: yup
|
oldPassword: yup.string().when([], {
|
||||||
.string()
|
is: () => !!user?.hasPassword,
|
||||||
|
then: (schema) =>
|
||||||
|
schema
|
||||||
.min(8, t("common.error.too-short", { length: 8 }))
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
.required(t("common.error.field-required")),
|
.required(t("common.error.field-required")),
|
||||||
|
otherwise: (schema) => schema.notRequired(),
|
||||||
|
}),
|
||||||
password: yup
|
password: yup
|
||||||
.string()
|
.string()
|
||||||
.min(8, t("common.error.too-short", { length: 8 }))
|
.min(8, t("common.error.too-short", { length: 8 }))
|
||||||
@ -96,6 +113,25 @@ 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);
|
||||||
|
refreshOAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Meta title={t("account.title")} />
|
<Meta title={t("account.title")} />
|
||||||
@ -143,7 +179,8 @@ const Account = () => {
|
|||||||
onSubmit={passwordForm.onSubmit((values) =>
|
onSubmit={passwordForm.onSubmit((values) =>
|
||||||
authService
|
authService
|
||||||
.updatePassword(values.oldPassword, values.password)
|
.updatePassword(values.oldPassword, values.password)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
refreshUser();
|
||||||
toast.success(t("account.notify.password.success"));
|
toast.success(t("account.notify.password.success"));
|
||||||
passwordForm.reset();
|
passwordForm.reset();
|
||||||
})
|
})
|
||||||
@ -151,10 +188,16 @@ const Account = () => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{user?.hasPassword ? (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("account.card.password.old")}
|
label={t("account.card.password.old")}
|
||||||
{...passwordForm.getInputProps("oldPassword")}
|
{...passwordForm.getInputProps("oldPassword")}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
<FormattedMessage id="account.card.password.noPasswordSet" />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("account.card.password.new")}
|
label={t("account.card.password.new")}
|
||||||
{...passwordForm.getInputProps("password")}
|
{...passwordForm.getInputProps("password")}
|
||||||
@ -167,7 +210,79 @@ const Account = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
{oauth.length > 0 && (
|
||||||
|
<Paper withBorder p="xl" mt="lg">
|
||||||
|
<Title order={5} mb="xs">
|
||||||
|
<FormattedMessage id="account.card.oauth.title" />
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Tabs defaultValue={oauth[0] || ""}>
|
||||||
|
<Tabs.List>
|
||||||
|
{oauth.map((provider) => (
|
||||||
|
<Tabs.Tab
|
||||||
|
value={provider}
|
||||||
|
icon={getOAuthIcon(provider)}
|
||||||
|
key={provider}
|
||||||
|
>
|
||||||
|
{t(`account.card.oauth.${provider}`)}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
{oauth.map((provider) => (
|
||||||
|
<Tabs.Panel value={provider} pt="xs" key={provider}>
|
||||||
|
<Group position="apart">
|
||||||
|
<Text>
|
||||||
|
{oauthStatus?.[provider]
|
||||||
|
? oauthStatus[provider].providerUsername
|
||||||
|
: t("account.card.oauth.unlinked")}
|
||||||
|
</Text>
|
||||||
|
{oauthStatus?.[provider] ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("account.modal.unlink.title"),
|
||||||
|
children: (
|
||||||
|
<Text>
|
||||||
|
{t("account.modal.unlink.description")}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: {
|
||||||
|
confirm: t("account.card.oauth.unlink"),
|
||||||
|
cancel: t("common.button.cancel"),
|
||||||
|
},
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => {
|
||||||
|
unlinkOAuth(provider)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
t("account.notify.oauth.unlinked.success"),
|
||||||
|
);
|
||||||
|
refreshOAuthStatus();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("account.card.oauth.unlink")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={getOAuthUrl(
|
||||||
|
config.get("general.appUrl"),
|
||||||
|
provider,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("account.card.oauth.link")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Tabs.Panel>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
<Paper withBorder p="xl" mt="lg">
|
<Paper withBorder p="xl" mt="lg">
|
||||||
<Title order={5} mb="xs">
|
<Title order={5} mb="xs">
|
||||||
<FormattedMessage id="account.card.security.title" />
|
<FormattedMessage id="account.card.security.title" />
|
||||||
|
@ -24,10 +24,7 @@ import CenterLoader from "../../../components/core/CenterLoader";
|
|||||||
import useConfig from "../../../hooks/config.hook";
|
import useConfig from "../../../hooks/config.hook";
|
||||||
import configService from "../../../services/config.service";
|
import configService from "../../../services/config.service";
|
||||||
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
|
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
|
||||||
import {
|
import { camelToKebab } from "../../../utils/string.util";
|
||||||
camelToKebab,
|
|
||||||
capitalizeFirstLetter,
|
|
||||||
} from "../../../utils/string.util";
|
|
||||||
import toast from "../../../utils/toast.util";
|
import toast from "../../../utils/toast.util";
|
||||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
|
|
||||||
@ -128,7 +125,7 @@ export default function AppShellDemo() {
|
|||||||
<>
|
<>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title mb="md" order={3}>
|
<Title mb="md" order={3}>
|
||||||
{capitalizeFirstLetter(categoryId)}
|
{t("admin.config.category." + categoryId)}
|
||||||
</Title>
|
</Title>
|
||||||
{configVariables.map((configVariable) => (
|
{configVariables.map((configVariable) => (
|
||||||
<Group key={configVariable.key} position="apart">
|
<Group key={configVariable.key} position="apart">
|
||||||
|
18
frontend/src/pages/auth/totp/[loginToken].tsx
Normal file
18
frontend/src/pages/auth/totp/[loginToken].tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
|
import Meta from "../../../components/Meta";
|
||||||
|
import TotpForm from "../../../components/auth/TotpForm";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const Totp = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta title={t("totp.title")} />
|
||||||
|
<TotpForm redirectPath={(router.query.redirect as string) || "/upload"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Totp;
|
49
frontend/src/pages/error.tsx
Normal file
49
frontend/src/pages/error.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button, createStyles, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import Meta from "../components/Meta";
|
||||||
|
import useTranslate from "../hooks/useTranslate.hook";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
|
const useStyle = createStyles({
|
||||||
|
title: {
|
||||||
|
fontSize: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Error() {
|
||||||
|
const { classes } = useStyle();
|
||||||
|
const t = useTranslate();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const params = router.query.params
|
||||||
|
? (router.query.params as string).split(",").map((param) => {
|
||||||
|
return t(`error.param.${param}`);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta title={t("error.title")} />
|
||||||
|
<Stack align="center">
|
||||||
|
<Title order={3} className={classes.title}>
|
||||||
|
{t("error.description")}
|
||||||
|
</Title>
|
||||||
|
<Text mt="xl" size="lg">
|
||||||
|
<FormattedMessage
|
||||||
|
id={`error.msg.${router.query.error || "default"}`}
|
||||||
|
values={Object.fromEntries(
|
||||||
|
[params].map((value, key) => [key.toString(), value]),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
mt="xl"
|
||||||
|
onClick={() => router.push((router.query.redirect as string) || "/")}
|
||||||
|
>
|
||||||
|
{t("error.button.back")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -56,7 +56,7 @@ const Upload = ({
|
|||||||
file.uploadingProgress = progress;
|
file.uploadingProgress = progress;
|
||||||
}
|
}
|
||||||
return file;
|
return file;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ const Upload = ({
|
|||||||
name: file.name,
|
name: file.name,
|
||||||
},
|
},
|
||||||
chunkIndex,
|
chunkIndex,
|
||||||
chunks
|
chunks,
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
fileId = response.id;
|
fileId = response.id;
|
||||||
@ -114,7 +114,7 @@ const Upload = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
Promise.all(fileUploadPromises);
|
Promise.all(fileUploadPromises);
|
||||||
@ -129,19 +129,19 @@ const Upload = ({
|
|||||||
isReverseShare,
|
isReverseShare,
|
||||||
appUrl: config.get("general.appUrl"),
|
appUrl: config.get("general.appUrl"),
|
||||||
allowUnauthenticatedShares: config.get(
|
allowUnauthenticatedShares: config.get(
|
||||||
"share.allowUnauthenticatedShares"
|
"share.allowUnauthenticatedShares",
|
||||||
),
|
),
|
||||||
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
||||||
},
|
},
|
||||||
files,
|
files,
|
||||||
uploadFiles
|
uploadFiles,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if there are any files that failed to upload
|
// Check if there are any files that failed to upload
|
||||||
const fileErrorCount = files.filter(
|
const fileErrorCount = files.filter(
|
||||||
(file) => file.uploadingProgress == -1
|
(file) => file.uploadingProgress == -1,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
if (fileErrorCount > 0) {
|
if (fileErrorCount > 0) {
|
||||||
@ -151,7 +151,7 @@ const Upload = ({
|
|||||||
{
|
{
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
errorToastShown = true;
|
errorToastShown = true;
|
||||||
|
@ -15,24 +15,11 @@ const signIn = async (emailOrUsername: string, password: string) => {
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signInTotp = async (
|
const signInTotp = (totp: string, loginToken: string) => {
|
||||||
emailOrUsername: string,
|
return api.post("auth/signIn/totp", {
|
||||||
password: string,
|
|
||||||
totp: string,
|
|
||||||
loginToken: string,
|
|
||||||
) => {
|
|
||||||
const emailOrUsernameBody = emailOrUsername.includes("@")
|
|
||||||
? { email: emailOrUsername }
|
|
||||||
: { username: emailOrUsername };
|
|
||||||
|
|
||||||
const response = await api.post("auth/signIn/totp", {
|
|
||||||
...emailOrUsernameBody,
|
|
||||||
password,
|
|
||||||
totp,
|
totp,
|
||||||
loginToken,
|
loginToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const signUp = async (email: string, username: string, password: string) => {
|
const signUp = async (email: string, username: string, password: string) => {
|
||||||
@ -96,6 +83,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 {
|
export default {
|
||||||
signIn,
|
signIn,
|
||||||
signInTotp,
|
signInTotp,
|
||||||
@ -108,4 +103,6 @@ export default {
|
|||||||
enableTOTP,
|
enableTOTP,
|
||||||
verifyTOTP,
|
verifyTOTP,
|
||||||
disableTOTP,
|
disableTOTP,
|
||||||
|
getAvailableOAuth,
|
||||||
|
getOAuthStatus,
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ type User = {
|
|||||||
email: string;
|
email: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
totpVerified: boolean;
|
totpVerified: boolean;
|
||||||
|
hasPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateUser = {
|
export type CreateUser = {
|
||||||
|
29
frontend/src/utils/oauth.util.tsx
Normal file
29
frontend/src/utils/oauth.util.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
SiDiscord,
|
||||||
|
SiGithub,
|
||||||
|
SiGoogle,
|
||||||
|
SiMicrosoft,
|
||||||
|
SiOpenid,
|
||||||
|
} from "react-icons/si";
|
||||||
|
import React from "react";
|
||||||
|
import api from "../services/api.service";
|
||||||
|
|
||||||
|
const getOAuthUrl = (appUrl: string, provider: string) => {
|
||||||
|
return `${appUrl}/api/oauth/auth/${provider}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOAuthIcon = (provider: string) => {
|
||||||
|
return {
|
||||||
|
google: <SiGoogle />,
|
||||||
|
microsoft: <SiMicrosoft />,
|
||||||
|
github: <SiGithub />,
|
||||||
|
discord: <SiDiscord />,
|
||||||
|
oidc: <SiOpenid />,
|
||||||
|
}[provider];
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlinkOAuth = (provider: string) => {
|
||||||
|
return api.post(`/oauth/unlink/${provider}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getOAuthUrl, getOAuthIcon, unlinkOAuth };
|
Loading…
Reference in New Issue
Block a user