diff --git a/docs/content/advanced/config/external-authentication.md b/docs/content/advanced/config/external-authentication.md new file mode 100644 index 00000000..420c3275 --- /dev/null +++ b/docs/content/advanced/config/external-authentication.md @@ -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=` + +### Redirect URIs + +You have to configure the following redirect URIs in your OAuth provider: + +- `https:///api/auth//callback` + Used to log in to with the provider +- `https:///api/auth//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:///api/auth//` + +### Provider Configuration + +#### Google + +| Env | Required | Description | +| ---------------------------- | -------- | -------------------- | +| `OAUTH_GOOGLE_CLIENT_ID` | ✔️ | Google Client ID | +| `OAUTH_GOOGLE_CLIENT_SECRET` | ✔️ | Google Client Secret | + +
Setup
+ +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 | + +
Setup
+ +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:///api/auth/oidc/callback + - https:///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. + +/// diff --git a/docs/content/examples/tutorials/basic-installation.md b/docs/content/examples/tutorials/basic-installation.md index 0ef8cddb..8df9f905 100644 --- a/docs/content/examples/tutorials/basic-installation.md +++ b/docs/content/examples/tutorials/basic-installation.md @@ -2,7 +2,7 @@ title: Basic Installation --- - + ## Requirements diff --git a/src/app/components/Base/FormSecondaryButton.vue b/src/app/components/Base/FormSecondaryButton.vue new file mode 100644 index 00000000..d9c6c71a --- /dev/null +++ b/src/app/components/Base/FormSecondaryButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/app/components/Form/SecondaryActionField.vue b/src/app/components/Form/SecondaryActionField.vue index 7d8cefaf..94878c55 100644 --- a/src/app/components/Form/SecondaryActionField.vue +++ b/src/app/components/Form/SecondaryActionField.vue @@ -1,8 +1,9 @@ diff --git a/src/app/components/Header/LangSelector.vue b/src/app/components/Header/LangSelector.vue index c0452def..1d302e9e 100644 --- a/src/app/components/Header/LangSelector.vue +++ b/src/app/components/Header/LangSelector.vue @@ -1,12 +1,14 @@ - - diff --git a/src/app/pages/login/2fa.vue b/src/app/pages/login/2fa.vue new file mode 100644 index 00000000..5910ce07 --- /dev/null +++ b/src/app/pages/login/2fa.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/app/pages/login/index.vue b/src/app/pages/login/index.vue new file mode 100644 index 00000000..39657322 --- /dev/null +++ b/src/app/pages/login/index.vue @@ -0,0 +1,147 @@ + + + diff --git a/src/app/pages/me.vue b/src/app/pages/me.vue index b0bb54a7..52a666e6 100644 --- a/src/app/pages/me.vue +++ b/src/app/pages/me.vue @@ -27,6 +27,7 @@ {{ $t('general.password') }} {{ $t('general.2fa') }} -
+

@@ -93,7 +95,7 @@

@@ -113,6 +115,60 @@

