userService.js
1 import crypto from "crypto"; 2 import bs58 from "bs58"; 3 import { ethers } from "ethers"; 4 import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; 5 import { SuiClient } from "@mysten/sui/client"; 6 import nacl from "tweetnacl"; 7 import { User } from "@/models/User.js"; 8 import { logger } from "@/middleware/logging.js"; 9 import config from "@/config.js"; 10 import { hdWalletDeriver } from "@/services/hdWalletDeriver.js"; 11 import { dollarsToCents, centsToDollars } from "@/utils/fiatUtils.js"; 12 import { hashIpAddress, checkIpHashAbuse } from "@/utils/ipHasher.js"; 13 import { timingSafeCompare, addTimingNoise, validateWalletAddress } from "@/utils/cryptoUtils.js"; 14 import cache, { CacheEvents } from "@/utils/cache.js"; 15 import networks, { getEnabledNetworksForType } from "@/data/crypto/networks.js"; 16 17 const validateSignature = (signature, chainType) => { 18 if (!signature || typeof signature !== "string") { 19 return false; 20 } else if (signature.length > 512) { 21 return false; 22 } else if (chainType === "solana" || chainType === "sui" || chainType === "bitcoin") { 23 return /^[1-9A-HJ-NP-Za-km-z]{87,88}$/.test(signature); 24 } else if (chainType === "evm") { 25 return /^0x[a-fA-F0-9]{130}$/.test(signature); 26 } else { 27 return false; 28 } 29 }; 30 31 class SignatureVerifier { 32 static verifySolana(message, signature, publicKey) { 33 try { 34 if (!message || !signature || !publicKey) { 35 return false; 36 } else if (typeof message !== "string" || message.length > 10000) { 37 return false; 38 } else { 39 const messageBytes = new TextEncoder().encode(message); 40 const signatureBytes = bs58.decode(signature); 41 const publicKeyBytes = bs58.decode(publicKey); 42 if (signatureBytes.length !== 64) { 43 return false; 44 } else if (publicKeyBytes.length !== 32) { 45 return false; 46 } else { 47 return nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes); 48 } 49 } 50 } catch (error) { 51 logger.error("Solana signature verification failed", { message: error.message }); 52 return false; 53 } 54 } 55 56 static verifyEthereum(message, signature, address) { 57 try { 58 if (!message || !signature || !address) { 59 return false; 60 } else if (typeof message !== "string" || message.length > 10000) { 61 return false; 62 } else { 63 const recoveredAddress = ethers.verifyMessage(message, signature); 64 return timingSafeCompare(recoveredAddress.toLowerCase(), address.toLowerCase()); 65 } 66 } catch (error) { 67 logger.error("Ethereum signature verification failed", { message: error.message }); 68 return false; 69 } 70 } 71 72 static verify(message, signature, address, chainType) { 73 if (!validateWalletAddress(address, chainType)) { 74 return false; 75 } else if (!validateSignature(signature, chainType)) { 76 return false; 77 } else if (chainType === "solana" || chainType === "sui" || chainType === "bitcoin") { 78 return this.verifySolana(message, signature, address); 79 } else if (chainType === "evm") { 80 return this.verifyEthereum(message, signature, address); 81 } else { 82 return false; 83 } 84 } 85 } 86 87 class SessionManager { 88 constructor() { 89 this.sessions = new Map(); 90 this.sessionDuration = config.security.sessionDuration || 24 * 60 * 60 * 1000; 91 this.maxSessions = config.security.maxSessions || 100000; 92 this.sessionsByUser = new Map(); 93 this.maxSessionsPerUser = config.security.maxSessionsPerUser || 5; 94 this.sessionRetryAttempts = new Map(); 95 this.maxFailedLogins = config.security.maxFailedLogins || 5; 96 this.accountLockDuration = config.security.accountLockDuration || 30 * 60 * 1000; 97 this.maxRetryAttempts = config.retries.few; 98 this.retryLockoutDuration = config.security.retryLockoutDuration || 5 * 60 * 1000; 99 } 100 101 generateSecureToken() { 102 return crypto.randomBytes(32).toString("hex"); 103 } 104 105 hashToken(token) { 106 return crypto.createHash("sha256").update(token).digest("hex"); 107 } 108 109 createSession(userId, walletAddress) { 110 const userIdStr = userId.toString(); 111 const userSessions = this.sessionsByUser.get(userIdStr) || []; 112 if (userSessions.length >= this.maxSessionsPerUser) { 113 const oldestToken = userSessions.shift(); 114 this.sessions.delete(oldestToken); 115 } 116 if (this.sessions.size >= this.maxSessions) { 117 const keysToDelete = Array.from(this.sessions.keys()).slice(0, 1000); 118 keysToDelete.forEach(k => { 119 const session = this.sessions.get(k); 120 if (session && session.userId) { 121 const userSessionList = this.sessionsByUser.get(session.userId); 122 if (userSessionList) { 123 const index = userSessionList.indexOf(k); 124 if (index > -1) { 125 userSessionList.splice(index, 1); 126 } 127 } 128 } 129 this.sessions.delete(k); 130 }); 131 } 132 const sessionToken = crypto.randomBytes(32).toString("hex"); 133 const tokenHash = this.hashToken(sessionToken); 134 const expiry = new Date(Date.now() + this.sessionDuration); 135 const session = { 136 userId: userIdStr, 137 walletAddress, 138 tokenHash, 139 expiry, 140 createdAt: new Date(), 141 }; 142 this.sessions.set(tokenHash, session); 143 userSessions.push(tokenHash); 144 this.sessionsByUser.set(userIdStr, userSessions); 145 setTimeout(() => { 146 this.deleteSession(sessionToken); 147 }, this.sessionDuration); 148 return { sessionToken, expiry }; 149 } 150 151 validateSession(sessionToken) { 152 if (!sessionToken || typeof sessionToken !== "string") { 153 return { valid: false, error: "Invalid token" }; 154 } 155 if (sessionToken.length < 32 || sessionToken.length > 128) { 156 return { valid: false, error: "Invalid token format" }; 157 } 158 159 const clientIdentifier = sessionToken.substring(0, 8); 160 const retryTracker = this.sessionRetryAttempts.get(clientIdentifier); 161 if (retryTracker && retryTracker.attempts >= this.maxRetryAttempts) { 162 const timeSinceLastAttempt = Date.now() - retryTracker.lastAttempt; 163 if (timeSinceLastAttempt < this.retryLockoutDuration) { 164 const remainingTime = Math.ceil((this.retryLockoutDuration - timeSinceLastAttempt) / 1000); 165 return { valid: false, error: `Too many attempts. Try again in ${remainingTime} seconds` }; 166 } else { 167 this.sessionRetryAttempts.delete(clientIdentifier); 168 } 169 } 170 171 const tokenHash = this.hashToken(sessionToken); 172 const session = this.sessions.get(tokenHash); 173 if (!session) { 174 this.trackRetryAttempt(clientIdentifier); 175 return { valid: false, error: "Session not found" }; 176 } 177 if (session.expiry && new Date() > session.expiry) { 178 this.deleteSession(sessionToken); 179 this.trackRetryAttempt(clientIdentifier); 180 return { valid: false, error: "Session expired" }; 181 } 182 183 if (retryTracker) { 184 this.sessionRetryAttempts.delete(clientIdentifier); 185 } 186 187 session.lastAccessed = new Date(); 188 return { valid: true, userId: session.userId, walletAddress: session.walletAddress }; 189 } 190 191 deleteSession(sessionToken) { 192 if (!sessionToken) { 193 return; 194 } 195 const tokenHash = this.hashToken(sessionToken); 196 const session = this.sessions.get(tokenHash); 197 if (session) { 198 const userIdStr = session.userId.toString(); 199 const userTokens = this.sessionsByUser.get(userIdStr) || []; 200 const idx = userTokens.indexOf(tokenHash); 201 if (idx > -1) { 202 userTokens.splice(idx, 1); 203 } 204 } 205 this.sessions.delete(tokenHash); 206 } 207 208 deleteAllUserSessions(userId) { 209 const userIdStr = userId.toString(); 210 const userTokens = this.sessionsByUser.get(userIdStr) || []; 211 userTokens.forEach(tokenHash => this.sessions.delete(tokenHash)); 212 this.sessionsByUser.delete(userIdStr); 213 } 214 215 refreshSession(sessionToken) { 216 if (!sessionToken) { 217 return null; 218 } 219 const tokenHash = this.hashToken(sessionToken); 220 const session = this.sessions.get(tokenHash); 221 if (!session) { 222 return null; 223 } 224 const expiry = new Date(Date.now() + this.sessionDuration); 225 session.expiry = expiry; 226 session.lastActivity = new Date(); 227 return { sessionToken, expiry }; 228 } 229 230 restoreSession(sessionToken, userId, walletAddress, expiry) { 231 if (!sessionToken || !userId) { 232 return; 233 } 234 const userIdStr = userId.toString(); 235 const tokenHash = this.hashToken(sessionToken); 236 this.sessions.set(tokenHash, { 237 userId: userIdStr, 238 walletAddress, 239 expiry: expiry || new Date(Date.now() + this.sessionDuration), 240 createdAt: new Date(), 241 lastActivity: new Date(), 242 }); 243 const userTokens = this.sessionsByUser.get(userIdStr) || []; 244 if (!userTokens.includes(tokenHash)) { 245 userTokens.push(tokenHash); 246 this.sessionsByUser.set(userIdStr, userTokens); 247 } 248 } 249 250 getActiveSessionsCount() { 251 return this.sessions.size; 252 } 253 254 cleanupExpiredSessions() { 255 const now = new Date(); 256 const expiredSessions = []; 257 258 for (const [tokenHash, session] of this.sessions.entries()) { 259 if (now > session.expiry) { 260 expiredSessions.push(tokenHash); 261 const userIdStr = session.userId.toString(); 262 const userTokens = this.sessionsByUser.get(userIdStr) || []; 263 const idx = userTokens.indexOf(tokenHash); 264 if (idx > -1) { 265 userTokens.splice(idx, 1); 266 } 267 } 268 } 269 270 expiredSessions.forEach(tokenHash => this.sessions.delete(tokenHash)); 271 272 const nowTime = Date.now(); 273 for (const [identifier, tracker] of this.sessionRetryAttempts.entries()) { 274 if (nowTime - tracker.lastAttempt > this.retryLockoutDuration) { 275 this.sessionRetryAttempts.delete(identifier); 276 } 277 } 278 } 279 280 trackRetryAttempt(clientIdentifier) { 281 const tracker = this.sessionRetryAttempts.get(clientIdentifier) || { attempts: 0, lastAttempt: 0 }; 282 tracker.attempts++; 283 tracker.lastAttempt = Date.now(); 284 this.sessionRetryAttempts.set(clientIdentifier, tracker); 285 } 286 } 287 288 const sessionManager = new SessionManager(); 289 290 setInterval(() => { 291 sessionManager.cleanupExpiredSessions(); 292 }, config.security.sessionCleanupInterval); 293 294 const generateRandomUsername = () => { 295 const prefix = "user"; 296 const randomBytes = crypto.randomBytes(6); 297 const randomSuffix = randomBytes.toString("hex").slice(0, 10); 298 return `${prefix}${randomSuffix}`; 299 }; 300 301 const generateUniqueUsername = async () => { 302 const maxAttempts = 50; 303 for (let attempts = 0; attempts < maxAttempts; attempts++) { 304 const username = generateRandomUsername(); 305 const existingUser = await User.findOne({ username }).select("_id").lean(); 306 if (!existingUser) { 307 return username; 308 } 309 } 310 const timestamp = Date.now().toString(36); 311 const random = crypto.randomBytes(4).toString("hex"); 312 return `user${timestamp}${random}`.slice(0, 16); 313 }; 314 315 export class AuthService { 316 constructor() { 317 this.challenges = new Map(); 318 this.challengeDuration = config.security.challengeDuration || 5 * 60 * 1000; 319 this.sessionManager = sessionManager; 320 this.maxFailedLogins = config.security.maxFailedLogins || 5; 321 this.accountLockDuration = config.security.accountLockDuration || 30 * 60 * 1000; 322 this.challengeRateLimit = new Map(); 323 this.maxChallengesPerIp = config.security.maxChallengesPerIp || 10; 324 this.challengeRateLimitWindow = config.security.challengeRateLimitWindow || 60000; 325 } 326 327 getChainTypeFromWalletType(walletType) { 328 const solanaWallets = ["phantom", "solflare", "brave"]; 329 return solanaWallets.includes(walletType) ? "solana" : "evm"; 330 } 331 332 checkChallengeRateLimit(identifier) { 333 const now = Date.now(); 334 let tracker = this.challengeRateLimit.get(identifier); 335 if (!tracker) { 336 tracker = { count: 0, windowStart: now }; 337 this.challengeRateLimit.set(identifier, tracker); 338 } 339 if (now - tracker.windowStart > this.challengeRateLimitWindow) { 340 tracker.count = 0; 341 tracker.windowStart = now; 342 } 343 if (tracker.count >= this.maxChallengesPerIp) { 344 return false; 345 } 346 tracker.count++; 347 return true; 348 } 349 350 generateChallenge(walletAddress, walletType = null, clientIp = null) { 351 if (!walletAddress || typeof walletAddress !== "string") { 352 return null; 353 } 354 355 const chainType = this.getChainTypeFromWalletType(walletType); 356 if (!validateWalletAddress(walletAddress, chainType)) { 357 return null; 358 } else if (clientIp && !this.checkChallengeRateLimit(clientIp)) { 359 return null; 360 } 361 362 if (this.challenges.size >= this.maxChallenges) { 363 const now = Date.now(); 364 const staleThreshold = 300000; 365 const entries = Array.from(this.challenges.entries()); 366 367 const staleEntries = entries.filter(([_, challenge]) => 368 now - challenge.timestamp > staleThreshold, 369 ); 370 371 if (staleEntries.length > 0) { 372 staleEntries.forEach(([key]) => this.challenges.delete(key)); 373 } else { 374 const oldestEntries = entries 375 .sort((a, b) => a[1].timestamp - b[1].timestamp) 376 .slice(0, 5000); 377 oldestEntries.forEach(([key]) => this.challenges.delete(key)); 378 } 379 } 380 const nonce = crypto.randomBytes(32).toString("hex"); 381 const timestamp = Date.now(); 382 const serverSecret = crypto.randomBytes(16).toString("hex"); 383 const message = `Sign this message to authenticate with Clushy:\n\nTimestamp: ${timestamp}\nNonce: ${nonce}\n\nThis signature will not trigger any blockchain transaction or cost any gas fees.`; 384 const challengeData = { 385 message, 386 nonce, 387 timestamp, 388 expiry: timestamp + this.challengeDuration, 389 serverSecret, 390 attempts: 0, 391 maxAttempts: 3, 392 walletAddress, 393 walletType, 394 }; 395 const walletKey = chainType === "solana" ? walletAddress : walletAddress.toLowerCase(); 396 397 logger.info(`Storing challenge for wallet: ${walletAddress}, type: ${walletType}, key: ${walletKey}`); 398 399 this.challenges.set(walletKey, challengeData); 400 setTimeout(() => { 401 this.challenges.delete(walletKey); 402 }, this.challengeDuration); 403 return { message, nonce, timestamp }; 404 } 405 406 async authenticateWallet(walletAddress, signature, walletType, clientInfo = {}, providedAddresses = {}, chainType = null) { 407 try { 408 await addTimingNoise(20, 50); 409 if (!walletAddress || typeof walletAddress !== "string") { 410 await addTimingNoise(); 411 return { success: false, error: "Invalid wallet address" }; 412 } 413 if (!chainType) { 414 chainType = this.getChainTypeFromWalletType(walletType); 415 } 416 417 if (!validateWalletAddress(walletAddress, chainType)) { 418 await addTimingNoise(); 419 return { success: false, error: "Invalid wallet address format" }; 420 } else if (!validateSignature(signature, chainType)) { 421 await addTimingNoise(); 422 return { success: false, error: "Invalid signature format" }; 423 } 424 425 const { getEnabledWallets } = await import("../data/crypto/wallets.js"); 426 const enabledWallets = getEnabledWallets(); 427 const validWalletTypes = enabledWallets.map(w => w.id); 428 if (!validWalletTypes.includes(walletType)) { 429 await addTimingNoise(); 430 return { success: false, error: "Invalid wallet type" }; 431 } 432 433 const walletKey = (walletType === "phantom" || walletType === "solflare" || walletType === "brave") 434 ? walletAddress 435 : walletAddress.toLowerCase(); 436 437 logger.info(`Looking up challenge for wallet: ${walletAddress}, type: ${walletType}, key: ${walletKey}`); 438 logger.info(`Current challenges in map: ${Array.from(this.challenges.keys()).join(", ")}`); 439 440 const challenge = this.challenges.get(walletKey); 441 if (!challenge) { 442 await addTimingNoise(); 443 return { success: false, error: "Challenge not found or expired" }; 444 } else if (challenge.attempts >= challenge.maxAttempts) { 445 this.challenges.delete(walletKey); 446 await addTimingNoise(); 447 return { success: false, error: "Too many verification attempts" }; 448 } 449 450 challenge.attempts++; 451 if (Date.now() > challenge.expiry) { 452 this.challenges.delete(walletKey); 453 await addTimingNoise(); 454 return { success: false, error: "Challenge expired" }; 455 } 456 let user = await User.findOne({ walletAddress: walletKey }); 457 if (user && user.security?.accountLocked) { 458 if (user.security.accountLockedUntil && new Date() > user.security.accountLockedUntil) { 459 await User.updateOne( 460 { _id: user._id }, 461 { $set: { "security.accountLocked": false, "security.failedLoginAttempts": 0 } }, 462 ); 463 user.security.accountLocked = false; 464 user.security.failedLoginAttempts = 0; 465 } else { 466 await addTimingNoise(); 467 return { success: false, error: "Account is temporarily locked" }; 468 } 469 } 470 if (user && user.isBanned) { 471 await addTimingNoise(); 472 return { success: false, error: "Account is suspended" }; 473 } 474 const verified = SignatureVerifier.verify(challenge.message, signature, walletAddress, chainType); 475 if (!verified) { 476 if (user) { 477 const failedAttempts = (user.security?.failedLoginAttempts || 0) + 1; 478 const updateData = { 479 "security.failedLoginAttempts": failedAttempts, 480 "security.lastFailedLogin": new Date(), 481 }; 482 if (failedAttempts >= this.maxFailedLogins) { 483 updateData["security.accountLocked"] = true; 484 updateData["security.accountLockedUntil"] = new Date(Date.now() + this.accountLockDuration); 485 } 486 await User.updateOne({ _id: user._id }, { $set: updateData }); 487 } 488 await addTimingNoise(); 489 return { success: false, error: "Invalid signature" }; 490 } 491 this.challenges.delete(walletKey); 492 493 const connectedAddresses = {}; 494 if (providedAddresses && typeof providedAddresses === "object") { 495 for (const [chain, address] of Object.entries(providedAddresses)) { 496 if (address && typeof address === "string" && address.trim().length > 0) { 497 const chainLower = chain.toLowerCase(); 498 if (chainLower === "ethereum" || chainLower.includes("eth") || 499 ["base", "polygon", "arbitrum", "optimism", "avalanche", "fantom", "zksync", "linea", "scroll"].includes(chainLower)) { 500 connectedAddresses[chain] = address.toLowerCase(); 501 } else { 502 connectedAddresses[chain] = address; 503 } 504 } 505 } 506 } 507 508 if (chainType === "solana" && networks.solana?.enabled !== false) { 509 connectedAddresses.solana = walletKey; 510 } 511 if (chainType === "ethereum" && networks.solana?.enabled !== false) { 512 connectedAddresses.ethereum = walletKey; 513 514 const { getEnabledNetworksForType } = await import("@/data/crypto/networks.js"); 515 const evmNetworks = Object.entries(getEnabledNetworksForType("evm")) 516 .map(([id, _]) => id); 517 518 evmNetworks.forEach(network => { 519 if (!connectedAddresses[network]) { 520 connectedAddresses[network] = walletKey; 521 } 522 }); 523 } 524 525 let ipHash; 526 if (!user) { 527 const derivationIndex = await hdWalletDeriver.getNextDerivationIndex(); 528 const depositAddresses = hdWalletDeriver.deriveAllAddresses(derivationIndex); 529 const username = await generateUniqueUsername(); 530 ipHash = hashIpAddress(clientInfo.ipAddress, "temp"); 531 532 const { count: ipCount, exceedsLimit } = await checkIpHashAbuse(ipHash); 533 if (exceedsLimit) { 534 logger.warn("Too many sessions from same IP hash", { ipHash, count: ipCount }); 535 await addTimingNoise(); 536 return { success: false, error: "Too many active sessions. Please try again later." }; 537 } 538 539 user = await User.create({ 540 walletAddress: walletKey, 541 walletType, 542 username, 543 connectedAddresses, 544 depositAddresses, 545 balances: [], 546 stats: { totalDeposited: 0, totalWithdrawn: 0, depositCount: 0, withdrawalCount: 0 }, 547 security: { failedLoginAttempts: 0 }, 548 withdrawalSettings: { dailyLimit: "100", withdrawnToday: "0", lastWithdrawalReset: new Date() }, 549 }); 550 551 const finalIpHash = hashIpAddress(clientInfo.ipAddress, user._id.toString()); 552 await User.updateOne( 553 { _id: user._id }, 554 { $set: { lastIpHash: finalIpHash } }, 555 ); 556 557 logger.info(`New user created: ${walletKey}`, { 558 ipHash: finalIpHash, 559 connectedAddresses, 560 depositAddresses: { 561 solana: depositAddresses.solana.address, 562 ethereum: depositAddresses.ethereum.address, 563 sui: depositAddresses.sui.address, 564 }, 565 }); 566 } else { 567 ipHash = hashIpAddress(clientInfo.ipAddress, user._id.toString()); 568 const { count: ipCount, exceedsLimit } = await checkIpHashAbuse(ipHash); 569 if (exceedsLimit) { 570 logger.warn("Too many sessions from same IP hash", { ipHash, count: ipCount }); 571 await addTimingNoise(); 572 return { success: false, error: "Too many active sessions. Please try again later." }; 573 } 574 575 const updateData = { 576 "security.failedLoginAttempts": 0, 577 lastSeen: new Date(), 578 lastIpHash: ipHash, 579 }; 580 581 if (user.walletAddress !== walletKey) { 582 updateData.walletAddress = walletKey; 583 } 584 if (!validWalletTypes.includes(user.walletType)) { 585 updateData.walletType = walletType; 586 } 587 588 for (const [chain, address] of Object.entries(connectedAddresses)) { 589 updateData[`connectedAddresses.${chain}`] = address; 590 } 591 592 if (chainType === "ethereum" && networks.ethereum?.enabled !== false) { 593 const { getEnabledNetworksForType } = await import("../data/crypto/networks.js"); 594 const evmNetworks = Object.entries(getEnabledNetworksForType("evm")) 595 .filter(([id, _]) => id !== "ethereum") 596 .map(([id, _]) => id); 597 598 evmNetworks.forEach(network => { 599 if (!user.connectedAddresses || !user.connectedAddresses[network]) { 600 updateData[`connectedAddresses.${network}`] = walletKey; 601 } 602 }); 603 } 604 605 await User.updateOne({ _id: user._id }, { $set: updateData }); 606 user.walletAddress = updateData.walletAddress || user.walletAddress; 607 user.walletType = updateData.walletType || user.walletType; 608 user.connectedAddresses = { ...(user.connectedAddresses || {}), ...connectedAddresses }; 609 } 610 611 const session = this.sessionManager.createSession(user._id, walletKey); 612 await User.updateOne( 613 { _id: user._id }, 614 { 615 sessionToken: session.sessionToken, 616 sessionExpiry: session.expiry, 617 lastSignature: signature, 618 lastSeen: new Date(), 619 lastIpHash: ipHash, 620 }, 621 ); 622 623 const { getSupportedCurrencies } = await import("../data/crypto/currencies.js"); 624 const supportedCurrencies = getSupportedCurrencies(); 625 const supportedChains = supportedCurrencies.map(c => c.chain); 626 const filteredConnectedAddresses = {}; 627 if (user.connectedAddresses) { 628 for (const chain of supportedChains) { 629 if (user.connectedAddresses[chain]) { 630 filteredConnectedAddresses[chain] = user.connectedAddresses[chain]; 631 } 632 } 633 } 634 635 const publicDepositAddresses = {}; 636 if (user.depositAddresses) { 637 for (const [chain, data] of Object.entries(user.depositAddresses)) { 638 if (data && data.address) { 639 publicDepositAddresses[chain] = data.address; 640 } 641 } 642 } 643 644 logger.info(`User authenticated: ${walletKey}`, { userId: user._id, ipHash }); 645 646 return { 647 success: true, 648 user: { 649 id: user._id.toString(), 650 username: user.username, 651 walletAddress: user.walletAddress, 652 walletType: user.walletType, 653 balance: centsToDollars(user.fiatBalanceUsd || 0), 654 stats: user.stats, 655 connectedAddresses: filteredConnectedAddresses, 656 depositAddresses: publicDepositAddresses, 657 hasPassword: !!user.passwordHash, 658 avatar: user.avatar || null, 659 }, 660 sessionToken: session.sessionToken, 661 sessionExpiry: session.expiry, 662 }; 663 } catch (error) { 664 logger.error("Authentication error:", error); 665 return { success: false, error: "Authentication failed. Please try again." }; 666 } 667 } 668 669 async verifyPassword(pendingSessionToken, password, clientInfo = {}) { 670 try { 671 const validation = this.sessionManager.validateSession(pendingSessionToken); 672 if (!validation.valid) { 673 const user = await User.findOne({ sessionToken: pendingSessionToken }); 674 if (!user) { 675 return { success: false, error: "Session not found or expired" }; 676 } 677 if (user.sessionExpiry && new Date() > user.sessionExpiry) { 678 return { success: false, error: "Session expired. Please reconnect your wallet." }; 679 } 680 this.sessionManager.restoreSession(pendingSessionToken, user._id.toString(), user.walletAddress, user.sessionExpiry); 681 validation = { valid: true, userId: user._id.toString(), walletAddress: user.walletAddress }; 682 } 683 684 const userId = validation.valid ? validation.userId : (await User.findOne({ sessionToken: pendingSessionToken }))?._id; 685 if (!userId) { 686 return { success: false, error: "User not found" }; 687 } 688 689 const user = await User.findById(userId); 690 if (!user) { 691 return { success: false, error: "User not found" }; 692 } 693 694 if (user.security?.accountLocked) { 695 if (user.security.accountLockedUntil && new Date() > user.security.accountLockedUntil) { 696 await User.updateOne( 697 { _id: user._id }, 698 { $set: { "security.accountLocked": false, "security.failedLoginAttempts": 0 } }, 699 ); 700 } else { 701 const remainingTime = Math.ceil((user.security.accountLockedUntil - new Date()) / 1000 / 60); 702 return { success: false, error: `Account is temporarily locked. Try again in ${remainingTime} minutes.` }; 703 } 704 } 705 706 if (!user.passwordHash || !user.passwordSalt) { 707 return { success: false, error: "Password not set for this account" }; 708 } 709 710 const crypto = await import("crypto"); 711 const hash = await new Promise((resolve, reject) => { 712 crypto.pbkdf2(password, user.passwordSalt, 100000, 64, "sha512", (err, derivedKey) => { 713 if (err) { 714 reject(err); 715 } else { 716 resolve(derivedKey.toString("hex")); 717 } 718 }); 719 }); 720 721 if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(user.passwordHash))) { 722 const failedAttempts = (user.security?.failedLoginAttempts || 0) + 1; 723 const updateData = { 724 "security.failedLoginAttempts": failedAttempts, 725 "security.lastFailedLogin": new Date(), 726 }; 727 728 if (failedAttempts >= this.maxFailedLogins) { 729 updateData["security.accountLocked"] = true; 730 updateData["security.accountLockedUntil"] = new Date(Date.now() + this.accountLockDuration); 731 await User.updateOne({ _id: user._id }, { $set: updateData }); 732 return { success: false, error: "Too many failed attempts. Account locked for 30 minutes." }; 733 } 734 735 await User.updateOne({ _id: user._id }, { $set: updateData }); 736 return { success: false, error: `Invalid password. ${this.maxFailedLogins - failedAttempts} attempts remaining.` }; 737 } 738 739 await User.updateOne( 740 { _id: user._id }, 741 { 742 $set: { 743 "security.failedLoginAttempts": 0, 744 lastSeen: new Date(), 745 lastIpHash: hashIpAddress(clientInfo.ipAddress, user._id.toString()), 746 }, 747 }, 748 ); 749 750 const { getSupportedCurrencies } = await import("../data/crypto/currencies.js"); 751 const supportedCurrencies = getSupportedCurrencies(); 752 const supportedChains = supportedCurrencies.map(c => c.chain); 753 const filteredConnectedAddresses = {}; 754 if (user.connectedAddresses) { 755 for (const chain of supportedChains) { 756 if (user.connectedAddresses[chain]) { 757 filteredConnectedAddresses[chain] = user.connectedAddresses[chain]; 758 } 759 } 760 } 761 762 const publicDepositAddresses = {}; 763 if (user.depositAddresses) { 764 for (const [chain, data] of Object.entries(user.depositAddresses)) { 765 if (data && data.address) { 766 publicDepositAddresses[chain] = data.address; 767 } 768 } 769 } 770 771 logger.info(`Password verified for user: ${user.walletAddress}`, { userId: user._id, ipHash: hashIpAddress(clientInfo.ipAddress, user._id.toString()) }); 772 773 return { 774 success: true, 775 user: { 776 id: user._id.toString(), 777 username: user.username, 778 walletAddress: user.walletAddress, 779 walletType: user.walletType, 780 balance: centsToDollars(user.fiatBalanceUsd || 0), 781 stats: user.stats, 782 connectedAddresses: filteredConnectedAddresses, 783 depositAddresses: publicDepositAddresses, 784 hasPassword: true, 785 avatar: user.avatar || null, 786 }, 787 sessionToken: pendingSessionToken, 788 sessionExpiry: user.sessionExpiry, 789 }; 790 } catch (error) { 791 logger.error("Password verification error:", error); 792 return { success: false, error: "Password verification failed. Please try again." }; 793 } 794 } 795 796 async validateSession(sessionToken) { 797 let validation = this.sessionManager.validateSession(sessionToken); 798 if (!validation.valid) { 799 const user = await User.findOne({ sessionToken }); 800 if (!user) { 801 return { valid: false, error: "Session not found" }; 802 } 803 if (user.sessionExpiry && new Date() > user.sessionExpiry) { 804 return { valid: false, error: "Session expired" }; 805 } 806 this.sessionManager.restoreSession(sessionToken, user._id.toString(), user.walletAddress, user.sessionExpiry); 807 validation = { valid: true, userId: user._id.toString(), walletAddress: user.walletAddress }; 808 } 809 810 const user = await User.findById(validation.userId); 811 if (!user) { 812 return { valid: false, error: "User not found" }; 813 } 814 815 const { getSupportedCurrencies } = await import("@/data/crypto/currencies.js"); 816 const supportedCurrencies = getSupportedCurrencies(); 817 const supportedChains = supportedCurrencies.map(c => c.chain); 818 const filteredConnectedAddresses = {}; 819 if (user.connectedAddresses) { 820 for (const chain of supportedChains) { 821 if (user.connectedAddresses[chain]) { 822 filteredConnectedAddresses[chain] = user.connectedAddresses[chain]; 823 } 824 } 825 } 826 827 const publicDepositAddresses = {}; 828 if (user.depositAddresses) { 829 for (const [chain, data] of Object.entries(user.depositAddresses)) { 830 if (data && data.address) { 831 publicDepositAddresses[chain] = data.address; 832 } 833 } 834 } 835 836 return { 837 valid: true, 838 user: { 839 id: user._id.toString(), 840 username: user.username, 841 walletAddress: user.walletAddress, 842 walletType: user.walletType, 843 balance: centsToDollars(user.fiatBalanceUsd || 0), 844 stats: user.stats, 845 connectedAddresses: filteredConnectedAddresses, 846 depositAddresses: publicDepositAddresses, 847 hasPassword: !!user.passwordHash, 848 avatar: user.avatar || null, 849 isBanned: user.isBanned, 850 }, 851 }; 852 } 853 854 async logout(sessionToken) { 855 const validation = this.sessionManager.validateSession(sessionToken); 856 if (validation.valid) { 857 await User.updateOne({ _id: validation.userId }, { sessionToken: null, sessionExpiry: null }); 858 } 859 860 this.sessionManager.deleteSession(sessionToken); 861 return { success: true }; 862 } 863 864 cleanupChallenges() { 865 const now = Date.now(); 866 const expiredChallenges = []; 867 868 for (const [address, challenge] of this.challenges.entries()) { 869 if (now > challenge.expiry) { 870 expiredChallenges.push(address); 871 } 872 } 873 874 expiredChallenges.forEach(address => this.challenges.delete(address)); 875 876 if (this.challenges.size > this.maxChallenges * 0.8) { 877 const entries = Array.from(this.challenges.entries()); 878 const oldestEntries = entries 879 .sort((a, b) => a[1].timestamp - b[1].timestamp) 880 .slice(0, Math.floor(this.maxChallenges * 0.2)); 881 oldestEntries.forEach(([key]) => this.challenges.delete(key)); 882 } 883 } 884 } 885 886 export const authService = new AuthService(); 887 export { sessionManager }; 888 889 setInterval(() => { 890 authService.cleanupChallenges(); 891 }, 60 * 1000); 892 893 const balanceCache = new Map(); 894 const transactionCache = new Map(); 895 const MAX_CACHE_SIZE = 1000; 896 897 const cleanupCache = (cache, maxSize) => { 898 if (cache.size > maxSize) { 899 const entriesToDelete = cache.size - maxSize; 900 const keys = Array.from(cache.keys()).slice(0, entriesToDelete); 901 keys.forEach(key => cache.delete(key)); 902 } 903 }; 904 905 const getRpcClients = () => { 906 const solanaRpc = config.rpc.solana.mainnet; 907 const ethereumRpc = config.rpc.ethereum.mainnet; 908 const suiRpc = config.rpc.sui.mainnet; 909 910 return { 911 solana: new Connection(solanaRpc, { commitment: "confirmed", confirmTransactionInitialTimeout: config.timeouts.long }), 912 ethereum: new ethers.JsonRpcProvider(ethereumRpc, undefined, { staticNetwork: true }), 913 sui: new SuiClient({ url: suiRpc }), 914 }; 915 }; 916 917 const rpcClients = getRpcClients(); 918 919 setInterval(() => { 920 cleanupCache(balanceCache, MAX_CACHE_SIZE); 921 cleanupCache(transactionCache, MAX_CACHE_SIZE); 922 }, config.timeouts.veryLong); 923 924 export const getWalletBalance = async (address, chain) => { 925 try { 926 const cacheKey = `${address}:${chain}`; 927 const cached = balanceCache.get(cacheKey); 928 if (cached && Date.now() - cached.timestamp < config.timeouts.long) { 929 return cached.data; 930 } 931 932 let balanceData; 933 if (chain === "solana") { 934 const pubkey = new PublicKey(address); 935 const balance = await rpcClients.solana.getBalance(pubkey); 936 const balanceInSol = balance / LAMPORTS_PER_SOL; 937 balanceData = { chain, address, balance: balance.toString(), denominatedBalance: balanceInSol.toString(), decimals: 9 }; 938 } else if (chain === "sui") { 939 const coins = await rpcClients.sui.getCoins({ owner: address, coinType: "0x2::sui::SUI" }); 940 let totalBalance = BigInt(0); 941 942 for (const coin of coins.data) { 943 totalBalance += BigInt(coin.balance); 944 } 945 946 const balanceInSui = Number(totalBalance) / 1e9; 947 balanceData = { chain, address, balance: totalBalance.toString(), denominatedBalance: balanceInSui.toString(), decimals: 9 }; 948 } else if (networks[chain]?.type === "evm" && networks[chain]?.enabled !== false) { 949 const balance = await rpcClients.ethereum.getBalance(address); 950 const balanceInEth = ethers.formatEther(balance); 951 balanceData = { chain, address, balance: balance.toString(), denominatedBalance: balanceInEth, decimals: 18 }; 952 } else { 953 throw new Error(`Unsupported chain: ${chain}`); 954 } 955 956 balanceCache.set(cacheKey, { timestamp: Date.now(), data: balanceData }); 957 cleanupCache(balanceCache, MAX_CACHE_SIZE); 958 return balanceData; 959 } catch (error) { 960 logger.error(`Error fetching wallet balance for ${address} on ${chain}:`, error); 961 throw error; 962 } 963 }; 964 965 export const getTransactionHistory = async (address, chain, options = {}) => { 966 try { 967 const { sort = "DESC", limit = 50 } = options; 968 const cacheKey = `${address}:${chain}:${sort}:${limit}`; 969 const cached = transactionCache.get(cacheKey); 970 if (cached && Date.now() - cached.timestamp < 2 * (config.timeouts.long || 30000)) { 971 return cached.data; 972 } 973 974 let transactions = []; 975 if (chain === "solana" && networks.solana?.enabled !== false) { 976 const pubkey = new PublicKey(address); 977 const signatures = await rpcClients.solana.getSignaturesForAddress(pubkey, { limit: Math.min(limit, 100) }); 978 transactions = signatures.map(sig => ({ 979 hash: sig.signature, 980 blockNumber: sig.slot, 981 timestamp: sig.blockTime ? sig.blockTime * 1000 : null, 982 status: sig.err ? "failed" : "confirmed", 983 })); 984 } else if (chain === "ethereum" && networks.ethereum?.enabled !== false) { 985 const currentBlock = await rpcClients.ethereum.getBlockNumber(); 986 const txs = []; 987 for (let i = 0; i < Math.min(10, limit); i++) { 988 try { 989 const block = await rpcClients.ethereum.getBlock(currentBlock - i, true); 990 if (block && block.transactions) { 991 for (const tx of block.transactions) { 992 if (tx.from?.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase()) { 993 txs.push({ 994 hash: tx.hash, 995 blockNumber: block.number, 996 timestamp: block.timestamp * 1000, 997 from: tx.from, 998 to: tx.to, 999 value: ethers.formatEther(tx.value), 1000 status: "confirmed", 1001 }); 1002 } 1003 } 1004 } 1005 } catch { 1006 break; 1007 } 1008 } 1009 transactions = txs; 1010 } else if (chain === "sui" && networks.sui?.enabled !== false) { 1011 const result = await rpcClients.sui.queryTransactionBlocks({ 1012 filter: { FromAddress: address }, 1013 options: { showEffects: true }, 1014 limit: Math.min(limit, 50), 1015 }); 1016 transactions = result.data.map(tx => ({ 1017 hash: tx.digest, 1018 blockNumber: tx.checkpoint ? parseInt(tx.checkpoint) : 0, 1019 timestamp: tx.timestampMs ? parseInt(tx.timestampMs) : null, 1020 status: tx.effects?.status?.status === "success" ? "confirmed" : "failed", 1021 })); 1022 } else { 1023 throw new Error(`Unsupported chain: ${chain}`); 1024 } 1025 1026 if (sort === "ASC") { 1027 transactions = transactions.sort((a, b) => a.blockNumber - b.blockNumber); 1028 } 1029 1030 const transactionData = { transactions, prevPage: "", nextPage: "" }; 1031 transactionCache.set(cacheKey, { timestamp: Date.now(), data: transactionData }); 1032 cleanupCache(transactionCache, MAX_CACHE_SIZE); 1033 return transactionData; 1034 } catch (error) { 1035 logger.error(`Error fetching transaction history for ${address} on ${chain}:`, error); 1036 throw error; 1037 } 1038 }; 1039 1040 export const findOrCreateUser = async (username) => { 1041 try { 1042 let user = await User.findOne({ username }); 1043 if (!user) { 1044 user = await User.create({ 1045 username, 1046 stats: { totalDeposited: 0, totalWithdrawn: 0 }, 1047 }); 1048 logger.info(`Created new user: ${username}`); 1049 } else { 1050 user.lastSeen = Date.now(); 1051 await user.save(); 1052 } 1053 1054 return user; 1055 } catch (error) { 1056 logger.error(error); 1057 throw error; 1058 } 1059 }; 1060 1061 export const depositCoins = async (userId, amount, currency, chain, priceAtTransaction) => { 1062 try { 1063 const user = await User.findById(userId); 1064 if (!user) { 1065 throw new Error("User not found"); 1066 } 1067 1068 const cryptoAmount = parseFloat(amount); 1069 const usdValue = cryptoAmount * priceAtTransaction; 1070 const currentCents = user.fiatBalanceUsd || 0; 1071 const addCents = dollarsToCents(usdValue); 1072 user.fiatBalanceUsd = currentCents + addCents; 1073 1074 user.stats.totalDeposited += cryptoAmount; 1075 await user.save(); 1076 return { success: true, newBalance: centsToDollars(user.fiatBalanceUsd) }; 1077 } catch (error) { 1078 logger.error("Error depositing coins:", error); 1079 throw error; 1080 } 1081 }; 1082 1083 export const deductCoins = async (userId, amount, currency, currentPrice) => { 1084 try { 1085 const user = await User.findById(userId); 1086 if (!user) { 1087 throw new Error("User not found"); 1088 } 1089 1090 const cryptoAmount = parseFloat(amount); 1091 const usdValue = cryptoAmount * currentPrice; 1092 const currentCents = user.fiatBalanceUsd || 0; 1093 const deductCents = dollarsToCents(usdValue); 1094 1095 if (currentCents < deductCents) { 1096 throw new Error("Insufficient balance"); 1097 } 1098 1099 user.fiatBalanceUsd = currentCents - deductCents; 1100 user.stats.totalWithdrawn += cryptoAmount; 1101 await user.save(); 1102 return { success: true, newBalance: centsToDollars(user.fiatBalanceUsd) }; 1103 } catch (error) { 1104 logger.error("Error withdrawing coins:", error); 1105 throw error; 1106 } 1107 }; 1108 1109 export const banUser = async (userId, reason = "Violation of terms") => { 1110 try { 1111 const user = await User.findById(userId); 1112 if (!user) { 1113 throw new Error("User not found"); 1114 } 1115 1116 if (user.isBanned) { 1117 return { success: false, error: "User is already banned" }; 1118 } 1119 1120 user.isBanned = true; 1121 user.bannedAt = new Date(); 1122 user.banReason = reason; 1123 await user.save(); 1124 1125 cache.emit(CacheEvents.USER_BANNED, { 1126 userId: userId.toString(), 1127 reason, 1128 timestamp: new Date(), 1129 clearAll: true, 1130 }); 1131 1132 logger.info(`User ${userId} banned: ${reason}`); 1133 return { success: true, message: "User banned successfully" }; 1134 } catch (error) { 1135 logger.error("Error banning user:", error); 1136 throw error; 1137 } 1138 }; 1139 1140 export const unbanUser = async (userId) => { 1141 try { 1142 const user = await User.findById(userId); 1143 if (!user) { 1144 throw new Error("User not found"); 1145 } 1146 1147 if (!user.isBanned) { 1148 return { success: false, error: "User is not banned" }; 1149 } 1150 1151 user.isBanned = false; 1152 user.bannedAt = null; 1153 user.banReason = null; 1154 await user.save(); 1155 1156 cache.emit(CacheEvents.USER_UNBANNED, { 1157 userId: userId.toString(), 1158 timestamp: new Date(), 1159 }); 1160 1161 logger.info(`User ${userId} unbanned`); 1162 return { success: true, message: "User unbanned successfully" }; 1163 } catch (error) { 1164 logger.error("Error unbanning user:", error); 1165 throw error; 1166 } 1167 }; 1168 1169 export const invalidateUserSessions = async (userId, reason = "Security action") => { 1170 try { 1171 cache.emit(CacheEvents.SESSION_INVALIDATED, { 1172 userId: userId.toString(), 1173 reason, 1174 timestamp: new Date(), 1175 }); 1176 1177 logger.info(`All sessions invalidated for user ${userId}: ${reason}`); 1178 return { success: true, message: "All sessions invalidated" }; 1179 } catch (error) { 1180 logger.error("Error invalidating sessions:", error); 1181 throw error; 1182 } 1183 };