From a8f1b86e371979e8d907c60328627bd3e0b149e1 Mon Sep 17 00:00:00 2001 From: Daniel Molenda Date: Tue, 26 May 2026 09:44:25 +0200 Subject: [PATCH] feat: add Google OAuth login support (#2625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 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 --- .../advanced/config/optional-config.md | 22 + src/app/pages/login.vue | 139 ++- src/i18n/locales/en.json | 2 + src/i18n/locales/pl.json | 2 + src/server/api/auth/google/callback.get.ts | 130 +++ src/server/api/auth/google/index.get.ts | 33 + src/server/api/auth/methods.get.ts | 5 + .../database/migrations/0005_google_oauth.sql | 1 + .../migrations/meta/0005_snapshot.json | 994 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../database/repositories/user/schema.ts | 1 + .../database/repositories/user/service.ts | 64 ++ src/server/utils/config.ts | 11 + src/server/utils/session.ts | 1 + 14 files changed, 1364 insertions(+), 48 deletions(-) create mode 100644 src/server/api/auth/google/callback.get.ts create mode 100644 src/server/api/auth/google/index.get.ts create mode 100644 src/server/api/auth/methods.get.ts create mode 100644 src/server/database/migrations/0005_google_oauth.sql create mode 100644 src/server/database/migrations/meta/0005_snapshot.json diff --git a/docs/content/advanced/config/optional-config.md b/docs/content/advanced/config/optional-config.md index 2f450b03..8d74b2e2 100644 --- a/docs/content/advanced/config/optional-config.md +++ b/docs/content/advanced/config/optional-config.md @@ -12,6 +12,28 @@ You can set these environment variables to configure the container. They are not | `DISABLE_IPV6` | `false` | `true` | If IPv6 support should be disabled | | `DISABLE_VERSION_CHECK` | `false` | `true` | If wg-easy should check for new updates | +## Google OAuth + +You can enable Google OAuth login alongside classic username/password authentication. When enabled, a "Sign in with Google" button appears on the login page. + +| Env | Default | Example | Description | +| ----------------------------- | ------- | -------------------------------------------- | ------------------------------------------------ | +| `OAUTH_GOOGLE_ENABLED` | `false` | `true` | Enable Google OAuth login | +| `OAUTH_GOOGLE_CLIENT_ID` | - | `123.apps.googleusercontent.com` | Google OAuth 2.0 Client ID | +| `OAUTH_GOOGLE_CLIENT_SECRET` | - | `GOCSPX-xxx` | Google OAuth 2.0 Client Secret | +| `OAUTH_GOOGLE_ALLOWED_DOMAIN` | - | `example.com` | Restrict login to a specific email domain | + +/// note | Google Cloud Console 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:///api/auth/google/callback` +4. Copy the Client ID and Client Secret to the environment variables + +If a user logs in with Google and their email matches an existing account, the accounts are automatically linked. + +/// + /// note | IPv6 Caveats Disabling IPv6 will disable the creation of the default IPv6 firewall rules and won't add a IPv6 address to the interface and clients. diff --git a/src/app/pages/login.vue b/src/app/pages/login.vue index 5ec66df3..a8daaef8 100644 --- a/src/app/pages/login.vue +++ b/src/app/pages/login.vue @@ -2,9 +2,8 @@
-
- + + + + + + + + + {{ $t('login.signInWithGoogle') }} + - + +
+
+ {{ + $t('login.or') + }} +
+
- + + + - + - - + + + + + + +
@@ -77,6 +118,8 @@ const password = ref(''); const totpRequired = ref(false); const totp = ref(''); +const { data: authMethods } = await useFetch('/api/auth/methods'); + const _submit = useSubmit( (data) => $fetch('/api/session', { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 86120f19..4c065256 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -72,6 +72,8 @@ }, "login": { "signIn": "Sign In", + "signInWithGoogle": "Sign in with Google", + "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..ebc989c3 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -72,6 +72,8 @@ }, "login": { "signIn": "Zaloguj się", + "signInWithGoogle": "Zaloguj się przez Google", + "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/server/api/auth/google/callback.get.ts b/src/server/api/auth/google/callback.get.ts new file mode 100644 index 00000000..e6960cf6 --- /dev/null +++ b/src/server/api/auth/google/callback.get.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; + +const CallbackQuerySchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +interface GoogleTokenResponse { + access_token: string; + id_token: string; + token_type: string; + expires_in: number; +} + +interface GoogleUserInfo { + sub: string; + email: string; + email_verified: boolean; + name: string; + picture?: string; +} + +export default defineEventHandler(async (event) => { + if (!OAUTH_GOOGLE_ENV.ENABLED) { + throw createError({ + statusCode: 404, + statusMessage: 'Google OAuth is not enabled', + }); + } + + const query = await getValidatedQuery( + event, + validateZod(CallbackQuerySchema, event) + ); + + // Verify state to prevent CSRF + const session = await useWGSession(event); + if (!session.data.oauthState || session.data.oauthState !== query.state) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid OAuth state', + }); + } + + // Clear the state + await session.update({ oauthState: undefined }); + + const host = getRequestHost(event); + const protocol = WG_ENV.INSECURE ? 'http' : 'https'; + const redirectUri = `${protocol}://${host}/api/auth/google/callback`; + + // Exchange code for tokens + const tokenResponse = await $fetch( + 'https://oauth2.googleapis.com/token', + { + method: 'POST', + body: { + code: query.code, + client_id: OAUTH_GOOGLE_ENV.CLIENT_ID, + client_secret: OAUTH_GOOGLE_ENV.CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }, + } + ); + + if (!tokenResponse.access_token) { + throw createError({ + statusCode: 401, + statusMessage: 'Failed to obtain access token from Google', + }); + } + + // Get user info from Google + const userInfo = await $fetch( + 'https://www.googleapis.com/oauth2/v3/userinfo', + { + headers: { + Authorization: `Bearer ${tokenResponse.access_token}`, + }, + } + ); + + if (!userInfo.email_verified) { + throw createError({ + statusCode: 401, + statusMessage: 'Google email is not verified', + }); + } + + // Check allowed domain if configured + if (OAUTH_GOOGLE_ENV.ALLOWED_DOMAIN) { + const emailDomain = userInfo.email.split('@')[1]; + if (emailDomain !== OAUTH_GOOGLE_ENV.ALLOWED_DOMAIN) { + throw createError({ + statusCode: 403, + statusMessage: 'Email domain is not allowed', + }); + } + } + + // Find or create user + const result = await Database.users.findOrCreateByGoogle( + userInfo.sub, + userInfo.email, + userInfo.name + ); + + if (!result.success) { + if (result.error === 'USER_DISABLED') { + throw createError({ + statusCode: 401, + statusMessage: 'User disabled', + }); + } + throw createError({ + statusCode: 500, + statusMessage: 'Unexpected error', + }); + } + + // Create session + await session.update({ userId: result.user.id }); + + SERVER_DEBUG( + `New Google OAuth Session for ${result.user.id} (${result.user.username})` + ); + + return sendRedirect(event, '/'); +}); diff --git a/src/server/api/auth/google/index.get.ts b/src/server/api/auth/google/index.get.ts new file mode 100644 index 00000000..d1f29fef --- /dev/null +++ b/src/server/api/auth/google/index.get.ts @@ -0,0 +1,33 @@ +export default defineEventHandler(async (event) => { + if (!OAUTH_GOOGLE_ENV.ENABLED) { + throw createError({ + statusCode: 404, + statusMessage: 'Google OAuth is not enabled', + }); + } + + const host = getRequestHost(event); + const protocol = WG_ENV.INSECURE ? 'http' : 'https'; + const redirectUri = `${protocol}://${host}/api/auth/google/callback`; + + const state = crypto.randomUUID(); + + // Store state in session to prevent CSRF + const session = await useWGSession(event); + await session.update({ oauthState: state }); + + const params = new URLSearchParams({ + client_id: OAUTH_GOOGLE_ENV.CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'online', + prompt: 'select_account', + }); + + return sendRedirect( + event, + `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` + ); +}); diff --git a/src/server/api/auth/methods.get.ts b/src/server/api/auth/methods.get.ts new file mode 100644 index 00000000..f6d94be5 --- /dev/null +++ b/src/server/api/auth/methods.get.ts @@ -0,0 +1,5 @@ +export default defineEventHandler(() => { + return { + google: OAUTH_GOOGLE_ENV.ENABLED, + }; +}); diff --git a/src/server/database/migrations/0005_google_oauth.sql b/src/server/database/migrations/0005_google_oauth.sql new file mode 100644 index 00000000..5d8ec1e7 --- /dev/null +++ b/src/server/database/migrations/0005_google_oauth.sql @@ -0,0 +1 @@ +ALTER TABLE `users_table` ADD `google_id` text; \ 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..2c4a0779 --- /dev/null +++ b/src/server/database/migrations/meta/0005_snapshot.json @@ -0,0 +1,994 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "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": true, + "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 + }, + "google_id": { + "name": "google_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 + } + }, + "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": {} + } +} diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index fefdc1c9..8c9d5e7a 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": 1716100000000, + "tag": "0005_google_oauth", + "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..750906a1 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -13,6 +13,7 @@ export const user = sqliteTable('users_table', { totpKey: text('totp_key'), totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(), enabled: int({ mode: 'boolean' }).notNull(), + googleId: text('google_id'), createdAt: text('created_at') .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index cecf0df8..e8677544 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -30,6 +30,16 @@ function createPreparedStatement(db: DBType) { where: eq(user.username, sql.placeholder('username')), }) .prepare(), + findByGoogleId: db.query.user + .findFirst({ + where: eq(user.googleId, sql.placeholder('googleId')), + }) + .prepare(), + findByEmail: db.query.user + .findFirst({ + where: eq(user.email, sql.placeholder('email')), + }) + .prepare(), update: db .update(user) .set({ @@ -81,6 +91,60 @@ export class UserService { return this.#statements.findByUsername.execute({ username }); } + async getByGoogleId(googleId: string) { + return this.#statements.findByGoogleId.execute({ googleId }); + } + + async getByEmail(email: string) { + return this.#statements.findByEmail.execute({ email }); + } + + async findOrCreateByGoogle(googleId: string, email: string, name: string) { + // First try to find by googleId + let existingUser = await this.getByGoogleId(googleId); + if (existingUser) { + if (!existingUser.enabled) { + return { success: false as const, error: 'USER_DISABLED' as const }; + } + return { success: true as const, user: existingUser }; + } + + // Try to find by email and link the Google account + existingUser = await this.getByEmail(email); + if (existingUser) { + if (!existingUser.enabled) { + return { success: false as const, error: 'USER_DISABLED' as const }; + } + await this.#db + .update(user) + .set({ googleId }) + .where(eq(user.id, existingUser.id)) + .execute(); + return { success: true as const, user: existingUser }; + } + + // Create new user with Google account + const randomPassword = crypto.randomUUID(); + const hash = await hashPassword(randomPassword); + + await this.#db.insert(user).values({ + username: email, + password: hash, + email, + name, + role: roles.ADMIN, + totpVerified: false, + enabled: true, + googleId, + }); + + const newUser = await this.getByGoogleId(googleId); + if (!newUser) { + return { success: false as const, error: 'UNEXPECTED_ERROR' as const }; + } + return { success: true as const, user: newUser }; + } + async create(username: string, password: string) { const hash = await hashPassword(password); diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts index 4498442a..51a10350 100644 --- a/src/server/utils/config.ts +++ b/src/server/utils/config.ts @@ -41,6 +41,17 @@ export const WG_ENV = { DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true', }; +export const OAUTH_GOOGLE_ENV = { + /** Enable Google OAuth login */ + ENABLED: process.env.OAUTH_GOOGLE_ENABLED === 'true', + /** Google OAuth Client ID */ + CLIENT_ID: process.env.OAUTH_GOOGLE_CLIENT_ID || '', + /** Google OAuth Client Secret */ + CLIENT_SECRET: process.env.OAUTH_GOOGLE_CLIENT_SECRET || '', + /** Allowed email domain (optional, e.g. "example.com") */ + ALLOWED_DOMAIN: process.env.OAUTH_GOOGLE_ALLOWED_DOMAIN || '', +}; + export const WG_INITIAL_ENV = { ENABLED: process.env.INIT_ENABLED === 'true', USERNAME: process.env.INIT_USERNAME, diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts index 1a144cea..3f3454ac 100644 --- a/src/server/utils/session.ts +++ b/src/server/utils/session.ts @@ -3,6 +3,7 @@ import type { UserType } from '#db/repositories/user/types'; export type WGSession = Partial<{ userId: ID; + oauthState: string; }>; const name = 'wg-easy';