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)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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' });
|
||||
|
||||
+9
-2
@@ -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 || ''),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="min-h-[calc(100vh-64px)] flex items-center justify-center py-10">
|
||||
<div class="w-full max-w-md bg-slate-800 rounded-xl shadow-xl p-8 text-center">
|
||||
<div *ngIf="!error()" class="text-slate-300">Connexion avec Google…</div>
|
||||
<div *ngIf="error() as e" class="space-y-4">
|
||||
<p class="rounded border border-red-600 bg-red-900/50 px-3 py-2 text-sm text-red-200">{{ e }}</p>
|
||||
<a routerLink="/auth/login" class="inline-block rounded bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500">Retour à la connexion</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@
|
||||
<button [disabled]="busy()" class="w-full py-2 rounded bg-red-600 hover:bg-red-500 disabled:opacity-60">Sign in</button>
|
||||
</form>
|
||||
|
||||
<div *ngIf="googleAvailable()" class="mt-4">
|
||||
<div class="mb-3 flex items-center gap-3 text-xs text-slate-500">
|
||||
<span class="h-px flex-1 bg-slate-700"></span><span>ou</span><span class="h-px flex-1 bg-slate-700"></span>
|
||||
</div>
|
||||
<button type="button" [disabled]="googleBusy()" (click)="loginWithGoogle()"
|
||||
class="flex w-full items-center justify-center gap-2 rounded border border-slate-600 bg-white px-3 py-2 font-medium text-slate-800 transition hover:bg-slate-100 disabled:opacity-60">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M23.5 12.3c0-.9-.1-1.5-.3-2.3H12v4.5h6.5c-.1 1.1-.8 2.7-2.4 3.8l-.1.4 3.5 2.7.2.1c2.2-2 3.6-5 3.6-9.2z"/><path fill="#34A853" d="M12 24c3.2 0 5.9-1.1 7.9-2.9l-3.8-2.9c-1 .7-2.4 1.2-4.1 1.2-3.1 0-5.8-2.1-6.8-5l-.4.1-3.6 2.8v.3C3.3 21.3 7.3 24 12 24z"/><path fill="#FBBC05" d="M5.2 14.4c-.2-.7-.4-1.5-.4-2.4s.1-1.7.4-2.4l-.1-.4-3.6-2.8-.1.1C.5 8.3 0 10 0 12s.5 3.7 1.4 5.3l3.8-2.9z"/><path fill="#EA4335" d="M12 4.6c1.8 0 3 .8 3.7 1.4l3.3-3.2C17.9 1 15.2 0 12 0 7.3 0 3.3 2.7 1.4 6.7l3.8 2.9c1-2.9 3.7-5 6.8-5z"/></svg>
|
||||
{{ googleBusy() ? 'Redirection…' : 'Se connecter avec Google' }}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-slate-500">Crée ou lie votre compte NewTube. Les abonnements et favoris YouTube deviennent importables depuis Bibliothèque › Importer.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-300">
|
||||
Don't have an account?
|
||||
<a routerLink="/auth/register" class="text-red-400 hover:text-red-300">Create one</a>
|
||||
|
||||
@@ -25,6 +25,32 @@ export class LoginComponent {
|
||||
remember = signal(true);
|
||||
error = signal<string | null>(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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
return this.http.get<any>('/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<never> {
|
||||
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<SessionInfo[]> {
|
||||
return this.http.get<SessionInfo[]>('/api/auth/sessions', { withCredentials: true }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
|
||||
Reference in New Issue
Block a user