feat(auth): kayıt, giriş, çıkış ve oturum (argon2 + JWT httpOnly cookie)
- Kayıt: kullanıcı + hane + örnek kategori/ödeme türü tek transaction'da - Giriş: beni hatırla (30 gün / oturum), e-posta başına deneme sınırı - AuthUser extractor: cookie'deki JWT'yi doğrular - ValidJson: JSON + doğrulama, Türkçe hata mesajları - api.http: auth istekleri eklendi - Birim testleri: şifre, JWT, deneme sınırı, seed (11 test) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+672
-18
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,18 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.6.0"
|
||||
axum = "0.8.9"
|
||||
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||
dotenvy = "0.15.7"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||
mongodb = "3.10.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
time = "0.3.55"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
uuid = { version = "1.29.2", features = ["v7"] }
|
||||
validator = { version = "0.21.0", features = ["derive"] }
|
||||
|
||||
@@ -5,3 +5,33 @@
|
||||
|
||||
### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
|
||||
GET {{baseUrl}}/health
|
||||
|
||||
# ─────────────────────────── Kimlik doğrulama ───────────────────────────
|
||||
# REST Client cookie'leri otomatik saklar: kayıt/giriş sonrası diğer istekler oturumlu gider.
|
||||
|
||||
### Kayıt (kullanıcı + hane + örnek kategoriler/ödeme türleri oluşur)
|
||||
POST {{baseUrl}}/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Suphi",
|
||||
"email": "suphi@ornek.com",
|
||||
"password": "gizli-sifre-123",
|
||||
"household_name": "Çakır Hanesi"
|
||||
}
|
||||
|
||||
### Giriş ("remember_me": true → 30 gün, false → tarayıcı kapanınca biter)
|
||||
POST {{baseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "suphi@ornek.com",
|
||||
"password": "gizli-sifre-123",
|
||||
"remember_me": true
|
||||
}
|
||||
|
||||
### Ben kimim? (oturum yoksa 401)
|
||||
GET {{baseUrl}}/auth/me
|
||||
|
||||
### Çıkış
|
||||
POST {{baseUrl}}/auth/logout
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Oturum token'ı (JWT, HS256).
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::user::Role,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Kullanıcı kimliği
|
||||
pub sub: String,
|
||||
/// Hane kimliği
|
||||
pub hid: String,
|
||||
pub role: Role,
|
||||
pub iat: u64,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
pub struct Jwt {
|
||||
enc: EncodingKey,
|
||||
dec: DecodingKey,
|
||||
}
|
||||
|
||||
impl Jwt {
|
||||
pub fn new(secret: &str) -> Self {
|
||||
Self {
|
||||
enc: EncodingKey::from_secret(secret.as_bytes()),
|
||||
dec: DecodingKey::from_secret(secret.as_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
household_id: Uuid,
|
||||
role: Role,
|
||||
ttl: Duration,
|
||||
) -> AppResult<String> {
|
||||
let now = unix_now();
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
hid: household_id.to_string(),
|
||||
role,
|
||||
iat: now,
|
||||
exp: now + ttl.as_secs(),
|
||||
};
|
||||
encode(&Header::default(), &claims, &self.enc)
|
||||
.map_err(|e| AppError::Internal(format!("JWT üretilemedi: {e}")))
|
||||
}
|
||||
|
||||
/// İmza ve süre geçerliyse claim'leri döner.
|
||||
pub fn verify(&self, token: &str) -> Option<Claims> {
|
||||
decode::<Claims>(token, &self.dec, &Validation::default())
|
||||
.ok()
|
||||
.map(|t| t.claims)
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::common::new_id;
|
||||
|
||||
const SECRET: &str = "test-secret-en-az-32-karakter-uzunlugunda!!";
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let jwt = Jwt::new(SECRET);
|
||||
let (uid, hid) = (new_id(), new_id());
|
||||
let token = jwt
|
||||
.issue(uid, hid, Role::Owner, Duration::from_secs(60))
|
||||
.unwrap();
|
||||
let c = jwt.verify(&token).unwrap();
|
||||
assert_eq!(c.sub, uid.to_string());
|
||||
assert_eq!(c.hid, hid.to_string());
|
||||
assert_eq!(c.role, Role::Owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_secret_and_garbage() {
|
||||
let token = Jwt::new(SECRET)
|
||||
.issue(new_id(), new_id(), Role::Member, Duration::from_secs(60))
|
||||
.unwrap();
|
||||
assert!(
|
||||
Jwt::new("baska-bir-gizli-anahtar-32-karakterden-uzun")
|
||||
.verify(&token)
|
||||
.is_none()
|
||||
);
|
||||
assert!(Jwt::new(SECRET).verify("rastgele.metin.token").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Basit, bellek içi giriş deneme sınırlayıcı.
|
||||
//!
|
||||
//! Anahtar: e-posta adresi. Pencere içinde `max_failures` hatalı denemeden sonra
|
||||
//! pencere dolana kadar giriş engellenir. Başarılı girişte sayaç sıfırlanır.
|
||||
//! (Tek sunucu için yeterli; sunucu yeniden başlarsa sayaçlar sıfırlanır.)
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct LoginLimiter {
|
||||
attempts: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
max_failures: u32,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl LoginLimiter {
|
||||
pub fn new(max_failures: u32, window: Duration) -> Self {
|
||||
Self {
|
||||
attempts: Mutex::new(HashMap::new()),
|
||||
max_failures,
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bu anahtar için giriş denemesine izin var mı?
|
||||
pub fn is_allowed(&self, key: &str) -> bool {
|
||||
let map = self.attempts.lock().unwrap();
|
||||
match map.get(key) {
|
||||
Some((count, since)) => *count < self.max_failures || since.elapsed() >= self.window,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(&self, key: &str) {
|
||||
let mut map = self.attempts.lock().unwrap();
|
||||
// Süresi dolmuş kayıtları temizle (bellek büyümesin)
|
||||
map.retain(|_, (_, since)| since.elapsed() < self.window);
|
||||
let entry = map.entry(key.to_string()).or_insert((0, Instant::now()));
|
||||
entry.0 += 1;
|
||||
}
|
||||
|
||||
pub fn reset(&self, key: &str) {
|
||||
self.attempts.lock().unwrap().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_after_max_failures_and_resets() {
|
||||
let l = LoginLimiter::new(3, Duration::from_secs(60));
|
||||
for _ in 0..3 {
|
||||
assert!(l.is_allowed("a@b.c"));
|
||||
l.record_failure("a@b.c");
|
||||
}
|
||||
assert!(!l.is_allowed("a@b.c"), "3 hatadan sonra engellenmeli");
|
||||
assert!(
|
||||
l.is_allowed("baska@b.c"),
|
||||
"diğer kullanıcılar etkilenmemeli"
|
||||
);
|
||||
l.reset("a@b.c");
|
||||
assert!(l.is_allowed("a@b.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unblocks_after_window() {
|
||||
let l = LoginLimiter::new(1, Duration::from_millis(20));
|
||||
l.record_failure("x");
|
||||
assert!(!l.is_allowed("x"));
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
assert!(l.is_allowed("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Kimlik doğrulama: şifre hash'leme, JWT, oturum cookie'si, giriş deneme sınırı.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod limiter;
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
|
||||
pub use session::AuthUser;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Argon2id ile şifre hash'leme.
|
||||
//!
|
||||
//! Hash'leme bilerek yavaştır (kaba kuvvet saldırısını zorlaştırmak için), bu yüzden
|
||||
//! async sunucuyu bloklamasın diye `spawn_blocking` ile ayrı bir iş parçacığında çalışır.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
|
||||
};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
fn hash_sync(password: &str) -> AppResult<String> {
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes())
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| AppError::Internal(format!("şifre hash'lenemedi: {e}")))
|
||||
}
|
||||
|
||||
fn verify_sync(password: &str, hash: &str) -> bool {
|
||||
PasswordHash::new(hash)
|
||||
.map(|h| {
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &h)
|
||||
.is_ok()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Kullanıcı bulunamadığında da aynı sürede yanıt vermek için kullanılan sahte hash.
|
||||
/// (Aksi halde yanıt süresinden "bu e-posta kayıtlı mı" anlaşılabilir.)
|
||||
static DUMMY_HASH: LazyLock<String> =
|
||||
LazyLock::new(|| hash_sync("kakeibo-dummy-password").expect("dummy hash"));
|
||||
|
||||
pub async fn hash(password: String) -> AppResult<String> {
|
||||
tokio::task::spawn_blocking(move || hash_sync(&password))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("hash görevi: {e}")))?
|
||||
}
|
||||
|
||||
/// `hash` `None` ise sahte hash ile doğrulama yapılır ve her zaman `false` döner.
|
||||
pub async fn verify(password: String, hash: Option<String>) -> bool {
|
||||
tokio::task::spawn_blocking(move || match hash {
|
||||
Some(h) => verify_sync(&password, &h),
|
||||
None => {
|
||||
let _ = verify_sync(&password, &DUMMY_HASH);
|
||||
false
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify() {
|
||||
let h = hash_sync("gizli-sifre-123").unwrap();
|
||||
assert!(h.starts_with("$argon2id$"));
|
||||
assert!(verify_sync("gizli-sifre-123", &h));
|
||||
assert!(!verify_sync("yanlis-sifre", &h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_password_different_hashes() {
|
||||
// Her hash'te rastgele "tuz" (salt) kullanılır
|
||||
assert_ne!(
|
||||
hash_sync("abc12345").unwrap(),
|
||||
hash_sync("abc12345").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Oturum cookie'si ve `AuthUser` extractor'ı.
|
||||
//!
|
||||
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce
|
||||
//! cookie'deki JWT'yi doğrular; geçersizse route hiç çalışmaz, 401 döner.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use mongodb::bson::Uuid;
|
||||
|
||||
use crate::{error::AppError, models::user::Role, state::AppState};
|
||||
|
||||
pub const COOKIE_NAME: &str = "kakeibo_session";
|
||||
|
||||
/// "Beni hatırla" seçiliyse oturum süresi
|
||||
pub const REMEMBER_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
/// "Beni hatırla" seçili değilse (tarayıcı kapanınca da cookie silinir)
|
||||
pub const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
|
||||
|
||||
pub fn ttl(remember: bool) -> Duration {
|
||||
if remember { REMEMBER_TTL } else { SESSION_TTL }
|
||||
}
|
||||
|
||||
pub fn session_cookie(token: String, remember: bool, secure: bool) -> Cookie<'static> {
|
||||
let mut c = Cookie::build((COOKIE_NAME, token))
|
||||
.http_only(true) // JavaScript okuyamaz → XSS ile çalınamaz
|
||||
.secure(secure) // yalnızca HTTPS'te gönderilir (canlıda)
|
||||
.same_site(SameSite::Lax) // başka sitelerden gelen POST'larda gönderilmez
|
||||
.path("/");
|
||||
if remember {
|
||||
c = c.max_age(time::Duration::seconds(REMEMBER_TTL.as_secs() as i64));
|
||||
}
|
||||
c.build()
|
||||
}
|
||||
|
||||
pub fn removal_cookie() -> Cookie<'static> {
|
||||
Cookie::build((COOKIE_NAME, "")).path("/").build()
|
||||
}
|
||||
|
||||
/// Oturum açmış kullanıcı (JWT'den).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: Uuid,
|
||||
pub household_id: Uuid,
|
||||
#[allow(dead_code)] // Faz 1 – Adım 4'te (davet, yetki kontrolü) kullanılacak
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, AppError> {
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
let token = jar.get(COOKIE_NAME).ok_or(AppError::Unauthorized)?.value();
|
||||
let claims = state.jwt.verify(token).ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let parse = |s: &str| Uuid::parse_str(s).map_err(|_| AppError::Unauthorized);
|
||||
Ok(Self {
|
||||
user_id: parse(&claims.sub)?,
|
||||
household_id: parse(&claims.hid)?,
|
||||
role: claims.role,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use std::env;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub mongodb_uri: String,
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te (JWT) kullanılacak
|
||||
pub jwt_secret: String,
|
||||
pub app_url: String,
|
||||
pub port: u16,
|
||||
@@ -49,7 +48,6 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te (kimlik doğrulama) kullanılacak
|
||||
pub fn is_https(&self) -> bool {
|
||||
self.app_url.starts_with("https://")
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
|
||||
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Benzersiz indeks ihlali mi? (ör. aynı e-postayla ikinci kayıt — hata kodu 11000)
|
||||
pub fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
|
||||
use mongodb::error::{ErrorKind, WriteFailure};
|
||||
match e.kind.as_ref() {
|
||||
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
|
||||
ErrorKind::Command(ce) => ce.code == 11000,
|
||||
ErrorKind::InsertMany(ime) => ime
|
||||
.write_errors
|
||||
.as_ref()
|
||||
.is_some_and(|errs| errs.iter().any(|we| we.code == 11000)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
|
||||
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
|
||||
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[allow(dead_code)] // NotFound/Forbidden: Faz 1 – Adım 4-5'te kullanılacak
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Kayıt bulunamadı")]
|
||||
@@ -16,6 +17,8 @@ pub enum AppError {
|
||||
BadRequest(String),
|
||||
#[error("Oturum açmanız gerekiyor")]
|
||||
Unauthorized,
|
||||
#[error("E-posta veya şifre hatalı")]
|
||||
InvalidCredentials,
|
||||
#[error("Bu işlem için yetkiniz yok")]
|
||||
Forbidden,
|
||||
#[error("{0}")]
|
||||
@@ -34,6 +37,7 @@ impl AppError {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
@@ -64,5 +68,18 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Doğrulama hatası → ilk alanın Türkçe mesajıyla 400.
|
||||
impl From<validator::ValidationErrors> for AppError {
|
||||
fn from(errors: validator::ValidationErrors) -> Self {
|
||||
let message = errors
|
||||
.field_errors()
|
||||
.values()
|
||||
.flat_map(|list| list.iter())
|
||||
.find_map(|e| e.message.as_ref().map(|m| m.to_string()))
|
||||
.unwrap_or_else(|| "Girilen bilgiler geçersiz".into());
|
||||
AppError::BadRequest(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Route fonksiyonlarında kısa dönüş tipi: `AppResult<Json<T>>`
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Özel extractor'lar.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRequest, Request},
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// `Json<T>` gibi çalışır, ek olarak `validator` kurallarını da kontrol eder.
|
||||
/// Hatalı JSON veya geçersiz alanlarda Türkçe 400 hatası döner.
|
||||
pub struct ValidJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for ValidJson<T>
|
||||
where
|
||||
T: DeserializeOwned + Validate,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, AppError> {
|
||||
let Json(value) = Json::<T>::from_request(req, state).await.map_err(|e| {
|
||||
tracing::debug!(error = %e, "JSON okunamadı");
|
||||
AppError::BadRequest("Gönderilen veri okunamadı veya eksik alan var".into())
|
||||
})?;
|
||||
value.validate()?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -1,16 +1,22 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak
|
||||
mod error;
|
||||
mod extract;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod state;
|
||||
|
||||
use std::{net::SocketAddr, process::ExitCode, sync::Arc};
|
||||
use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration};
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::{config::Config, state::AppState};
|
||||
use crate::{
|
||||
auth::{jwt::Jwt, limiter::LoginLimiter},
|
||||
config::Config,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
@@ -53,6 +59,9 @@ async fn main() -> ExitCode {
|
||||
let port = config.port;
|
||||
let state = AppState {
|
||||
db,
|
||||
jwt: Arc::new(Jwt::new(&config.jwt_secret)),
|
||||
// Aynı e-postaya 15 dakikada en fazla 5 hatalı giriş denemesi
|
||||
login_limiter: Arc::new(LoginLimiter::new(5, Duration::from_secs(15 * 60))),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
let app = routes::router(state);
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! Her kayıt şu alanları taşır:
|
||||
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
|
||||
|
||||
#![allow(dead_code)] // Faz 1 – Adım 3 itibarıyla kullanılacak
|
||||
|
||||
use mongodb::bson::{DateTime, Document, Uuid, doc};
|
||||
|
||||
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
|
||||
@@ -38,6 +36,7 @@ pub fn scope(household_id: Uuid) -> Document {
|
||||
doc! { "household_id": household_id, "deleted_at": null }
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Faz 1 – Adım 5'te (kategori güncelleme) kullanılacak
|
||||
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
|
||||
pub fn touch() -> Document {
|
||||
doc! {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! `households` koleksiyonu (DATA_MODEL.md §3.2).
|
||||
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Veritabanından okunan hane (yalnızca ihtiyaç duyulan alanlar).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Household {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Yeni hane için varsayılan ayarlar.
|
||||
pub fn default_settings() -> Document {
|
||||
doc! {
|
||||
"currency": "TRY",
|
||||
"budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 },
|
||||
"budget_suggestion": "default",
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,5 @@
|
||||
//! Şema: `docs/DATA_MODEL.md`
|
||||
|
||||
pub mod common;
|
||||
pub mod household;
|
||||
pub mod user;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! `users` koleksiyonu (DATA_MODEL.md §3.1).
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Owner,
|
||||
Member,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Role::Owner => "owner",
|
||||
Role::Member => "member",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Veritabanından okunan kullanıcı (yalnızca ihtiyaç duyulan alanlar).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct User {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub household_id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub name: String,
|
||||
pub role: Role,
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Kimlik doğrulama uç noktaları:
|
||||
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use mongodb::bson::{Uuid, doc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthUser, password,
|
||||
session::{removal_cookie, session_cookie, ttl},
|
||||
},
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::{
|
||||
common::{now, scope},
|
||||
household::Household,
|
||||
user::{Role, User},
|
||||
},
|
||||
services::account::{self, NewAccount},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ---------- İstek / yanıt yapıları ----------
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct RegisterRequest {
|
||||
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
||||
name: String,
|
||||
#[validate(email(message = "Geçerli bir e-posta adresi girin"))]
|
||||
email: String,
|
||||
#[validate(length(min = 8, max = 128, message = "Şifre en az 8 karakter olmalı"))]
|
||||
password: String,
|
||||
#[validate(length(max = 80, message = "Hane adı en fazla 80 karakter olabilir"))]
|
||||
household_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(length(min = 1, message = "E-posta gerekli"))]
|
||||
email: String,
|
||||
#[validate(length(min = 1, message = "Şifre gerekli"))]
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
remember_me: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MeResponse {
|
||||
user: UserDto,
|
||||
household: HouseholdDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserDto {
|
||||
id: String,
|
||||
name: String,
|
||||
email: String,
|
||||
role: Role,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HouseholdDto {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ---------- Uç noktalar ----------
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
ValidJson(req): ValidJson<RegisterRequest>,
|
||||
) -> AppResult<(StatusCode, CookieJar, Json<MeResponse>)> {
|
||||
let name = req.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("Ad boş olamaz".into()));
|
||||
}
|
||||
let email = normalize_email(&req.email);
|
||||
let household_name = req
|
||||
.household_name
|
||||
.map(|h| h.trim().to_string())
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_else(|| format!("{name} Hanesi"));
|
||||
|
||||
let password_hash = password::hash(req.password).await?;
|
||||
let created = account::register(
|
||||
&state.db,
|
||||
NewAccount {
|
||||
name,
|
||||
email,
|
||||
password_hash,
|
||||
household_name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let token = state.jwt.issue(
|
||||
created.user_id,
|
||||
created.household_id,
|
||||
Role::Owner,
|
||||
ttl(true),
|
||||
)?;
|
||||
let jar = jar.add(session_cookie(token, true, state.config.is_https()));
|
||||
|
||||
let me = load_me(&state, created.user_id, created.household_id).await?;
|
||||
Ok((StatusCode::CREATED, jar, Json(me)))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
ValidJson(req): ValidJson<LoginRequest>,
|
||||
) -> AppResult<(CookieJar, Json<MeResponse>)> {
|
||||
let email = normalize_email(&req.email);
|
||||
|
||||
if !state.login_limiter.is_allowed(&email) {
|
||||
tracing::warn!(%email, "giriş deneme sınırı aşıldı");
|
||||
return Err(AppError::TooManyRequests);
|
||||
}
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.collection::<User>(col::USERS)
|
||||
.find_one(doc! { "email": &email, "deleted_at": null })
|
||||
.await?;
|
||||
|
||||
let hash = user.as_ref().map(|u| u.password_hash.clone());
|
||||
if !password::verify(req.password, hash).await {
|
||||
state.login_limiter.record_failure(&email);
|
||||
return Err(AppError::InvalidCredentials);
|
||||
}
|
||||
// verify() true döndüyse kullanıcı kesin vardır
|
||||
let user = user.ok_or(AppError::InvalidCredentials)?;
|
||||
state.login_limiter.reset(&email);
|
||||
|
||||
state
|
||||
.db
|
||||
.collection::<User>(col::USERS)
|
||||
.update_one(
|
||||
doc! { "_id": user.id },
|
||||
doc! { "$set": { "last_login_at": now() } },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let token = state
|
||||
.jwt
|
||||
.issue(user.id, user.household_id, user.role, ttl(req.remember_me))?;
|
||||
let jar = jar.add(session_cookie(
|
||||
token,
|
||||
req.remember_me,
|
||||
state.config.is_https(),
|
||||
));
|
||||
|
||||
let me = load_me(&state, user.id, user.household_id).await?;
|
||||
Ok((jar, Json(me)))
|
||||
}
|
||||
|
||||
pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) {
|
||||
(StatusCode::NO_CONTENT, jar.remove(removal_cookie()))
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<Json<MeResponse>> {
|
||||
load_me(&state, auth.user_id, auth.household_id)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
// ---------- Yardımcılar ----------
|
||||
|
||||
fn normalize_email(email: &str) -> String {
|
||||
email.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Kullanıcı ve hanesini okur. Kullanıcı silinmişse oturum geçersiz sayılır.
|
||||
async fn load_me(state: &AppState, user_id: Uuid, household_id: Uuid) -> AppResult<MeResponse> {
|
||||
let mut user_filter = scope(household_id);
|
||||
user_filter.insert("_id", user_id);
|
||||
let user = state
|
||||
.db
|
||||
.collection::<User>(col::USERS)
|
||||
.find_one(user_filter)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let mut hh_filter = scope(household_id);
|
||||
hh_filter.insert("_id", household_id);
|
||||
let household = state
|
||||
.db
|
||||
.collection::<Household>(col::HOUSEHOLDS)
|
||||
.find_one(hh_filter)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
Ok(MeResponse {
|
||||
user: UserDto {
|
||||
id: user.id.to_string(),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
household: HouseholdDto {
|
||||
id: household_id.to_string(),
|
||||
name: household.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod auth;
|
||||
mod health;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let api = Router::new().route("/health", get(health::health));
|
||||
let api = Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/auth/register", post(auth::register))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me));
|
||||
|
||||
Router::new().nest("/api", api).with_state(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Hesap işlemleri: kayıt (kullanıcı + hane + başlangıç verileri).
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::{col, is_duplicate_key},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_id, new_meta},
|
||||
household::default_settings,
|
||||
user::Role,
|
||||
},
|
||||
services::seed,
|
||||
};
|
||||
|
||||
pub struct NewAccount {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub household_name: String,
|
||||
}
|
||||
|
||||
pub struct CreatedAccount {
|
||||
pub user_id: Uuid,
|
||||
pub household_id: Uuid,
|
||||
}
|
||||
|
||||
/// Kullanıcıyı, hanesini, örnek kategori ve ödeme türlerini **tek transaction** içinde oluşturur.
|
||||
/// Herhangi bir adım başarısız olursa hiçbir kayıt oluşmaz.
|
||||
pub async fn register(db: &Database, acc: NewAccount) -> AppResult<CreatedAccount> {
|
||||
let household_id = new_id();
|
||||
let user_id = new_id();
|
||||
|
||||
// Hanede household_id = _id (DATA_MODEL §3.2)
|
||||
let mut household = new_meta(household_id);
|
||||
household.insert("_id", household_id);
|
||||
household.insert("name", acc.household_name);
|
||||
household.insert("owner_id", user_id);
|
||||
household.insert("settings", default_settings());
|
||||
|
||||
let mut user = new_meta(household_id);
|
||||
user.insert("_id", user_id);
|
||||
user.insert("email", acc.email);
|
||||
user.insert("password_hash", acc.password_hash);
|
||||
user.insert("name", acc.name);
|
||||
user.insert("role", Role::Owner.as_str());
|
||||
user.insert("preferences", doc! { "theme": "system" });
|
||||
user.insert("last_login_at", Bson::Null);
|
||||
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
|
||||
let result: mongodb::error::Result<()> = async {
|
||||
let docs = |name| db.collection::<Document>(name);
|
||||
docs(col::HOUSEHOLDS)
|
||||
.insert_one(household)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
docs(col::USERS)
|
||||
.insert_one(user)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
docs(col::CATEGORIES)
|
||||
.insert_many(seed::categories(household_id))
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
docs(col::PAYMENT_METHODS)
|
||||
.insert_many(seed::payment_methods(household_id))
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
return Err(if is_duplicate_key(&e) {
|
||||
AppError::Conflict("Bu e-posta adresi zaten kayıtlı".into())
|
||||
} else {
|
||||
e.into()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreatedAccount {
|
||||
user_id,
|
||||
household_id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
|
||||
|
||||
pub mod account;
|
||||
pub mod seed;
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Yeni hane için örnek alt kategoriler ve ödeme türleri (DATA_MODEL.md §3.5, §3.6).
|
||||
|
||||
use mongodb::bson::{Document, Uuid};
|
||||
|
||||
use crate::models::common::new_meta;
|
||||
|
||||
/// (grup, ad, emoji)
|
||||
const CATEGORIES: &[(&str, &str, &str)] = &[
|
||||
("needs", "Market", "🛒"),
|
||||
("needs", "Kira", "🏠"),
|
||||
("needs", "Faturalar", "💡"),
|
||||
("needs", "Ulaşım", "🚌"),
|
||||
("wants", "Dışarıda yemek", "🍽️"),
|
||||
("wants", "Giyim", "👕"),
|
||||
("wants", "Eğlence", "🎉"),
|
||||
("culture", "Kitap", "📖"),
|
||||
("culture", "Eğitim", "🎓"),
|
||||
("culture", "Sinema/Konser", "🎬"),
|
||||
("unexpected", "Sağlık", "💊"),
|
||||
("unexpected", "Tamir", "🔧"),
|
||||
("unexpected", "Hediye", "🎁"),
|
||||
];
|
||||
|
||||
/// (ad, emoji)
|
||||
const PAYMENT_METHODS: &[(&str, &str)] = &[
|
||||
("Nakit", "💵"),
|
||||
("Kredi Kartı", "💳"),
|
||||
("Banka Kartı", "🏧"),
|
||||
("Havale/EFT", "🔁"),
|
||||
];
|
||||
|
||||
pub fn categories(household_id: Uuid) -> Vec<Document> {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (group, name, emoji))| {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("group", *group);
|
||||
d.insert("name", *name);
|
||||
d.insert("emoji", *emoji);
|
||||
d.insert("sort_order", (i as i32 + 1) * 10);
|
||||
d.insert("is_active", true);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn payment_methods(household_id: Uuid) -> Vec<Document> {
|
||||
PAYMENT_METHODS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, emoji))| {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("name", *name);
|
||||
d.insert("emoji", *emoji);
|
||||
d.insert("sort_order", (i as i32 + 1) * 10);
|
||||
d.insert("is_active", true);
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::common::new_id;
|
||||
|
||||
#[test]
|
||||
fn every_group_has_categories() {
|
||||
let docs = categories(new_id());
|
||||
for g in ["needs", "wants", "culture", "unexpected"] {
|
||||
assert!(docs.iter().any(|d| d.get_str("group").unwrap() == g));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ use std::sync::Arc;
|
||||
|
||||
use mongodb::Database;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::{
|
||||
auth::{jwt::Jwt, limiter::LoginLimiter},
|
||||
config::Config,
|
||||
};
|
||||
|
||||
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te kullanılacak
|
||||
pub config: Arc<Config>,
|
||||
pub jwt: Arc<Jwt>,
|
||||
pub login_limiter: Arc<LoginLimiter>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user