diff --git a/backend/.env.example b/backend/.env.example index f7b941c..2ef91ed 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,4 +6,7 @@ DATABASE_URL=postgres://guild_user:guild_password@localhost:5432/guild_genesis # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-in-production -JWT_EXPIRATION=86400 \ No newline at end of file +JWT_EXPIRATION=86400 + +# Admin Configuration (comma-separated wallet addresses) +# ADMIN_ADDRESSES=0xYourAdminAddress1,0xAnotherAdminAddress2 \ No newline at end of file diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index 3148b99..bef05ef 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -22,6 +22,8 @@ use tower_http::{ }; use super::handlers::{ + // Admin handlers + admin_delete_profile_handler, // Profile handlers create_profile_handler, // Project handlers @@ -39,7 +41,7 @@ use super::handlers::{ update_project_handler, }; -use super::middlewares::{eth_auth_layer, test_auth_layer}; +use super::middlewares::{admin_auth_layer, eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { let profile_repository = Arc::from(PostgresProfileRepository::new(pool.clone())); @@ -72,6 +74,21 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { protected_routes.layer(from_fn_with_state(state.clone(), eth_auth_layer)) }; + // Admin routes (require admin authentication via SIWE with admin wallet) + let admin_routes = Router::new() + .route( + "/admin/profiles/:address", + delete(admin_delete_profile_handler), + ) + .with_state(state.clone()); + + let admin_with_auth = if std::env::var("TEST_MODE").is_ok() { + // In test mode, still check x-eth-address header but skip signature verification + admin_routes.layer(from_fn(test_auth_layer)) + } else { + admin_routes.layer(from_fn_with_state(state.clone(), admin_auth_layer)) + }; + // Public routes (no authentication) let public_routes = Router::new() // Profile public routes @@ -86,6 +103,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { Router::new() .merge(protected_with_auth) + .merge(admin_with_auth) .merge(public_routes) .with_state(state.clone()) .layer( @@ -129,6 +147,15 @@ pub fn test_api(state: AppState) -> Router { .with_state(state.clone()) .layer(from_fn(test_auth_layer)); + // Admin routes (require admin authentication) + let admin_routes = Router::new() + .route( + "/admin/profiles/:address", + delete(admin_delete_profile_handler), + ) + .with_state(state.clone()) + .layer(from_fn(test_auth_layer)); + // Public routes (no authentication) let public_routes = Router::new() // Profile public routes @@ -143,6 +170,7 @@ pub fn test_api(state: AppState) -> Router { Router::new() .merge(protected_routes) + .merge(admin_routes) .merge(public_routes) .with_state(state.clone()) .layer( diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 505415e..16cf092 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -252,3 +252,58 @@ pub async fn delete_project_handler( } } } + +// ============================================================================ +// Admin Handlers +// ============================================================================ + +/// DELETE /admin/profiles/:address - Admin delete a profile (bypasses ownership check) +/// Used for deleting deprecated profiles or spam that we don't have access to anymore. +pub async fn admin_delete_profile_handler( + State(state): State, + Path(address): Path, +) -> axum::response::Response { + let wallet_address = WalletAddress(address.clone()); + + // Check if profile exists first + let profile_exists = match state + .profile_repository + .find_by_address(&wallet_address) + .await + { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + tracing::error!("Error finding profile {}: {}", address, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Error finding profile: {}", e)})), + ) + .into_response(); + } + }; + + if !profile_exists { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Profile not found"})), + ) + .into_response(); + } + + // Profile exists, delete it + match state.profile_repository.delete(&wallet_address).await { + Ok(_) => { + tracing::info!("Admin deleted profile: {}", address); + StatusCode::NO_CONTENT.into_response() + } + Err(e) => { + tracing::error!("Failed to delete profile {}: {}", address, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to delete profile: {}", e)})), + ) + .into_response() + } + } +} diff --git a/backend/src/presentation/middlewares.rs b/backend/src/presentation/middlewares.rs index d4c2146..8ddced4 100644 --- a/backend/src/presentation/middlewares.rs +++ b/backend/src/presentation/middlewares.rs @@ -100,3 +100,106 @@ pub async fn test_auth_layer(mut req: Request, next: Next) -> Result, + mut req: Request, + next: Next, +) -> Result { + // Get admin addresses from environment variable + let admin_addresses = std::env::var("ADMIN_ADDRESSES").unwrap_or_default(); + let admin_list: Vec<&str> = admin_addresses + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + if admin_list.is_empty() { + tracing::warn!("No admin addresses configured. Set ADMIN_ADDRESSES env variable."); + return Err(StatusCode::FORBIDDEN); + } + + // First, authenticate the user using existing eth_auth logic + let headers = req.headers(); + + // Try JWT token first + let verified_address = if let Some(auth_header) = headers.get("authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + let jwt_manager = JwtManager::new(); + jwt_manager + .validate_token(token) + .ok() + .map(|claims| claims.address) + } else { + None + } + } else { + None + } + } else { + None + }; + + // Fall back to signature verification if no JWT + let verified_address = match verified_address { + Some(addr) => addr, + None => { + let address = headers + .get("x-eth-address") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let signature = headers + .get("x-eth-signature") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let wallet_address = crate::domain::value_objects::WalletAddress(address.clone()); + let nonce = state + .profile_repository + .get_login_nonce_by_wallet_address(&wallet_address) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .unwrap_or(1); + + let result = state + .auth_service + .verify_signature( + &AuthChallenge { + address: address.clone(), + nonce, + }, + &signature, + ) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if result.is_none() { + return Err(StatusCode::UNAUTHORIZED); + } + + address + } + }; + + // Check if the verified address is in the admin list (case-insensitive) + let is_admin = admin_list + .iter() + .any(|admin| admin.eq_ignore_ascii_case(&verified_address)); + + if !is_admin { + tracing::warn!("Access denied: {} is not an admin", verified_address); + return Err(StatusCode::FORBIDDEN); + } + + req.extensions_mut() + .insert(VerifiedWallet(verified_address)); + + Ok(next.run(req).await) +} diff --git a/scripts/test_admin_delete.sh b/scripts/test_admin_delete.sh new file mode 100755 index 0000000..7e957b1 --- /dev/null +++ b/scripts/test_admin_delete.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test the /admin/profiles/:address delete endpoint. +# Requirements: curl, node, npm. Installs ethers locally into /tmp by default. +# +# Inputs (env): +# ADMIN_ADDRESS (required) - admin wallet address +# ADMIN_PRIVATE_KEY (required) - admin wallet private key (0x-prefixed) +# TARGET_ADDRESS (required) - target profile address to delete +# API_URL (optional) - defaults to http://localhost:3001 + +API_URL="${API_URL:-http://localhost:3001}" +ADMIN_ADDRESS="${ADMIN_ADDRESS:-}" +ADMIN_PRIVATE_KEY="${ADMIN_PRIVATE_KEY:-}" +TARGET_ADDRESS="${TARGET_ADDRESS:-}" + +# If not provided via env, prompt interactively +if [[ -z "${ADMIN_ADDRESS}" ]]; then + read -r -p "Enter ADMIN_ADDRESS (0x...): " ADMIN_ADDRESS +fi +if [[ -z "${ADMIN_PRIVATE_KEY}" ]]; then + read -r -s -p "Enter ADMIN_PRIVATE_KEY (0x..., hidden): " ADMIN_PRIVATE_KEY + echo +fi +if [[ -z "${TARGET_ADDRESS}" ]]; then + read -r -p "Enter TARGET_ADDRESS to delete (0x...): " TARGET_ADDRESS +fi + +if [[ -z "${ADMIN_ADDRESS}" || -z "${ADMIN_PRIVATE_KEY}" || -z "${TARGET_ADDRESS}" ]]; then + echo "ADMIN_ADDRESS, ADMIN_PRIVATE_KEY and TARGET_ADDRESS are required. Aborting." + exit 1 +fi + +# Ensure we have ethers available +TOOLS_DIR="${TOOLS_DIR:-/tmp/theguildgenesis-login}" +export NODE_PATH="${TOOLS_DIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" +export PATH="${TOOLS_DIR}/node_modules/.bin:${PATH}" +if ! node -e "require('ethers')" >/dev/null 2>&1; then + echo "Installing ethers@6 to ${TOOLS_DIR}..." + mkdir -p "${TOOLS_DIR}" + npm install --prefix "${TOOLS_DIR}" ethers@6 >/dev/null +fi + +echo "Fetching nonce for admin ${ADMIN_ADDRESS}..." +nonce_resp="$(curl -sS "${API_URL}/auth/nonce/${ADMIN_ADDRESS}")" +echo "Nonce response: ${nonce_resp}" + +# Parse nonce +nonce="$(RESP="${nonce_resp}" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RESP"]) +print(data["nonce"]) +PY +)" +if [[ -z "${nonce}" ]]; then + echo "Failed to parse nonce from response" + exit 1 +fi + +message=$'Sign this message to authenticate with The Guild.\n\nNonce: '"${nonce}" + +echo "Signing nonce..." +signature="$( + ADDRESS="${ADMIN_ADDRESS}" PRIVATE_KEY="${ADMIN_PRIVATE_KEY}" MESSAGE="${message}" \ + node - <<'NODE' +const { Wallet, hashMessage } = require('ethers'); + +const address = process.env.ADDRESS; +const pk = process.env.PRIVATE_KEY; +const message = process.env.MESSAGE; + +if (!address || !pk || !message) { + console.error("Missing ADDRESS, PRIVATE_KEY or MESSAGE"); + process.exit(1); +} + +const wallet = new Wallet(pk); +if (wallet.address.toLowerCase() !== address.toLowerCase()) { + console.error(`Private key does not match address. Wallet: ${wallet.address}, Provided: ${address}`); + process.exit(1); +} + +(async () => { + const sig = await wallet.signMessage(message); + console.log(sig); +})(); +NODE +)" + +echo "Signature: ${signature}" + +echo "Logging in as admin..." +login_tmp="$(mktemp)" +http_status="$(curl -sS -o "${login_tmp}" -w "%{http_code}" -X POST \ + -H "x-eth-address: ${ADMIN_ADDRESS}" \ + -H "x-eth-signature: ${signature}" \ + "${API_URL}/auth/login")" +login_resp="$(cat "${login_tmp}")" +rm -f "${login_tmp}" + +echo "Login HTTP ${http_status}: ${login_resp}" +if [[ "${http_status}" != "200" ]]; then + echo "Login failed with status ${http_status}" + exit 1 +fi + +jwt="$(RESP="${login_resp}" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RESP"]) +print(data["token"]) +PY +)" +if [[ -z "${jwt}" ]]; then + echo "Failed to parse JWT from login response" + exit 1 +fi + +echo "JWT obtained: ${jwt:0:20}..." + +echo "Deleting profile ${TARGET_ADDRESS} via admin endpoint..." +delete_tmp="$(mktemp)" +delete_status="$(curl -sS -o "${delete_tmp}" -w "%{http_code}" -X DELETE \ + -H "Authorization: Bearer ${jwt}" \ + "${API_URL}/admin/profiles/${TARGET_ADDRESS}")" +delete_resp="$(cat "${delete_tmp}")" +rm -f "${delete_tmp}" + +echo "Delete HTTP ${delete_status}: ${delete_resp}" +if [[ "${delete_status}" == "204" ]]; then + echo "✅ Profile deleted successfully!" +elif [[ "${delete_status}" == "404" ]]; then + echo "⚠️ Profile not found" +elif [[ "${delete_status}" == "403" ]]; then + echo "❌ Forbidden - address ${ADMIN_ADDRESS} is not an admin" +elif [[ "${delete_status}" == "401" ]]; then + echo "❌ Unauthorized - authentication failed" +else + echo "❌ Delete failed with status ${delete_status}" + exit 1 +fi + +echo "Done."