From 783003822d76a7f5a50059f6f03175b74d264917 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 21:02:12 -0400 Subject: [PATCH] feat(auth): connexion avec un compte Google (find-or-create, import likes/abos inclus) - Backend: GET /api/auth/google/{url,callback} (state login distinct du link import), echange code, profil userinfo, find-or-create (OAuth sub, puis email), session + cookies comme login interne, connexion stockee dans oauth_connections pour l'import direct - Front: bouton 'Se connecter avec Google' sur /auth/login (visible si configure), page /auth/google/callback (initSession + prefs + redirect /library/import), cles i18n nav.import deja en place - Comptes Google-only: password_hash aleatoire (login mot de passe impossible) --- server/db.mjs | 17 ++++ server/index.mjs | 88 +++++++++++++++++++ server/oauth.mjs | 11 ++- src/app.routes.ts | 5 ++ .../google-callback.component.html | 9 ++ .../google-callback.component.ts | 54 ++++++++++++ .../auth/login/login.component.html | 12 +++ src/components/auth/login/login.component.ts | 26 ++++++ src/services/auth.service.ts | 32 +++++++ 9 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/components/auth/google-callback/google-callback.component.html create mode 100644 src/components/auth/google-callback/google-callback.component.ts diff --git a/server/db.mjs b/server/db.mjs index af12b02..55d5854 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -140,6 +140,23 @@ export function getUserById(id) { return db.prepare('SELECT * FROM users WHERE id = ?').get(id); } +export function getUserByEmail(email) { + const clean = String(email || '').trim().toLowerCase(); + if (!clean) return null; + return db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(clean) || null; +} + +/** Retrouve l'utilisateur lié à un compte OAuth externe (ex. Google sub). */ +export function getUserByOAuth(provider, externalUserId) { + try { + ensureOAuthTables(); + const row = db.prepare(`SELECT u.* FROM oauth_connections oc + JOIN users u ON u.id = oc.user_id + WHERE oc.provider = ? AND oc.external_user_id = ?`).get(provider, String(externalUserId || '')); + return row || null; + } catch { return null; } +} + export function insertUser({ id, username, email, passwordHash }) { const ts = nowIso(); db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at) diff --git a/server/index.mjs b/server/index.mjs index 176e66d..f46a55b 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -75,6 +75,8 @@ import { setSubscriptionGroupMembers, setSubscriptionGroups, listSubscriptionGroupMembersByUser, + getUserByEmail, + getUserByOAuth, upsertOAuthConnection, listOAuthConnections, getOAuthConnection, @@ -2070,6 +2072,92 @@ r.post('/auth/login', loginLimiter, async (req, res) => { return res.json({ user: { id: user.id, username: user.username, email: user.email }, accessToken, sessionId }); }); +// -------------------- Connexion avec Google (compte Google au lieu du compte interne) -------------------- +// Même OAuth que l'import : le consentement `youtube.readonly` sert ensuite +// directement à l'import abonnements + favoris (aucun 2e consentement). +// Comptes Google-only : password_hash aléatoire (login mot de passe impossible). + +function frontBaseForOAuth(req) { + try { + const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, ''); + if (explicit) return explicit; + const proto = String(req.headers?.['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim() || 'http'; + const host = String(req.headers?.['x-forwarded-host'] || req.headers?.host || '').trim(); + const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim(); + if (host && publicPort) return `${proto}://${host.split(':')[0]}:${publicPort}`; + if (host) return `${proto}://${host}`; + } catch {} + return 'http://localhost:4200'; +} + +r.get('/auth/google/url', loginLimiter, (req, res) => { + try { + const status = oauthStatus().google; + if (!status?.configured) return res.status(503).json({ error: 'google_oauth_not_configured', missing: status?.missing || [] }); + const state = createOAuthState(null, 'google', 'login'); + return res.json({ url: buildAuthUrl('google', state, req) }); + } catch (error) { + return res.status(error?.status || 500).json({ error: error?.message || 'google_auth_url_failed' }); + } +}); + +r.get('/auth/google/callback', loginLimiter, async (req, res) => { + const frontBase = frontBaseForOAuth(req); + const fail = (code) => res.redirect(302, `${frontBase}/auth/login?error=${encodeURIComponent(code)}`); + try { + if (req.query?.error) return fail(String(req.query.error_description || req.query.error)); + const { code, state } = req.query || {}; + if (!code || !state) return fail('google_missing_code_or_state'); + const entry = consumeOAuthState(String(state)); + if (!entry || entry.provider !== 'google' || entry.purpose !== 'login') return fail('google_invalid_state'); + const tokens = await exchangeCode('google', String(code), req); + if (!tokens?.accessToken) return fail('google_token_failed'); + const profile = await fetchGoogleProfile(tokens.accessToken); + if (!profile?.id) return fail('google_profile_failed'); + const email = String(profile.email || '').trim() || null; + + // 1) Compte déjà lié à ce Google sub → lui. 2) Email identique → on lie. + // 3) Sinon création (username dérivé, suffixe si collision). + let user = getUserByOAuth('google', profile.id); + if (!user && email) user = getUserByEmail(email); + if (!user) { + const base = String(profile.displayName || (email ? email.split('@')[0] : 'google') || 'google') + .trim().replace(/\s+/g, ' ').slice(0, 40) || 'google-user'; + let username = base; + let n = 0; + while (getUserByUsername(username)) { + n++; + username = `${base} ${n}`.slice(0, 40); + if (n > 50) return fail('username_taken'); + } + const id = cryptoRandomUUID(); + await insertUser({ id, username, email, passwordHash: `oauth-google:${cryptoRandomId()}` }); + user = getUserById(id); + } + if (!user) return fail('google_login_failed'); + upsertOAuthConnection({ + userId: user.id, provider: 'google', externalUserId: profile.id, + displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null, + accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, scopes: tokens.scopes, + }); + + const sessionId = cryptoRandomId(); + const refreshToken = cryptoRandomId(); + const refreshTokenHash = await hashToken(refreshToken); + const days = REMEMBER_TTL_DAYS; + const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); + const ua = req.headers['user-agent'] || ''; + insertSession({ id: sessionId, userId: user.id, refreshTokenHash, isRemember: true, userAgent: ua, deviceInfo: '', ip: getClientIp(req), expiresAt }); + setUserLastLogin(user.id); + insertLoginAudit({ userId: user.id, username: user.username, ip: getClientIp(req), userAgent: ua, success: true, reason: 'google' }); + setRefreshCookies(res, { sessionId, token: refreshToken, days }, req); + return res.redirect(302, `${frontBase}/auth/google/callback?connected=1`); + } catch { + return fail('google_login_failed'); + } +}); + r.post('/auth/refresh', async (req, res) => { const { sid, refreshToken } = req.cookies || {}; if (!sid || !refreshToken) return res.status(401).json({ error: 'Unauthorized' }); diff --git a/server/oauth.mjs b/server/oauth.mjs index bb67727..3a273ac 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -36,9 +36,14 @@ function randomState() { return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; } -export function createOAuthState(userId, provider) { +export function createOAuthState(userId, provider, purpose = 'link') { const state = randomState(); - pendingStates.set(state, { userId: String(userId), provider: String(provider), createdAt: Date.now() }); + pendingStates.set(state, { + userId: userId == null ? null : String(userId), + provider: String(provider), + purpose: String(purpose || 'link'), + createdAt: Date.now(), + }); // Purge opportuniste. try { const now = Date.now(); @@ -233,6 +238,8 @@ export async function fetchGoogleProfile(accessToken) { const data = await getJson('https://www.googleapis.com/oauth2/v2/userinfo', accessToken); return { id: String(data.id || ''), + email: String(data.email || ''), + verifiedEmail: data.verified_email !== false, displayName: String(data.name || data.email || 'Google'), avatarUrl: String(data.picture || ''), }; diff --git a/src/app.routes.ts b/src/app.routes.ts index b03b748..678e3c1 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -109,6 +109,11 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./components/auth/register/register.component').then(m => m.RegisterComponent), title: 'NewTube - Register' }, + { + path: 'auth/google/callback', + loadComponent: () => import('./components/auth/google-callback/google-callback.component').then(m => m.GoogleCallbackComponent), + title: 'NewTube - Google login' + }, { path: 'info/utilisation', loadComponent: () => import('./components/info/utilisation/utilisation.component').then(m => m.UtilisationComponent), diff --git a/src/components/auth/google-callback/google-callback.component.html b/src/components/auth/google-callback/google-callback.component.html new file mode 100644 index 0000000..fbcd06c --- /dev/null +++ b/src/components/auth/google-callback/google-callback.component.html @@ -0,0 +1,9 @@ +
+
+
Connexion avec Google…
+
+

{{ e }}

+ Retour à la connexion +
+
+
diff --git a/src/components/auth/google-callback/google-callback.component.ts b/src/components/auth/google-callback/google-callback.component.ts new file mode 100644 index 0000000..3df33d0 --- /dev/null +++ b/src/components/auth/google-callback/google-callback.component.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; +import { AuthService } from '../../../services/auth.service'; +import { UserService } from '../../../services/user.service'; +import { InstanceService } from '../../../services/instance.service'; + +/** Retour du login Google : la session est déjà posée en cookies par le backend. */ +@Component({ + selector: 'app-google-callback-page', + standalone: true, + templateUrl: './google-callback.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, RouterLink], +}) +export class GoogleCallbackComponent implements OnInit { + private auth = inject(AuthService); + private users = inject(UserService); + private instances = inject(InstanceService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + error = signal(null); + + async ngOnInit(): Promise { + const err = this.route.snapshot.queryParamMap.get('error'); + if (err) { + this.error.set(`Google : ${err}`); + return; + } + try { + const ok = await firstValueFrom(this.auth.initSession()); + if (!ok) { + this.error.set('Session Google introuvable. Réessayez.'); + return; + } + try { + await firstValueFrom(this.auth.fetchMe()); + } catch {} + try { + const prefs = await firstValueFrom(this.users.loadPreferences()); + if (prefs) { + if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any); + if (prefs.region) this.instances.setRegion(prefs.region); + try { document.documentElement.setAttribute('data-theme', prefs.theme || 'system'); } catch {} + } + } catch {} + this.router.navigate(['/library/import'], { queryParams: { provider: 'google', connected: 1 } }); + } catch { + this.error.set('Connexion Google impossible. Réessayez.'); + } + } +} diff --git a/src/components/auth/login/login.component.html b/src/components/auth/login/login.component.html index 07056c7..5675cb2 100644 --- a/src/components/auth/login/login.component.html +++ b/src/components/auth/login/login.component.html @@ -24,6 +24,18 @@ +
+
+ ou +
+ +

Crée ou lie votre compte NewTube. Les abonnements et favoris YouTube deviennent importables depuis Bibliothèque › Importer.

+
+
Don't have an account? Create one diff --git a/src/components/auth/login/login.component.ts b/src/components/auth/login/login.component.ts index 11ec1a1..759b0a2 100644 --- a/src/components/auth/login/login.component.ts +++ b/src/components/auth/login/login.component.ts @@ -25,6 +25,32 @@ export class LoginComponent { remember = signal(true); error = signal(null); busy = signal(false); + googleAvailable = signal(false); + googleBusy = signal(false); + + constructor() { + // Bouton Google visible seulement si OAuth configuré côté serveur. + try { + this.auth.googleAuthUrlAvailable().subscribe({ next: (ok) => this.googleAvailable.set(ok) }); + } catch {} + // Erreur OAuth renvoyée par le callback (redirect /auth/login?error=...). + try { + const params = new URLSearchParams(window.location.search); + const oauthError = params.get('error'); + if (oauthError) this.error.set(`Google : ${oauthError}`); + } catch {} + } + + loginWithGoogle(): void { + this.error.set(null); + this.googleBusy.set(true); + this.auth.redirectToGoogle().subscribe({ + error: (e: any) => { + this.error.set(e?.error?.error || e?.message || 'Google login failed.'); + this.googleBusy.set(false); + }, + }); + } async submit() { this.error.set(null); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 997bbfd..6767936 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -155,6 +155,38 @@ export class AuthService { ); } + /** URL d'autorisation Google (connexion par compte Google). */ + googleAuthUrl(): Observable<{ url: string }> { + return this.http.get<{ url: string }>('/api/auth/google/url', { withCredentials: true }).pipe( + catchError((err: HttpErrorResponse) => { + if (!err || err.status === 0 || err.status === 404) { + return this.http.get<{ url: string }>('/proxy/api/auth/google/url', { withCredentials: true }); + } + throw err; + }) + ); + } + + /** Bouton Google visible seulement si OAuth configuré côté serveur. */ + googleAuthUrlAvailable(): Observable { + return this.http.get('/api/oauth/status', { withCredentials: true }).pipe( + map((s) => !!s?.google?.configured), + catchError(() => of(false)) + ); + } + + /** Récupère l'URL Google puis redirige (ne complète jamais en cas de succès). */ + redirectToGoogle(): Observable { + return this.googleAuthUrl().pipe( + tap((res) => { + const url = res?.url; + if (!url) throw new Error('google_auth_url_failed'); + window.location.href = url; + }), + map(() => { throw new Error('redirecting'); }) + ); + } + listSessions(): Observable { return this.http.get('/api/auth/sessions', { withCredentials: true }).pipe( catchError((err: HttpErrorResponse) => {