mirror of https://github.com/wg-easy/wg-easy
Browse Source
* feat: add Google OAuth login support (#2625) * 🔧 Add login via google * 🔧 Update code style and docs * Add fix for db migrate * 🔧 Update docker-compose * 🔧 Fix sqlite" * 🔧 Update docker-compose * ⚰️ Remove environments * 🔧 Fix: remove ensureGoogleIdColumn workaround from sqlite.ts * 🔧 Remove space * move oauth section * add openid client * wip make oauth more generic * properly allow multiple providers * fix type import * github login flow * adjust github logo with theme * move docs to own page * nullable password, prevent timing attack this prevents timing attacks by always checking hash even if there is none prevents using basic auth if 2fa is enabled * support generic oidc * add ability to set password for oidc users this allows oidc users to add password login cant be removed after * move password login route move password login route from /api/session to /api/auth/password align with oauth * unique index on oauth * link/unlink logic * improve docs * support allowed domains * support auto register * refactoring * disable pw auth * move 2fa to its own page * 2fa for oauth, rework 2fa system * fix design, fix link Closes #2650 * add auto launch * improve docs * improvements --------- Co-authored-by: Daniel Molenda <[email protected]>pull/2556/merge
committed by
GitHub
44 changed files with 2847 additions and 221 deletions
@ -0,0 +1,182 @@ |
|||
--- |
|||
title: External Authentication |
|||
--- |
|||
|
|||
## OAuth |
|||
|
|||
### Setup |
|||
|
|||
To enable OAuth set the env var `OAUTH_PROVIDERS` to any of the following providers: |
|||
|
|||
| Provider | Value | |
|||
| ----------------------------- | -------- | |
|||
| [Google](#google) | `google` | |
|||
| [GitHub](#github) | `github` | |
|||
| [Generic OIDC](#generic-oidc) | `oidc` | |
|||
|
|||
You can enable multiple providers by separating them with a comma: |
|||
|
|||
e.g. `google,github` |
|||
|
|||
### Auto Register |
|||
|
|||
To automatically register users that log in with an OAuth provider, set the following environment variable to `true`: |
|||
|
|||
| Env | Required | Default | Description | |
|||
| --------------------- | -------- | ------- | ------------------------ | |
|||
| `OAUTH_AUTO_REGISTER` | ✖️ | `false` | Enable auto-registration | |
|||
|
|||
When enabled: |
|||
|
|||
- If a user logs in with an email address that is not yet registered, a new account will be created for them. |
|||
|
|||
- If a user logs in with an email address that is already registered, their account will be linked to the OAuth provider (if not already linked), regardless of the value of `OAUTH_AUTO_REGISTER`. |
|||
|
|||
/// warning | Security |
|||
|
|||
Users will be created with Admin Permissions, as the permissions system is not yet implemented. Only enable this if you trust all users that can log in with the OAuth provider. |
|||
|
|||
Use [Allowed Domains](#allowed-domains) to restrict which users can log in. |
|||
|
|||
/// |
|||
|
|||
### Allowed Domains |
|||
|
|||
To only allow users with an email address from a specific domain to log in, set the following environment variable to the allowed domain. |
|||
|
|||
| Env | Required | Default | Description | |
|||
| ----------------------- | -------- | ------- | --------------------- | |
|||
| `OAUTH_ALLOWED_DOMAINS` | ✖️ | - | Allowed email domains | |
|||
|
|||
You can allow multiple domains by separating them with a comma: |
|||
|
|||
e.g. `example.com,example.org` |
|||
|
|||
### Auto Launch |
|||
|
|||
To automatically launch the OAuth login flow when visiting the login page, set the following environment variable to the provider you want to launch: |
|||
|
|||
| Env | Required | Default | Description | |
|||
| ------------------- | -------- | ------- | ----------------------------- | |
|||
| `OAUTH_AUTO_LAUNCH` | ✖️ | - | Auto launch an OAuth provider | |
|||
|
|||
When enabled: |
|||
|
|||
- Visiting the login page will automatically redirect to the selected provider's login page |
|||
- The user can still access the normal login page by visiting `/login?auto_launch=false` |
|||
- You can auto launch any provider by visiting `/login?auto_launch=<provider>` |
|||
|
|||
### Redirect URIs |
|||
|
|||
You have to configure the following redirect URIs in your OAuth provider: |
|||
|
|||
- `https://<your-domain>/api/auth/<provider>/callback` |
|||
Used to log in to with the provider |
|||
- `https://<your-domain>/api/auth/<provider>/link` |
|||
Used to link an existing account to the provider |
|||
|
|||
If your provider does not support multiple redirect URIs (e.g. GitHub) but allows multiple URIs under the same base, then configure: |
|||
|
|||
- `https://<your-domain>/api/auth/<provider>/` |
|||
|
|||
### Provider Configuration |
|||
|
|||
#### Google |
|||
|
|||
| Env | Required | Description | |
|||
| ---------------------------- | -------- | -------------------- | |
|||
| `OAUTH_GOOGLE_CLIENT_ID` | ✔️ | Google Client ID | |
|||
| `OAUTH_GOOGLE_CLIENT_SECRET` | ✔️ | Google Client Secret | |
|||
|
|||
<h5>Setup</h5> |
|||
|
|||
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
|||
2. Create an OAuth 2.0 Client ID (Web application) |
|||
3. Add Authorized redirect URI: See [Redirect URIs](#redirect-uris) |
|||
4. Copy the Client ID and Client Secret to the environment variables |
|||
|
|||
#### GitHub |
|||
|
|||
| Env | Required | Description | |
|||
| ---------------------------- | -------- | -------------------- | |
|||
| `OAUTH_GITHUB_CLIENT_ID` | ✔️ | GitHub Client ID | |
|||
| `OAUTH_GITHUB_CLIENT_SECRET` | ✔️ | GitHub Client Secret | |
|||
|
|||
<h5>Setup</h5> |
|||
|
|||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers) |
|||
2. Create a new OAuth App |
|||
3. Add Authorization callback URL: See [Redirect URIs](#redirect-uris) |
|||
4. Create a new client secret |
|||
5. Copy the Client ID and Client Secret to the environment variables |
|||
|
|||
#### Generic OIDC |
|||
|
|||
This supports generic OIDC providers like Authelia, Authentik, etc. |
|||
|
|||
The provider needs to support: |
|||
|
|||
- PKCE |
|||
- default scopes: `openid email profile` |
|||
- Client Secret Authentication `client_secret_post` |
|||
|
|||
The provider needs to be available with HTTPS and have a valid certificate. |
|||
|
|||
| Env | Required | Default | Example | Description | |
|||
| -------------------------- | -------- | ------- | -------------------------- | ------------------ | |
|||
| `OAUTH_OIDC_SERVER` | ✔️ | - | `https://auth.example.com` | OIDC Server | |
|||
| `OAUTH_OIDC_CLIENT_ID` | ✔️ | - | - | OIDC Client ID | |
|||
| `OAUTH_OIDC_CLIENT_SECRET` | ✔️ | - | - | OIDC Client Secret | |
|||
| `OAUTH_OIDC_NAME` | ✖️ | OIDC | `Authelia` | Provider Name | |
|||
|
|||
##### Authelia Setup |
|||
|
|||
Generate Client ID and Secret: |
|||
|
|||
```shell |
|||
# Client ID |
|||
docker run --rm authelia/authelia:latest authelia crypto rand --length 72 --charset rfc3986 |
|||
# Client Secret |
|||
docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986 |
|||
``` |
|||
|
|||
```yaml |
|||
- client_id: '...' |
|||
client_name: wg-easy |
|||
client_secret: '$pbkdf2-...' |
|||
redirect_uris: |
|||
- https://<your-domain>/api/auth/oidc/callback |
|||
- https://<your-domain>/api/auth/oidc/link |
|||
scopes: |
|||
- openid |
|||
- profile |
|||
- email |
|||
authorization_policy: one_factor |
|||
pre_configured_consent_duration: 1 week |
|||
require_pkce: true |
|||
token_endpoint_auth_method: client_secret_post |
|||
``` |
|||
|
|||
#### Generic OAuth |
|||
|
|||
Not currently supported |
|||
|
|||
### Disable Password Authentication |
|||
|
|||
To disable password-based authentication and only allow login via OAuth providers, set the following environment variable to `true`: |
|||
|
|||
| Env | Required | Default | Description | |
|||
| ----------------------- | -------- | ------- | ------------------------------- | |
|||
| `DISABLE_PASSWORD_AUTH` | ✖️ | `false` | Disable password authentication | |
|||
|
|||
When enabled: |
|||
|
|||
- Users will not be able to log in with a password |
|||
|
|||
/// warning | Access Recovery |
|||
|
|||
Before disabling password authentication, ensure that at least one OAuth provider is configured and that you have successfully linked an administrator account. |
|||
|
|||
If no login method is available, you will not be able to log in to the application and will need to reset the configuration to regain access. |
|||
|
|||
/// |
|||
@ -0,0 +1,26 @@ |
|||
<template> |
|||
<component |
|||
:is="elementType" |
|||
role="button" |
|||
class="rounded-lg border-2 border-gray-100 text-gray-500 hover:border-red-800 hover:bg-red-800 hover:text-white focus:border-red-800 focus:outline-0 focus:ring-0 disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400 dark:disabled:border-neutral-800 dark:disabled:bg-neutral-700 dark:disabled:text-neutral-400" |
|||
v-bind="attrs" |
|||
> |
|||
<slot /> |
|||
</component> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const props = defineProps({ |
|||
as: { |
|||
type: String, |
|||
default: 'button', |
|||
}, |
|||
}); |
|||
|
|||
const elementType = computed(() => props.as); |
|||
|
|||
const attrs = computed(() => { |
|||
const { as, ...rest } = props; |
|||
return rest; |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,23 @@ |
|||
<template> |
|||
<!-- https://brand.github.com/foundations/logo --> |
|||
<svg |
|||
width="98" |
|||
height="96" |
|||
viewBox="0 0 98 96" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<g clip-path="url(#clip0_730_27136)"> |
|||
<path |
|||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" |
|||
fill="white" |
|||
class="fill-black dark:fill-white" |
|||
/> |
|||
</g> |
|||
<defs> |
|||
<clipPath id="clip0_730_27136"> |
|||
<rect width="98" height="96" fill="white" /> |
|||
</clipPath> |
|||
</defs> |
|||
</svg> |
|||
</template> |
|||
@ -0,0 +1,247 @@ |
|||
<template> |
|||
<!-- By Original: Google Vector: Designism, Liquinoid, and YeBoy371 - Own work based on: Google updating its ‘G’ icon for the first time in 10 years, Public Domain, https://commons.wikimedia.org/w/index.php?curid=165142698 --> |
|||
<svg |
|||
version="1.1" |
|||
viewBox="0 0 268.1522 273.8827" |
|||
overflow="hidden" |
|||
xml:space="preserve" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:svg="http://www.w3.org/2000/svg" |
|||
> |
|||
<defs> |
|||
<linearGradient id="a"> |
|||
<stop offset="0" stop-color="#0fbc5c" /> |
|||
<stop offset="1" stop-color="#0cba65" /> |
|||
</linearGradient> |
|||
<linearGradient id="g"> |
|||
<stop offset=".2312727" stop-color="#0fbc5f" /> |
|||
<stop offset=".3115468" stop-color="#0fbc5f" /> |
|||
<stop offset=".3660131" stop-color="#0fbc5e" /> |
|||
<stop offset=".4575163" stop-color="#0fbc5d" /> |
|||
<stop offset=".540305" stop-color="#12bc58" /> |
|||
<stop offset=".6993464" stop-color="#28bf3c" /> |
|||
<stop offset=".7712418" stop-color="#38c02b" /> |
|||
<stop offset=".8605665" stop-color="#52c218" /> |
|||
<stop offset=".9150327" stop-color="#67c30f" /> |
|||
<stop offset="1" stop-color="#86c504" /> |
|||
</linearGradient> |
|||
<linearGradient id="h"> |
|||
<stop offset=".1416122" stop-color="#1abd4d" /> |
|||
<stop offset=".2475151" stop-color="#6ec30d" /> |
|||
<stop offset=".3115468" stop-color="#8ac502" /> |
|||
<stop offset=".3660131" stop-color="#a2c600" /> |
|||
<stop offset=".4456735" stop-color="#c8c903" /> |
|||
<stop offset=".540305" stop-color="#ebcb03" /> |
|||
<stop offset=".6156363" stop-color="#f7cd07" /> |
|||
<stop offset=".6993454" stop-color="#fdcd04" /> |
|||
<stop offset=".7712418" stop-color="#fdce05" /> |
|||
<stop offset=".8605661" stop-color="#ffce0a" /> |
|||
</linearGradient> |
|||
<linearGradient id="f"> |
|||
<stop offset=".3159041" stop-color="#ff4c3c" /> |
|||
<stop offset=".6038179" stop-color="#ff692c" /> |
|||
<stop offset=".7268366" stop-color="#ff7825" /> |
|||
<stop offset=".884534" stop-color="#ff8d1b" /> |
|||
<stop offset="1" stop-color="#ff9f13" /> |
|||
</linearGradient> |
|||
<linearGradient id="b"> |
|||
<stop offset=".2312727" stop-color="#ff4541" /> |
|||
<stop offset=".3115468" stop-color="#ff4540" /> |
|||
<stop offset=".4575163" stop-color="#ff4640" /> |
|||
<stop offset=".540305" stop-color="#ff473f" /> |
|||
<stop offset=".6993464" stop-color="#ff5138" /> |
|||
<stop offset=".7712418" stop-color="#ff5b33" /> |
|||
<stop offset=".8605665" stop-color="#ff6c29" /> |
|||
<stop offset="1" stop-color="#ff8c18" /> |
|||
</linearGradient> |
|||
<linearGradient id="d"> |
|||
<stop offset=".4084578" stop-color="#fb4e5a" /> |
|||
<stop offset="1" stop-color="#ff4540" /> |
|||
</linearGradient> |
|||
<linearGradient id="c"> |
|||
<stop offset=".1315461" stop-color="#0cba65" /> |
|||
<stop offset=".2097843" stop-color="#0bb86d" /> |
|||
<stop offset=".2972969" stop-color="#09b479" /> |
|||
<stop offset=".3962575" stop-color="#08ad93" /> |
|||
<stop offset=".4771242" stop-color="#0aa6a9" /> |
|||
<stop offset=".5684245" stop-color="#0d9cc6" /> |
|||
<stop offset=".667385" stop-color="#1893dd" /> |
|||
<stop offset=".7687273" stop-color="#258bf1" /> |
|||
<stop offset=".8585063" stop-color="#3086ff" /> |
|||
</linearGradient> |
|||
<linearGradient id="e"> |
|||
<stop offset=".3660131" stop-color="#ff4e3a" /> |
|||
<stop offset=".4575163" stop-color="#ff8a1b" /> |
|||
<stop offset=".540305" stop-color="#ffa312" /> |
|||
<stop offset=".6156363" stop-color="#ffb60c" /> |
|||
<stop offset=".7712418" stop-color="#ffcd0a" /> |
|||
<stop offset=".8605665" stop-color="#fecf0a" /> |
|||
<stop offset=".9150327" stop-color="#fecf08" /> |
|||
<stop offset="1" stop-color="#fdcd01" /> |
|||
</linearGradient> |
|||
<linearGradient |
|||
id="s" |
|||
xlink:href="#a" |
|||
x1="219.6997" |
|||
y1="329.5351" |
|||
x2="254.4673" |
|||
y2="329.5351" |
|||
gradientUnits="userSpaceOnUse" |
|||
/> |
|||
<radialGradient |
|||
id="m" |
|||
xlink:href="#b" |
|||
gradientUnits="userSpaceOnUse" |
|||
gradientTransform="matrix(-1.936885,1.043001,1.455731,2.555422,290.5254,-400.6338)" |
|||
cx="109.6267" |
|||
cy="135.8619" |
|||
fx="109.6267" |
|||
fy="135.8619" |
|||
r="71.46001" |
|||
/> |
|||
<radialGradient |
|||
id="n" |
|||
xlink:href="#c" |
|||
gradientUnits="userSpaceOnUse" |
|||
gradientTransform="matrix(-3.512595,-4.45809,-1.692547,1.260616,870.8006,191.554)" |
|||
cx="45.25866" |
|||
cy="279.2738" |
|||
fx="45.25866" |
|||
fy="279.2738" |
|||
r="71.46001" |
|||
/> |
|||
<radialGradient |
|||
id="l" |
|||
xlink:href="#d" |
|||
cx="304.0166" |
|||
cy="118.0089" |
|||
fx="304.0166" |
|||
fy="118.0089" |
|||
r="47.85445" |
|||
gradientTransform="matrix(2.064353,-4.926832e-6,-2.901531e-6,2.592041,-297.6788,-151.7469)" |
|||
gradientUnits="userSpaceOnUse" |
|||
/> |
|||
<radialGradient |
|||
id="o" |
|||
xlink:href="#e" |
|||
gradientUnits="userSpaceOnUse" |
|||
gradientTransform="matrix(-0.2485783,2.083138,2.962486,0.3341668,-255.1463,-331.1636)" |
|||
cx="181.001" |
|||
cy="177.2013" |
|||
fx="181.001" |
|||
fy="177.2013" |
|||
r="71.46001" |
|||
/> |
|||
<radialGradient |
|||
id="p" |
|||
xlink:href="#f" |
|||
cx="207.6733" |
|||
cy="108.0972" |
|||
fx="207.6733" |
|||
fy="108.0972" |
|||
r="41.1025" |
|||
gradientTransform="matrix(-1.249206,1.343263,-3.896837,-3.425693,880.5011,194.9051)" |
|||
gradientUnits="userSpaceOnUse" |
|||
/> |
|||
<radialGradient |
|||
id="r" |
|||
xlink:href="#g" |
|||
gradientUnits="userSpaceOnUse" |
|||
gradientTransform="matrix(-1.936885,-1.043001,1.455731,-2.555422,290.5254,838.6834)" |
|||
cx="109.6267" |
|||
cy="135.8619" |
|||
fx="109.6267" |
|||
fy="135.8619" |
|||
r="71.46001" |
|||
/> |
|||
<radialGradient |
|||
id="j" |
|||
xlink:href="#h" |
|||
gradientUnits="userSpaceOnUse" |
|||
gradientTransform="matrix(-0.081402,-1.93722,2.926737,-0.1162508,-215.1345,632.8606)" |
|||
cx="154.8697" |
|||
cy="145.9691" |
|||
fx="154.8697" |
|||
fy="145.9691" |
|||
r="71.46001" |
|||
/> |
|||
<filter |
|||
id="q" |
|||
x="-.04842873" |
|||
y="-.0582241" |
|||
width="1.096857" |
|||
height="1.116448" |
|||
color-interpolation-filters="sRGB" |
|||
> |
|||
<feGaussianBlur stdDeviation="1.700914" /> |
|||
</filter> |
|||
<filter |
|||
id="k" |
|||
x="-.01670084" |
|||
y="-.01009856" |
|||
width="1.033402" |
|||
height="1.020197" |
|||
color-interpolation-filters="sRGB" |
|||
> |
|||
<feGaussianBlur stdDeviation=".2419367" /> |
|||
</filter> |
|||
<clipPath id="i" clipPathUnits="userSpaceOnUse"> |
|||
<path |
|||
d="M371.3784 193.2406H237.0825v53.4375h77.167c-1.2405 7.5627-4.0259 15.0024-8.1049 21.7862-4.6734 7.7723-10.4511 13.6895-16.373 18.1957-17.7389 13.4983-38.42 16.2584-52.7828 16.2584-36.2824 0-67.2833-23.2865-79.2844-54.9287-.4843-1.1482-.8059-2.3344-1.1975-3.5068-2.652-8.0533-4.101-16.5825-4.101-25.4474 0-9.226 1.5691-18.0575 4.4301-26.3985 11.2851-32.8967 42.9849-57.4674 80.1789-57.4674 7.4811 0 14.6854.8843 21.5173 2.6481 15.6135 4.0309 26.6578 11.9698 33.4252 18.2494l40.834-39.7111c-24.839-22.616-57.2194-36.3201-95.8444-36.3201-30.8782-.00066-59.3863 9.55308-82.7477 25.6992-18.9454 13.0941-34.4833 30.6254-44.9695 50.9861-9.75366 18.8785-15.09441 39.7994-15.09441 62.2934 0 22.495 5.34891 43.6334 15.10261 62.3374v.126c10.3023 19.8567 25.3678 36.9537 43.6783 49.9878 15.9962 11.3866 44.6789 26.5516 84.0307 26.5516 22.6301 0 42.6867-4.0517 60.3748-11.6447 12.76-5.4775 24.0655-12.6217 34.3012-21.8036 13.5247-12.1323 24.1168-27.1388 31.3465-44.4041 7.2297-17.2654 11.097-36.7895 11.097-57.957 0-9.858-.9971-19.8694-2.6881-28.9684Z" |
|||
fill="#000" |
|||
/> |
|||
</clipPath> |
|||
</defs> |
|||
<g transform="matrix(0.957922,0,0,0.985255,-90.17436,-78.85577)"> |
|||
<g clip-path="url(#i)"> |
|||
<path |
|||
d="M92.07563 219.9585c.14844 22.14 6.5014 44.983 16.11767 63.4234v.1269c6.9482 13.3919 16.4444 23.9704 27.2604 34.4518l65.326-23.67c-12.3593-6.2344-14.2452-10.0546-23.1048-17.0253-9.0537-9.0658-15.8015-19.4735-20.0038-31.677h-.1693l.1693-.1269c-2.7646-8.0587-3.0373-16.6129-3.1393-25.5029Z" |
|||
fill="url(#j)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M237.0835 79.02491c-6.4568 22.52569-3.988 44.42139 0 57.16129 7.4561.0055 14.6388.8881 21.4494 2.6464 15.6135 4.0309 26.6566 11.97 33.424 18.2496l41.8794-40.7256c-24.8094-22.58904-54.6663-37.2961-96.7528-37.33169Z" |
|||
fill="url(#l)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M236.9434 78.84678c-31.6709-.00068-60.9107 9.79833-84.8718 26.35902-8.8968 6.149-17.0612 13.2521-24.3311 21.1509-1.9045 17.7429 14.2569 39.5507 46.2615 39.3702 15.5284-17.9373 38.4946-29.5427 64.0561-29.5427.0233 0 .046.0019.0693.002l-1.0439-57.33536c-.0472-.00003-.0929-.00406-.1401-.00406Z" |
|||
fill="url(#m)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="m341.4751 226.3788-28.2685 19.2848c-1.2405 7.5627-4.0278 15.0023-8.1068 21.7861-4.6734 7.7723-10.4506 13.6898-16.3725 18.196-17.7022 13.4704-38.3286 16.2439-52.6877 16.2553-14.8415 25.1018-17.4435 37.6749 1.0439 57.9342 22.8762-.0167 43.157-4.1174 61.0458-11.7965 12.9312-5.551 24.3879-12.7913 34.7609-22.0964 13.7061-12.295 24.4421-27.5034 31.7688-45.0003 7.3267-17.497 11.2446-37.2822 11.2446-58.7336Z" |
|||
fill="url(#n)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M234.9956 191.2104v57.4981h136.0062c1.1962-7.8745 5.1523-18.0644 5.1523-26.5001 0-9.858-.9963-21.899-2.6873-30.998Z" |
|||
fill="#3086ff" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M128.3894 124.3268c-8.393 9.1191-15.5632 19.326-21.2483 30.3646-9.75351 18.8785-15.09402 41.8295-15.09402 64.3235 0 .317.02642.6271.02855.9436 4.31953 8.2244 59.66647 6.6495 62.45617 0-.0035-.3103-.0387-.6128-.0387-.9238 0-9.226 1.5696-16.0262 4.4306-24.3672 3.5294-10.2885 9.0557-19.7628 16.1223-27.9257 1.6019-2.0309 5.8748-6.3969 7.1214-9.0157.4749-.9975-.8621-1.5574-.9369-1.9085-.0836-.3927-1.8762-.0769-2.2778-.3694-1.2751-.9288-3.8001-1.4138-5.3334-1.8449-3.2772-.9215-8.7085-2.9536-11.7252-5.0601-9.5357-6.6586-24.417-14.6122-33.5047-24.2164Z" |
|||
fill="url(#o)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M162.0989 155.8569c22.1123 13.3013 28.4714-6.7139 43.173-12.9771L179.698 90.21568c-9.4075 3.92642-18.2957 8.80465-26.5426 14.50442-12.316 8.5122-23.192 18.8995-32.1763 30.7204Z" |
|||
fill="url(#p)" |
|||
filter="url(#q)" |
|||
/> |
|||
<path |
|||
d="M171.0987 290.222c-29.6829 10.6413-34.3299 11.023-37.0622 29.2903 5.2213 5.0597 10.8312 9.74 16.7926 13.9835 15.9962 11.3867 46.766 26.5517 86.1178 26.5517.0462 0 .0904-.004.1366-.004v-59.1574c-.0298.0001-.064.002-.0938.002-14.7359 0-26.5113-3.8435-38.5848-10.5273-2.9768-1.6479-8.3775 2.7772-11.1229.799-3.7865-2.7284-12.8991 2.3508-16.1833-.9378Z" |
|||
fill="url(#r)" |
|||
filter="url(#k)" |
|||
/> |
|||
<path |
|||
d="M219.6997 299.0227v59.9959c5.506.6402 11.2361 1.0289 17.2472 1.0289 6.0259 0 11.8556-.3073 17.5204-.8723v-59.7481c-6.3482 1.0777-12.3272 1.461-17.4776 1.461-5.9318 0-11.7005-.6858-17.29-1.8654Z" |
|||
opacity=".5" |
|||
fill="url(#s)" |
|||
filter="url(#k)" |
|||
/> |
|||
</g> |
|||
</g> |
|||
</svg> |
|||
</template> |
|||
@ -0,0 +1,10 @@ |
|||
<template> |
|||
<IconsBrandsGitHub v-if="provider === 'github'" /> |
|||
<IconsBrandsGoogle v-else-if="provider === 'google'" /> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineProps<{ |
|||
provider: OAUTH_PROVIDER; |
|||
}>(); |
|||
</script> |
|||
@ -0,0 +1,21 @@ |
|||
<template> |
|||
<a |
|||
:href="`/api/auth/${provider}`" |
|||
class="flex items-center justify-center gap-2 rounded border border-gray-300 bg-white py-2 text-sm text-gray-700 shadow-sm transition hover:bg-gray-50 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700" |
|||
> |
|||
<IconsBrandsProvider :provider="provider" class="h-4 w-4" /> |
|||
<span> |
|||
{{ $t('login.signInWith', [info.friendlyName]) }} |
|||
</span> |
|||
</a> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineProps<{ |
|||
info: { |
|||
enabled: boolean; |
|||
friendlyName: string; |
|||
}; |
|||
provider: OAUTH_PROVIDER; |
|||
}>(); |
|||
</script> |
|||
@ -0,0 +1,100 @@ |
|||
<template> |
|||
<div> |
|||
<form class="flex flex-col gap-5" @submit.prevent="submit"> |
|||
<BaseInput |
|||
v-model="totp" |
|||
type="text" |
|||
name="totp" |
|||
:placeholder="$t('general.2faCode')" |
|||
autocomplete="one-time-code" |
|||
inputmode="numeric" |
|||
maxlength="6" |
|||
pattern="\d{6}" |
|||
autofocus |
|||
/> |
|||
|
|||
<button |
|||
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800" |
|||
:disabled="!totp || authenticating" |
|||
> |
|||
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" /> |
|||
<span v-else>{{ $t('general.continue') }}</span> |
|||
</button> |
|||
|
|||
<button |
|||
type="button" |
|||
class="rounded border-2 border-gray-100 py-2 text-sm text-gray-700 transition hover:border-red-800 hover:bg-red-800 hover:text-white dark:border-neutral-600 dark:text-neutral-200" |
|||
@click="cancel" |
|||
> |
|||
{{ $t('dialog.cancel') }} |
|||
</button> |
|||
</form> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const toast = useToast(); |
|||
const { t } = useI18n(); |
|||
|
|||
const authenticating = ref(false); |
|||
const totp = ref<string>(''); |
|||
|
|||
const { error } = await useFetch('/api/auth/pending'); |
|||
if (error.value) { |
|||
await navigateTo('/login'); |
|||
} |
|||
|
|||
const _submit = useSubmit( |
|||
(data) => |
|||
$fetch('/api/auth/verify-2fa', { |
|||
method: 'post', |
|||
body: data, |
|||
}), |
|||
{ |
|||
revert: async (success, data) => { |
|||
if (success) { |
|||
if (data?.status === 'success') { |
|||
await navigateTo('/'); |
|||
return; |
|||
} else if (data?.status === 'INVALID_TOTP_CODE') { |
|||
authenticating.value = false; |
|||
totp.value = ''; |
|||
toast.showToast({ |
|||
title: t('general.2fa'), |
|||
message: t('login.2faWrong'), |
|||
type: 'error', |
|||
}); |
|||
return; |
|||
} |
|||
} |
|||
authenticating.value = false; |
|||
}, |
|||
noSuccessToast: true, |
|||
} |
|||
); |
|||
|
|||
async function submit() { |
|||
if (!totp.value || authenticating.value) return; |
|||
|
|||
authenticating.value = true; |
|||
return _submit({ totpCode: totp.value }); |
|||
} |
|||
|
|||
const _cancel = useSubmit( |
|||
(data) => |
|||
$fetch('/api/auth/cancel', { |
|||
method: 'post', |
|||
body: data, |
|||
}), |
|||
{ |
|||
revert: async () => { |
|||
await navigateTo('/login'); |
|||
}, |
|||
noSuccessToast: true, |
|||
} |
|||
); |
|||
|
|||
async function cancel() { |
|||
return _cancel({}); |
|||
} |
|||
</script> |
|||
@ -0,0 +1,147 @@ |
|||
<template> |
|||
<div class="flex flex-col gap-5"> |
|||
<div |
|||
v-if="authMethods && authMethods.oauthEnabled" |
|||
class="flex flex-col gap-5" |
|||
> |
|||
<UiLoginOauthButton |
|||
v-for="(info, provider) in authMethods.providers" |
|||
:key="provider" |
|||
:provider="provider" |
|||
:info="info" |
|||
/> |
|||
|
|||
<!-- Divider --> |
|||
<div |
|||
v-if="authMethods.oauthEnabled && !authMethods.passwordDisabled" |
|||
class="flex items-center gap-2" |
|||
> |
|||
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div> |
|||
<span class="text-xs text-gray-500 dark:text-neutral-400"> |
|||
{{ $t('login.or') }} |
|||
</span> |
|||
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Classic Login Form --> |
|||
<form |
|||
v-if="!authMethods?.passwordDisabled" |
|||
class="flex flex-col gap-5" |
|||
@submit.prevent="submit" |
|||
> |
|||
<BaseInput |
|||
v-model="username" |
|||
type="text" |
|||
:placeholder="$t('general.username')" |
|||
autocomplete="username" |
|||
autofocus |
|||
name="username" |
|||
/> |
|||
|
|||
<BaseInput |
|||
v-model="password" |
|||
type="password" |
|||
name="password" |
|||
:placeholder="$t('general.password')" |
|||
autocomplete="current-password" |
|||
/> |
|||
|
|||
<label |
|||
class="flex gap-2 whitespace-nowrap" |
|||
:title="$t('login.rememberMeDesc')" |
|||
> |
|||
<BaseSwitch v-model="remember" /> |
|||
<span class="text-sm">{{ $t('login.rememberMe') }}</span> |
|||
</label> |
|||
|
|||
<button |
|||
class="rounded bg-red-800 py-2 text-sm text-white shadow transition hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-200 dark:bg-red-800 dark:text-white dark:hover:bg-red-700 disabled:dark:bg-neutral-800" |
|||
:disabled="!password || !username" |
|||
> |
|||
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" /> |
|||
<span v-else>{{ $t('login.signIn') }}</span> |
|||
</button> |
|||
</form> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const toast = useToast(); |
|||
const { t } = useI18n(); |
|||
const route = useRoute(); |
|||
|
|||
const authenticating = ref(false); |
|||
const remember = ref(false); |
|||
const username = ref<string>(''); |
|||
const password = ref<string>(''); |
|||
|
|||
const { data: authMethods } = await useFetch('/api/auth/methods'); |
|||
|
|||
watchEffect(() => { |
|||
const autoLauchQuery = |
|||
typeof route.query.auto_launch === 'string' && !!route.query.auto_launch |
|||
? route.query.auto_launch |
|||
: undefined; |
|||
|
|||
if (authMethods.value?.autoLaunchProvider && !autoLauchQuery) { |
|||
navigateTo(`/api/auth/${authMethods.value.autoLaunchProvider}`, { |
|||
external: true, |
|||
}); |
|||
} |
|||
|
|||
if ( |
|||
autoLauchQuery && |
|||
autoLauchQuery !== 'false' && |
|||
authMethods.value?.providers?.[autoLauchQuery as OAUTH_PROVIDER] |
|||
) { |
|||
navigateTo(`/api/auth/${autoLauchQuery}`, { |
|||
external: true, |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
const _submit = useSubmit( |
|||
(data) => |
|||
$fetch('/api/auth/password', { |
|||
method: 'post', |
|||
body: data, |
|||
}), |
|||
{ |
|||
revert: async (success, data) => { |
|||
if (success) { |
|||
if (data?.status === 'success') { |
|||
await navigateTo('/'); |
|||
} else if (data?.status === 'TOTP_REQUIRED') { |
|||
authenticating.value = false; |
|||
await navigateTo('/login/2fa'); |
|||
return; |
|||
} else if (data?.status === 'INVALID_TOTP_CODE') { |
|||
authenticating.value = false; |
|||
toast.showToast({ |
|||
title: t('general.2fa'), |
|||
message: t('login.2faWrong'), |
|||
type: 'error', |
|||
}); |
|||
return; |
|||
} |
|||
} |
|||
authenticating.value = false; |
|||
password.value = ''; |
|||
}, |
|||
noSuccessToast: true, |
|||
} |
|||
); |
|||
|
|||
async function submit() { |
|||
if (!username.value || !password.value || authenticating.value) return; |
|||
|
|||
authenticating.value = true; |
|||
|
|||
return _submit({ |
|||
username: username.value, |
|||
password: password.value, |
|||
remember: remember.value, |
|||
}); |
|||
} |
|||
</script> |
|||
@ -77,6 +77,9 @@ importers: |
|||
obug: |
|||
specifier: ^2.1.1 |
|||
version: 2.1.1 |
|||
openid-client: |
|||
specifier: ^6.8.4 |
|||
version: 6.8.4 |
|||
otpauth: |
|||
specifier: ^9.5.1 |
|||
version: 9.5.1 |
|||
@ -4452,6 +4455,9 @@ packages: |
|||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} |
|||
hasBin: true |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} |
|||
|
|||
@ -4839,6 +4845,9 @@ packages: |
|||
engines: {node: '>=18'} |
|||
hasBin: true |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} |
|||
engines: {node: '>=0.10.0'} |
|||
@ -4888,6 +4897,9 @@ packages: |
|||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} |
|||
engines: {node: '>=8'} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} |
|||
engines: {node: '>= 0.8.0'} |
|||
@ -10475,6 +10487,8 @@ snapshots: |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
@ -11076,6 +11090,8 @@ snapshots: |
|||
pathe: 2.0.3 |
|||
tinyexec: 1.2.3 |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
@ -11124,6 +11140,11 @@ snapshots: |
|||
is-docker: 2.2.1 |
|||
is-wsl: 2.2.0 |
|||
|
|||
[email protected]: |
|||
dependencies: |
|||
jose: 6.2.3 |
|||
oauth4webapi: 3.8.6 |
|||
|
|||
[email protected]: |
|||
dependencies: |
|||
deep-is: 0.1.4 |
|||
|
|||
@ -0,0 +1,87 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const { config, provider, providerConfig } = await buildOauthConfig(event); |
|||
|
|||
const session = await useWGSession(event); |
|||
if ( |
|||
!session.data.oauth_nonce || |
|||
!session.data.oauth_verifier || |
|||
!session.data.oauth_state |
|||
) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Missing OAuth State', |
|||
}); |
|||
} |
|||
|
|||
const userInfo = await getUserInfo( |
|||
event, |
|||
config, |
|||
{ |
|||
oauth_nonce: session.data.oauth_nonce, |
|||
oauth_verifier: session.data.oauth_verifier, |
|||
oauth_state: session.data.oauth_state, |
|||
}, |
|||
providerConfig |
|||
); |
|||
|
|||
const result = await Database.users.loginWithOAuth( |
|||
provider, |
|||
userInfo.sub, |
|||
userInfo.preferred_username || userInfo.email, |
|||
userInfo.email, |
|||
userInfo.name || 'User' |
|||
); |
|||
|
|||
if (!result.success) { |
|||
switch (result.error) { |
|||
case 'TOTP_REQUIRED': |
|||
await session.update({ |
|||
pendingLogin: { |
|||
type: 'oauth', |
|||
userId: result.userId, |
|||
remember: false, |
|||
}, |
|||
oauth_nonce: undefined, |
|||
oauth_state: undefined, |
|||
oauth_verifier: undefined, |
|||
}); |
|||
return sendRedirect(event, '/login/2fa'); |
|||
case 'USER_DISABLED': |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'User disabled', |
|||
}); |
|||
case 'USER_ALREADY_LINKED': |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: |
|||
'User already linked with different account or provider', |
|||
}); |
|||
case 'AUTO_REGISTER_DISABLED': |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Auto registration is disabled', |
|||
}); |
|||
case 'UNEXPECTED_ERROR': |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Unexpected error', |
|||
}); |
|||
} |
|||
assertUnreachable(result); |
|||
} |
|||
|
|||
// Create session
|
|||
const data = await session.update({ |
|||
userId: result.user.id, |
|||
oauth_nonce: undefined, |
|||
oauth_state: undefined, |
|||
oauth_verifier: undefined, |
|||
}); |
|||
|
|||
SERVER_DEBUG( |
|||
`New OAuth Session: ${data.id} for ${result.user.id} (${result.user.username}) with ${provider}` |
|||
); |
|||
|
|||
return sendRedirect(event, '/'); |
|||
}); |
|||
@ -0,0 +1,50 @@ |
|||
import * as client from 'openid-client'; |
|||
import { z } from 'zod'; |
|||
|
|||
const OauthQuerySchema = z.object({ |
|||
link: z.coerce.boolean().optional(), |
|||
}); |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const params = await getValidatedQuery( |
|||
event, |
|||
validateZod(OauthQuerySchema, event) |
|||
); |
|||
|
|||
const { config, provider, providerConfig } = await buildOauthConfig(event); |
|||
|
|||
const host = getRequestHost(event); |
|||
const protocol = WG_ENV.INSECURE ? 'http' : 'https'; |
|||
const baseUri = `${protocol}://${host}/api/auth/${provider}`; |
|||
|
|||
let redirectUri = `${baseUri}/callback`; |
|||
if (params.link) { |
|||
redirectUri = `${baseUri}/link`; |
|||
} |
|||
|
|||
const codeVerifier = client.randomPKCECodeVerifier(); |
|||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); |
|||
const nonce = client.randomNonce(); |
|||
const state = client.randomState(); |
|||
|
|||
const parameters: Record<string, string> = { |
|||
...providerConfig.params, |
|||
redirect_uri: redirectUri, |
|||
scope: providerConfig.scope, |
|||
code_challenge: codeChallenge, |
|||
code_challenge_method: 'S256', |
|||
nonce: nonce, |
|||
state: state, |
|||
}; |
|||
|
|||
const session = await useWGSession(event); |
|||
await session.update({ |
|||
oauth_nonce: nonce, |
|||
oauth_verifier: codeVerifier, |
|||
oauth_state: state, |
|||
}); |
|||
|
|||
const redirectTo = client.buildAuthorizationUrl(config, parameters); |
|||
|
|||
return sendRedirect(event, redirectTo.toString()); |
|||
}); |
|||
@ -0,0 +1,36 @@ |
|||
export default definePermissionEventHandler( |
|||
'me', |
|||
'update', |
|||
async ({ event, user, checkPermissions }) => { |
|||
checkPermissions(user); |
|||
|
|||
const { config, provider, providerConfig } = await buildOauthConfig(event); |
|||
|
|||
const session = await useWGSession(event); |
|||
if ( |
|||
!session.data.oauth_nonce || |
|||
!session.data.oauth_verifier || |
|||
!session.data.oauth_state |
|||
) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Missing OAuth State', |
|||
}); |
|||
} |
|||
|
|||
const userInfo = await getUserInfo( |
|||
event, |
|||
config, |
|||
{ |
|||
oauth_nonce: session.data.oauth_nonce, |
|||
oauth_verifier: session.data.oauth_verifier, |
|||
oauth_state: session.data.oauth_state, |
|||
}, |
|||
providerConfig |
|||
); |
|||
|
|||
await Database.users.linkOauth(user.id, provider, userInfo.sub); |
|||
|
|||
return sendRedirect(event, '/me'); |
|||
} |
|||
); |
|||
@ -0,0 +1,10 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const session = await useWGSession(event, false); |
|||
await session.update({ |
|||
pendingLogin: undefined, |
|||
oauth_nonce: undefined, |
|||
oauth_state: undefined, |
|||
oauth_verifier: undefined, |
|||
}); |
|||
return { success: true as const }; |
|||
}); |
|||
@ -0,0 +1,18 @@ |
|||
export default defineEventHandler(() => { |
|||
return { |
|||
providers: WG_ENV.OAUTH_PROVIDERS?.reduce( |
|||
(acc, curr) => { |
|||
acc[curr] = { |
|||
enabled: true, |
|||
friendlyName: OAUTH_PROVIDERS[curr].friendlyName, |
|||
}; |
|||
return acc; |
|||
}, |
|||
{} as Record<OAUTH_PROVIDER, { enabled: true; friendlyName: string }> |
|||
), |
|||
oauthEnabled: |
|||
WG_ENV.OAUTH_PROVIDERS !== undefined && WG_ENV.OAUTH_PROVIDERS.length > 0, |
|||
passwordDisabled: WG_ENV.DISABLE_PASSWORD_AUTH, |
|||
autoLaunchProvider: WG_ENV.OAUTH_AUTO_LAUNCH, |
|||
}; |
|||
}); |
|||
@ -0,0 +1,14 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const session = await useWGSession(event); |
|||
|
|||
if (!session.data.pendingLogin) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'No pending authentication', |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
type: session.data.pendingLogin.type, |
|||
}; |
|||
}); |
|||
@ -0,0 +1,11 @@ |
|||
export default definePermissionEventHandler( |
|||
'me', |
|||
'update', |
|||
async ({ user, checkPermissions }) => { |
|||
checkPermissions(user); |
|||
|
|||
await Database.users.unlinkOauth(user.id); |
|||
|
|||
return { success: true }; |
|||
} |
|||
); |
|||
@ -0,0 +1,50 @@ |
|||
import { z } from 'zod'; |
|||
|
|||
const Verify2faSchema = z.object({ |
|||
totpCode: z.string().min(6).max(6), |
|||
}); |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const { totpCode } = await readValidatedBody( |
|||
event, |
|||
validateZod(Verify2faSchema, event) |
|||
); |
|||
const session = await useWGSession(event); |
|||
|
|||
const pendingLogin = session.data.pendingLogin; |
|||
if (!pendingLogin) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'No pending authentication', |
|||
}); |
|||
} |
|||
|
|||
const totpStatus = await Database.users.validateTotpCode( |
|||
pendingLogin.userId, |
|||
totpCode |
|||
); |
|||
|
|||
switch (totpStatus) { |
|||
case 'INVALID_TOTP_CODE': |
|||
return { status: 'INVALID_TOTP_CODE' as const }; |
|||
case 'USER_DISABLED': |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'User disabled', |
|||
}); |
|||
case 'success': |
|||
break; |
|||
default: |
|||
assertUnreachable(totpStatus); |
|||
} |
|||
|
|||
await session.update({ |
|||
userId: pendingLogin.userId, |
|||
pendingLogin: undefined, |
|||
oauth_nonce: undefined, |
|||
oauth_state: undefined, |
|||
oauth_verifier: undefined, |
|||
}); |
|||
|
|||
return { status: 'success' as const }; |
|||
}); |
|||
@ -0,0 +1,23 @@ |
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint |
|||
CREATE TABLE `__new_users_table` ( |
|||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
`username` text NOT NULL, |
|||
`password` text, |
|||
`email` text, |
|||
`name` text NOT NULL, |
|||
`role` integer NOT NULL, |
|||
`totp_key` text, |
|||
`totp_verified` integer NOT NULL, |
|||
`enabled` integer NOT NULL, |
|||
`oauth_provider` text, |
|||
`oauth_id` text, |
|||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, |
|||
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL |
|||
); |
|||
--> statement-breakpoint |
|||
INSERT INTO `__new_users_table`("id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at") SELECT "id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint |
|||
DROP TABLE `users_table`;--> statement-breakpoint |
|||
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint |
|||
PRAGMA foreign_keys=ON;--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `oauth_provider_id_unique` ON `users_table` (`oauth_provider`,`oauth_id`); |
|||
File diff suppressed because it is too large
@ -0,0 +1,258 @@ |
|||
import type { H3Event } from 'h3'; |
|||
import * as client from 'openid-client'; |
|||
|
|||
type OAuthConfig = { |
|||
friendlyName: string; |
|||
server: string; |
|||
scope: string; |
|||
clientId: string | undefined; |
|||
clientSecret: string | undefined; |
|||
params: Record<string, string>; |
|||
isOIDC?: false; |
|||
userInfoFlow?: 'github'; |
|||
}; |
|||
|
|||
const GoogleConfig: OAuthConfig = { |
|||
friendlyName: 'Google', |
|||
server: 'https://accounts.google.com', |
|||
scope: 'openid email profile', |
|||
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID, |
|||
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET, |
|||
params: { |
|||
access_type: 'online', |
|||
prompt: 'select_account', |
|||
}, |
|||
}; |
|||
const GithubConfig: OAuthConfig = { |
|||
friendlyName: 'GitHub', |
|||
server: 'https://github.com/login/oauth', |
|||
scope: 'read:user user:email', |
|||
clientId: process.env.OAUTH_GITHUB_CLIENT_ID, |
|||
clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET, |
|||
params: { |
|||
allow_signup: 'false', |
|||
prompt: 'select_account', |
|||
}, |
|||
isOIDC: false, |
|||
userInfoFlow: 'github', |
|||
}; |
|||
const OidcConfig: OAuthConfig = { |
|||
friendlyName: process.env.OAUTH_OIDC_NAME ?? 'OIDC', |
|||
server: process.env.OAUTH_OIDC_SERVER ?? '', |
|||
scope: 'openid email profile', |
|||
clientId: process.env.OAUTH_OIDC_CLIENT_ID, |
|||
clientSecret: process.env.OAUTH_OIDC_CLIENT_SECRET, |
|||
params: {}, |
|||
}; |
|||
|
|||
export const OAUTH_PROVIDERS = { |
|||
google: GoogleConfig, |
|||
github: GithubConfig, |
|||
oidc: OidcConfig, |
|||
}; |
|||
|
|||
export type OAUTH_PROVIDER = keyof typeof OAUTH_PROVIDERS; |
|||
|
|||
export function isValidOauthProvider( |
|||
provider: string |
|||
): provider is OAUTH_PROVIDER { |
|||
if (provider in OAUTH_PROVIDERS) { |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
export function isConfiguredOauthProvider( |
|||
oauthProvider: (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] |
|||
): oauthProvider is (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] & { |
|||
clientId: string; |
|||
clientSecret: string; |
|||
} { |
|||
if (!oauthProvider.clientId || !oauthProvider.clientSecret) { |
|||
return false; |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
function isEnabledProvider(provider: OAUTH_PROVIDER) { |
|||
return WG_ENV.OAUTH_PROVIDERS?.includes(provider); |
|||
} |
|||
|
|||
// TODO: simplify logic between WG_ENV.OAUTH_PROVIDERS and buildOauthConfig
|
|||
export async function buildOauthConfig(event: H3Event) { |
|||
const provider = getRouterParam(event, 'provider'); |
|||
if (!provider || !isValidOauthProvider(provider)) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'Invalid provider', |
|||
}); |
|||
} |
|||
|
|||
if (!isEnabledProvider(provider)) { |
|||
throw createError({ |
|||
statusCode: 403, |
|||
statusMessage: 'Provider is not enabled', |
|||
}); |
|||
} |
|||
|
|||
const oauthProvider = OAUTH_PROVIDERS[provider]; |
|||
|
|||
if (!isConfiguredOauthProvider(oauthProvider)) { |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Provider is not configured', |
|||
}); |
|||
} |
|||
|
|||
const config = await client.discovery( |
|||
new URL(oauthProvider.server), |
|||
oauthProvider.clientId, |
|||
{ |
|||
client_secret: oauthProvider.clientSecret, |
|||
} |
|||
); |
|||
|
|||
return { config, providerConfig: oauthProvider, provider }; |
|||
} |
|||
|
|||
export async function githubUserInfoFlow(accessToken: string) { |
|||
const OAUTH_GITHUB_FLOW = { |
|||
userinfo_endpoint: 'https://api.github.com/user', |
|||
email_endpoint: 'https://api.github.com/user/emails', |
|||
}; |
|||
type OAUTH_GITHUB_USERINFO = { |
|||
id: number; |
|||
login: string; |
|||
avatar_url: string; |
|||
email: string | null; |
|||
name: string | null; |
|||
}; |
|||
type OAUTH_GITHUB_EMAIL = { |
|||
email: string; |
|||
primary: boolean; |
|||
verified: boolean; |
|||
visibility: string | null; |
|||
}[]; |
|||
|
|||
const response = await $fetch<OAUTH_GITHUB_USERINFO>( |
|||
OAUTH_GITHUB_FLOW.userinfo_endpoint, |
|||
{ |
|||
headers: { |
|||
'User-Agent': 'wg-easy', |
|||
Authorization: `Bearer ${accessToken}`, |
|||
}, |
|||
} |
|||
); |
|||
if (!response.email) { |
|||
const emailResponse = await $fetch<OAUTH_GITHUB_EMAIL>( |
|||
OAUTH_GITHUB_FLOW.email_endpoint, |
|||
{ |
|||
headers: { |
|||
'User-Agent': 'wg-easy', |
|||
Authorization: `Bearer ${accessToken}`, |
|||
}, |
|||
} |
|||
); |
|||
const primaryEmail = emailResponse.find((v) => v.primary && v.verified); |
|||
response.email = primaryEmail?.email || null; |
|||
} |
|||
return { |
|||
sub: response.id.toString(), |
|||
email: response.email ?? undefined, |
|||
email_verified: true, |
|||
preferred_username: response.login, |
|||
name: response.name || response.login, |
|||
}; |
|||
} |
|||
|
|||
type OauthState = { |
|||
oauth_nonce: string; |
|||
oauth_verifier: string; |
|||
oauth_state: string; |
|||
}; |
|||
|
|||
export async function getUserInfo( |
|||
event: H3Event, |
|||
config: client.Configuration, |
|||
state: OauthState, |
|||
providerConfig: OAuthConfig |
|||
) { |
|||
const currentUrl = getRequestURL(event); |
|||
|
|||
const tokens = await client.authorizationCodeGrant(config, currentUrl, { |
|||
pkceCodeVerifier: state.oauth_verifier, |
|||
expectedNonce: |
|||
providerConfig.isOIDC === false ? undefined : state.oauth_nonce, |
|||
expectedState: state.oauth_state, |
|||
idTokenExpected: providerConfig.isOIDC ?? true, |
|||
}); |
|||
|
|||
type SubjectType = string | undefined | typeof client.skipSubjectCheck; |
|||
let subject: SubjectType = tokens.claims()?.sub; |
|||
if (providerConfig.isOIDC === false) { |
|||
subject = client.skipSubjectCheck; |
|||
} |
|||
|
|||
if (!subject) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "Can't get subject", |
|||
}); |
|||
} |
|||
|
|||
let userInfo: client.UserInfoResponse; |
|||
if (providerConfig.userInfoFlow === 'github') { |
|||
userInfo = await githubUserInfoFlow(tokens.access_token); |
|||
} else { |
|||
userInfo = await client.fetchUserInfo(config, tokens.access_token, subject); |
|||
} |
|||
|
|||
assertHasOauthProps(userInfo); |
|||
|
|||
if (!isAllowedDomain(userInfo.email)) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Email domain not allowed', |
|||
}); |
|||
} |
|||
|
|||
return userInfo; |
|||
} |
|||
|
|||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>>; |
|||
|
|||
function assertHasOauthProps<T extends client.UserInfoResponse>( |
|||
userInfo: T |
|||
): asserts userInfo is T & RequireKeys<T, 'sub' | 'email' | 'email_verified'> { |
|||
if (!userInfo.sub) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'No sub set', |
|||
}); |
|||
} |
|||
|
|||
if (!userInfo.email) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: 'No email set', |
|||
}); |
|||
} |
|||
|
|||
if (!userInfo.email_verified) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Email is not verified', |
|||
}); |
|||
} |
|||
} |
|||
|
|||
function isAllowedDomain(email: string) { |
|||
const emailDomain = email.slice(email.lastIndexOf('@') + 1); |
|||
if ( |
|||
WG_ENV.OAUTH_ALLOWED_DOMAINS && |
|||
!WG_ENV.OAUTH_ALLOWED_DOMAINS.includes(emailDomain) |
|||
) { |
|||
return false; |
|||
} |
|||
return true; |
|||
} |
|||
Loading…
Reference in new issue