Browse Source

🔧 Add login via google

pull/2625/head
Daniel Molenda 2 weeks ago
parent
commit
925d961fed
  1. 5
      docker-compose.yml
  2. 142
      src/app/pages/login.vue
  3. 2
      src/i18n/locales/en.json
  4. 2
      src/i18n/locales/pl.json
  5. 130
      src/server/api/auth/google/callback.get.ts
  6. 33
      src/server/api/auth/google/index.get.ts
  7. 5
      src/server/api/auth/methods.get.ts
  8. 1
      src/server/database/migrations/0005_google_oauth.sql
  9. 7
      src/server/database/migrations/meta/_journal.json
  10. 1
      src/server/database/repositories/user/schema.ts
  11. 65
      src/server/database/repositories/user/service.ts
  12. 11
      src/server/utils/config.ts
  13. 1
      src/server/utils/session.ts

5
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

142
src/app/pages/login.vue

@ -2,9 +2,8 @@
<main>
<UiBanner />
<HeaderInsecure />
<form
<div
class="mx-auto mt-10 flex w-64 flex-col gap-5 overflow-hidden rounded-md bg-white p-5 text-gray-700 shadow dark:bg-neutral-700 dark:text-neutral-200"
@submit.prevent="submit"
>
<!-- Avatar -->
<div
@ -13,56 +12,101 @@
<IconsAvatar class="m-5 h-10 w-10 text-white dark:text-white" />
</div>
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<!-- Google OAuth Button -->
<a
v-if="authMethods?.google"
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"
>
<svg class="h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
<span>{{ $t('login.signInWithGoogle') }}</span>
</a>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<!-- Divider -->
<div
v-if="authMethods?.google"
class="flex items-center gap-2"
>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
<span class="text-xs text-gray-500 dark:text-neutral-400">{{
$t('login.or')
}}</span>
<div class="h-px flex-1 bg-gray-300 dark:bg-neutral-600"></div>
</div>
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<!-- Classic Login Form -->
<form class="flex flex-col gap-5" @submit.prevent="submit">
<BaseInput
v-model="username"
type="text"
:placeholder="$t('general.username')"
autocomplete="username"
autofocus
name="username"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<BaseInput
v-model="password"
type="password"
name="password"
:placeholder="$t('general.password')"
autocomplete="current-password"
/>
<button
class="rounded py-2 text-sm text-white shadow transition dark:text-white"
:class="{
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password && username,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
!password || !username,
}"
>
<IconsLoading v-if="authenticating" class="mx-auto w-5 animate-spin" />
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
<BaseInput
v-if="totpRequired"
v-model="totp"
type="text"
name="totp"
:placeholder="$t('general.2faCode')"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
/>
<label
class="flex gap-2 whitespace-nowrap"
:title="$t('login.rememberMeDesc')"
>
<BaseSwitch v-model="remember" />
<span class="text-sm">{{ $t('login.rememberMe') }}</span>
</label>
<button
class="rounded py-2 text-sm text-white shadow transition dark:text-white"
:class="{
'cursor-pointer bg-red-800 hover:bg-red-700 dark:bg-red-800 dark:hover:bg-red-700':
password && username,
'cursor-not-allowed bg-gray-200 dark:bg-neutral-800':
!password || !username,
}"
>
<IconsLoading
v-if="authenticating"
class="mx-auto w-5 animate-spin"
/>
<span v-else>{{ $t('login.signIn') }}</span>
</button>
</form>
</div>
</main>
</template>
@ -77,6 +121,8 @@ const password = ref<string>('');
const totpRequired = ref(false);
const totp = ref<string>('');
const { data: authMethods } = await useFetch('/api/auth/methods');
const _submit = useSubmit(
'/api/session',
{

2
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.",

2
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.",

130
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<GoogleTokenResponse>(
'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<GoogleUserInfo>(
'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, '/');
});

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

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

@ -0,0 +1,5 @@
export default defineEventHandler(() => {
return {
google: OAUTH_GOOGLE_ENV.ENABLED,
};
});

1
src/server/database/migrations/0005_google_oauth.sql

@ -0,0 +1 @@
ALTER TABLE `users_table` ADD `google_id` text;

7
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
}
]
}

1
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)`),

65
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);

11
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,

1
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';

Loading…
Cancel
Save