feat: oauth integration (#2659)

* 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 <dm@fotc.com>
This commit is contained in:
Bernd Storath 2026-06-12 13:38:32 +02:00 committed by GitHub
parent c70ad1d08b
commit ac547bbf7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2848 additions and 222 deletions

View file

@ -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.
///

View file

@ -2,7 +2,7 @@
title: Basic Installation
---
<!-- TOOD: add docs for pihole, nginx, caddy, traefik -->
<!-- TODO: add docs for pihole, nginx, caddy, traefik -->
## Requirements

View file

@ -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>

View file

@ -1,8 +1,9 @@
<template>
<input
<BaseFormSecondaryButton
as="input"
:value="label"
:type="type ?? 'button'"
class="col-span-2 rounded-lg border-2 border-gray-100 py-2 text-gray-500 hover:border-red-800 hover:bg-red-800 hover:text-white focus:border-red-800 focus:outline-0 focus:ring-0 dark:border-neutral-800 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
class="col-span-2 py-2"
/>
</template>

View file

@ -1,12 +1,14 @@
<template>
<SelectRoot v-model="langProxy" :default-value="locale">
<SelectTrigger
class="inline-flex h-8 items-center justify-around gap-2 rounded bg-gray-200 px-3 text-sm leading-none dark:bg-neutral-700 dark:text-neutral-400"
class="group inline-flex h-8 items-center justify-around gap-2 rounded bg-gray-200 px-3 text-sm leading-none dark:bg-neutral-700 dark:text-neutral-400"
aria-label="Select language"
>
<IconsLanguage class="size-3" />
<SelectValue />
<IconsArrowDown class="size-3" />
<IconsArrowDown
class="size-3 transition-transform group-data-[state=open]:rotate-180"
/>
</SelectTrigger>
<SelectPortal>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -29,5 +29,7 @@
<script setup lang="ts">
const route = useRoute();
const loggedIn = computed(() => route.path !== '/login');
const loggedIn = computed(
() => route.path !== '/login' && route.path !== '/login/2fa'
);
</script>

View file

@ -9,8 +9,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authStore = useAuthStore();
authStore.userData = await authStore.getSession(event);
// skip login if already logged in
if (to.path === '/login') {
const isLoginRoute = to.path === '/login' || to.path === '/login/2fa';
// skip login pages if already logged in
if (isLoginRoute) {
if (authStore.userData?.username) {
return navigateTo('/', { redirectCode: 302 });
}

View file

@ -2,9 +2,8 @@
<main>
<UiBanner />
<HeaderInsecure />
<form
<div
class="mx-auto mt-10 flex w-64 flex-col gap-5 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
@submit.prevent="submit"
>
<!-- Avatar -->
<div
@ -13,118 +12,7 @@
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<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"
/>
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<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 py-2 text-sm text-white shadow transition dark:text-white"
:class="{
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password && username,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
!password || !username,
}"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
<NuxtPage />
</div>
</main>
</template>
<script setup lang="ts">
const toast = useToast();
const { t } = useI18n();
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>('');
const password = ref<string>('');
const totpRequired = ref(false);
const totp = ref<string>('');
const _submit = useSubmit(
(data) =>
$fetch('/api/session', {
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;
totpRequired.value = true;
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
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;
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,
totpCode: totpRequired.value ? totp.value : undefined,
});
}
</script>

100
src/app/pages/login/2fa.vue Normal file
View file

@ -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>

View file

@ -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>

View file

@ -27,6 +27,7 @@
<FormGroup>
<FormHeading>{{ $t('general.password') }}</FormHeading>
<FormPasswordField
v-if="hasPassword"
id="current-password"
v-model="currentPassword"
autocomplete="current-password"
@ -46,24 +47,25 @@
/>
<FormSecondaryActionField
type="submit"
:label="$t('general.updatePassword')"
:label="
hasPassword
? $t('general.updatePassword')
: $t('general.addPassword')
"
/>
</FormGroup>
</FormElement>
<FormElement @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.2fa') }}</FormHeading>
<div
v-if="!authStore.userData?.totpVerified && !twofa"
class="col-span-2 flex flex-col"
>
<template v-if="!authStore.userData?.totpVerified && !twofa">
<FormSecondaryActionField
:label="$t('me.enable2fa')"
@click="setup2fa"
/>
</div>
</template>
<div
v-if="!authStore.userData?.totpVerified && twofa"
v-else-if="!authStore.userData?.totpVerified && twofa"
class="col-span-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
@ -93,7 +95,7 @@
</div>
</div>
<div
v-if="authStore.userData?.totpVerified"
v-else-if="authStore.userData?.totpVerified"
class="col-span-2 flex flex-col gap-2"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
@ -113,6 +115,60 @@
</div>
</FormGroup>
</FormElement>
<FormElement v-if="authMethods?.oauthEnabled" @submit.prevent>
<FormGroup>
<FormHeading>{{ $t('general.externalAuth') }}</FormHeading>
<template v-if="oauthProvider && oauthProviderInfo?.enabled">
<div class="flex items-center gap-2">
<IconsBrandsProvider
:provider="oauthProvider"
class="h-4 w-4"
/>
<span>
{{ $t('me.linkedWith', [oauthProviderInfo.friendlyName]) }}
</span>
</div>
<div>
<FormSecondaryActionField
:label="$t('me.unlinkOauth')"
class="w-full"
:disabled="!hasPassword"
@click="unlinkOauth"
/>
</div>
</template>
<template v-else-if="oauthProvider && !oauthProviderInfo?.enabled">
<span class="flex items-center">
{{ $t('me.providerDisabled') }}
</span>
<div>
<FormSecondaryActionField
:label="$t('me.unlinkOauth')"
class="w-full"
:disabled="!hasPassword"
@click="unlinkOauth"
/>
</div>
</template>
<template v-else>
<div class="flex items-center">
<span>{{ $t('me.linkOauth') }}</span>
</div>
<div class="flex flex-col gap-2">
<BaseFormSecondaryButton
v-for="(info, provider) in authMethods.providers"
:key="provider"
class="flex items-center gap-2 p-2"
as="a"
:href="`/api/auth/${provider}?link=true`"
>
<IconsBrandsProvider :provider="provider" class="h-4 w-4" />
<span>{{ info.friendlyName }}</span>
</BaseFormSecondaryButton>
</div>
</template>
</FormGroup>
</FormElement>
</PanelBody>
</Panel>
</main>
@ -123,8 +179,18 @@ import { encodeQR } from 'qr';
const authStore = useAuthStore();
const { data: authMethods } = await useFetch('/api/auth/methods');
const name = ref(authStore.userData?.name);
const email = ref(authStore.userData?.email);
const hasPassword = computed(() => authStore.userData?.hasPassword);
const oauthProvider = computed(() => authStore.userData?.oauthProvider);
const oauthProviderInfo = computed(() => {
if (!authStore.userData?.oauthProvider) {
return null;
}
return authMethods.value?.providers?.[authStore.userData.oauthProvider];
});
const _submit = useSubmit(
(data) =>
@ -158,13 +224,14 @@ const _updatePassword = useSubmit(
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
return authStore.update();
},
}
);
function updatePassword() {
return _updatePassword({
currentPassword: currentPassword.value,
currentPassword: hasPassword.value ? currentPassword.value : null,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
});
@ -249,4 +316,21 @@ async function disable2fa() {
currentPassword: disable2faPassword.value,
});
}
const _unlinkOauth = useSubmit(
(data) =>
$fetch(`/api/auth/unlink`, {
method: 'post',
body: data,
}),
{
revert: async () => {
return authStore.update();
},
}
);
async function unlinkOauth() {
return _unlinkOauth({});
}
</script>

