/** * Authentication routes. * Extracted from server.js -- BIP39, Discord, and Google authentication. * Mount point: app.use('/api/auth', router) */ const express = require('express'); const router = express.Router(); const axios = require('axios'); const crypto = require('crypto'); const bip39 = require('bip39'); const { v4: uuidv4 } = require('uuid'); const rateLimit = require('express-rate-limit'); const { ipKeyGenerator } = require('express-rate-limit'); const { issueJwt, getAuthIfValid } = require('../middleware/auth'); const { getPool } = require('../mysqlPool'); const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore'); const { verifyTurnstileFromRequest } = require('../utils/turnstile'); const { SUPPORTED_PROVIDERS, isSupportedProvider, getLinkedTarget, getLinksForTarget, setLink, removeLinksForTargetProvider, } = require('../utils/accountLinks'); // Rate limiter pour les endpoints d'authentification // 10 tentatives par IP toutes les 15 minutes const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // 10 requêtes max par fenêtre store: createRedisRateLimitStore({ prefix: 'rate-limit:auth:login:' }), passOnStoreError: true, standardHeaders: true, // Retourne les headers RateLimit-* standard legacyHeaders: false, // Désactive X-RateLimit-* message: { success: false, error: 'Trop de tentatives de connexion. Réessayez dans 15 minutes.' }, keyGenerator: (req) => req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0].trim() || ipKeyGenerator(req.ip), validate: { xForwardedForHeader: false, ip: false } }); // Récupération de la liste de mots française const frenchWordlist = bip39.wordlists.french; const DEFAULT_AVATAR = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp'; // Lazy imports from sync module let _syncModule = null; function getSyncModule() { if (!_syncModule) _syncModule = require('./sync'); return _syncModule; } // === Session helpers === const generateDeviceFingerprint = (userAgent, ip) => { const fingerprint = crypto.createHash('sha256') .update(`${userAgent}-${ip || 'unknown'}-${Date.now()}`) .digest('hex') .substring(0, 32); return fingerprint; }; const encryptDeviceInfo = (deviceInfo) => { const algorithm = 'aes-256-cbc'; const salt = crypto.randomBytes(16); const key = crypto.scryptSync(process.env.JWT_SECRET, salt, 32); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(algorithm, key, iv); let encrypted = cipher.update(deviceInfo, 'utf8', 'base64'); encrypted += cipher.final('base64'); return `${salt.toString('base64')}.${iv.toString('base64')}.${encrypted}`; }; const createUserSession = async (userType, userId, req) => { try { const pool = getPool(); if (!pool) { console.error('MySQL pool not ready for session creation'); return null; } const userAgent = req.headers['user-agent'] || 'Unknown'; const ip = req.ip || req.connection.remoteAddress || 'Unknown'; const sessionId = uuidv4(); const deviceInfo = generateDeviceFingerprint(userAgent, ip); const encryptedDevice = encryptDeviceInfo(deviceInfo); await pool.execute( 'INSERT INTO user_sessions (id, user_id, user_type, device, user_agent) VALUES (?, ?, ?, ?, ?)', [sessionId, userId, userType, encryptedDevice, userAgent] ); console.log(`[SESSION] Created new session ${sessionId} for ${userType}:${userId}`); return sessionId; } catch (error) { console.error('Error creating user session:', error); return null; } }; // === Helpers === function normalizeMnemonic(mnemonic) { return mnemonic.normalize('NFKD').toLowerCase().trim().replace(/\s+/g, ' '); } function validateBip39Mnemonic(mnemonic) { return bip39.validateMnemonic(mnemonic, frenchWordlist) || bip39.validateMnemonic(mnemonic); } function createBip39UserId(normalizedMnemonic) { return crypto.createHash('sha256').update(normalizedMnemonic).digest('hex').substring(0, 16); } function buildBip39Profile(userId, username, avatar, existingProfile = null) { return { id: userId, username: (username || existingProfile?.username || `Utilisateur-${userId.substring(0, 8)}`).trim(), avatar: avatar || existingProfile?.avatar || DEFAULT_AVATAR, provider: 'bip39', createdAt: existingProfile?.createdAt || new Date().toISOString(), }; } function buildDiscordProfile(user) { return { id: String(user.id), username: user.username || `Discord-${user.id}`, avatar: user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` : DEFAULT_AVATAR, provider: 'discord', createdAt: new Date().toISOString(), }; } function buildGoogleProfile(user) { return { id: String(user.sub), username: user.name || user.email || `Google-${user.sub}`, avatar: user.picture || DEFAULT_AVATAR, provider: 'google', createdAt: new Date().toISOString(), }; } function parseStoredAuth(userData) { if (!userData?.auth || typeof userData.auth !== 'string') return null; try { const authData = JSON.parse(userData.auth); if (authData && authData.userProfile) return authData; } catch (error) { console.error('Error parsing stored auth data:', error.message); } return null; } async function ensureStoredAuthData(userType, userId, authData, extraFields = {}) { const { readUserData, writeUserData } = getSyncModule(); const userData = await readUserData(userType, userId) || {}; let shouldWrite = false; if (!parseStoredAuth(userData)) { userData.auth = JSON.stringify(authData); shouldWrite = true; } for (const [key, value] of Object.entries(extraFields)) { if (userData[key] !== value) { userData[key] = value; shouldWrite = true; } } if (shouldWrite) { userData.lastUpdated = Date.now(); await writeUserData(userType, userId, userData); } return userData; } async function getResolvedAuthData(userType, userId, fallbackProfile, fallbackProvider) { const { readUserData } = getSyncModule(); const userData = await readUserData(userType, userId) || {}; const storedAuth = parseStoredAuth(userData); if (storedAuth?.userProfile) { return { userData, authData: { ...storedAuth, provider: storedAuth.provider || storedAuth.userProfile.provider || fallbackProvider, } }; } return { userData, authData: { userProfile: fallbackProfile, provider: fallbackProvider, } }; } function getProviderLabel(provider) { if (provider === 'discord') return 'Discord'; if (provider === 'google') return 'Google'; if (provider === 'bip39') return 'BIP39'; return 'ce moyen de connexion'; } async function getAccountPrimaryProvider(userType, userId) { const fallbackProvider = userType === 'bip39' ? 'bip39' : 'oauth'; const { userData, authData } = await getResolvedAuthData( userType, userId, { id: String(userId), provider: fallbackProvider }, fallbackProvider ); const providerCandidates = [ authData?.userProfile?.provider, authData?.provider, userData?.oauth_provider, ]; for (const candidate of providerCandidates) { if (isSupportedProvider(candidate)) { return candidate; } } return userType === 'bip39' ? 'bip39' : null; } async function ensureLinkManagementAccess(auth) { const accountProvider = await getAccountPrimaryProvider(auth.userType, auth.userId); if (!accountProvider) { return { allowed: false, status: 403, error: 'Impossible de déterminer le provider principal de ce compte. Reconnectez-vous avec le compte d’origine.', }; } if (auth.authMethod !== accountProvider) { return { allowed: false, status: 403, error: `Reconnectez-vous avec ${getProviderLabel(accountProvider)} pour gérer les liaisons de ce compte.`, }; } return { allowed: true, accountProvider }; } async function getOrCreateBip39Account({ mnemonic, username = null, avatar = null, overwriteProfile = false }) { const normalizedMnemonic = normalizeMnemonic(mnemonic); if (!validateBip39Mnemonic(normalizedMnemonic)) { return { success: false, error: 'Phrase secrète invalide' }; } const userId = createBip39UserId(normalizedMnemonic); const { readUserData, writeUserData } = getSyncModule(); const userData = await readUserData('bip39', userId) || {}; const storedAuth = parseStoredAuth(userData); const existingProfile = storedAuth?.userProfile || null; const shouldWriteProfile = overwriteProfile || !existingProfile; const userProfile = buildBip39Profile(userId, username, avatar, existingProfile); if (shouldWriteProfile) { userData.auth = JSON.stringify({ userProfile, provider: 'bip39' }); userData.bip39_auth = 'true'; userData.lastUpdated = Date.now(); await writeUserData('bip39', userId, userData); } return { success: true, normalizedMnemonic, userId, userProfile }; } async function verifyDiscordAccessToken(accessToken) { const resp = await axios.get('https://discord.com/api/users/@me', { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 10000, }); const user = resp.data; if (!user || !user.id) { throw new Error('Token Discord invalide'); } return user; } async function verifyGoogleAccessToken(accessToken) { const resp = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 10000, }); const user = resp.data; if (!user || !user.sub) { throw new Error('Token Google invalide'); } return user; } async function finalizeResolvedSession(req, { provider, providerUserId, fallbackUserType, sourceAuthData, externalUser, }) { const sourceExtraFields = provider === 'bip39' ? { bip39_auth: 'true' } : { oauth_provider: provider }; await ensureStoredAuthData(fallbackUserType, providerUserId, sourceAuthData, sourceExtraFields); const linkedTarget = await getLinkedTarget(provider, providerUserId); const resolvedUserType = linkedTarget?.targetUserType || fallbackUserType; const resolvedUserId = linkedTarget?.targetUserId || String(providerUserId); const sessionId = await createUserSession(resolvedUserType, resolvedUserId, req); const token = issueJwt(resolvedUserType, resolvedUserId, sessionId, provider); const fallbackResolvedProvider = resolvedUserType === 'bip39' ? 'bip39' : sourceAuthData?.provider || provider; const fallbackResolvedProfile = { ...(sourceAuthData?.userProfile || {}), id: resolvedUserId, provider: fallbackResolvedProvider, }; const { authData } = await getResolvedAuthData( resolvedUserType, resolvedUserId, fallbackResolvedProfile, fallbackResolvedProvider ); // Si le pseudo provider (Discord/Google) viole la policy (trop long, contient // des caractères de contrôle/zero-width), on remonte un flag pour que le // frontend force l'user à le changer via POST /api/auth/username. const { isProfileNameValid } = require('../utils/syncPolicy'); const currentUsername = authData?.userProfile?.username; const requiresUsernameChange = !isProfileNameValid(currentUsername); return { success: true, sessionId, token, user: externalUser, account: { userType: resolvedUserType, userId: resolvedUserId, linked: Boolean(linkedTarget), linkProvider: linkedTarget?.provider || null, }, authData, requiresUsernameChange, }; } async function resolveProviderIdentity(provider, body) { switch (provider) { case 'discord': { const accessToken = body?.access_token; if (!accessToken) { return { success: false, status: 400, error: 'access_token requis' }; } const user = await verifyDiscordAccessToken(accessToken); return { success: true, provider, providerUserId: String(user.id), authData: { userProfile: buildDiscordProfile(user), provider: 'discord' }, externalUser: user, }; } case 'google': { const accessToken = body?.access_token; if (!accessToken) { return { success: false, status: 400, error: 'access_token requis' }; } const user = await verifyGoogleAccessToken(accessToken); return { success: true, provider, providerUserId: String(user.sub), authData: { userProfile: buildGoogleProfile(user), provider: 'google' }, externalUser: user, }; } case 'bip39': { const mnemonic = body?.mnemonic; if (!mnemonic) { return { success: false, status: 400, error: 'Phrase secrète requise' }; } const account = await getOrCreateBip39Account({ mnemonic }); if (!account.success) { return { success: false, status: 400, error: account.error }; } return { success: true, provider, providerUserId: account.userId, authData: { userProfile: account.userProfile, provider: 'bip39' }, externalUser: { id: account.userId, username: account.userProfile.username, avatar: account.userProfile.avatar, }, }; } default: return { success: false, status: 400, error: 'Provider non supporté' }; } } function getProviderStatusMap(records) { return SUPPORTED_PROVIDERS.reduce((acc, provider) => { const record = records.find((item) => item.provider === provider) || null; acc[provider] = record ? { linked: true, providerUserId: record.providerUserId, linkedAt: record.linkedAt, updatedAt: record.updatedAt, } : { linked: false, providerUserId: null, linkedAt: null, updatedAt: null, }; return acc; }, {}); } // === Routes === // GET /bip39/generate router.get('/bip39/generate', (req, res) => { try { const mnemonic = bip39.generateMnemonic(128, undefined, frenchWordlist); res.status(200).json({ success: true, mnemonic }); } catch (error) { console.error('Error generating mnemonic:', error); res.status(500).json({ success: false, error: 'Erreur lors de la génération de la phrase secrète' }); } }); // POST /bip39/create router.post('/bip39/create', authRateLimit, async (req, res) => { try { const { mnemonic, username, avatar, turnstileToken } = req.body; if (!mnemonic || !username) { return res.status(400).json({ success: false, error: 'Phrase secrète et nom d\'utilisateur requis' }); } const turnstileResult = await verifyTurnstileFromRequest(req, turnstileToken); if (!turnstileResult.valid) { return res.status(turnstileResult.status).json({ success: false, error: turnstileResult.error }); } const account = await getOrCreateBip39Account({ mnemonic, username, avatar, overwriteProfile: true, }); if (!account.success) { return res.status(400).json({ success: false, error: account.error }); } const payload = await finalizeResolvedSession(req, { provider: 'bip39', providerUserId: account.userId, fallbackUserType: 'bip39', sourceAuthData: { userProfile: account.userProfile, provider: 'bip39' }, externalUser: { id: account.userId, username: account.userProfile.username, avatar: account.userProfile.avatar, }, }); return res.status(200).json({ ...payload, userId: payload.account.userId, userProfile: payload.authData.userProfile, message: 'Compte créé avec succès', }); } catch (error) { console.error('Error creating BIP39 account:', error); res.status(500).json({ success: false, error: 'Erreur lors de la création du compte' }); } }); // POST /bip39/login router.post('/bip39/login', authRateLimit, async (req, res) => { try { const { mnemonic, turnstileToken } = req.body; if (!mnemonic) { return res.status(400).json({ success: false, error: 'Phrase secrète requise' }); } const turnstileResult = await verifyTurnstileFromRequest(req, turnstileToken); if (!turnstileResult.valid) { return res.status(turnstileResult.status).json({ success: false, error: turnstileResult.error }); } const account = await getOrCreateBip39Account({ mnemonic }); if (!account.success) { return res.status(400).json({ success: false, error: account.error }); } const payload = await finalizeResolvedSession(req, { provider: 'bip39', providerUserId: account.userId, fallbackUserType: 'bip39', sourceAuthData: { userProfile: account.userProfile, provider: 'bip39' }, externalUser: { id: account.userId, username: account.userProfile.username, avatar: account.userProfile.avatar, }, }); return res.status(200).json({ ...payload, userId: payload.account.userId, userProfile: payload.authData.userProfile, message: 'Connexion réussie', }); } catch (error) { console.error('Error logging in with BIP39:', error); res.status(500).json({ success: false, error: 'Erreur lors de la connexion' }); } }); // POST /discord/verify router.post('/discord/verify', authRateLimit, async (req, res) => { try { const { access_token: accessToken } = req.body || {}; if (!accessToken) { return res.status(400).json({ success: false, error: 'access_token requis' }); } const user = await verifyDiscordAccessToken(accessToken); const payload = await finalizeResolvedSession(req, { provider: 'discord', providerUserId: String(user.id), fallbackUserType: 'oauth', sourceAuthData: { userProfile: buildDiscordProfile(user), provider: 'discord' }, externalUser: user, }); return res.status(200).json(payload); } catch (error) { console.error('Discord verify error:', error.response?.status || error.message); return res.status(401).json({ success: false, error: 'Échec de la vérification Discord' }); } }); // POST /google/verify router.post('/google/verify', authRateLimit, async (req, res) => { try { const { access_token: accessToken } = req.body || {}; if (!accessToken) { return res.status(400).json({ success: false, error: 'access_token requis' }); } const user = await verifyGoogleAccessToken(accessToken); const payload = await finalizeResolvedSession(req, { provider: 'google', providerUserId: String(user.sub), fallbackUserType: 'oauth', sourceAuthData: { userProfile: buildGoogleProfile(user), provider: 'google' }, externalUser: user, }); return res.status(200).json(payload); } catch (error) { console.error('Google verify error:', error.response?.status || error.message); return res.status(401).json({ success: false, error: 'Échec de la vérification Google' }); } }); // GET /links router.get('/links', async (req, res) => { try { const auth = await getAuthIfValid(req); if (!auth || !['oauth', 'bip39'].includes(auth.userType)) { return res.status(401).json({ success: false, error: 'Non autorisé' }); } const records = await getLinksForTarget(auth.userType, auth.userId); const fallbackProvider = auth.userType === 'bip39' ? 'bip39' : 'oauth'; const { authData } = await getResolvedAuthData( auth.userType, auth.userId, { id: String(auth.userId), provider: fallbackProvider }, fallbackProvider ); const resolvedProviderCandidate = authData?.userProfile?.provider || authData?.provider; const accountProvider = await getAccountPrimaryProvider(auth.userType, auth.userId) || (isSupportedProvider(resolvedProviderCandidate) ? resolvedProviderCandidate : null) || (auth.userType === 'bip39' ? 'bip39' : null); const { isProfileNameValid } = require('../utils/syncPolicy'); const currentUsername = authData?.userProfile?.username; const requiresUsernameChange = !isProfileNameValid(currentUsername); return res.status(200).json({ success: true, account: { userType: auth.userType, userId: auth.userId, provider: accountProvider, authMethod: isSupportedProvider(auth.authMethod) ? auth.authMethod : null, canManageLinks: Boolean(accountProvider && auth.authMethod && auth.authMethod === accountProvider), manageWithProvider: accountProvider, }, links: getProviderStatusMap(records), requiresUsernameChange, currentUsername: currentUsername || null, }); } catch (error) { console.error('Error getting account links:', error); return res.status(500).json({ success: false, error: 'Erreur lors de la récupération des liaisons' }); } }); // POST /links/:provider router.post('/links/:provider', authRateLimit, async (req, res) => { try { const auth = await getAuthIfValid(req); if (!auth || !['oauth', 'bip39'].includes(auth.userType)) { return res.status(401).json({ success: false, error: 'Non autorisé' }); } const accessCheck = await ensureLinkManagementAccess(auth); if (!accessCheck.allowed) { return res.status(accessCheck.status || 403).json({ success: false, error: accessCheck.error }); } const provider = String(req.params.provider || '').toLowerCase(); if (!isSupportedProvider(provider)) { return res.status(400).json({ success: false, error: 'Provider non supporté' }); } const resolved = await resolveProviderIdentity(provider, req.body); if (!resolved.success) { return res.status(resolved.status || 400).json({ success: false, error: resolved.error }); } const isSelfLink = ((provider === 'google' || provider === 'discord') && auth.userType === 'oauth' && String(auth.userId) === String(resolved.providerUserId)) || (provider === 'bip39' && auth.userType === 'bip39' && String(auth.userId) === String(resolved.providerUserId)); if (isSelfLink) { return res.status(400).json({ success: false, error: 'Ce moyen de connexion correspond déjà à ce compte.', }); } const extraFields = provider === 'bip39' ? { bip39_auth: 'true' } : { oauth_provider: provider }; await ensureStoredAuthData( provider === 'bip39' ? 'bip39' : 'oauth', resolved.providerUserId, resolved.authData, extraFields ); const link = await setLink({ provider, providerUserId: resolved.providerUserId, targetUserType: auth.userType, targetUserId: auth.userId, }); return res.status(200).json({ success: true, link: { provider: link.provider, providerUserId: link.providerUserId, linkedAt: link.linkedAt, updatedAt: link.updatedAt, targetUserType: link.targetUserType, targetUserId: link.targetUserId, }, message: 'Compte lié avec succès', }); } catch (error) { console.error('Error linking account:', error); return res.status(500).json({ success: false, error: 'Erreur lors de la liaison du compte' }); } }); // DELETE /links/:provider router.delete('/links/:provider', async (req, res) => { try { const auth = await getAuthIfValid(req); if (!auth || !['oauth', 'bip39'].includes(auth.userType)) { return res.status(401).json({ success: false, error: 'Non autorisé' }); } const accessCheck = await ensureLinkManagementAccess(auth); if (!accessCheck.allowed) { return res.status(accessCheck.status || 403).json({ success: false, error: accessCheck.error }); } const provider = String(req.params.provider || '').toLowerCase(); if (!isSupportedProvider(provider)) { return res.status(400).json({ success: false, error: 'Provider non supporté' }); } const removed = await removeLinksForTargetProvider(auth.userType, auth.userId, provider); return res.status(200).json({ success: true, removed, message: removed ? 'Liaison supprimée avec succès' : 'Aucune liaison active à supprimer', }); } catch (error) { console.error('Error unlinking account:', error); return res.status(500).json({ success: false, error: 'Erreur lors de la suppression de la liaison' }); } }); // POST /username — l'user met à jour son pseudo (auth.userProfile.username) // quand le pseudo provider (Discord/Google) dépasse 32 caractères ou contient // des caractères dangereux. Le frontend appelle ça après affichage de la // modale bloquante. Met aussi à jour le profil par défaut si son nom matchait // l'ancien username (pour garder la cohérence visuelle). router.post('/username', authRateLimit, async (req, res) => { try { const auth = await getAuthIfValid(req); if (!auth || !['oauth', 'bip39'].includes(auth.userType)) { return res.status(401).json({ success: false, error: 'Non autorisé' }); } const { validateProfileName, SyncPolicyError } = require('../utils/syncPolicy'); let cleanedName; try { cleanedName = validateProfileName(req.body?.username); } catch (e) { if (e instanceof SyncPolicyError) { return res.status(e.status).json({ success: false, error: e.message, code: e.code }); } throw e; } const { readUserData, writeUserData } = getSyncModule(); const userData = await readUserData(auth.userType, auth.userId) || {}; let parsedAuth = null; if (typeof userData.auth === 'string') { try { parsedAuth = JSON.parse(userData.auth); } catch { parsedAuth = null; } } if (!parsedAuth || !parsedAuth.userProfile) { // Pas de userProfile stocké : on en crée un minimal pour porter le nom. parsedAuth = { ...(parsedAuth || {}), userProfile: { id: String(auth.userId), provider: auth.userType === 'bip39' ? 'bip39' : (parsedAuth?.provider || 'oauth'), username: cleanedName, createdAt: new Date().toISOString(), }, }; } else { const oldUsername = parsedAuth.userProfile.username; parsedAuth.userProfile = { ...parsedAuth.userProfile, username: cleanedName }; // Met aussi à jour le nom du profil par défaut si l'user n'a jamais // renommé son profil (sinon on ne touche pas — il a fait un choix conscient). if (Array.isArray(userData.profiles) && typeof oldUsername === 'string') { const defaultProfile = userData.profiles.find((p) => p && p.isDefault); if (defaultProfile && defaultProfile.name === oldUsername) { defaultProfile.name = cleanedName; } } } userData.auth = JSON.stringify(parsedAuth); userData.lastUpdated = Date.now(); const success = await writeUserData(auth.userType, auth.userId, userData); if (!success) { return res.status(500).json({ success: false, error: 'Impossible de sauvegarder le nouveau pseudo' }); } return res.status(200).json({ success: true, username: cleanedName, authData: parsedAuth, }); } catch (error) { console.error('Error updating username:', error); return res.status(500).json({ success: false, error: 'Erreur lors de la mise à jour du pseudo' }); } }); module.exports = router;