Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
JWT_EXPIRATION=86400

# Admin Configuration (comma-separated wallet addresses)
# ADMIN_ADDRESSES=0xYourAdminAddress1,0xAnotherAdminAddress2
30 changes: 29 additions & 1 deletion backend/src/presentation/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ use tower_http::{
};

use super::handlers::{
// Admin handlers
admin_delete_profile_handler,
// Profile handlers
create_profile_handler,
// Project handlers
Expand All @@ -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()));
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions backend/src/presentation/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
Path(address): Path<String>,
) -> 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()
}
}
}
103 changes: 103 additions & 0 deletions backend/src/presentation/middlewares.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,106 @@ pub async fn test_auth_layer(mut req: Request<Body>, next: Next) -> Result<Respo
req.extensions_mut().insert(VerifiedWallet(address));
Ok(next.run(req).await)
}

/// Middleware to verify admin wallet addresses.
/// Admin addresses are configured via the ADMIN_ADDRESSES environment variable
/// as a comma-separated list of wallet addresses.
pub async fn admin_auth_layer(
State(state): State<AppState>,
mut req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// 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)
}
143 changes: 143 additions & 0 deletions scripts/test_admin_delete.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading