diff --git a/.env.example b/.env.example index 09e9696..c4671e3 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,10 @@ APP_URL=http://localhost:8080 FEE_BPS=100 EXCHANGE_API_URL=https://api.exchangerate-api.com/v4/latest DEFAULT_COUNTRY=Nigeria + +# SMTP Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASS=your_app_password +SMTP_FROM=noreply@payego.com diff --git a/Cargo.lock b/Cargo.lock index e042fc5..f804957 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2123,6 +2123,7 @@ dependencies = [ "dotenvy", "eyre", "fake", + "hex", "http 1.4.0", "once_cell", "payego-api", @@ -2132,6 +2133,7 @@ dependencies = [ "secrecy", "serde_json", "serial_test", + "sha2 0.11.0-rc.3", "tokio", "tower-http", "tracing", diff --git a/bin/payego/Cargo.toml b/bin/payego/Cargo.toml index b6d27c9..3885721 100644 --- a/bin/payego/Cargo.toml +++ b/bin/payego/Cargo.toml @@ -32,3 +32,5 @@ fake = { version = "2.9", features = ["derive"] } validator.workspace = true argon2.workspace = true reqwest = { version = "0.12", features = ["json", "blocking"] } +sha2 = { workspace = true } +hex = { workspace = true } diff --git a/bin/payego/tests/verification_tests.rs b/bin/payego/tests/verification_tests.rs new file mode 100644 index 0000000..3831ded --- /dev/null +++ b/bin/payego/tests/verification_tests.rs @@ -0,0 +1,154 @@ +mod common; + +use axum::http::StatusCode; +use axum_test::TestServer; +use common::{create_test_app, create_test_app_state}; +use diesel::prelude::*; +use payego_primitives::schema::verification_tokens::dsl::*; +use serde_json::json; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_full_email_verification_flow() { + let state = create_test_app_state(); + let app = create_test_app(state.clone()); + let server = TestServer::new(app).unwrap(); + + let email_str = format!("verify_{}@example.com", uuid::Uuid::new_v4()); + + // 1. Register user + let res = server + .post("/api/auth/register") + .json(&json!({ + "email": email_str, + "password": "SecurePass123!", + "username": Some(format!("user_{}", uuid::Uuid::new_v4())) + })) + .await; + res.assert_status(StatusCode::CREATED); + + // 2. Check user status (should be unverified) + let login_res = server + .post("/api/auth/login") + .json(&json!({ + "email": email_str, + "password": "SecurePass123!" + })) + .await; + login_res.assert_status(StatusCode::OK); + let login_body: serde_json::Value = login_res.json(); + let token = login_body["token"].as_str().unwrap(); + + // Get user info to check verification + let user_res = server + .get("/api/user/current") + .add_header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", token), + ) + .await; + user_res.assert_status(StatusCode::OK); + let user_body: serde_json::Value = user_res.json(); + assert!(user_body["email_verified_at"].is_null()); + + // 3. Manually create a known verification token for testing + let raw_test_token = "test-token-uuid-12345"; + let hashed_test_token = + payego_core::services::auth_service::verification::VerificationService::hash_token( + raw_test_token, + ); + + let mut conn = state.db.get().unwrap(); + + // Find the user ID we just created + use payego_primitives::schema::users::dsl::*; + let user_id_val: uuid::Uuid = users + .filter(email.eq(email_str)) + .select(id) + .first(&mut conn) + .expect("User should exist"); + + // Insert our known token + use payego_core::repositories::verification_repository::VerificationRepository; + use payego_primitives::models::entities::verification_token::NewVerificationToken; + + VerificationRepository::delete_for_user(&mut conn, user_id_val).unwrap(); + VerificationRepository::create( + &mut conn, + NewVerificationToken { + user_id: user_id_val, + token_hash: hashed_test_token, + expires_at: chrono::Utc::now().naive_utc() + chrono::Duration::hours(1), + }, + ) + .unwrap(); + + // 4. Verify email using the RAW token + let verify_res = server + .get(&format!("/api/auth/verify-email?token={}", raw_test_token)) + .await; + verify_res.assert_status(StatusCode::OK); + + // 5. Check user status again (should be verified) + let user_res_after = server + .get("/api/user/current") + .add_header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", token), + ) + .await; + user_res_after.assert_status(StatusCode::OK); + let user_body_after: serde_json::Value = user_res_after.json(); + println!("DBG: User body after verification: {:#?}", user_body_after); + assert!(!user_body_after["email_verified_at"].is_null()); +} + +#[tokio::test] +#[serial] +async fn test_resend_verification_email() { + let state = create_test_app_state(); + let app = create_test_app(state.clone()); + let server = TestServer::new(app).unwrap(); + + let email_str = format!("resend_{}@example.com", uuid::Uuid::new_v4()); + + // Register + server + .post("/api/auth/register") + .json(&json!({ + "email": email_str, + "password": "SecurePass123!", + "username": Some(format!("user_{}", uuid::Uuid::new_v4())) + })) + .await + .assert_status(StatusCode::CREATED); + + // Login to get token + let login_res = server + .post("/api/auth/login") + .json(&json!({ + "email": email_str, + "password": "SecurePass123!" + })) + .await; + let auth_token = login_res.json::()["token"] + .as_str() + .unwrap() + .to_string(); + + // Resend + let resend_res = server + .post("/api/auth/resend-verification") + .add_header( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", auth_token), + ) + .await; + resend_res.assert_status(StatusCode::OK); + + // Verify a new token was created + let mut conn = state.db.get().unwrap(); + let count: i64 = verification_tokens.count().get_result(&mut conn).unwrap(); + assert!(count >= 1); +} diff --git a/crates/core/src/clients/email.rs b/crates/core/src/clients/email.rs index 3a9b7e3..d814141 100644 --- a/crates/core/src/clients/email.rs +++ b/crates/core/src/clients/email.rs @@ -1,10 +1,12 @@ +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; use payego_primitives::error::ApiError; -// use lettre::{SmtpTransport, Transport, Message}; -// use secrecy::ExposeSecret; +use std::env; #[derive(Clone)] pub struct EmailClient { - // transport: SmtpTransport, + transport: Option, + from_email: String, } impl Default for EmailClient { @@ -15,12 +17,66 @@ impl Default for EmailClient { impl EmailClient { pub fn new() -> Self { - Self {} + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_port = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(587); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let from_email = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@payego.com".to_string()); + + let transport = + if let (Some(host), Some(user), Some(pass)) = (smtp_host, smtp_user, smtp_pass) { + let creds = Credentials::new(user, pass); + Some( + SmtpTransport::relay(&host) + .unwrap() + .credentials(creds) + .port(smtp_port) + .build(), + ) + } else { + tracing::warn!("SMTP configuration missing, email client running in mock mode"); + None + }; + + Self { + transport, + from_email, + } } - pub async fn send_email(&self, _to: &str, _subject: &str, _body: &str) -> Result<(), ApiError> { - // Placeholder for real email sending logic - tracing::info!("Sending email to: {}, subject: {}", _to, _subject); + pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<(), ApiError> { + if let Some(ref transport) = self.transport { + let email = Message::builder() + .from( + self.from_email + .parse() + .map_err(|e| ApiError::Internal(format!("Invalid from email: {}", e)))?, + ) + .to(to + .parse() + .map_err(|e| ApiError::Internal(format!("Invalid recipient email: {}", e)))?) + .subject(subject) + .body(body.to_string()) + .map_err(|e| ApiError::Internal(format!("Failed to build email: {}", e)))?; + + transport.send(&email).map_err(|e| { + tracing::error!("Failed to send email: {}", e); + ApiError::Internal("Failed to send email".to_string()) + })?; + + tracing::info!("Email sent successfully to: {}", to); + } else { + tracing::info!( + "[MOCK EMAIL] To: {}, Subject: {}, Body: {}", + to, + subject, + body + ); + } + Ok(()) } } diff --git a/crates/core/src/services/auth_service/user.rs b/crates/core/src/services/auth_service/user.rs index c6fce4f..69760a7 100644 --- a/crates/core/src/services/auth_service/user.rs +++ b/crates/core/src/services/auth_service/user.rs @@ -42,6 +42,7 @@ impl UserService { username: user_data.username, wallets: walletz, created_at: user_data.created_at, + email_verified_at: user_data.email_verified_at, }) } } diff --git a/crates/core/src/services/auth_service/verification.rs b/crates/core/src/services/auth_service/verification.rs index 5b6eb8a..d20d589 100644 --- a/crates/core/src/services/auth_service/verification.rs +++ b/crates/core/src/services/auth_service/verification.rs @@ -57,7 +57,7 @@ impl VerificationService { Ok(()) } - fn hash_token(token: &str) -> String { + pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) diff --git a/crates/primitives/src/models/dtos/auth_dto.rs b/crates/primitives/src/models/dtos/auth_dto.rs index 9b02458..ddc20ba 100644 --- a/crates/primitives/src/models/dtos/auth_dto.rs +++ b/crates/primitives/src/models/dtos/auth_dto.rs @@ -104,6 +104,7 @@ pub struct CurrentUserResponse { pub username: Option, pub wallets: Vec, pub created_at: chrono::DateTime, + pub email_verified_at: Option>, } // --- Health --- diff --git a/payego_ui/src/App.tsx b/payego_ui/src/App.tsx index 3658fe0..3b65dbb 100644 --- a/payego_ui/src/App.tsx +++ b/payego_ui/src/App.tsx @@ -17,6 +17,7 @@ import Transactions from './components/Transactions'; import Profile from './components/Profile'; import Sidebar from './components/Sidebar'; import VerifyEmail from './pages/VerifyEmail'; +import Security from './pages/Security'; import { useState } from 'react'; import { ThemeProvider } from './contexts/ThemeContext'; import ThemeToggle from './components/ThemeToggle'; @@ -117,6 +118,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/payego_ui/src/api/auth.ts b/payego_ui/src/api/auth.ts index 294b666..4d9df31 100644 --- a/payego_ui/src/api/auth.ts +++ b/payego_ui/src/api/auth.ts @@ -11,4 +11,5 @@ export const authApi = { getCurrentUser: () => client.get('/api/user/current').then(res => res.data), verifyEmail: (token: string) => client.get(`/api/auth/verify-email?token=${token}`), resendVerification: () => client.post('/api/auth/resend-verification', {}), + getAuditLogs: (page: number = 1, size: number = 20) => client.get(`/api/user/audit-logs?page=${page}&size=${size}`), }; diff --git a/payego_ui/src/components/Dashboard.tsx b/payego_ui/src/components/Dashboard.tsx index 5f7860e..f22a305 100644 --- a/payego_ui/src/components/Dashboard.tsx +++ b/payego_ui/src/components/Dashboard.tsx @@ -110,6 +110,28 @@ const Dashboard: React.FC = () => { )} + {user && !user.email_verified_at && !isLoading && ( +
+
+
+
+ 📧 +
+
+

