diff --git a/docker-compose.yml b/docker-compose.yml index 1dc53c2a..208ed61d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,11 @@ services: # - PORT=51821 # - HOST=0.0.0.0 # - INSECURE=false + # Google OAuth (optional): + # - OAUTH_GOOGLE_ENABLED=true + # - OAUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com + # - OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret + # - OAUTH_GOOGLE_ALLOWED_DOMAIN=example.com image: ghcr.io/wg-easy/wg-easy:15 container_name: wg-easy diff --git a/src/app/pages/login.vue b/src/app/pages/login.vue index 1d716fb4..be1fbab6 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 +121,8 @@ const password = ref(''); const totpRequired = ref(false); const totp = ref(''); +const { data: authMethods } = await useFetch('/api/auth/methods'); + const _submit = useSubmit( '/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/_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 f130da20..3e80cbee 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({ @@ -70,6 +80,61 @@ 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 userCount = await this.#db.$count(user); + const randomPassword = crypto.randomUUID(); + const hash = await hashPassword(randomPassword); + + await this.#db.insert(user).values({ + username: email, + password: hash, + email, + name, + role: userCount === 0 ? roles.ADMIN : roles.CLIENT, + 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 1c5b2081..93de32f5 100644 --- a/src/server/utils/config.ts +++ b/src/server/utils/config.ts @@ -40,6 +40,17 @@ export const WG_ENV = { WG_EXECUTABLE: await detectAwg(), }; +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';