+ + + {{ $t('general.externalAuth') }} + + + + + @@ -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({}); +} diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 64bac790..64aeb889 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -1,5 +1,4 @@ import type { H3Event } from 'h3'; -import type { SharedPublicUser } from '~~/shared/utils/permissions'; export const useAuthStore = defineStore('Auth', () => { const userData = useState('user-data', () => null); diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 0736ab97..b293ce98 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -4,3 +4,5 @@ export type ToastParams = { title: string; message: string; }; + +export type OAUTH_PROVIDER = 'google' | 'github' | 'oidc'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 86120f19..6a863f7d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 2d572eaa..be500131 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -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.", diff --git a/src/package.json b/src/package.json index 96429ef1..031e5971 100644 --- a/src/package.json +++ b/src/package.json @@ -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", diff --git a/src/pnpm-lock.yaml b/src/pnpm-lock.yaml index fdd1ecbf..008219ff 100644 --- a/src/pnpm-lock.yaml +++ b/src/pnpm-lock.yaml @@ -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 diff --git a/src/server/api/auth/[provider]/callback.get.ts b/src/server/api/auth/[provider]/callback.get.ts new file mode 100644 index 00000000..2d710f3f --- /dev/null +++ b/src/server/api/auth/[provider]/callback.get.ts @@ -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, '/'); +}); diff --git a/src/server/api/auth/[provider]/index.get.ts b/src/server/api/auth/[provider]/index.get.ts new file mode 100644 index 00000000..dd973195 --- /dev/null +++ b/src/server/api/auth/[provider]/index.get.ts @@ -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 = { + ...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()); +}); diff --git a/src/server/api/auth/[provider]/link.get.ts b/src/server/api/auth/[provider]/link.get.ts new file mode 100644 index 00000000..51b6191d --- /dev/null +++ b/src/server/api/auth/[provider]/link.get.ts @@ -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'); + } +); diff --git a/src/server/api/auth/cancel.post.ts b/src/server/api/auth/cancel.post.ts new file mode 100644 index 00000000..2f0c2fcd --- /dev/null +++ b/src/server/api/auth/cancel.post.ts @@ -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 }; +}); diff --git a/src/server/api/auth/methods.get.ts b/src/server/api/auth/methods.get.ts new file mode 100644 index 00000000..48b9fb2e --- /dev/null +++ b/src/server/api/auth/methods.get.ts @@ -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 + ), + oauthEnabled: + WG_ENV.OAUTH_PROVIDERS !== undefined && WG_ENV.OAUTH_PROVIDERS.length > 0, + passwordDisabled: WG_ENV.DISABLE_PASSWORD_AUTH, + autoLaunchProvider: WG_ENV.OAUTH_AUTO_LAUNCH, + }; +}); diff --git a/src/server/api/session.post.ts b/src/server/api/auth/password.post.ts similarity index 70% rename from src/server/api/session.post.ts rename to src/server/api/auth/password.post.ts index 07a46021..14829ce8 100644 --- a/src/server/api/session.post.ts +++ b/src/server/api/auth/password.post.ts @@ -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, }); diff --git a/src/server/api/auth/pending.get.ts b/src/server/api/auth/pending.get.ts new file mode 100644 index 00000000..623817ce --- /dev/null +++ b/src/server/api/auth/pending.get.ts @@ -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, + }; +}); diff --git a/src/server/api/auth/unlink.post.ts b/src/server/api/auth/unlink.post.ts new file mode 100644 index 00000000..6be0e644 --- /dev/null +++ b/src/server/api/auth/unlink.post.ts @@ -0,0 +1,11 @@ +export default definePermissionEventHandler( + 'me', + 'update', + async ({ user, checkPermissions }) => { + checkPermissions(user); + + await Database.users.unlinkOauth(user.id); + + return { success: true }; + } +); diff --git a/src/server/api/auth/verify-2fa.post.ts b/src/server/api/auth/verify-2fa.post.ts new file mode 100644 index 00000000..2fa19d86 --- /dev/null +++ b/src/server/api/auth/verify-2fa.post.ts @@ -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 }; +}); diff --git a/src/server/api/session.get.ts b/src/server/api/session.get.ts index 5d975ee5..7cb38bb6 100644 --- a/src/server/api/session.get.ts +++ b/src/server/api/session.get.ts @@ -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; }); diff --git a/src/server/database/migrations/0005_clumsy_korg.sql b/src/server/database/migrations/0005_clumsy_korg.sql new file mode 100644 index 00000000..315ed5ed --- /dev/null +++ b/src/server/database/migrations/0005_clumsy_korg.sql @@ -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`); \ No newline at end of file diff --git a/src/server/database/migrations/meta/0005_snapshot.json b/src/server/database/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..acb5a605 --- /dev/null +++ b/src/server/database/migrations/meta/0005_snapshot.json @@ -0,0 +1,1009 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d41b21e2-3977-4e94-8250-d26a26678703", + "prevId": "0f072f91-cd10-4702-ae7b-245255d69d1e", + "tables": { + "clients_table": { + "name": "clients_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interface_id": { + "name": "interface_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_address": { + "name": "ipv4_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_address": { + "name": "ipv6_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_shared_key": { + "name": "pre_shared_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_allowed_ips": { + "name": "server_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_ips": { + "name": "firewall_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_keepalive": { + "name": "persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dns": { + "name": "dns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_endpoint": { + "name": "server_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "clients_table_ipv4_address_unique": { + "name": "clients_table_ipv4_address_unique", + "columns": [ + "ipv4_address" + ], + "isUnique": true + }, + "clients_table_ipv6_address_unique": { + "name": "clients_table_ipv6_address_unique", + "columns": [ + "ipv6_address" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "clients_table_interface_id_interfaces_table_name_fk": { + "name": "clients_table_interface_id_interfaces_table_name_fk", + "tableFrom": "clients_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "interface_id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "general_table": { + "name": "general_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "setup_step": { + "name": "setup_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_password": { + "name": "session_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_prometheus": { + "name": "metrics_prometheus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_json": { + "name": "metrics_json", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_password": { + "name": "metrics_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hooks_table": { + "name": "hooks_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "hooks_table_id_interfaces_table_name_fk": { + "name": "hooks_table_id_interfaces_table_name_fk", + "tableFrom": "hooks_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "interfaces_table": { + "name": "interfaces_table", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_cidr": { + "name": "ipv4_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_cidr": { + "name": "ipv6_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "s1": { + "name": "s1", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 128 + }, + "s2": { + "name": "s2", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 56 + }, + "s3": { + "name": "s3", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s4": { + "name": "s4", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1": { + "name": "h1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h2": { + "name": "h2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h3": { + "name": "h3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h4": { + "name": "h4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_enabled": { + "name": "firewall_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "interfaces_table_port_unique": { + "name": "interfaces_table_port_unique", + "columns": [ + "port" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "one_time_links_table": { + "name": "one_time_links_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "one_time_link": { + "name": "one_time_link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "one_time_links_table_one_time_link_unique": { + "name": "one_time_links_table_one_time_link_unique", + "columns": [ + "one_time_link" + ], + "isUnique": true + } + }, + "foreignKeys": { + "one_time_links_table_id_clients_table_id_fk": { + "name": "one_time_links_table_id_clients_table_id_fk", + "tableFrom": "one_time_links_table", + "tableTo": "clients_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_key": { + "name": "totp_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified": { + "name": "totp_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "oauth_provider_id_unique": { + "name": "oauth_provider_id_unique", + "columns": [ + "oauth_provider", + "oauth_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_configs_table": { + "name": "user_configs_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_mtu": { + "name": "default_mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_persistent_keepalive": { + "name": "default_persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_dns": { + "name": "default_dns", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_allowed_ips": { + "name": "default_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_j_c": { + "name": "default_j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "default_j_min": { + "name": "default_j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "default_j_max": { + "name": "default_j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_i1": { + "name": "default_i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i2": { + "name": "default_i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i3": { + "name": "default_i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i4": { + "name": "default_i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i5": { + "name": "default_i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_configs_table_id_interfaces_table_name_fk": { + "name": "user_configs_table_id_interfaces_table_name_fk", + "tableFrom": "user_configs_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index fefdc1c9..4650fa31 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1771352889394, "tag": "0004_optimal_mandrill", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1780035570366, + "tag": "0005_clumsy_korg", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts index 77eb13e2..ede3d577 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -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().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().notNull(), + totpKey: text('totp_key'), + totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(), + enabled: int({ mode: 'boolean' }).notNull(), + oauthProvider: text('oauth_provider').$type(), + 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), diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index cecf0df8..e3526315 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -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 => { const txUser = await tx.query.user .findFirst({ where: eq(user.username, username) }) .execute(); - if (!txUser) { - return { success: false, error: 'INCORRECT_CREDENTIALS' }; - } - - const passwordValid = await isPasswordValid(password, txUser.password); + // always check to avoid timing attack + const userHashPassword = txUser?.password ?? null; + const passwordValid = await isPasswordValid(password, userHashPassword); - if (!passwordValid) { + if (!txUser || !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 { + 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(); + }); + } } diff --git a/src/server/database/repositories/user/types.ts b/src/server/database/repositories/user/types.ts index 4743be73..bb5a7290 100644 --- a/src/server/database/repositories/user/types.ts +++ b/src/server/database/repositories/user/types.ts @@ -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, }) diff --git a/src/server/plugins/manager.ts b/src/server/plugins/manager.ts index 701e052e..f12811b5 100644 --- a/src/server/plugins/manager.ts +++ b/src/server/plugins/manager.ts @@ -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(); diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts index 4498442a..4e3b5121 100644 --- a/src/server/utils/config.ts +++ b/src/server/utils/config.ts @@ -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, diff --git a/src/server/utils/oauth.ts b/src/server/utils/oauth.ts new file mode 100644 index 00000000..d334fe00 --- /dev/null +++ b/src/server/utils/oauth.ts @@ -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; + 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_FLOW.userinfo_endpoint, + { + headers: { + 'User-Agent': 'wg-easy', + Authorization: `Bearer ${accessToken}`, + }, + } + ); + if (!response.email) { + const emailResponse = await $fetch( + 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 = Required>; + +function assertHasOauthProps( + userInfo: T +): asserts userInfo is T & RequireKeys { + 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; +} diff --git a/src/server/utils/password.ts b/src/server/utils/password.ts index 915795c7..0a4e0c62 100644 --- a/src/server/utils/password.ts +++ b/src/server/utils/password.ts @@ -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 { + if (hash === null) { + await argon2.verify(DUMMY_HASH, password); + return false; + } return argon2.verify(hash, password); } diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts index 1a144cea..5c697e85 100644 --- a/src/server/utils/session.ts +++ b/src/server/utils/session.ts @@ -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', diff --git a/src/shared/utils/permissions.ts b/src/shared/utils/permissions.ts index 12fd5570..e9f5fc18 100644 --- a/src/shared/utils/permissions.ts +++ b/src/shared/utils/permissions.ts @@ -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 = | boolean @@ -144,7 +144,10 @@ export function hasPermissionsWithData( const isAllowed = hasPermissions(user, resource, action, data); if (!isAllowed) { - throw new Error('Permission denied'); + throw createError({ + statusCode: 403, + statusMessage: 'Permission denied', + }); } return isAllowed; diff --git a/src/test/unit/password.spec.ts b/src/test/unit/password.spec.ts index 855c5f24..5f404530 100644 --- a/src/test/unit/password.spec.ts +++ b/src/test/unit/password.spec.ts @@ -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); + }); });