Browse Source

link/unlink logic

dev-oauth
Bernd Storath 1 week ago
parent
commit
09c358522b
  1. 33
      docs/content/advanced/config/external-authentication.md
  2. 26
      src/app/components/Base/FormSecondaryButton.vue
  3. 5
      src/app/components/Form/SecondaryActionField.vue
  4. 6
      src/app/components/Header/LangSelector.vue
  5. 10
      src/app/components/Icons/Brands/Provider.vue
  6. 21
      src/app/components/Ui/LoginOauthButton.vue
  7. 53
      src/app/pages/login.vue
  8. 98
      src/app/pages/me.vue
  9. 1
      src/app/stores/auth.ts
  10. 2
      src/app/utils/types.ts
  11. 11
      src/i18n/locales/en.json
  12. 41
      src/server/api/auth/[provider]/callback.get.ts
  13. 17
      src/server/api/auth/[provider]/index.get.ts
  14. 43
      src/server/api/auth/[provider]/link.get.ts
  15. 4
      src/server/api/auth/methods.get.ts
  16. 11
      src/server/api/auth/unlink.post.ts
  17. 3
      src/server/api/session.get.ts
  18. 59
      src/server/database/repositories/user/service.ts
  19. 54
      src/server/utils/oauth.ts
  20. 2
      src/shared/utils/permissions.ts

33
docs/content/advanced/config/external-authentication.md

@ -18,21 +18,34 @@ You can enable multiple providers by separating them with a comma:
e.g. `google,github`
### 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>/`
### Google
<!-- TODO support allowed domain -->
| Env | Required | Example | Description |
| ----------------------------- | -------- | -------------------------------- | ----------------------------------------- |
| `OAUTH_GOOGLE_CLIENT_ID` | ✔️ | `123.apps.googleusercontent.com` | Google Client ID |
| `OAUTH_GOOGLE_CLIENT_SECRET` | ✔️ | `GOCSPX-xxx` | Google Client Secret |
| `OAUTH_GOOGLE_ALLOWED_DOMAIN` | ✖️ | `example.com` | Restrict login to a specific email domain |
| Env | Required | Example | Description |
| ----------------------------- | -------- | ------------- | ----------------------------------------- |
| `OAUTH_GOOGLE_CLIENT_ID` | ✔️ | - | Google Client ID |
| `OAUTH_GOOGLE_CLIENT_SECRET` | ✔️ | - | Google Client Secret |
| `OAUTH_GOOGLE_ALLOWED_DOMAIN` | ✖️ | `example.com` | Restrict login to a specific email domain |
#### 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: `https://<your-domain>/api/auth/google/callback`
3. Add Authorized redirect URI: See [Redirect URIs](#redirect-uris)
4. Copy the Client ID and Client Secret to the environment variables
### GitHub
@ -52,14 +65,15 @@ The provider needs to support:
- PKCE
- default scopes: `openid email profile`
- Valid HTTPS
- 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` | ✔️ | - | `xxx` | OIDC Client ID |
| `OAUTH_OIDC_CLIENT_SECRET` | ✔️ | - | `xxx` | OIDC Client Secret |
| `OAUTH_OIDC_CLIENT_ID` | ✔️ | - | - | OIDC Client ID |
| `OAUTH_OIDC_CLIENT_SECRET` | ✔️ | - | - | OIDC Client Secret |
| `OAUTH_OIDC_NAME` | ✖️ | OIDC | `Authelia` | Provider Name |
#### Authelia Setup
@ -79,6 +93,7 @@ docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --
client_secret: '$pbkdf2-...'
redirect_uris:
- https://<your-domain>/api/auth/oidc/callback
- https://<your-domain>/api/auth/oidc/link
scopes:
- openid
- profile

26
src/app/components/Base/FormSecondaryButton.vue

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

5
src/app/components/Form/SecondaryActionField.vue

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

6
src/app/components/Header/LangSelector.vue

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

10
src/app/components/Icons/Brands/Provider.vue

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

21
src/app/components/Ui/LoginOauthButton.vue

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

53
src/app/pages/login.vue

@ -12,49 +12,16 @@
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<div v-if="authMethods" class="flex flex-col gap-5">
<!-- Google OAuth Button -->
<a
v-if="authMethods.providers?.google?.enabled"
href="/api/auth/google"
class="flex cursor-pointer 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"
>
<IconsBrandsGoogle class="h-4 w-4" />
<span>
{{
$t('login.signInWith', [
authMethods.providers.google.friendlyName,
])
}}
</span>
</a>
<!-- GitHub OAuth Button -->
<a
v-if="authMethods.providers?.github?.enabled"
href="/api/auth/github"
class="flex cursor-pointer 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"
>
<IconsBrandsGitHub class="h-4 w-4" />
<span>
{{
$t('login.signInWith', [
authMethods.providers.github.friendlyName,
])
}}
</span>
</a>
<!-- OIDC OAuth Button -->
<a
v-if="authMethods.providers?.oidc?.enabled"
href="/api/auth/oidc"
class="flex cursor-pointer items-center justify-center 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"
>
<span>
{{
$t('login.signInWith', [authMethods.providers.oidc.friendlyName])
}}
</span>
</a>
<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" class="flex items-center gap-2">

98
src/app/pages/me.vue

@ -58,17 +58,14 @@
<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">
@ -98,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">
@ -118,6 +115,59 @@
</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"
: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>
@ -128,9 +178,21 @@ 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],
provider: authStore.userData.oauthProvider,
};
});
const _submit = useSubmit(
(data) =>
@ -256,4 +318,26 @@ async function disable2fa() {
currentPassword: disable2faPassword.value,
});
}
async function linkOauth() {
await navigateTo(`/api/auth/google?link=true`, {
external: true,
});
}
const _unlinkOauth = useSubmit(
`/api/auth/unlink`,
{
method: 'post',
},
{
revert: async () => {
return authStore.update();
},
}
);
async function unlinkOauth() {
return _unlinkOauth({});
}
</script>

1
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<SharedPublicUser | null>('user-data', () => null);

2
src/app/utils/types.ts

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

11
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",
@ -42,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",
@ -73,7 +78,7 @@
},
"login": {
"signIn": "Sign In",
"signInWith": "Sign In with {0}",
"signInWith": "Sign in with {0}",
"or": "or",
"rememberMe": "Remember me",
"rememberMeDesc": "Stay logged after closing the browser",

41
src/server/api/auth/[provider]/callback.get.ts

@ -1,5 +1,3 @@
import * as client from 'openid-client';
export default defineEventHandler(async (event) => {
const { config, provider, providerConfig } = await buildOauthConfig(event);
@ -15,35 +13,16 @@ export default defineEventHandler(async (event) => {
});
}
const currentUrl = getRequestURL(event);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: session.data.oauth_verifier,
expectedNonce:
providerConfig.isOIDC === false ? undefined : session.data.oauth_nonce,
expectedState: session.data.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;
if (providerConfig.userInfoFlow === 'github') {
userInfo = await githubUserInfoFlow(tokens.access_token);
} else {
userInfo = await client.fetchUserInfo(config, tokens.access_token, subject);
}
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
);
if (!userInfo.sub) {
throw createError({

17
src/server/api/auth/[provider]/index.get.ts

@ -1,11 +1,26 @@
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 redirectUri = `${protocol}://${host}/api/auth/${provider}/callback`;
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);