View file

@ -1,5 +1,4 @@
import type { H3Event } from 'h3';
import type { SharedPublicUser } from '~~/shared/utils/permissions';
export const useAuthStore = defineStore('Auth', () => {
const userData = useState<SharedPublicUser | null>('user-data', () => null);

View file

@ -4,3 +4,5 @@ export type ToastParams = {
title: string;
message: string;
};
export type OAUTH_PROVIDER = 'google' | 'github' | 'oidc';

View file

@ -20,7 +20,11 @@
"2faKey": "TOTP Key",
"2faCodeDesc": "Enter the code from your authenticator app.",
"disable2fa": "Disable Two Factor Authentication",
"disable2faDesc": "Enter your password to disable Two Factor Authentication."
"disable2faDesc": "Enter your password to disable Two Factor Authentication.",
"linkOauth": "Link your account with an external provider",
"unlinkOauth": "Unlink",
"linkedWith": "Linked with {0}",
"providerDisabled": "Your currently linked provider is not enabled"
},
"general": {
"name": "Name",
@ -28,6 +32,7 @@
"password": "Password",
"newPassword": "New Password",
"updatePassword": "Update Password",
"addPassword": "Add Password",
"mtu": "MTU",
"allowedIps": "Allowed IPs",
"dns": "DNS",
@ -41,7 +46,8 @@
"confirmPassword": "Confirm Password",
"loading": "Loading...",
"2fa": "Two Factor Authentication",
"2faCode": "TOTP Code"
"2faCode": "TOTP Code",
"externalAuth": "External Authentication"
},
"setup": {
"welcome": "Welcome to your first setup of wg-easy",
@ -72,6 +78,8 @@
},
"login": {
"signIn": "Sign In",
"signInWith": "Sign in with {0}",
"or": "or",
"rememberMe": "Remember me",
"rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS.",

View file

@ -72,6 +72,7 @@
},
"login": {
"signIn": "Zaloguj się",
"or": "lub",
"rememberMe": "Zapamiętaj mnie",
"rememberMeDesc": "Pozostań zalogowany po zamknięciu przeglądarki",
"insecure": "Nie możesz zalogować się przez niezabezpieczone połączenie. Użyj HTTPS.",

View file

@ -44,6 +44,7 @@
"js-sha256": "^0.11.1",
"nuxt": "^3.21.6",
"obug": "^2.1.1",
"openid-client": "^6.8.4",
"otpauth": "^9.5.1",
"pinia": "^3.0.4",
"qr": "^0.6.0",

21
src/pnpm-lock.yaml generated
View file

@ -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
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@ -4839,6 +4845,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-assign@4.1.1:
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'}
openid-client@6.8.4:
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -10475,6 +10487,8 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.3: {}
js-base64@3.7.8: {}
js-sha256@0.11.1: {}
@ -11076,6 +11090,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.2.3
oauth4webapi@3.8.6: {}
object-assign@4.1.1: {}
object-deep-merge@2.0.1: {}
@ -11124,6 +11140,11 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openid-client@6.8.4:
dependencies:
jose: 6.2.3
oauth4webapi: 3.8.6
optionator@0.9.4:
dependencies:
deep-is: 0.1.4

View file

@ -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, '/');
});

