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: 5 additions & 0 deletions crates/api/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::config::swagger_config::ApiDoc;
use crate::handlers::{
add_bank::add_bank_account,
all_banks::all_banks,
audit_logs::get_user_audit_logs,
current_user::current_user_details,
exchange_rate::get_exchange_rate as get_exchange_rate_handler,
get_transaction::get_transactions,
Expand All @@ -21,6 +22,7 @@ use crate::handlers::{
user_bank_accounts::user_bank_accounts,
user_transaction::get_user_transaction,
user_wallets::get_user_wallets,
verify_email::{resend_verification, verify_email},
withdraw::withdraw,
};
use axum::{
Expand Down Expand Up @@ -138,6 +140,8 @@ fn create_secured_routers(state: &Arc<AppState>) -> Router<Arc<AppState>> {
"/api/wallet/withdraw/{bank_account_id}",
axum::routing::post(withdraw),
)
.route("/api/user/audit-logs", get(get_user_audit_logs))
.route("/api/auth/resend-verification", post(resend_verification))
.layer(middleware::from_fn_with_state(
state.clone(),
SecurityConfig::auth_middleware,
Expand All @@ -161,6 +165,7 @@ fn create_public_routers(metric_handle: PrometheusHandle) -> Router<Arc<AppState
.route("/api/users/resolve", get(resolve_user))
.route("/api/exchange-rate", get(get_exchange_rate_handler))
.route("/api/health", axum::routing::get(health_check))
.route("/api/auth/verify-email", get(verify_email))
}

async fn https_redirect_middleware(
Expand Down
37 changes: 37 additions & 0 deletions crates/api/src/handlers/audit_logs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use axum::{extract::Query, extract::State, Extension, Json};
use payego_core::app_state::AppState;
use payego_core::repositories::audit_repository::AuditLogRepository;
use payego_core::security::Claims;
use payego_primitives::error::ApiError;
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize)]
pub struct AuditLogQuery {
pub page: Option<i64>,
pub size: Option<i64>,
}

pub async fn get_user_audit_logs(
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
Query(query): Query<AuditLogQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user_id = claims.user_id()?;
let limit = query.size.unwrap_or(20).min(100);
let offset = (query.page.unwrap_or(1) - 1) * limit;

let mut conn = state
.db
.get()
.map_err(|e| ApiError::DatabaseConnection(e.to_string()))?;

let logs = AuditLogRepository::find_by_user_paginated(&mut conn, user_id, limit, offset)?;

Ok(Json(serde_json::json!({
"status": "success",
"data": logs,
"page": query.page.unwrap_or(1),
"limit": limit
})))
}
3 changes: 2 additions & 1 deletion crates/api/src/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod add_bank;
pub mod all_banks;
pub mod audit_logs;
pub mod current_user;
pub mod delete_bank;
pub mod exchange_rate;
Expand All @@ -16,7 +17,6 @@ pub mod refresh_token;
pub mod register;
pub mod resolve_account;
pub mod resolve_user;
pub mod send_verification;
pub mod social_login;
pub mod stripe_webhook;
pub mod top_up;
Expand All @@ -25,4 +25,5 @@ pub mod transfer_internal;
pub mod user_bank_accounts;
pub mod user_transaction;
pub mod user_wallets;
pub mod verify_email;
pub mod withdraw;
46 changes: 46 additions & 0 deletions crates/api/src/handlers/verify_email.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use axum::{extract::Query, extract::State, Extension, Json};
use payego_core::app_state::AppState;
use payego_core::security::Claims;
use payego_core::services::auth_service::verification::VerificationService;
use payego_primitives::error::{ApiError, AuthError};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize)]
pub struct VerifyEmailQuery {
pub token: String,
}

pub async fn verify_email(
State(state): State<Arc<AppState>>,
Query(query): Query<VerifyEmailQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
VerificationService::verify_email(&state, &query.token).await?;

Ok(Json(serde_json::json!({
"status": "success",
"message": "Email verified successfully"
})))
}

pub async fn resend_verification(
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user_id = claims.user_id()?;

let mut conn = state
.db
.get()
.map_err(|e| ApiError::DatabaseConnection(e.to_string()))?;
let user =
payego_core::repositories::user_repository::UserRepository::find_by_id(&mut conn, user_id)?
.ok_or_else(|| ApiError::Auth(AuthError::InternalError("User not found".into())))?;

VerificationService::send_verification_email(&state, user_id, &user.email).await?;

Ok(Json(serde_json::json!({
"status": "success",
"message": "Verification email resent"
})))
}
15 changes: 15 additions & 0 deletions crates/core/src/repositories/audit_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,19 @@ impl AuditLogRepository {
.map_err(ApiError::Database)?;
Ok(())
}