43
src/server/api/auth/[provider]/link.get.ts

@ -0,0 +1,43 @@
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
);
if (!userInfo.sub) {
throw createError({
statusCode: 400,
statusMessage: 'No sub set',
});
}
await Database.users.linkOauth(user.id, provider, userInfo.sub);
return sendRedirect(event, '/me');
}
);

4
src/server/api/auth/methods.get.ts

@ -8,9 +8,7 @@ export default defineEventHandler(() => {
};
return acc;
},
{} as Partial<
Record<OAUTH_PROVIDER, { enabled: true; friendlyName: string }>
>
{} as Record<OAUTH_PROVIDER, { enabled: true; friendlyName: string }>
),
oauthEnabled:
WG_ENV.OAUTH_PROVIDERS !== undefined && WG_ENV.OAUTH_PROVIDERS.length > 0,

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

3
src/server/api/session.get.ts

@ -1,5 +1,3 @@
import type { SharedPublicUser } from '~~/shared/utils/permissions';
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
@ -26,6 +24,7 @@ export default defineEventHandler(async (event) => {
name: user.name,
email: user.email,
totpVerified: user.totpVerified,
oauthProvider: user.oauthProvider,
hasPassword: user.password !== null,
} satisfies SharedPublicUser;
});

59
src/server/database/repositories/user/service.ts

@ -105,6 +105,7 @@ export class UserService {
return this.#statements.findByEmail.execute({ email });
}
// TODO: improve, use transaction
async findOrCreateByProvider(
provider: OAUTH_PROVIDER,
oauthId: string,
@ -330,4 +331,62 @@ export class UserService {
.execute();
});
}
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();
});
}
}

54
src/server/utils/oauth.ts

@ -1,5 +1,6 @@
import type { H3Event } from 'h3';
import { discovery } from 'openid-client';
import type { Configuration } from 'openid-client';
import * as client from 'openid-client';
type OAuthConfig = {
friendlyName: string;
@ -91,7 +92,7 @@ export async function buildOauthConfig(event: H3Event) {
if (!isEnabledProvider(provider)) {
throw createError({
statusCode: 400,
statusMessage: 'Disabled provider',
statusMessage: 'Provider is not enabled',
});
}
@ -104,7 +105,7 @@ export async function buildOauthConfig(event: H3Event) {
});
}
const config = await discovery(
const config = await client.discovery(
new URL(oauthProvider.server),
oauthProvider.clientId,
{
@ -158,9 +159,54 @@ export async function githubUserInfoFlow(accessToken: string) {
}
return {
sub: response.id.toString(),
email: response.email,
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: 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);
}
return userInfo;
}

2
src/shared/utils/permissions.ts

@ -47,7 +47,7 @@ type SharedUserType =
export type SharedPublicUser = Pick<
UserType,
'id' | 'username' | 'name' | 'email' | 'totpVerified'
'id' | 'username' | 'name' | 'email' | 'totpVerified' | 'oauthProvider'
> & { role: BrandedNumber; hasPassword: boolean };
type PermissionCheck<Key extends keyof Permissions> =

Loading…
Cancel
Save