View file

@ -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());
});

View file

@ -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');
}
);

View file

@ -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 };
});

View file

@ -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,
};
});

View file

@ -1,12 +1,21 @@
import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => {
const { username, password, remember, totpCode } = await readValidatedBody(
if (WG_ENV.DISABLE_PASSWORD_AUTH) {
throw createError({
statusCode: 403,
statusMessage: 'Password authentication is disabled',
});
}
const { username, password, remember } = await readValidatedBody(
event,
validateZod(UserLoginSchema, event)
);
const result = await Database.users.login(username, password, totpCode);
const result = await Database.users.login(username, password);
const session = await useWGSession(event, remember);
// TODO: add localization support
@ -18,6 +27,13 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Invalid username or password',
});
case 'TOTP_REQUIRED':
await session.update({
pendingLogin: {
type: 'password',
userId: result.userId,
remember,
},
});
return { status: 'TOTP_REQUIRED' as const };
case 'INVALID_TOTP_CODE':
return { status: 'INVALID_TOTP_CODE' as const };
@ -32,13 +48,11 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unexpected error',
});
}
assertUnreachable(result.error);
assertUnreachable(result);
}
const user = result.user;
const session = await useWGSession(event, remember);
const data = await session.update({
userId: user.id,
});

View file

@ -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,
};
});