pub fn find_by_user_paginated(
conn: &mut PgConnection,
user_id: uuid::Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<payego_primitives::models::entities::audit_log::AuditLog>, ApiError> {
audit_logs::table
.filter(audit_logs::user_id.eq(user_id))
.order(audit_logs::created_at.desc())
.limit(limit)
.offset(offset)
.load::<payego_primitives::models::entities::audit_log::AuditLog>(conn)
.map_err(ApiError::Database)
}
}
1 change: 1 addition & 0 deletions crates/core/src/repositories/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub mod bank_repository;
pub mod token_repository;
pub mod transaction_repository;
pub mod user_repository;
pub mod verification_repository;
pub mod wallet_repository;
8 changes: 8 additions & 0 deletions crates/core/src/repositories/user_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,12 @@ impl UserRepository {
}
})
}

pub fn mark_email_verified(conn: &mut PgConnection, user_id: Uuid) -> Result<(), ApiError> {
diesel::update(users::table.find(user_id))
.set(users::email_verified_at.eq(chrono::Utc::now()))
.execute(conn)
.map(|_| ())
.map_err(ApiError::Database)
}
}
63 changes: 63 additions & 0 deletions crates/core/src/repositories/verification_repository.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use crate::repositories::user_repository::UserRepository;
use diesel::prelude::*;
use payego_primitives::error::{ApiError, AuthError};
use payego_primitives::models::entities::verification_token::{
NewVerificationToken, VerificationToken,
};
use payego_primitives::schema::verification_tokens;
use uuid::Uuid;

pub struct VerificationRepository;

impl VerificationRepository {
pub fn create(
conn: &mut PgConnection,
new_token: NewVerificationToken,
) -> Result<VerificationToken, ApiError> {
diesel::insert_into(verification_tokens::table)
.values(&new_token)
.get_result(conn)
.map_err(ApiError::Database)
}

pub fn find_by_token(
conn: &mut PgConnection,
token_hash: &str,
) -> Result<Option<VerificationToken>, ApiError> {
verification_tokens::table
.filter(verification_tokens::token_hash.eq(token_hash))
.first::<VerificationToken>(conn)
.optional()
.map_err(ApiError::Database)
}

pub fn delete_for_user(conn: &mut PgConnection, user_id: Uuid) -> Result<(), ApiError> {
diesel::delete(verification_tokens::table.filter(verification_tokens::user_id.eq(user_id)))
.execute(conn)
.map(|_| ())
.map_err(ApiError::Database)
}

pub fn consume_token(
conn: &mut PgConnection,
token_hash: &str,
) -> Result<VerificationToken, ApiError> {
let token = Self::find_by_token(conn, token_hash)?.ok_or_else(|| {
ApiError::Auth(AuthError::VerificationError(
"Invalid or expired verification token".into(),
))
})?;

if token.expires_at < chrono::Utc::now().naive_utc() {
return Err(ApiError::Auth(AuthError::VerificationError(
"Verification token has expired".into(),
)));
}

// Verify user and delete token
UserRepository::mark_email_verified(conn, token.user_id)?;
Self::delete_for_user(conn, token.user_id)?;

Ok(token)
}
}
1 change: 1 addition & 0 deletions crates/core/src/services/auth_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pub mod logout;
pub mod register;
pub mod token;
pub mod user;
pub mod verification;
11 changes: 11 additions & 0 deletions crates/core/src/services/auth_service/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ impl RegisterService {
ApiError::Internal("Authentication service error".into())
})?;

// 📧 Trigger Email Verification
let _ = Box::pin(crate::services::auth_service::verification::VerificationService::send_verification_email(
state,
user.id,
&user.email,
))
.await
.map_err(|e| {
error!(user_id = %user.id, "Failed to send verification email: {}", e);
});

let _ = AuditService::log_event(
state,
Some(user.id),
Expand Down
65 changes: 65 additions & 0 deletions crates/core/src/services/auth_service/verification.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use crate::app_state::AppState;
use crate::repositories::verification_repository::VerificationRepository;
use payego_primitives::error::ApiError;
use payego_primitives::models::entities::verification_token::NewVerificationToken;
use sha2::{Digest, Sha256};
use uuid::Uuid;

pub struct VerificationService;

impl VerificationService {
pub async fn send_verification_email(
state: &AppState,
user_id: Uuid,
email: &str,
) -> Result<(), ApiError> {
let token = Uuid::new_v4().to_string();
let token_hash = Self::hash_token(&token);

let mut conn = state
.db
.get()
.map_err(|e| ApiError::DatabaseConnection(e.to_string()))?;

// 24 hour expiry
let expires_at = chrono::Utc::now().naive_utc() + chrono::Duration::hours(24);

VerificationRepository::delete_for_user(&mut conn, user_id)?;
VerificationRepository::create(
&mut conn,
NewVerificationToken {
user_id,
token_hash,
expires_at,
},
)?;

let subject = "Verify your email - Payego";
let body = format!(
"Please verify your email by clicking here: /verify-email?token={}",
token
);

state.email.send_email(email, subject, &body).await?;

Ok(())
}

pub async fn verify_email(state: &AppState, token: &str) -> Result<(), ApiError> {
let token_hash = Self::hash_token(token);
let mut conn = state
.db
.get()
.map_err(|e| ApiError::DatabaseConnection(e.to_string()))?;

VerificationRepository::consume_token(&mut conn, &token_hash)?;

Ok(())
}

fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
}
Loading