(feat) auth
This commit is contained in:
parent
15b92ba3a4
commit
0977d47ec2
17 changed files with 711 additions and 58 deletions
34
.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json
generated
Normal file
34
.sqlx/query-5f7d837ff17893ba5aa8d004e2ba89d8a8da04b967f08bb1b10542c934f7c5de.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
36
.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json
generated
Normal file
36
.sqlx/query-dc09058128a7c72fa340f74b522fa111420a69e49170f6af47503b73fccbd9db.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
34
.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json
generated
Normal file
34
.sqlx/query-e002906973088f3105c0a1f4a572cf084534c0b1be339f371f256c8149358440.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
80
Cargo.lock
generated
80
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
14
migrations/20251107073144_create_users_table.sql
Normal file
14
migrations/20251107073144_create_users_table.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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<T> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)] // Added Serialize here
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Meta {
|
||||
pub total_pages: Option<i32>,
|
||||
|
|
@ -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<Account>,
|
||||
}
|
||||
|
||||
#[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<Vec<AccountIdentification>>,
|
||||
}
|
||||
|
||||
#[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<Balance>,
|
||||
}
|
||||
|
||||
#[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<Transaction>,
|
||||
}
|
||||
|
||||
#[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<BankTransactionCode>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
|
|
|
|||
8
src/auth.rs
Normal file
8
src/auth.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// src/auth.rs
|
||||
|
||||
pub mod handlers;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::Claims;
|
||||
135
src/auth/handlers.rs
Normal file
135
src/auth/handlers.rs
Normal file
|
|
@ -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<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> 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<serde_json::Value>)>((
|
||||
StatusCode::CREATED,
|
||||
Json(AuthResponse {
|
||||
token,
|
||||
username: user.username,
|
||||
bank_user_id: user.bank_user_id,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn login_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> 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<serde_json::Value>)>((
|
||||
StatusCode::OK,
|
||||
Json(AuthResponse {
|
||||
token,
|
||||
username: payload.username,
|
||||
bank_user_id,
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn me_handler(
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"user_id": claims.sub,
|
||||
"username": claims.username,
|
||||
"bank_user_id": claims.bank_user_id,
|
||||
}))
|
||||
)
|
||||
}
|
||||
43
src/auth/jwt.rs
Normal file
43
src/auth/jwt.rs
Normal file
|
|
@ -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<String, jsonwebtoken::errors::Error> {
|
||||
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<Claims, jsonwebtoken::errors::Error> {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "super_secret_key_change_in_production".to_string());
|
||||
|
||||
decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
}
|
||||
38
src/auth/middleware.rs
Normal file
38
src/auth/middleware.rs
Normal file
|
|
@ -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<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
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)
|
||||
}
|
||||
25
src/auth/password.rs
Normal file
25
src/auth/password.rs
Normal file
|
|
@ -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<String, argon2::password_hash::Error> {
|
||||
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<bool, argon2::password_hash::Error> {
|
||||
let parsed_hash = PasswordHash::new(hash)?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.map(|_| true)
|
||||
.or(Ok(false))
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
pub mod consents;
|
||||
pub mod accounts;
|
||||
pub mod transactions;
|
||||
pub mod users;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
|
|
|
|||
65
src/db/users.rs
Normal file
65
src/db/users.rs
Normal file
|
|
@ -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<User, sqlx::Error> {
|
||||
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<Option<(i32, String, String)>, 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<Option<User>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"
|
||||
SELECT id, username, bank_user_id
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ mod route;
|
|||
mod db;
|
||||
mod state;
|
||||
mod api;
|
||||
mod auth;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use crate::state::AppState;
|
||||
|
|
|
|||
34
src/route.rs
34
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
) -> 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<AppState>,
|
||||
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::<Bank>()
|
||||
.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<serde_json::Value>)>((
|
||||
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<AppState>,
|
||||
Path((bank_code, user_id)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let bank = bank_code.parse::<Bank>()
|
||||
.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<AppState>,
|
||||
Path((bank_code, user_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let bank = bank_code.parse::<Bank>()
|
||||
.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<u32>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn get_transactions_handler(
|
||||
State(state): State<AppState>,
|
||||
Path((bank_code, user_id, account_id)): Path<(String, String, String)>,
|
||||
Query(params): Query<TransactionQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let bank = bank_code.parse::<Bank>()
|
||||
.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<serde_json::Value>) {
|
||||
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() }))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue