diff --git a/.sqlx/query-36a82976eed28484e6fb3becd13fa8f3e04e8ed027114b175a1b07e872b2732e.json b/.sqlx/query-36a82976eed28484e6fb3becd13fa8f3e04e8ed027114b175a1b07e872b2732e.json new file mode 100644 index 0000000..edd3e9e --- /dev/null +++ b/.sqlx/query-36a82976eed28484e6fb3becd13fa8f3e04e8ed027114b175a1b07e872b2732e.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, password_hash\n FROM users\n WHERE bank_user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "36a82976eed28484e6fb3becd13fa8f3e04e8ed027114b175a1b07e872b2732e" +} diff --git a/.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json b/.sqlx/query-802dc1370bbb3bb38f7e83cdf7008aa6a44f2cf7a630390c66fe95e56fcf944f.json similarity index 54% rename from .sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json rename to .sqlx/query-802dc1370bbb3bb38f7e83cdf7008aa6a44f2cf7a630390c66fe95e56fcf944f.json index 7ec5e2d..817cdbb 100644 --- a/.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json +++ b/.sqlx/query-802dc1370bbb3bb38f7e83cdf7008aa6a44f2cf7a630390c66fe95e56fcf944f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, password_hash, bank_user_id\n FROM users\n WHERE username = $1\n ", + "query": "\n INSERT INTO users (bank_user_id, password_hash)\n VALUES ($1, $2)\n RETURNING id, bank_user_id\n ", "describe": { "columns": [ { @@ -10,25 +10,20 @@ }, { "ordinal": 1, - "name": "password_hash", - "type_info": "Text" - }, - { - "ordinal": 2, "name": "bank_user_id", "type_info": "Varchar" } ], "parameters": { "Left": [ + "Varchar", "Text" ] }, "nullable": [ - false, false, false ] }, - "hash": "5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de" + "hash": "802dc1370bbb3bb38f7e83cdf7008aa6a44f2cf7a630390c66fe95e56fcf944f" } diff --git a/.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json b/.sqlx/query-9fe0c11697bc27b1f32a5cbe3bfdcfc17d275a5c088eb72cc4a8d7a3d7a86f08.json similarity index 56% rename from .sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json rename to .sqlx/query-9fe0c11697bc27b1f32a5cbe3bfdcfc17d275a5c088eb72cc4a8d7a3d7a86f08.json index 055315a..3d914f3 100644 --- a/.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json +++ b/.sqlx/query-9fe0c11697bc27b1f32a5cbe3bfdcfc17d275a5c088eb72cc4a8d7a3d7a86f08.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, username, bank_user_id\n FROM users\n WHERE id = $1\n ", + "query": "\n SELECT id, bank_user_id\n FROM users\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -10,11 +10,6 @@ }, { "ordinal": 1, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 2, "name": "bank_user_id", "type_info": "Varchar" } @@ -25,10 +20,9 @@ ] }, "nullable": [ - false, false, false ] }, - "hash": "e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440" + "hash": "9fe0c11697bc27b1f32a5cbe3bfdcfc17d275a5c088eb72cc4a8d7a3d7a86f08" } diff --git a/.sqlx/query-abbec0055ff4abbb562f9924626679db8c42489cbcf167e8193d2674143313b3.json b/.sqlx/query-abbec0055ff4abbb562f9924626679db8c42489cbcf167e8193d2674143313b3.json new file mode 100644 index 0000000..17782ea --- /dev/null +++ b/.sqlx/query-abbec0055ff4abbb562f9924626679db8c42489cbcf167e8193d2674143313b3.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT account_id, nickname\n FROM accounts\n WHERE user_id = $1 AND bank_code = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "nickname", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "abbec0055ff4abbb562f9924626679db8c42489cbcf167e8193d2674143313b3" +} diff --git a/.sqlx/query-d0f42e2b8bb9d1bb3ab65fcd208c80a1956f883407d05bd680760b4cc8885968.json b/.sqlx/query-d0f42e2b8bb9d1bb3ab65fcd208c80a1956f883407d05bd680760b4cc8885968.json new file mode 100644 index 0000000..d0aef7b --- /dev/null +++ b/.sqlx/query-d0f42e2b8bb9d1bb3ab65fcd208c80a1956f883407d05bd680760b4cc8885968.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO accounts \n (account_id, user_id, bank_code, status, currency, account_type, \n account_sub_type, nickname, description, opening_date)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ON CONFLICT (account_id, bank_code)\n DO UPDATE SET updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Date" + ] + }, + "nullable": [] + }, + "hash": "d0f42e2b8bb9d1bb3ab65fcd208c80a1956f883407d05bd680760b4cc8885968" +} diff --git a/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json b/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json deleted file mode 100644 index 3397fa2..0000000 --- a/.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "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/migrations/20251107073144_create_users_table.sql b/migrations/20251107073144_create_users_table.sql deleted file mode 100644 index af05ce7..0000000 --- a/migrations/20251107073144_create_users_table.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 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/migrations/20251107120039_drop_and_recreate_users_table.sql b/migrations/20251107120039_drop_and_recreate_users_table.sql new file mode 100644 index 0000000..eb9a0ac --- /dev/null +++ b/migrations/20251107120039_drop_and_recreate_users_table.sql @@ -0,0 +1,12 @@ +-- Drop old table +DROP TABLE IF EXISTS users CASCADE; + +-- Create simplified users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + bank_user_id VARCHAR(50) UNIQUE NOT NULL, -- e.g., "team275-6" + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_bank_user_id ON users(bank_user_id); diff --git a/src/auth.rs b/src/auth.rs index f13cc7e..98540cc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -6,3 +6,4 @@ pub mod middleware; pub mod password; pub use jwt::Claims; +pub use middleware::auth_middleware; diff --git a/src/auth/handlers.rs b/src/auth/handlers.rs index 7bbe6c4..5fed2c2 100644 --- a/src/auth/handlers.rs +++ b/src/auth/handlers.rs @@ -1,8 +1,11 @@ // src/auth/handlers.rs // Authentication HTTP handlers +// User registers with bank_user_number (1-10) + password +// Gets JWT for accessing Multiberry endpoints +// Real credentials: bank_user_id + password use axum::{ - extract::{Extension, State}, + extract::State, http::StatusCode, response::IntoResponse, Json, @@ -11,24 +14,23 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{state::AppState, db}; -use super::{jwt::{generate_token, Claims}, password::{hash_password, verify_password}}; +use super::{jwt::generate_token, password::{hash_password, verify_password}}; #[derive(Debug, Deserialize)] pub struct RegisterRequest { - pub username: String, - pub password: String, + pub bank_user_number: u8, // 1-10: User picks their slot (e.g., 6 → team275-6) + pub password: String, // User-created password } #[derive(Debug, Deserialize)] pub struct LoginRequest { - pub username: String, + pub bank_user_id: String, // e.g., "team275-6" pub password: String, } #[derive(Debug, Serialize)] pub struct AuthResponse { pub token: String, - pub username: String, pub bank_user_id: String, } @@ -36,6 +38,7 @@ pub async fn register_handler( State(state): State, Json(payload): Json, ) -> impl IntoResponse { + // Validate password length if payload.password.len() < 3 { return Err(( StatusCode::BAD_REQUEST, @@ -43,23 +46,34 @@ pub async fn register_handler( )); } + // Validate bank_user_number (1-10) + if payload.bank_user_number < 1 || payload.bank_user_number > 10 { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "bank_user_number must be between 1 and 10" })) + )); + } + + // Hash password 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); + // Build bank_user_id from environment + number + let bank_user_id = format!("{}-{}", state.bank_team_id, payload.bank_user_number); - let user = db::users::create_user(&state.db_pool, &payload.username, &password_hash, &bank_user_id) + // Create user in database + let user = db::users::create_user(&state.db_pool, &bank_user_id, &password_hash) .await .map_err(|e| ( StatusCode::CONFLICT, - Json(json!({ "error": format!("Username already exists or database error: {}", e) })) + Json(json!({ "error": format!("User already exists or database error: {}", e) })) ))?; - let token = generate_token(user.id, &user.username, &user.bank_user_id) + // Generate JWT token + let token = generate_token(user.id, &user.bank_user_id) .map_err(|_| ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Failed to generate token" })) @@ -69,7 +83,6 @@ pub async fn register_handler( StatusCode::CREATED, Json(AuthResponse { token, - username: user.username, bank_user_id: user.bank_user_id, }) )) @@ -79,7 +92,8 @@ 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) + // Look up user by bank_user_id + let user_data = db::users::get_user_by_bank_user_id(&state.db_pool, &payload.bank_user_id) .await .map_err(|_| ( StatusCode::INTERNAL_SERVER_ERROR, @@ -87,11 +101,12 @@ pub async fn login_handler( ))? .ok_or_else(|| ( StatusCode::UNAUTHORIZED, - Json(json!({ "error": "Invalid username or password" })) + Json(json!({ "error": "Invalid bank_user_id or password" })) ))?; - let (user_id, password_hash, bank_user_id) = user_data; + let (user_id, password_hash) = user_data; + // Verify password let is_valid = verify_password(&payload.password, &password_hash) .map_err(|_| ( StatusCode::INTERNAL_SERVER_ERROR, @@ -101,11 +116,12 @@ pub async fn login_handler( if !is_valid { return Err(( StatusCode::UNAUTHORIZED, - Json(json!({ "error": "Invalid username or password" })) + Json(json!({ "error": "Invalid bank_user_id or password" })) )); } - let token = generate_token(user_id, &payload.username, &bank_user_id) + // Generate JWT token + let token = generate_token(user_id, &payload.bank_user_id) .map_err(|_| ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Failed to generate token" })) @@ -115,20 +131,18 @@ pub async fn login_handler( StatusCode::OK, Json(AuthResponse { token, - username: payload.username, - bank_user_id, + bank_user_id: payload.bank_user_id, }) )) } pub async fn me_handler( - Extension(claims): Extension, + axum::extract::Extension(claims): axum::extract::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 index f9c6eb3..e6e3c62 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -1,5 +1,6 @@ // src/auth/jwt.rs // JWT token generation and validation +// Token contains bank_user_id so backend knows which Multiberry user is calling use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; @@ -8,18 +9,16 @@ 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 sub: i32, // Internal user_id from database + pub bank_user_id: String, // e.g., "team275-6" - identifies which Multiberry user + pub exp: i64, // Expiration timestamp } -pub fn generate_token(user_id: i32, username: &str, bank_user_id: &str) -> Result { +pub fn generate_token(user_id: i32, 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(), }; diff --git a/src/db/accounts.rs b/src/db/accounts.rs index e69de29..6c2aef1 100644 --- a/src/db/accounts.rs +++ b/src/db/accounts.rs @@ -0,0 +1,62 @@ +// src/db/accounts.rs + +use sqlx::PgPool; +use chrono::{DateTime, Utc, NaiveDate}; + +pub async fn store_account( + pool: &PgPool, + account_id: &str, + user_id: &str, // team275-6 + bank_code: &str, // vbank + status: &str, + currency: &str, + account_type: &str, + account_sub_type: Option<&str>, + nickname: &str, + description: Option<&str>, + opening_date: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO accounts + (account_id, user_id, bank_code, status, currency, account_type, + account_sub_type, nickname, description, opening_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (account_id, bank_code) + DO UPDATE SET updated_at = NOW() + "#, + account_id, + user_id, + bank_code, + status, + currency, + account_type, + account_sub_type, + nickname, + description, + opening_date + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_accounts_for_user( + pool: &PgPool, + user_id: &str, + bank_code: &str, +) -> Result, sqlx::Error> { // Returns (account_id, nickname) + sqlx::query!( + r#" + SELECT account_id, nickname + FROM accounts + WHERE user_id = $1 AND bank_code = $2 + "#, + user_id, + bank_code + ) + .fetch_all(pool) + .await + .map(|rows| rows.into_iter().map(|r| (r.account_id, r.nickname)).collect()) +} diff --git a/src/db/users.rs b/src/db/users.rs index 0d5bf83..29570cd 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -5,46 +5,43 @@ 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 bank_user_id: String, } pub async fn create_user( pool: &PgPool, - username: &str, - password_hash: &str, bank_user_id: &str, + password_hash: &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 + INSERT INTO users (bank_user_id, password_hash) + VALUES ($1, $2) + RETURNING id, bank_user_id "#, - username, - password_hash, - bank_user_id + bank_user_id, + password_hash ) .fetch_one(pool) .await } -pub async fn get_user_by_username( +pub async fn get_user_by_bank_user_id( pool: &PgPool, - username: &str, -) -> Result, sqlx::Error> { + bank_user_id: &str, +) -> Result, sqlx::Error> { sqlx::query!( r#" - SELECT id, password_hash, bank_user_id + SELECT id, password_hash FROM users - WHERE username = $1 + WHERE bank_user_id = $1 "#, - username + bank_user_id ) .fetch_optional(pool) .await - .map(|row| row.map(|r| (r.id, r.password_hash, r.bank_user_id))) + .map(|row| row.map(|r| (r.id, r.password_hash))) } pub async fn get_user_by_id( @@ -54,7 +51,7 @@ pub async fn get_user_by_id( sqlx::query_as!( User, r#" - SELECT id, username, bank_user_id + SELECT id, bank_user_id FROM users WHERE id = $1 "#, diff --git a/src/route.rs b/src/route.rs index 7834ca9..d9b07b8 100644 --- a/src/route.rs +++ b/src/route.rs @@ -2,29 +2,29 @@ pub mod handlers; -use axum::{routing::{get, post}, Router}; -use crate::state::AppState; +use axum::{middleware, routing::{get, post}, Router}; +use crate::{state::AppState, auth}; pub fn router(app_state: AppState) -> Router { Router::new() - // Health check + // Public routes (no auth required) .route("/api/health", get(handlers::health_handler)) + .route("/api/auth/register", post(auth::handlers::register_handler)) + .route("/api/auth/login", post(auth::handlers::login_handler)) - // Consent management + // Protected routes (auth required) + .route("/api/auth/me", get(auth::handlers::me_handler)) .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) ) + .layer(middleware::from_fn(auth::auth_middleware)) .with_state(app_state) } diff --git a/src/state.rs b/src/state.rs index 7943de3..4871cf4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,12 +3,14 @@ use crate::db; use crate::api::BankingClients; use sqlx::PgPool; +use std::env; -/// Общее состояние приложения, доступное во всех handlers +/// Global application state, accessible in all handlers #[derive(Clone)] pub struct AppState { pub db_pool: PgPool, pub banking_clients: BankingClients, + pub bank_team_id: String, // e.g., "team275" from VBANK_CLIENT_ID env var } impl AppState { @@ -16,13 +18,18 @@ impl AppState { let db_pool = db::init_pool().await; println!("✅ Database connection pool created successfully."); - // FIX: Add .await and handle the potential error - let banking_clients = BankingClients::new().await.expect("Failed to initialize banking clients"); + let banking_clients = BankingClients::new() + .await + .expect("Failed to initialize banking clients"); println!("✅ Banking API clients initialized."); + let bank_team_id = env::var("VBANK_CLIENT_ID") + .expect("VBANK_CLIENT_ID environment variable must be set"); + Self { db_pool, banking_clients, + bank_team_id, } } }