From 751845d009ea86af1c346d65b80fa1d2d5a98566 Mon Sep 17 00:00:00 2001 From: Saswat Date: Fri, 2 Jan 2026 12:02:52 +0530 Subject: [PATCH 1/2] Improved wallet authentication by adding expiring nonces, safer signature verification, and stricter JWT validation to prevent replay attacks and enhance security --- backend/app/auth.py | 149 ++++++++++++++++++++-- backend/app/chain.py | 241 +++++++++++++++++++++++++++++++++++- backend/app/main.py | 13 ++ backend/app/routers/auth.py | 26 ++-- 4 files changed, 404 insertions(+), 25 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index a721d3a..e8ae9df 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,6 +1,8 @@ import time import secrets -from typing import Dict +import logging +import asyncio +from typing import Dict, Tuple import jwt from eth_account.messages import encode_defunct @@ -8,34 +10,159 @@ from .config import get_settings -NONCE_STORE: Dict[str, str] = {} +## AUTH config ## + + +# nonce lifetime in seconds (5 minutes) +NONCE_TTL = 300 + +# JWT expiry in seconds (24 hours) +JWT_EXPIRY_SECONDS = 86400 + +# wallet -> (nonce, created_at) +NONCE_STORE: Dict[str, Tuple[str, int]] = {} # kept in localMemory + +# Message versioning for signature verification +AUTH_MESSAGE_VERSION = "MLSA_AUTH_V1" + +# Cleanup control +_cleanup_task = None + +#Authentication log +logger = logging.getLogger(__name__) + + +## NONCE functions ## def generate_nonce(wallet: str) -> str: + wallet = wallet.lower() nonce = secrets.token_hex(16) - NONCE_STORE[wallet.lower()] = nonce + NONCE_STORE[wallet] = (nonce, int(time.time())) + logger.debug(f"Generated nonce for wallet {wallet}") return nonce +def get_nonce(wallet: str): + wallet = wallet.lower() + return NONCE_STORE.get(wallet) -def pop_nonce(wallet: str) -> str: - return NONCE_STORE.pop(wallet.lower(), "") +def remove_nonce(wallet: str) -> None: + wallet = wallet.lower() + NONCE_STORE.pop(wallet, None) + logger.debug(f"Removed nonce for wallet {wallet}") + +def verify_signature( + wallet: str, + nonce: str, + signature: str, + chain_id: int, + app_name: str +) -> bool: + wallet = wallet.lower() + stored = NONCE_STORE.get(wallet) + if not stored: + logger.warning(f"No nonce found for wallet {wallet}") + return False + + stored_nonce, created_at = stored + + # nonce mismatch + if stored_nonce != nonce: + return False + + # nonce expired + if created_at + NONCE_TTL < time.time(): + remove_nonce(wallet) + logger.warning(f"Expired nonce for wallet {wallet}") + return False + + message = ( + f"{AUTH_MESSAGE_VERSION}\n" + f"Sign in to {app_name} with wallet {wallet} " + f"on chain {chain_id}. Nonce: {nonce}" + ) -def verify_signature(wallet: str, nonce: str, signature: str, chain_id: int, app_name: str) -> bool: - message = f"Sign in to {app_name} with wallet {wallet.lower()} on chain {chain_id}. Nonce: {nonce}" encoded = encode_defunct(text=message) - recovered = Account.recover_message(encoded, signature=signature) - return recovered.lower() == wallet.lower() + + + try: + recovered = Account.recover_message(encoded, signature=signature) + except Exception as ex: + logger.warning(f"Signature recovery failed for wallet {wallet}: {ex}") + return False + if recovered.lower() == wallet: + remove_nonce(wallet) + logger.info(f"Successfully authenticated wallet {wallet}") + return True + logger.warning(f"Recovered address mismatch for wallet {wallet}") + return False def issue_jwt(user_id: str) -> str: settings = get_settings() now = int(time.time()) - payload = {"sub": user_id, "iat": now, "exp": now + 86400} # 24 hours + payload = {"sub": user_id, "iat": now, "exp": now + JWT_EXPIRY_SECONDS, "iss": "mlsa-cards-backend", "aud": "mlsa-cards-frontend"} # 24 hours return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") def verify_jwt(token: str) -> dict: settings = get_settings() - payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) + payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"], audience="mlsa-cards-frontend", issuer="mlsa-cards-backend") return payload + + +## Nonce cleanup background task ## + + +async def cleanup_expired_nonces(): + now = int(time.time()) + + expired = [ + wallet for wallet, (nonce, created_at) in NONCE_STORE.items() + if created_at + NONCE_TTL < now + ] + + for wallet in expired: + NONCE_STORE.pop(wallet, None) + + if expired: + logger.info(f"Cleaned up {len(expired)} expired nonces") + + +async def _cleanup_loop(): + logger.info("Nonce cleanup loop started") + while True: + try: + await cleanup_expired_nonces() + except Exception as e: + logger.error(f"Error in nonce cleanup: {e}") + + await asyncio.sleep(60) # Run every 60 seconds + +def start_nonce_cleanup(): + global _cleanup_task + + if _cleanup_task is not None: + logger.warning("Nonce cleanup already running") + return + + _cleanup_task = asyncio.create_task(_cleanup_loop()) + logger.info("Nonce cleanup background task started") + + +async def stop_nonce_cleanup(): + global _cleanup_task + + if _cleanup_task is None: + return + + logger.info("Stopping nonce cleanup background task") + _cleanup_task.cancel() + try: + await _cleanup_task + except asyncio.CancelledError: + pass + _cleanup_task = None + logger.info("Nonce cleanup background task stopped") + diff --git a/backend/app/chain.py b/backend/app/chain.py index 71d0809..445f264 100644 --- a/backend/app/chain.py +++ b/backend/app/chain.py @@ -1,17 +1,248 @@ +from functools import lru_cache +from typing import Optional, Dict, Any +import logging from web3 import Web3 from web3.middleware import geth_poa_middleware +from web3.types import Wei, TxReceipt, TxParams from eth_account import Account +from eth_account.signers.local import LocalAccount +from eth_typing import ChecksumAddress from .config import get_settings +# Configure logging +logger = logging.getLogger(__name__) + +@lru_cache(maxsize=1) def get_web3() -> Web3: settings = get_settings() - w3 = Web3(Web3.HTTPProvider(settings.rpc_url)) - w3.middleware_onion.inject(geth_poa_middleware, layer=0) - return w3 + + if not settings.rpc_url: + raise RuntimeError("RPC_URL is not configured. Please set it in your .env file") + + try: + w3 = Web3(Web3.HTTPProvider( + settings.rpc_url, + request_kwargs={'timeout': 30} # Add timeout for better reliability + )) + + # Required for Hardhat, Sepolia, Polygon, Amoy + w3.middleware_onion.inject(geth_poa_middleware, layer=0) + + # Verify connection + if not w3.is_connected(): + raise RuntimeError(f"Web3 RPC not reachable at: {settings.rpc_url}") + + # Log connection info + chain_id = w3.eth.chain_id + block_number = w3.eth.block_number + logger.info(f"✅ Connected to Web3 - Chain ID: {chain_id}, Block: {block_number}") + + return w3 + + except Exception as e: + logger.error(f"❌ Failed to initialize Web3: {str(e)}") + raise RuntimeError(f"Web3 initialization failed: {str(e)}") from e -def get_signer(): +@lru_cache(maxsize=1) +def get_signer() -> LocalAccount: settings = get_settings() - return Account.from_key(settings.private_key) + + if not settings.private_key: + raise RuntimeError("PRIVATE_KEY is not set in environment variables") + + try: + # Validate private key format + if settings.private_key.startswith('0x'): + private_key = settings.private_key + else: + private_key = f"0x{settings.private_key}" + + account = Account.from_key(private_key) + logger.info(f"✅ Signer initialized: {account.address}") + + return account + + except Exception as e: + logger.error(f"❌ Invalid PRIVATE_KEY format: {str(e)}") + raise RuntimeError("Invalid PRIVATE_KEY format. Ensure it's a valid Ethereum private key") from e + + +def get_signer_address() -> ChecksumAddress: + return Web3.to_checksum_address(get_signer().address) + + +def get_chain_id() -> int: + return get_web3().eth.chain_id + + +def get_network_name() -> str: + chain_id = get_chain_id() + network_map = { + 1: "Ethereum Mainnet", + 5: "Goerli Testnet", + 11155111: "Sepolia Testnet", + 137: "Polygon Mainnet", + 80002: "Polygon Amoy Testnet", + 80001: "Mumbai Testnet (deprecated)", + 31337: "Hardhat Local", + 1337: "Ganache Local", + } + return network_map.get(chain_id, f"Unknown Network (Chain ID: {chain_id})") + + +def get_balance(address: str) -> Wei: + w3 = get_web3() + checksum_addr = Web3.to_checksum_address(address) + return w3.eth.get_balance(checksum_addr) + + +def get_balance_ether(address: str) -> float: + w3 = get_web3() + balance_wei = get_balance(address) + return float(w3.from_wei(balance_wei, 'ether')) + + +def get_signer_balance() -> float: + return get_balance_ether(get_signer_address()) + + +def get_gas_price() -> Wei: + w3 = get_web3() + return w3.eth.gas_price + + +def estimate_gas(transaction: TxParams) -> int: + w3 = get_web3() + return w3.eth.estimate_gas(transaction) + + +def get_transaction_receipt(tx_hash: str) -> Optional[TxReceipt]: + w3 = get_web3() + try: + if not tx_hash.startswith('0x'): + tx_hash = f"0x{tx_hash}" + return w3.eth.get_transaction_receipt(tx_hash) + except Exception as e: + logger.warning(f"Transaction receipt not found for {tx_hash}: {str(e)}") + return None + + +def wait_for_transaction(tx_hash: str, timeout: int = 120) -> TxReceipt: + w3 = get_web3() + if not tx_hash.startswith('0x'): + tx_hash = f"0x{tx_hash}" + + logger.info(f"⏳ Waiting for transaction {tx_hash}...") + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) + logger.info(f"✅ Transaction mined in block {receipt['blockNumber']}") + + return receipt + + +def get_nonce(address: Optional[str] = None) -> int: + w3 = get_web3() + addr = address or get_signer_address() + checksum_addr = Web3.to_checksum_address(addr) + return w3.eth.get_transaction_count(checksum_addr) + + +def is_contract(address: str) -> bool: + w3 = get_web3() + checksum_addr = Web3.to_checksum_address(address) + code = w3.eth.get_code(checksum_addr) + return len(code) > 0 + + +def get_block_number() -> int: + w3 = get_web3() + return w3.eth.block_number + + +def get_chain_info() -> Dict[str, Any]: + w3 = get_web3() + chain_id = get_chain_id() + signer_address = get_signer_address() + signer_balance = get_signer_balance() + + return { + "connected": w3.is_connected(), + "chain_id": chain_id, + "network_name": get_network_name(), + "block_number": get_block_number(), + "gas_price_gwei": float(w3.from_wei(get_gas_price(), 'gwei')), + "signer_address": signer_address, + "signer_balance": f"{signer_balance:.4f}", + "rpc_url": get_settings().rpc_url, + } + + +def validate_address(address: str) -> bool: + try: + Web3.to_checksum_address(address) + return True + except Exception: + return False + + +def to_checksum_address(address: str) -> ChecksumAddress: + return Web3.to_checksum_address(address) + + +# Health check function +def health_check() -> Dict[str, Any]: + try: + info = get_chain_info() + + # Check if signer has sufficient balance + min_balance = 0.01 + signer_balance = float(info["signer_balance"]) + + status = "healthy" if signer_balance >= min_balance else "warning" + warnings = [] + + if signer_balance < min_balance: + warnings.append(f"Low signer balance: {signer_balance:.4f} (minimum recommended: {min_balance})") + + return { + "status": status, + "chain": info, + "warnings": warnings, + "timestamp": get_web3().eth.get_block('latest')['timestamp'] + } + + except Exception as e: + logger.error(f"❌ Health check failed: {str(e)}") + return { + "status": "unhealthy", + "error": str(e), + "chain": None, + "warnings": ["Blockchain connection failed"] + } + + +# Export all functions +__all__ = [ + "get_web3", + "get_signer", + "get_signer_address", + "get_chain_id", + "get_network_name", + "get_balance", + "get_balance_ether", + "get_signer_balance", + "get_gas_price", + "estimate_gas", + "get_transaction_receipt", + "wait_for_transaction", + "get_nonce", + "is_contract", + "get_block_number", + "get_chain_info", + "validate_address", + "to_checksum_address", + "health_check", +] + diff --git a/backend/app/main.py b/backend/app/main.py index cb4ddf0..2d6bfa3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from .routers import auth, game from .database import engine from .models import Base +from .auth import start_nonce_cleanup, stop_nonce_cleanup settings = get_settings() app = FastAPI(title="Game Collectible API") @@ -14,6 +15,18 @@ # Create database tables Base.metadata.create_all(bind=engine) +# Start nonce cleanup background task +@app.on_event("startup") +async def startup_event(): + start_nonce_cleanup() + +#Stop nonce cleanup background task +@app.on_event("shutdown") +async def shutdown_event(): + """Stop background tasks on app shutdown""" + await stop_nonce_cleanup() + + app.add_middleware(SessionMiddleware, secret_key=settings.jwt_secret) app.add_middleware( CORSMiddleware, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0d4718c..a79dd5b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -6,11 +6,14 @@ from starlette.config import Config from datetime import datetime -from ..auth import generate_nonce, pop_nonce, verify_signature, issue_jwt, verify_jwt +from ..auth import (generate_nonce, get_nonce, remove_nonce, verify_signature, issue_jwt, verify_jwt, AUTH_MESSAGE_VERSION) from ..config import get_settings from ..database import get_db from ..models import User +from slowapi import Limiter +from slowapi.util import get_remote_address + router = APIRouter(prefix="/auth", tags=["auth"]) # OAuth configuration @@ -43,21 +46,26 @@ class VerifyRequest(BaseModel): @router.post("/nonce") -async def get_nonce(req: NonceRequest): +async def get_nonce_endpoint(req: NonceRequest): + from ..auth import AUTH_MESSAGE_VERSION nonce = generate_nonce(req.wallet) settings = get_settings() - message = f"Sign in to {settings.app_name} with wallet {req.wallet.lower()} on chain {settings.chain_id}. Nonce: {nonce}" + message = ( + f"{AUTH_MESSAGE_VERSION}\n" + f"Sign in to {settings.app_name} with wallet {req.wallet.lower()} " + f"on chain {settings.chain_id}. Nonce: {nonce}" + ) return {"nonce": nonce, "message": message} @router.post("/verify") async def verify(req: VerifyRequest, db: Session = Depends(get_db)): settings = get_settings() - nonce = pop_nonce(req.wallet) - if not nonce: + stored = get_nonce(req.wallet) + if not stored: raise HTTPException(status_code=400, detail="Nonce not found or expired") - if not verify_signature(req.wallet, nonce, req.signature, settings.chain_id, settings.app_name): + if not verify_signature(req.wallet, stored[0], req.signature, settings.chain_id, settings.app_name): raise HTTPException(status_code=401, detail="Invalid signature") # Find or create user by wallet @@ -137,11 +145,11 @@ async def link_wallet(req: VerifyRequest, token: str, db: Session = Depends(get_ # Verify wallet signature settings = get_settings() - nonce = pop_nonce(req.wallet) - if not nonce: + stored = get_nonce(req.wallet) + if not stored: raise HTTPException(status_code=400, detail="Nonce not found or expired") - if not verify_signature(req.wallet, nonce, req.signature, settings.chain_id, settings.app_name): + if not verify_signature(req.wallet, stored[0], req.signature, settings.chain_id, settings.app_name): raise HTTPException(status_code=401, detail="Invalid signature") # Link wallet to user From 3e9dd0d35d5ef5c2ca54ba323f0166b6c83290be Mon Sep 17 00:00:00 2001 From: Saswat Date: Fri, 2 Jan 2026 12:43:40 +0530 Subject: [PATCH 2/2] Fix issue in previous PR --- backend/app/chain.py | 14 +++++++------- backend/app/routers/auth.py | 3 --- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/app/chain.py b/backend/app/chain.py index 445f264..7cd1f0c 100644 --- a/backend/app/chain.py +++ b/backend/app/chain.py @@ -37,12 +37,12 @@ def get_web3() -> Web3: # Log connection info chain_id = w3.eth.chain_id block_number = w3.eth.block_number - logger.info(f"✅ Connected to Web3 - Chain ID: {chain_id}, Block: {block_number}") + logger.info(f"Connected to Web3 - Chain ID: {chain_id}, Block: {block_number}") return w3 except Exception as e: - logger.error(f"❌ Failed to initialize Web3: {str(e)}") + logger.error(f"Failed to initialize Web3: {str(e)}") raise RuntimeError(f"Web3 initialization failed: {str(e)}") from e @@ -61,12 +61,12 @@ def get_signer() -> LocalAccount: private_key = f"0x{settings.private_key}" account = Account.from_key(private_key) - logger.info(f"✅ Signer initialized: {account.address}") + logger.info(f"Signer initialized: {account.address}") return account except Exception as e: - logger.error(f"❌ Invalid PRIVATE_KEY format: {str(e)}") + logger.error(f"Invalid PRIVATE_KEY format: {str(e)}") raise RuntimeError("Invalid PRIVATE_KEY format. Ensure it's a valid Ethereum private key") from e @@ -135,9 +135,9 @@ def wait_for_transaction(tx_hash: str, timeout: int = 120) -> TxReceipt: if not tx_hash.startswith('0x'): tx_hash = f"0x{tx_hash}" - logger.info(f"⏳ Waiting for transaction {tx_hash}...") + logger.info(f"Waiting for transaction {tx_hash}...") receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout) - logger.info(f"✅ Transaction mined in block {receipt['blockNumber']}") + logger.info(f"Transaction mined in block {receipt['blockNumber']}") return receipt @@ -214,7 +214,7 @@ def health_check() -> Dict[str, Any]: } except Exception as e: - logger.error(f"❌ Health check failed: {str(e)}") + logger.error(f"Health check failed: {str(e)}") return { "status": "unhealthy", "error": str(e), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a79dd5b..4e16ec0 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -11,8 +11,6 @@ from ..database import get_db from ..models import User -from slowapi import Limiter -from slowapi.util import get_remote_address router = APIRouter(prefix="/auth", tags=["auth"]) @@ -47,7 +45,6 @@ class VerifyRequest(BaseModel): @router.post("/nonce") async def get_nonce_endpoint(req: NonceRequest): - from ..auth import AUTH_MESSAGE_VERSION nonce = generate_nonce(req.wallet) settings = get_settings() message = (