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:
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user