diff --git a/backend/.sqlx/query-d79097da6ad7c82305d728beb248ed4aab6d7f3a5f5d88ae151cb6c21666761d.json b/backend/.sqlx/query-d79097da6ad7c82305d728beb248ed4aab6d7f3a5f5d88ae151cb6c21666761d.json new file mode 100644 index 0000000..08b1944 --- /dev/null +++ b/backend/.sqlx/query-d79097da6ad7c82305d728beb248ed4aab6d7f3a5f5d88ae151cb6c21666761d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT login_nonce\n FROM profiles\n WHERE address = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "login_nonce", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d79097da6ad7c82305d728beb248ed4aab6d7f3a5f5d88ae151cb6c21666761d" +} diff --git a/backend/.sqlx/query-7acd8c9bc567ef80f66a38130bb708068882a4559856e38e6231405e9acc5a74.json b/backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json similarity index 58% rename from backend/.sqlx/query-7acd8c9bc567ef80f66a38130bb708068882a4559856e38e6231405e9acc5a74.json rename to backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json index 634fb2e..86f6186 100644 --- a/backend/.sqlx/query-7acd8c9bc567ef80f66a38130bb708068882a4559856e38e6231405e9acc5a74.json +++ b/backend/.sqlx/query-e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO profiles (address, name, description, avatar_url, github_login, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "query": "\n INSERT INTO profiles (address, name, description, avatar_url, github_login, login_nonce, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", "describe": { "columns": [], "parameters": { @@ -10,11 +10,12 @@ "Text", "Text", "Text", + "Int8", "Timestamptz", "Timestamptz" ] }, "nullable": [] }, - "hash": "7acd8c9bc567ef80f66a38130bb708068882a4559856e38e6231405e9acc5a74" + "hash": "e4c05cdcfce8dddaf75c30de7e20d020c23a6650ec0bac46c56c30bc3d9c8142" } diff --git a/backend/.sqlx/query-fe47a9ce9d61692552d87b31b3596ded14179f08a15e3235d3e95b95964bd2c7.json b/backend/.sqlx/query-fe47a9ce9d61692552d87b31b3596ded14179f08a15e3235d3e95b95964bd2c7.json new file mode 100644 index 0000000..afb34ed --- /dev/null +++ b/backend/.sqlx/query-fe47a9ce9d61692552d87b31b3596ded14179f08a15e3235d3e95b95964bd2c7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE profiles\n SET login_nonce = login_nonce + 1\n WHERE address = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "fe47a9ce9d61692552d87b31b3596ded14179f08a15e3235d3e95b95964bd2c7" +} diff --git a/backend/migrations/003_add_nonces.sql b/backend/migrations/003_add_nonces.sql new file mode 100644 index 0000000..a8e4b55 --- /dev/null +++ b/backend/migrations/003_add_nonces.sql @@ -0,0 +1,3 @@ +-- Add login_nonce column to profiles table +-- The nonce starts at 1 and increments with each successful login +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS login_nonce BIGINT NOT NULL DEFAULT 1; diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index ac3cc0c..d5e1704 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -1,4 +1,2 @@ pub mod create_profile; -pub mod get_all_profiles; -pub mod get_profile; pub mod update_profile; diff --git a/backend/src/application/dtos/auth_dtos.rs b/backend/src/application/dtos/auth_dtos.rs index 8b38ce4..c94f36c 100644 --- a/backend/src/application/dtos/auth_dtos.rs +++ b/backend/src/application/dtos/auth_dtos.rs @@ -1,10 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] pub struct VerifyMessageRequest { pub address: String, pub nonce: String, pub message: String, } +#[derive(Debug, Serialize, Deserialize)] pub struct VerifyMessageResponse { pub success: bool, pub address: String, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct NonceResponse { + pub nonce: i64, + pub address: String, +} diff --git a/backend/src/application/mod.rs b/backend/src/application/mod.rs index 31002c8..fd62725 100644 --- a/backend/src/application/mod.rs +++ b/backend/src/application/mod.rs @@ -1,2 +1,3 @@ pub mod commands; pub mod dtos; +pub mod queries; diff --git a/backend/src/application/commands/get_all_profiles.rs b/backend/src/application/queries/get_all_profiles.rs similarity index 100% rename from backend/src/application/commands/get_all_profiles.rs rename to backend/src/application/queries/get_all_profiles.rs diff --git a/backend/src/application/queries/get_login_nonce.rs b/backend/src/application/queries/get_login_nonce.rs new file mode 100644 index 0000000..2921c8f --- /dev/null +++ b/backend/src/application/queries/get_login_nonce.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use crate::domain::{repositories::ProfileRepository, value_objects::WalletAddress}; + +pub async fn get_login_nonce( + profile_repository: Arc, + address: String, +) -> Result { + let wallet_address = WalletAddress(address); + + match profile_repository + .get_login_nonce_by_wallet_address(&wallet_address) + .await + { + Ok(Some(nonce)) => Ok(nonce), + Ok(None) => Err("Profile not found".to_string()), + Err(e) => Err(format!("Error fetching nonce: {}", e)), + } +} diff --git a/backend/src/application/commands/get_profile.rs b/backend/src/application/queries/get_profile.rs similarity index 100% rename from backend/src/application/commands/get_profile.rs rename to backend/src/application/queries/get_profile.rs diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs new file mode 100644 index 0000000..298cba8 --- /dev/null +++ b/backend/src/application/queries/mod.rs @@ -0,0 +1,3 @@ +pub mod get_all_profiles; +pub mod get_login_nonce; +pub mod get_profile; diff --git a/backend/src/domain/entities/profile.rs b/backend/src/domain/entities/profile.rs index c13301d..fd42982 100644 --- a/backend/src/domain/entities/profile.rs +++ b/backend/src/domain/entities/profile.rs @@ -10,6 +10,7 @@ pub struct Profile { pub description: Option, pub avatar_url: Option, pub github_login: Option, + pub login_nonce: i64, pub created_at: DateTime, pub updated_at: DateTime, } @@ -23,6 +24,7 @@ impl Profile { description: None, avatar_url: None, github_login: None, + login_nonce: 1, created_at: now, updated_at: now, } diff --git a/backend/src/domain/repositories/profile_repository.rs b/backend/src/domain/repositories/profile_repository.rs index 61b6245..3658458 100644 --- a/backend/src/domain/repositories/profile_repository.rs +++ b/backend/src/domain/repositories/profile_repository.rs @@ -16,4 +16,12 @@ pub trait ProfileRepository: Send + Sync { &self, github_login: &str, ) -> Result, Box>; + async fn get_login_nonce_by_wallet_address( + &self, + address: &WalletAddress, + ) -> Result, Box>; + async fn increment_login_nonce( + &self, + address: &WalletAddress, + ) -> Result<(), Box>; } diff --git a/backend/src/domain/services/auth_service.rs b/backend/src/domain/services/auth_service.rs index 3afaf9f..2ca750c 100644 --- a/backend/src/domain/services/auth_service.rs +++ b/backend/src/domain/services/auth_service.rs @@ -4,7 +4,7 @@ use crate::domain::value_objects::wallet_address::WalletAddress; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthChallenge { - pub nonce: String, + pub nonce: i64, pub address: String, } diff --git a/backend/src/infrastructure/repositories/postgres_profile_repository.rs b/backend/src/infrastructure/repositories/postgres_profile_repository.rs index b480585..98b5159 100644 --- a/backend/src/infrastructure/repositories/postgres_profile_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_profile_repository.rs @@ -40,6 +40,7 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), })) @@ -64,6 +65,7 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), }) @@ -73,14 +75,15 @@ impl ProfileRepository for PostgresProfileRepository { async fn create(&self, profile: &Profile) -> Result<(), Box> { sqlx::query!( r#" - INSERT INTO profiles (address, name, description, avatar_url, github_login, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO profiles (address, name, description, avatar_url, github_login, login_nonce, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, profile.address.as_str(), profile.name, profile.description, profile.avatar_url, profile.github_login, + profile.login_nonce, profile.created_at, profile.updated_at ) @@ -149,8 +152,47 @@ impl ProfileRepository for PostgresProfileRepository { description: r.description, avatar_url: r.avatar_url, github_login: r.github_login, + login_nonce: 0, // Not needed for regular profile queries created_at: r.created_at.unwrap(), updated_at: r.updated_at.unwrap(), })) } + + async fn get_login_nonce_by_wallet_address( + &self, + address: &WalletAddress, + ) -> Result, Box> { + let row = sqlx::query!( + r#" + SELECT login_nonce + FROM profiles + WHERE address = $1 + "#, + address.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row.map(|r| r.login_nonce)) + } + + async fn increment_login_nonce( + &self, + address: &WalletAddress, + ) -> Result<(), Box> { + sqlx::query!( + r#" + UPDATE profiles + SET login_nonce = login_nonce + 1 + WHERE address = $1 + "#, + address.as_str() + ) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(()) + } } diff --git a/backend/src/infrastructure/services/ethereum_address_verification_service.rs b/backend/src/infrastructure/services/ethereum_address_verification_service.rs index e8ff93f..970dddf 100644 --- a/backend/src/infrastructure/services/ethereum_address_verification_service.rs +++ b/backend/src/infrastructure/services/ethereum_address_verification_service.rs @@ -2,21 +2,19 @@ use async_trait::async_trait; use ethers::core::utils::hash_message; use ethers::types::{Address, Signature}; use std::str::FromStr; +use std::sync::Arc; +use crate::domain::repositories::ProfileRepository; use crate::domain::services::auth_service::{AuthChallenge, AuthResult, AuthService}; use crate::domain::value_objects::WalletAddress; -pub struct EthereumAddressVerificationService {} - -impl EthereumAddressVerificationService { - pub fn new() -> Self { - Self {} - } +pub struct EthereumAddressVerificationService { + profile_repository: Arc, } -impl Default for EthereumAddressVerificationService { - fn default() -> Self { - Self::new() +impl EthereumAddressVerificationService { + pub fn new(profile_repository: Arc) -> Self { + Self { profile_repository } } } @@ -27,10 +25,14 @@ impl AuthService for EthereumAddressVerificationService { challenge: &AuthChallenge, signature: &str, ) -> Result, Box> { - const EXPECTED_MSG: &str = "LOGIN_NONCE"; // or whatever constant string you are signing + // Create the message with the nonce + let message = format!( + "Sign this message to authenticate with The Guild.\n\nNonce: {}", + challenge.nonce + ); // EIP-191 prefix + keccak256 - let msg_hash = hash_message(EXPECTED_MSG); + let msg_hash = hash_message(message); // Parse signature and expected address let sig = Signature::from_str(signature)?; @@ -40,9 +42,13 @@ impl AuthService for EthereumAddressVerificationService { let recovered = sig.recover(msg_hash)?; if recovered == expected { - Ok(Some(AuthResult { - wallet_address: WalletAddress(challenge.address.clone()), - })) + // Increment the nonce after successful verification + let wallet_address = WalletAddress(challenge.address.clone()); + self.profile_repository + .increment_login_nonce(&wallet_address) + .await?; + + Ok(Some(AuthResult { wallet_address })) } else { Ok(None) } diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index 09fc099..b712dfd 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -20,18 +20,18 @@ use tower_http::{ }; use super::handlers::{ - create_profile_handler, delete_profile_handler, get_all_profiles_handler, get_profile_handler, - update_profile_handler, + create_profile_handler, delete_profile_handler, get_all_profiles_handler, get_nonce_handler, + get_profile_handler, update_profile_handler, }; use super::middlewares::{eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { - let auth_service = EthereumAddressVerificationService::new(); - let profile_repository = PostgresProfileRepository::new(pool); + let profile_repository = Arc::from(PostgresProfileRepository::new(pool)); + let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); let state: AppState = AppState { - profile_repository: Arc::from(profile_repository), + profile_repository, auth_service: Arc::from(auth_service), }; @@ -50,6 +50,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { let public_routes = Router::new() .route("/profiles/:address", get(get_profile_handler)) .route("/profiles", get(get_all_profiles_handler)) + .route("/auth/nonce/:address", get(get_nonce_handler)) .with_state(state.clone()); Router::new() @@ -86,6 +87,7 @@ pub fn test_api(state: AppState) -> Router { let public_routes = Router::new() .route("/profiles/:address", get(get_profile_handler)) .route("/profiles", get(get_all_profiles_handler)) + .route("/auth/nonce/:address", get(get_nonce_handler)) .with_state(state.clone()); Router::new() diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index ffc1387..79ebd72 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -7,11 +7,12 @@ use axum::{ use crate::{ application::{ - commands::{ - create_profile::create_profile, get_all_profiles::get_all_profiles, - get_profile::get_profile, update_profile::update_profile, + commands::{create_profile::create_profile, update_profile::update_profile}, + dtos::{CreateProfileRequest, NonceResponse, ProfileResponse, UpdateProfileRequest}, + queries::{ + get_all_profiles::get_all_profiles, get_login_nonce::get_login_nonce, + get_profile::get_profile, }, - dtos::{CreateProfileRequest, ProfileResponse, UpdateProfileRequest}, }, domain::value_objects::WalletAddress, }; @@ -76,3 +77,13 @@ pub async fn delete_profile_handler( .unwrap(); StatusCode::ACCEPTED } + +pub async fn get_nonce_handler( + State(state): State, + Path(address): Path, +) -> impl IntoResponse { + match get_login_nonce(state.profile_repository, address.clone()).await { + Ok(nonce) => Json(NonceResponse { nonce, address }).into_response(), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), + } +} diff --git a/backend/src/presentation/middlewares.rs b/backend/src/presentation/middlewares.rs index 903dd32..ab07ee9 100644 --- a/backend/src/presentation/middlewares.rs +++ b/backend/src/presentation/middlewares.rs @@ -43,20 +43,31 @@ pub async fn eth_auth_layer( .map(str::to_owned) .ok_or(StatusCode::UNAUTHORIZED)?; - let nonce = "NONCE"; + // Get the current nonce from the database + 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)? + .ok_or(StatusCode::UNAUTHORIZED)?; // Profile must exist - state + let result = state .auth_service .verify_signature( &AuthChallenge { address: address.clone().to_string(), - nonce: nonce.to_string(), + nonce, }, &signature, - ) // define the signature you like + ) .await .map_err(|_| StatusCode::UNAUTHORIZED)?; + if result.is_none() { + return Err(StatusCode::UNAUTHORIZED); + } + // Inject identity for handlers: req.extensions_mut() .insert(VerifiedWallet(address.to_string())); diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index bd41e0b..fa2723c 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -13,11 +13,12 @@ async fn valid_github_handle_works() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); - let profile_repository = - guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()); - let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(); + let profile_repository = std::sync::Arc::new( + guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), + ); + let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let state = AppState { - profile_repository: std::sync::Arc::new(profile_repository), + profile_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -80,11 +81,12 @@ async fn invalid_format_rejected() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); - let profile_repository = - guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()); - let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(); + let profile_repository = std::sync::Arc::new( + guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), + ); + let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let state = AppState { - profile_repository: std::sync::Arc::new(profile_repository), + profile_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -154,11 +156,12 @@ async fn conflict_case_insensitive() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); - let profile_repository = - guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()); - let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(); + let profile_repository = std::sync::Arc::new( + guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), + ); + let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let state = AppState { - profile_repository: std::sync::Arc::new(profile_repository), + profile_repository, auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs index 16c2b3b..ca0a27b 100644 --- a/backend/tests/profile_tests.rs +++ b/backend/tests/profile_tests.rs @@ -60,6 +60,20 @@ mod github_handle_tests { }) .cloned()) } + + async fn get_login_nonce_by_wallet_address( + &self, + _address: &WalletAddress, + ) -> Result, Box> { + Ok(Some(1)) + } + + async fn increment_login_nonce( + &self, + _address: &WalletAddress, + ) -> Result<(), Box> { + Ok(()) + } } #[tokio::test] @@ -72,6 +86,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; @@ -102,6 +117,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; @@ -133,6 +149,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: Some("Alice".into()), + login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; @@ -143,6 +160,7 @@ mod github_handle_tests { description: None, avatar_url: None, github_login: None, + login_nonce: 1, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), };