View file

@ -0,0 +1,11 @@
export default definePermissionEventHandler(
'me',
'update',
async ({ user, checkPermissions }) => {
checkPermissions(user);
await Database.users.unlinkOauth(user.id);
return { success: true };
}
);

View file

@ -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 };
});

View file

@ -1,9 +1,7 @@
import type { SharedPublicUser } from '~~/shared/utils/permissions';
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.userId) {
if (!session.data.userId || session.data.pendingLogin) {
// not logged in
throw createError({
statusCode: 401,
@ -18,6 +16,12 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Not found in Database',
});
}
if (!user.enabled) {
throw createError({
statusCode: 403,
statusMessage: 'User is disabled',
});
}
return {
id: user.id,
@ -26,5 +30,7 @@ export default defineEventHandler(async (event) => {
name: user.name,
email: user.email,
totpVerified: user.totpVerified,
oauthProvider: user.oauthProvider,
hasPassword: user.password !== null,
} satisfies SharedPublicUser;
});

View file

@ -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 Load diff

View file

@ -36,6 +36,13 @@
"when": 1771352889394,
"tag": "0004_optimal_mandrill",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780035570366,
"tag": "0005_clumsy_korg",
"breakpoints": true
}
]
}

View file

@ -1,26 +1,38 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { client } from '../../schema';
export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
password: text().notNull(),
email: text(),
name: text().notNull(),
role: int().$type<Role>().notNull(),
totpKey: text('totp_key'),
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const user = sqliteTable(
'users_table',
{
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
/** `password == null` means password login disabled */
password: text(),
email: text(),
name: text().notNull(),
role: int().$type<Role>().notNull(),
totpKey: text('totp_key'),
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
oauthProvider: text('oauth_provider').$type<OAUTH_PROVIDER>(),
oauthId: text('oauth_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex('oauth_provider_id_unique').on(
table.oauthProvider,
table.oauthId
),
]
);
export const usersRelations = relations(user, ({ many }) => ({
clients: many(client),

View file

@ -1,4 +1,4 @@
import { eq, sql } from 'drizzle-orm';
import { eq, sql, and } from 'drizzle-orm';
import { TOTP } from 'otpauth';
import { user } from './schema';
import type { UserType } from './types';
@ -13,10 +13,33 @@ type LoginResult =
success: false;
error:
| 'INCORRECT_CREDENTIALS'
| 'TOTP_REQUIRED'
| 'USER_DISABLED'
| 'INVALID_TOTP_CODE'
| 'UNEXPECTED_ERROR';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
};
type LoginWithOAuthResult =
| {
success: true;
user: UserType;
}
| {
success: false;
error:
| 'USER_DISABLED'
| 'USER_ALREADY_LINKED'
| 'UNEXPECTED_ERROR'
| 'AUTO_REGISTER_DISABLED';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
};
function createPreparedStatement(db: DBType) {
@ -113,7 +136,11 @@ export class UserService {
return this.#statements.update.execute({ id, name, email });
}
async updatePassword(id: ID, currentPassword: string, newPassword: string) {
async updatePassword(
id: ID,
currentPassword: string | null,
newPassword: string
) {
const hash = await hashPassword(newPassword);
return this.#db.transaction(async (tx) => {
@ -126,13 +153,20 @@ export class UserService {
throw new Error('User not found');
}
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
// only check password if already set
if (txUser.password !== null) {
if (!currentPassword) {
throw new Error('Invalid password');
}
if (!passwordValid) {
throw new Error('Invalid password');
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
if (!passwordValid) {
throw new Error('Invalid password');
}
}
await tx
@ -147,42 +181,32 @@ export class UserService {
return this.#statements.updateKey.execute({ id, key });
}
login(username: string, password: string, code: string | undefined) {
login(username: string, password: string) {
return this.#db.transaction(async (tx): Promise<LoginResult> => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.username, username) })
.execute();
if (!txUser) {
// always check to avoid timing attack
const userHashPassword = txUser?.password ?? null;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!txUser || !passwordValid) {
return { success: false, error: 'INCORRECT_CREDENTIALS' };
}
const passwordValid = await isPasswordValid(password, txUser.password);
if (!passwordValid) {
return { success: false, error: 'INCORRECT_CREDENTIALS' };
}
if (txUser.totpVerified) {
if (!code) {
return { success: false, error: 'TOTP_REQUIRED' };
} else {
const totpKey = txUser.totpKey;
if (!totpKey) {
return { success: false, error: 'UNEXPECTED_ERROR' };
}
const totp = this.#createTotp({ username: txUser.username, totpKey });
if (totp.validate({ token: code, window: 1 }) === null) {
return { success: false, error: 'INVALID_TOTP_CODE' };
}
}
}
if (!txUser.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (txUser.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: txUser.id,
};
}
return { success: true, user: txUser };
});
}
@ -241,4 +265,187 @@ export class UserService {
.execute();
});
}
async validateTotpCode(id: ID, code: string) {
const txUser = await this.#db.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser || !txUser.totpVerified || !txUser.totpKey) {
return 'INVALID_TOTP_CODE' as const;
}
const totp = this.#createTotp({
username: txUser.username,
totpKey: txUser.totpKey,
});
const isValid = totp.validate({ token: code, window: 1 }) !== null;
if (!isValid) {
return 'INVALID_TOTP_CODE' as const;
}
if (!txUser.enabled) {
return 'USER_DISABLED' as const;
}
return 'success' as const;
}
/**
* Login or register user with OAuth provider.
* If user with the same email already exists, link account with OAuth provider.
* Otherwise, create new user.
*/
async loginWithOAuth(
provider: OAUTH_PROVIDER,
oauthId: string,
username: string,
email: string,
name: string
): Promise<LoginWithOAuthResult> {
return this.#db.transaction(async (tx) => {
const userById = await tx.query.user
.findFirst({
where: and(
eq(user.oauthProvider, provider),
eq(user.oauthId, oauthId)
),
})
.execute();
if (userById) {
if (!userById.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (userById.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userById.id,
};
}
return { success: true, user: userById };
}
const userByEmail = await tx.query.user
.findFirst({
where: eq(user.email, email),
})
.execute();
if (userByEmail) {
if (!userByEmail.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (userByEmail.oauthProvider && userByEmail.oauthId) {
return {
success: false,
error: 'USER_ALREADY_LINKED',
};
}
await tx
.update(user)
.set({ oauthProvider: provider, oauthId: oauthId })
.where(eq(user.id, userByEmail.id))
.execute();
if (userByEmail.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userByEmail.id,
};
}
// TODO: return updated user
return { success: true, user: userByEmail };
}
if (!WG_ENV.OAUTH_AUTO_REGISTER) {
return { success: false, error: 'AUTO_REGISTER_DISABLED' };
}
// Create new user
const newUsers = await tx
.insert(user)
.values({
username,
password: null,
email,
name,
role: roles.ADMIN,
totpVerified: false,
enabled: true,
oauthProvider: provider,
oauthId,
})
.returning();
const newUser = newUsers[0];
if (!newUser) {
return { success: false as const, error: 'UNEXPECTED_ERROR' as const };
}
return { success: true as const, user: newUser };
});
}
unlinkOauth(id: ID) {
return this.#db.transaction(async (tx) => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
// can't unlink if no way to log back in
if (!txUser.password) {
throw new Error('Password login not enabled');
}
await tx
.update(user)
.set({ oauthProvider: null, oauthId: null })
.where(eq(user.id, id))
.execute();
});
}
async linkOauth(id: ID, provider: OAUTH_PROVIDER, oauthId: string) {
return this.#db.transaction(async (tx) => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
if (txUser.oauthProvider || txUser.oauthId) {
throw new Error('User already linked with an OAuth provider');
}
const existingUser = await tx.query.user
.findFirst({
where: and(
eq(user.oauthProvider, provider),
eq(user.oauthId, oauthId)
),
})
.execute();
if (existingUser) {
throw new Error('OAuth account already linked with another user');
}
await tx
.update(user)
.set({ oauthProvider: provider, oauthId: oauthId })
.where(eq(user.id, id))
.execute();
});
}
}

View file

@ -25,7 +25,6 @@ export const UserLoginSchema = z.object({
username: username,
password: password,
remember: remember,
totpCode: totpCode.optional(),
});
export const UserSetupSchema = z
@ -57,7 +56,7 @@ export const UserUpdateSchema = z.object({
export const UserUpdatePasswordSchema = z
.object({
currentPassword: password,
currentPassword: password.nullable(),
newPassword: password,
confirmPassword: password,
})

View file

@ -1,12 +1,14 @@
export default defineNitroPlugin((nitroApp) => {
console.log(`====================================================`);
console.log(` wg-easy - https://github.com/wg-easy/wg-easy `);
console.log(`====================================================`);
console.log(`| wg-easy: ${RELEASE.padEnd(38)} |`);
console.log(`| Node: ${process.version.padEnd(38)} |`);
console.log(`| Platform: ${process.platform.padEnd(38)} |`);
console.log(`| Arch: ${process.arch.padEnd(38)} |`);
console.log(`====================================================`);
console.log(`
wg-easy - https://github.com/wg-easy/wg-easy ┃
wg-easy: ${RELEASE.padEnd(38)}
Node: ${process.version.padEnd(38)}
Platform: ${process.platform.padEnd(38)}
Arch: ${process.arch.padEnd(38)}
`);
nitroApp.hooks.hook('close', async () => {
console.log('Shutting down');
await WireGuard.Shutdown();

View file

@ -30,6 +30,11 @@ const detectAwg = async (): Promise<'awg' | 'wg'> => {
} else return 'wg';
};
const oauthProviders = process.env.OAUTH_PROVIDERS?.split(',')
.map((v) => v.trim())
.filter((v) => isValidOauthProvider(v))
.filter((v) => isConfiguredOauthProvider(OAUTH_PROVIDERS[v]));
export const WG_ENV = {
/** UI is hosted on HTTP instead of HTTPS */
INSECURE: process.env.INSECURE === 'true',
@ -39,8 +44,31 @@ export const WG_ENV = {
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
WG_EXECUTABLE: await detectAwg(),
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
/** List of enabled OAuth providers */
OAUTH_PROVIDERS: oauthProviders,
/** List of allowed OAuth domains */
OAUTH_ALLOWED_DOMAINS: process.env.OAUTH_ALLOWED_DOMAINS?.split(',').map(
(v) => v.trim()
),
/** Automatically register users that log in with an OAuth provider */
OAUTH_AUTO_REGISTER: process.env.OAUTH_AUTO_REGISTER === 'true',
/** Which OAuth provider to automatically launch */
OAUTH_AUTO_LAUNCH:
oauthProviders?.find((p) => p === process.env.OAUTH_AUTO_LAUNCH) ?? null,
/** Disable password authentication */
DISABLE_PASSWORD_AUTH: process.env.DISABLE_PASSWORD_AUTH === 'true',
};
if (WG_ENV.OAUTH_PROVIDERS && WG_ENV.OAUTH_PROVIDERS.length > 0) {
SERVER_DEBUG(`
Enabled OAuth providers: ${WG_ENV.OAUTH_PROVIDERS.join(', ')}
Allowed OAuth domains: ${WG_ENV.OAUTH_ALLOWED_DOMAINS?.join(', ') ?? 'All'}
OAuth auto register: ${WG_ENV.OAUTH_AUTO_REGISTER ? 'Enabled' : 'Disabled'}
Password authentication: ${WG_ENV.DISABLE_PASSWORD_AUTH ? 'Disabled' : 'Enabled'}
Auto launch OAuth provider: ${WG_ENV.OAUTH_AUTO_LAUNCH ?? 'None'}
`);
}
export const WG_INITIAL_ENV = {
ENABLED: process.env.INIT_ENABLED === 'true',
USERNAME: process.env.INIT_USERNAME,

258
src/server/utils/oauth.ts Normal file
View file

@ -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;
}

View file

@ -3,13 +3,22 @@
import argon2 from 'argon2';
import { deserialize } from '@phc/format';
const DUMMY_HASH =
'$argon2id$v=19$m=65536,t=3,p=4$jsh6z1/SbZHYAiO/Ww9HZw$ikzkoXWqc2b0Pc4O8ZNJjp1xKZSb7SNM/3dPMNUPk9Y';
/**
* Checks if `password` matches the hash.
* Checks if `password` matches the `hash`.
*
* Checks against `DUMMY_HASH` and returns false if `hash` is null
*/
export function isPasswordValid(
export async function isPasswordValid(
password: string,
hash: string
hash: string | null
): Promise<boolean> {
if (hash === null) {
await argon2.verify(DUMMY_HASH, password);
return false;
}
return argon2.verify(hash, password);
}

View file

@ -3,6 +3,15 @@ import type { UserType } from '#db/repositories/user/types';
export type WGSession = Partial<{
userId: ID;
// TODO: add pending login expiration
pendingLogin: {
type: 'password' | 'oauth';
userId: ID;
remember: boolean;
};
oauth_verifier: string;
oauth_nonce: string;
oauth_state: string;
}>;
const name = 'wg-easy';
@ -70,21 +79,14 @@ export async function getCurrentUser(event: H3Event) {
});
}
// TODO: timing can be used to enumerate usernames
const foundUser = await Database.users.getByUsername(username);
if (!foundUser) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const userHashPassword = foundUser.password;
// always check to avoid timing attack
const userHashPassword = foundUser?.password ?? null;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!passwordValid) {
// can't login through basic auth if 2fa enabled
if (!foundUser || !passwordValid || foundUser.totpVerified) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',

View file

@ -47,8 +47,8 @@ type SharedUserType =
export type SharedPublicUser = Pick<
UserType,
'id' | 'username' | 'name' | 'email' | 'totpVerified'
> & { role: BrandedNumber };
'id' | 'username' | 'name' | 'email' | 'totpVerified' | 'oauthProvider'
> & { role: BrandedNumber; hasPassword: boolean };
type PermissionCheck<Key extends keyof Permissions> =
| boolean
@ -144,7 +144,10 @@ export function hasPermissionsWithData<Resource extends keyof Permissions>(
const isAllowed = hasPermissions(user, resource, action, data);
if (!isAllowed) {
throw new Error('Permission denied');
throw createError({
statusCode: 403,
statusMessage: 'Permission denied',
});
}
return isAllowed;

View file

@ -17,4 +17,8 @@ describe('password', () => {
expect(isValidPasswordHash(hash.replace('argon2', 'argon3'))).toBe(false);
});
test('missing password hash is never valid', async () => {
await expect(isPasswordValid('password', null)).resolves.toBe(false);
});
});