From 0977d47ec23c784dc75eb5c2e304f0e804d54fcf Mon Sep 17 00:00:00 2001 From: Rorik Star Platinum Date: Fri, 7 Nov 2025 11:27:12 +0300 Subject: [PATCH] (feat) auth --- ...a89d8a8da04b967f08bb1b10542c934f7c5de.json | 34 ++++ ...fa111420a69e49170f6af47503b73fccbd9db.json | 36 ++++ ...2cf084534c0b1be339f371f256c8149358440.json | 34 ++++ Cargo.lock | 80 ++++++++ Cargo.toml | 2 + .../20251107073144_create_users_table.sql | 14 ++ src/api/models.rs | 27 ++- src/auth.rs | 8 + src/auth/handlers.rs | 135 ++++++++++++ src/auth/jwt.rs | 43 ++++ src/auth/middleware.rs | 38 ++++ src/auth/password.rs | 25 +++ src/db.rs | 1 + src/db/users.rs | 65 ++++++ src/main.rs | 1 + src/route.rs | 34 ++-- src/route/handlers.rs | 192 +++++++++++++++--- 17 files changed, 711 insertions(+), 58 deletions(-) create mode 100644 .sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json create mode 100644 .sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json create mode 100644 .sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json create mode 100644 migrations/20251107073144_create_users_table.sql create mode 100644 src/auth.rs create mode 100644 src/auth/handlers.rs create mode 100644 src/auth/jwt.rs create mode 100644 src/auth/middleware.rs create mode 100644 src/auth/password.rs create mode 100644 src/db/users.rs diff --git a/.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json b/.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json new file mode 100644 index 0000000..7ec5e2d --- /dev/null +++ b/.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, password_hash, bank_user_id\n FROM users\n WHERE username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "bank_user_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de" +} diff --git a/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json b/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json new file mode 100644 index 0000000..3397fa2 --- /dev/null +++ b/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (username, password_hash, bank_user_id)\n VALUES ($1, $2, $3)\n RETURNING id, username, bank_user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "bank_user_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db" +} diff --git a/.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json b/.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json new file mode 100644 index 0000000..055315a --- /dev/null +++ b/.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, username, bank_user_id\n FROM users\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "bank_user_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440" +} diff --git a/Cargo.lock b/Cargo.lock index 292a754..5f7c72b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,18 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -139,6 +151,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5136e6c5e7e7978fe23e9876fb924af2c0f84c72127ac6ac17e7c46f457d362c" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -180,6 +214,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -689,6 +732,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" @@ -1212,7 +1279,9 @@ name = "multiberry-backend" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "axum", + "axum-extra", "base64", "chrono", "dotenvy", @@ -1408,6 +1477,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" diff --git a/Cargo.toml b/Cargo.toml index f140dae..00f4ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ axum = "0.8" tokio = { version = "1.48", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } url = "2.5" +argon2 = "0.5" # Password hashing +axum-extra = { version = "0.12", features = ["typed-header"] } # For Authorization sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] } dotenvy = "0.15" diff --git a/migrations/20251107073144_create_users_table.sql b/migrations/20251107073144_create_users_table.sql new file mode 100644 index 0000000..af05ce7 --- /dev/null +++ b/migrations/20251107073144_create_users_table.sql @@ -0,0 +1,14 @@ +-- migrations/XXXXXX_create_users_table.sql + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + bank_user_id VARCHAR(50) NOT NULL, -- e.g., "team275-1" + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT username_length CHECK (char_length(username) >= 3) +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_bank_user_id ON users(bank_user_id); diff --git a/src/api/models.rs b/src/api/models.rs index 7524558..cc140cb 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -6,14 +6,14 @@ use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc, NaiveDate}; // --- Generic API Wrappers --- -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct ApiResponse { pub data: T, pub links: Links, pub meta: Meta, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct Links { #[serde(rename = "self")] @@ -22,7 +22,7 @@ pub struct Links { pub prev: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct Meta { pub total_pages: Option, @@ -42,7 +42,7 @@ pub struct ConsentRequestBody { pub requesting_bank_name: String, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "snake_case")] pub struct ConsentResponse { pub request_id: String, @@ -54,12 +54,12 @@ pub struct ConsentResponse { } // --- Account & Transaction Models --- -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct AccountData { pub account: Vec, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct Account { pub account_id: String, @@ -73,7 +73,7 @@ pub struct Account { pub account: Option>, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct AccountIdentification { pub scheme_name: String, @@ -81,12 +81,12 @@ pub struct AccountIdentification { pub name: String, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct BalanceData { pub balance: Vec, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct Balance { pub account_id: String, @@ -97,13 +97,13 @@ pub struct Balance { pub credit_debit_indicator: String, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct TransactionData { pub transaction: Vec, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here #[serde(rename_all = "camelCase")] pub struct Transaction { pub account_id: String, @@ -117,14 +117,13 @@ pub struct Transaction { pub bank_transaction_code: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct Amount { pub amount: String, pub currency: String, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here pub struct BankTransactionCode { pub code: String, } - diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..f13cc7e --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,8 @@ +// src/auth.rs + +pub mod handlers; +pub mod jwt; +pub mod middleware; +pub mod password; + +pub use jwt::Claims; diff --git a/src/auth/handlers.rs b/src/auth/handlers.rs new file mode 100644 index 0000000..7bbe6c4 --- /dev/null +++ b/src/auth/handlers.rs @@ -0,0 +1,135 @@ +// src/auth/handlers.rs +// Authentication HTTP handlers + +use axum::{ + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{state::AppState, db}; +use super::{jwt::{generate_token, Claims}, password::{hash_password, verify_password}}; + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub token: String, + pub username: String, + pub bank_user_id: String, +} + +pub async fn register_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + if payload.password.len() < 3 { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Password must be at least 3 characters" })) + )); + } + + let password_hash = hash_password(&payload.password) + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to hash password" })) + ))?; + + // Generate bank_user_id based on username (you can make this more sophisticated) + let bank_user_id = format!("team275-{}", payload.username); + + let user = db::users::create_user(&state.db_pool, &payload.username, &password_hash, &bank_user_id) + .await + .map_err(|e| ( + StatusCode::CONFLICT, + Json(json!({ "error": format!("Username already exists or database error: {}", e) })) + ))?; + + let token = generate_token(user.id, &user.username, &user.bank_user_id) + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to generate token" })) + ))?; + + Ok::<_, (StatusCode, Json)>(( + StatusCode::CREATED, + Json(AuthResponse { + token, + username: user.username, + bank_user_id: user.bank_user_id, + }) + )) +} + +pub async fn login_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let user_data = db::users::get_user_by_username(&state.db_pool, &payload.username) + .await + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Database error" })) + ))? + .ok_or_else(|| ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid username or password" })) + ))?; + + let (user_id, password_hash, bank_user_id) = user_data; + + let is_valid = verify_password(&payload.password, &password_hash) + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Password verification failed" })) + ))?; + + if !is_valid { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid username or password" })) + )); + } + + let token = generate_token(user_id, &payload.username, &bank_user_id) + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Failed to generate token" })) + ))?; + + Ok::<_, (StatusCode, Json)>(( + StatusCode::OK, + Json(AuthResponse { + token, + username: payload.username, + bank_user_id, + }) + )) +} + +pub async fn me_handler( + Extension(claims): Extension, +) -> impl IntoResponse { + ( + StatusCode::OK, + Json(json!({ + "user_id": claims.sub, + "username": claims.username, + "bank_user_id": claims.bank_user_id, + })) + ) +} diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs new file mode 100644 index 0000000..f9c6eb3 --- /dev/null +++ b/src/auth/jwt.rs @@ -0,0 +1,43 @@ +// src/auth/jwt.rs +// JWT token generation and validation + +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; +use std::env; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: i32, // user_id + pub username: String, + pub bank_user_id: String, + pub exp: i64, +} + +pub fn generate_token(user_id: i32, username: &str, bank_user_id: &str) -> Result { + let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "super_secret_key_change_in_production".to_string()); + + let claims = Claims { + sub: user_id, + username: username.to_string(), + bank_user_id: bank_user_id.to_string(), + exp: (Utc::now() + Duration::days(7)).timestamp(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) +} + +pub fn validate_token(token: &str) -> Result { + let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "super_secret_key_change_in_production".to_string()); + + decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + ) + .map(|data| data.claims) +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..3cb5dea --- /dev/null +++ b/src/auth/middleware.rs @@ -0,0 +1,38 @@ +// src/auth/middleware.rs +// Axum middleware to protect routes with JWT authentication + +use axum::{ + body::Body, + extract::Request, + http::{HeaderMap, StatusCode}, + middleware::Next, + response::Response, + Json, +}; +use serde_json::json; + +use super::jwt::{validate_token, Claims}; + +pub async fn auth_middleware( + headers: HeaderMap, + mut req: Request, + next: Next, +) -> Result)> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Missing or invalid authorization header" })) + ))?; + + let claims = validate_token(token).map_err(|_| ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid or expired token" })) + ))?; + + req.extensions_mut().insert(claims); + + Ok(next.run(req).await) +} diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..ba18a52 --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,25 @@ +// src/auth/password.rs +// Password hashing and verification using Argon2 + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + argon2 + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash)?; + + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .map(|_| true) + .or(Ok(false)) +} diff --git a/src/db.rs b/src/db.rs index ea712b8..928c758 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,6 +3,7 @@ pub mod consents; pub mod accounts; pub mod transactions; +pub mod users; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; diff --git a/src/db/users.rs b/src/db/users.rs new file mode 100644 index 0000000..0d5bf83 --- /dev/null +++ b/src/db/users.rs @@ -0,0 +1,65 @@ +// src/db/users.rs + +use sqlx::PgPool; + +#[derive(Debug, Clone)] +pub struct User { + pub id: i32, + pub username: String, + pub bank_user_id: String, // Keep this - it's team275-X +} + +pub async fn create_user( + pool: &PgPool, + username: &str, + password_hash: &str, + bank_user_id: &str, +) -> Result { + sqlx::query_as!( + User, + r#" + INSERT INTO users (username, password_hash, bank_user_id) + VALUES ($1, $2, $3) + RETURNING id, username, bank_user_id + "#, + username, + password_hash, + bank_user_id + ) + .fetch_one(pool) + .await +} + +pub async fn get_user_by_username( + pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + sqlx::query!( + r#" + SELECT id, password_hash, bank_user_id + FROM users + WHERE username = $1 + "#, + username + ) + .fetch_optional(pool) + .await + .map(|row| row.map(|r| (r.id, r.password_hash, r.bank_user_id))) +} + +pub async fn get_user_by_id( + pool: &PgPool, + user_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as!( + User, + r#" + SELECT id, username, bank_user_id + FROM users + WHERE id = $1 + "#, + user_id + ) + .fetch_optional(pool) + .await +} diff --git a/src/main.rs b/src/main.rs index 7dd790a..178883e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod route; mod db; mod state; mod api; +mod auth; use std::net::SocketAddr; use crate::state::AppState; diff --git a/src/route.rs b/src/route.rs index 9c7ebca..7834ca9 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,24 +1,30 @@ - // src/route.rs - // Почему это здесь? - // - Это центр управления всеми HTTP маршрутами (роутами) - // - Здесь объявляются все подмодули (handlers, будущие сервисы и т.д.) - // - Здесь собирается финальный Router для Axum +// src/route.rs -pub mod handlers; // Объявляем подмодуль handlers +pub mod handlers; -use axum::{routing::get, Router}; +use axum::{routing::{get, post}, Router}; use crate::state::AppState; - /// Создаёт и возвращает Router со всеми сконфигурированными роутами - /// Функция принимает AppState и передаёт его всем handlers'ам pub fn router(app_state: AppState) -> Router { Router::new() - // GET /api/health — health-check эндпоинт + // Health check .route("/api/health", get(handlers::health_handler)) - .route("/api/test-token", get(handlers::test_token_handler)) - // Сюда добавим новые роуты по мере разработки: - // .route("/api/accounts", get(handlers::accounts::get_accounts)) - // .route("/api/payments", post(handlers::payments::create_payment)) + + // Consent management + .route("/api/consent/:bank/:user_id", + post(handlers::create_consent_handler) + .get(handlers::get_consent_handler) + ) + + // Account access + .route("/api/accounts/:bank/:user_id", + get(handlers::get_accounts_handler) + ) + + // Transaction access + .route("/api/transactions/:bank/:user_id/:account_id", + get(handlers::get_transactions_handler) + ) .with_state(app_state) } diff --git a/src/route/handlers.rs b/src/route/handlers.rs index 0f8bce5..a509358 100644 --- a/src/route/handlers.rs +++ b/src/route/handlers.rs @@ -1,52 +1,184 @@ - // src/route/handlers.rs - // Почему это здесь? - // - Это всё, что обрабатывает HTTP запросы - // - Каждый handler'а — это async функция, которая обрабатывает запрос и возвращает ответ +// src/route/handlers.rs +// HTTP request handlers for the banking API use axum::{ - extract::State, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; +use serde::{Deserialize, Serialize}; use serde_json::json; -use crate::state::AppState; +use crate::{ + state::AppState, + api::{client::Bank, BankingError}, + db, +}; + +// --- Health Check --- - /// Health-check handler - /// - /// Что он делает: - /// 1. Принимает AppState через extract::State (это параметр, который Axum инжектирует) - /// 2. Пытается выполнить простой SELECT 1 в БД (проверка подключения) - /// 3. Возвращает 200 OK если всё хорошо, или 500 если БД недоступна pub async fn health_handler( State(state): State, ) -> impl IntoResponse { - // Пытаемся выполнить простой запрос к БД - let result = sqlx::query("SELECT 1") + sqlx::query("SELECT 1") .execute(&state.db_pool) - .await; - - // Обрабатываем результат - match result { - Ok(_) => ( + .await + .map(|_| ( StatusCode::OK, - Json(json!({ "status": "Database connection is successful." })), - ).into_response(), - Err(e) => ( + Json(json!({ "status": "healthy", "database": "connected" })) + )) + .unwrap_or_else(|e| ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "status": format!("Database connection failed: {}", e) })), - ).into_response(), - } + Json(json!({ "status": "unhealthy", "error": e.to_string() })) + )) + .into_response() } -pub async fn test_token_handler( +// --- Consent Management --- + +#[derive(Debug, Serialize)] +struct ConsentCreatedResponse { + consent_id: String, + expires_at: String, + status: String, + message: String, +} + +pub async fn create_consent_handler( State(state): State, + Path((bank_code, user_id)): Path<(String, String)>, ) -> impl IntoResponse { - match state.banking_clients.vbank.get_token().await { - Ok(token) => (StatusCode::OK, Json(json!({ "vbank_token": token }))), - Err(e) => ( + let bank = bank_code.parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ + "error": "Invalid bank code. Use: vbank, abank, or sbank" + }))))?; + + let client = state.banking_clients.get_client(bank); + + let consent_response = client.request_consent(&user_id).await + .map_err(|e| map_banking_error(e))?; + + db::consents::store_consent( + &state.db_pool, + &user_id, + bank.code(), + &consent_response.consent_id, + consent_response.created_at + chrono::Duration::days(365), + ) + .await + .map_err(|e| ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to store consent: {}", e) })) + ))?; + + Ok::<_, (StatusCode, Json)>(( + StatusCode::CREATED, + Json(json!({ + "consent_id": consent_response.consent_id, + "expires_at": consent_response.created_at + chrono::Duration::days(365), + "status": consent_response.status, + "message": consent_response.message, + })) + )) +} + +pub async fn get_consent_handler( + State(state): State, + Path((bank_code, user_id)): Path<(String, String)>, +) -> impl IntoResponse { + let bank = bank_code.parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ + "error": "Invalid bank code" + }))))?; + + db::consents::get_valid_consent(&state.db_pool, &user_id, bank.code()) + .await + .map_err(|e| ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": e.to_string() })), + Json(json!({ "error": e.to_string() })) + ))? + .map(|consent_id| ( + StatusCode::OK, + Json(json!({ "consent_id": consent_id, "bank": bank_code, "user_id": user_id })) + )) + .ok_or_else(|| ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "No valid consent found for this user and bank" })) + )) +} + +// --- Account Management --- + +pub async fn get_accounts_handler( + State(state): State, + Path((bank_code, user_id)): Path<(String, String)>, +) -> Result, (StatusCode, Json)> { + let bank = bank_code.parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid bank code" }))))?; + + let consent_id = db::consents::get_valid_consent(&state.db_pool, &user_id, bank.code()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))))? + .ok_or_else(|| ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "No valid consent. Please request consent first." })) + ))?; + + let client = state.banking_clients.get_client(bank); + + client.get_accounts(&user_id, &consent_id) + .await + .map(|accounts| Json(serde_json::to_value(accounts).unwrap())) + .map_err(map_banking_error) +} + +// --- Transaction Management --- + +#[derive(Debug, Deserialize)] +pub struct TransactionQuery { + page: Option, + limit: Option, +} + +pub async fn get_transactions_handler( + State(state): State, + Path((bank_code, user_id, account_id)): Path<(String, String, String)>, + Query(params): Query, +) -> Result, (StatusCode, Json)> { + let bank = bank_code.parse::() + .map_err(|_| (StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid bank code" }))))?; + + let consent_id = db::consents::get_valid_consent(&state.db_pool, &user_id, bank.code()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() }))))? + .ok_or_else(|| ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "No valid consent" })) + ))?; + + let client = state.banking_clients.get_client(bank); + + client.get_transactions(&account_id, &consent_id, params.page, params.limit) + .await + .map(|transactions| Json(serde_json::to_value(transactions).unwrap())) + .map_err(map_banking_error) +} + +// --- Error Mapping --- + +fn map_banking_error(err: BankingError) -> (StatusCode, Json) { + match err { + BankingError::ApiError { status, body } => ( + StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(json!({ "error": "Bank API error", "details": body })) + ), + BankingError::RequestFailed(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": "Failed to communicate with bank", "details": e.to_string() })) + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": err.to_string() })) ), } }