Action Required

+

Please verify your email to unlock full account features.

+
+
+ + Verify Now + +
+
+ )} + {user && !isLoading && (
{/* Stats */} diff --git a/payego_ui/src/components/Sidebar.tsx b/payego_ui/src/components/Sidebar.tsx index 622db04..148e2eb 100644 --- a/payego_ui/src/components/Sidebar.tsx +++ b/payego_ui/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ const Sidebar: React.FC = ({ isOpen, setIsOpen }) => { { to: "/banks", label: "Banks", icon: "🏛️" }, { to: "/wallets", label: "Wallets", icon: "💳" }, { to: "/transactions", label: "Transactions", icon: "📜" }, + { to: "/security", label: "Security", icon: "🛡️" }, { to: "/profile", label: "Profile", icon: "👤" }, ]; diff --git a/payego_ui/src/components/TransferForm.tsx b/payego_ui/src/components/TransferForm.tsx index bce779e..1922cb0 100644 --- a/payego_ui/src/components/TransferForm.tsx +++ b/payego_ui/src/components/TransferForm.tsx @@ -11,6 +11,8 @@ import { usersApi } from '../api/users'; import client from '../api/client'; import { ResolvedUser } from '@/types'; import { getErrorMessage } from '../utils/errorHandler'; +import { useAuth } from '../contexts/AuthContext'; +import { Link } from 'react-router-dom'; const transferSchema = z.discriminatedUnion('transferType', [ z.object({ @@ -34,6 +36,7 @@ type TransferFormValues = z.infer; const TransferForm: React.FC = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useAuth(); const { data: wallets } = useWallets(); const { data: banks } = useBanks(); const [resolving, setResolving] = useState(false); @@ -177,6 +180,24 @@ const TransferForm: React.FC = () => {

Transfer Funds

+ {!user?.email_verified_at && ( +
+
+
+ ⚠️ +
+
+

+ Email Verification Required +

+

+ Please verify your email to unlock transfers. Go to Security +

+
+
+
+ )} +
@@ -227,7 +248,11 @@ const TransferForm: React.FC = () => { )} - {error &&

{error}

} diff --git a/payego_ui/src/components/WithdrawForm.tsx b/payego_ui/src/components/WithdrawForm.tsx index de74f37..ca42441 100644 --- a/payego_ui/src/components/WithdrawForm.tsx +++ b/payego_ui/src/components/WithdrawForm.tsx @@ -8,6 +8,8 @@ import { useWallets } from '../hooks/useWallets'; import { useUserBankAccounts } from '../hooks/useBanks'; import { transactionApi } from '../api/transactions'; import { getErrorMessage } from '../utils/errorHandler'; +import { useAuth } from '../contexts/AuthContext'; +import { Link } from 'react-router-dom'; const withdrawSchema = z.object({ amount: z.number().min(1, 'Minimum 1 required'), @@ -20,6 +22,7 @@ type WithdrawFormValues = z.infer; const WithdrawForm: React.FC = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useAuth(); const { data: wallets } = useWallets(); const { data: bankAccounts } = useUserBankAccounts(); const [error, setError] = useState(null); @@ -96,6 +99,24 @@ const WithdrawForm: React.FC = () => {

Withdraw Funds

+ {!user?.email_verified_at && ( +
+
+
+ ⚠️ +
+
+

+ Email Verification Required +

+

+ Please verify your email to unlock withdrawals. Go to Security +

+
+
+
+ )} +
@@ -125,7 +146,11 @@ const WithdrawForm: React.FC = () => { {errors.bankAccountId &&

{errors.bankAccountId.message}

}
- {error &&

{error}

} diff --git a/payego_ui/src/pages/Security.tsx b/payego_ui/src/pages/Security.tsx new file mode 100644 index 0000000..3253819 --- /dev/null +++ b/payego_ui/src/pages/Security.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState } from 'react'; +import { authApi } from '../api/auth'; +import { getErrorMessage } from '../utils/errorHandler'; +import { useAuth } from '../contexts/AuthContext'; + +interface AuditLog { + id: string; + event_type: string; + metadata: any; + created_at: string; + ip_address?: string; +} + +const Security: React.FC = () => { + const { user } = useAuth(); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resending, setResending] = useState(false); + const [resendSuccess, setResendSuccess] = useState(false); + + useEffect(() => { + fetchLogs(); + }, []); + + const fetchLogs = async () => { + try { + setLoading(true); + const response = await authApi.getAuditLogs(1, 20); + setLogs(response.data.data || []); + } catch (err: any) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const handleResend = async () => { + try { + setResending(true); + await authApi.resendVerification(); + setResendSuccess(true); + setTimeout(() => setResendSuccess(false), 5000); + } catch (err: any) { + setError(getErrorMessage(err)); + } finally { + setResending(false); + } + }; + + const isVerified = !!user?.email_verified_at; + + return ( +
+ {/* Account Status Header */} +
+
+
+
+

Account Security

+

Manage your account protection and view recent activity.

+
+ +
+ {isVerified ? ( +
+ + + + Email Verified +
+ ) : ( +
+
+ + + + Action Needed: Verify Email +
+ + {resendSuccess && Resent successfully!} +
+ )} +
+
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Activity Feed */} +
+
+

Recent Activity

+ +
+ +
+ {loading && logs.length === 0 ? ( +
+
+

Crunching your logs...

+
+ ) : logs.length === 0 ? ( +
+ No security events recorded yet. +
+ ) : ( + logs.map((log) => ( +
+
+
+
+ {getEventIcon(log.event_type)} + + {formatEventName(log.event_type)} + +
+

+ {new Date(log.created_at).toLocaleString()} + {log.ip_address && ` • IP: ${log.ip_address}`} +

+
+
+ {/* Optional details placeholder */} +
+
+
+ )) + )} +
+
+ + {/* Enhanced Protection Section */} +
+
+

Transaction Protection

+

Unverified accounts are restricted from making transfers and withdrawals to ensure fund safety.

+
+
+

Automated Audit

+

Every security-critical event is cryptographically logged for your protection and review.

+
+
+
+ ); +}; + +const getEventIcon = (type: string) => { + if (type.includes('login.success')) return '🔐'; + if (type.includes('login.failure')) return '❌'; + if (type.includes('register')) return '👋'; + if (type.includes('transfer')) return '💸'; + if (type.includes('withdraw')) return '🏦'; + if (type.includes('conversion')) return '🔄'; + if (type.includes('wallet')) return '💳'; + if (type.includes('bank')) return '🏛️'; + return '📝'; +}; + +const formatEventName = (type: string) => { + return type + .split('.') + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); +}; + +export default Security; diff --git a/payego_ui/src/types/index.ts b/payego_ui/src/types/index.ts index 75c1bae..0c3d22a 100644 --- a/payego_ui/src/types/index.ts +++ b/payego_ui/src/types/index.ts @@ -3,6 +3,7 @@ export interface User { username: string | null; email: string; created_at: string; + email_verified_at: string | null; } export type Currency =