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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
argon2 = "0.6.0"
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||||
mongodb = "3.10.0"
|
mongodb = "3.10.0"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
serde_json = "1.0.154"
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
|
time = "0.3.55"
|
||||||
tokio = { version = "1.54.1", features = ["full"] }
|
tokio = { version = "1.54.1", features = ["full"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
uuid = { version = "1.29.2", features = ["v7"] }
|
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")
|
### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
|
||||||
GET {{baseUrl}}/health
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub mongodb_uri: String,
|
pub mongodb_uri: String,
|
||||||
#[allow(dead_code)] // Faz 1 – Adım 3'te (JWT) kullanılacak
|
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub app_url: String,
|
pub app_url: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -49,7 +48,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
|
/// 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 {
|
pub fn is_https(&self) -> bool {
|
||||||
self.app_url.starts_with("https://")
|
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(|_| ())
|
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.
|
/// Açılışta çalışır: bekleyen migration'lar, ardından indeksler.
|
||||||
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
|
/// İkisi de tekrar çalıştırılabilir (idempotent); zaten yapılmış olanı atlar.
|
||||||
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
|
pub async fn prepare(db: &Database) -> mongodb::error::Result<()> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[allow(dead_code)] // NotFound/Forbidden: Faz 1 – Adım 4-5'te kullanılacak
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("Kayıt bulunamadı")]
|
#[error("Kayıt bulunamadı")]
|
||||||
@@ -16,6 +17,8 @@ pub enum AppError {
|
|||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
#[error("Oturum açmanız gerekiyor")]
|
#[error("Oturum açmanız gerekiyor")]
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
|
#[error("E-posta veya şifre hatalı")]
|
||||||
|
InvalidCredentials,
|
||||||
#[error("Bu işlem için yetkiniz yok")]
|
#[error("Bu işlem için yetkiniz yok")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -34,6 +37,7 @@ impl AppError {
|
|||||||
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
|
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
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>>`
|
/// Route fonksiyonlarında kısa dönüş tipi: `AppResult<Json<T>>`
|
||||||
pub type AppResult<T> = Result<T, AppError>;
|
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 config;
|
||||||
mod db;
|
mod db;
|
||||||
#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak
|
|
||||||
mod error;
|
mod error;
|
||||||
|
mod extract;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod services;
|
||||||
mod state;
|
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 tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::{config::Config, state::AppState};
|
use crate::{
|
||||||
|
auth::{jwt::Jwt, limiter::LoginLimiter},
|
||||||
|
config::Config,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
@@ -53,6 +59,9 @@ async fn main() -> ExitCode {
|
|||||||
let port = config.port;
|
let port = config.port;
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
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),
|
config: Arc::new(config),
|
||||||
};
|
};
|
||||||
let app = routes::router(state);
|
let app = routes::router(state);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
//! Her kayıt şu alanları taşır:
|
//! Her kayıt şu alanları taşır:
|
||||||
//! `_id` (UUID v7), `household_id`, `created_at`, `updated_at`, `deleted_at`, `version`
|
//! `_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};
|
use mongodb::bson::{DateTime, Document, Uuid, doc};
|
||||||
|
|
||||||
/// Yeni UUID v7 üretir. İlk 48 bit zaman damgası olduğu için
|
/// 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 }
|
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.
|
/// Güncellemelerde eklenecek ortak kısım: `updated_at` yenilenir, `version` +1.
|
||||||
pub fn touch() -> Document {
|
pub fn touch() -> Document {
|
||||||
doc! {
|
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`
|
//! Şema: `docs/DATA_MODEL.md`
|
||||||
|
|
||||||
pub mod common;
|
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.
|
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||||
|
|
||||||
|
mod auth;
|
||||||
mod health;
|
mod health;
|
||||||
|
|
||||||
use axum::{Router, routing::get};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router {
|
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)
|
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 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.
|
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
#[allow(dead_code)] // Faz 1 – Adım 3'te kullanılacak
|
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
|
pub jwt: Arc<Jwt>,
|
||||||
|
pub login_limiter: Arc<LoginLimiter>,
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 1 – Adım 3:** Kimlik doğrulama: `POST /api/auth/register` (kullanıcı + hane + örnek kategori/ödeme türü, transaction), `login`, `logout`, `GET /api/auth/me`; argon2, JWT httpOnly cookie, giriş rate limit.
|
**Faz 1 – Adım 4:** Hane izolasyonu (AuthUser'ın tüm hane route'larında zorunlu kullanımı, `scope()`), üye davet kodu: `POST /api/household/invites` (yalnızca owner), `POST /api/auth/register` ile `invite_code` → mevcut haneye `member` olarak katılma; `GET /api/household` üye listesi.
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
- Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır.
|
- Veri modeli v1 onaylandı (2026-11-11): ölçekli tam sayı (`_e6`), tarih `YYYY-MM-DD` metin, dönem bitişi hariç, kullanıcı = tek hane, ayarlar haneye gömülü, haftalık bütçe hesaplanır.
|
||||||
- Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir.
|
- Model yaklaşımı: `#[serde(flatten)]` yerine ortak alanlar `models/common.rs` yardımcılarıyla (`new_meta`, `scope`, `touch`). Modeller ilgili adımda eklenir.
|
||||||
- `households.household_id = _id` (scope filtresi her yerde aynı).
|
- `households.household_id = _id` (scope filtresi her yerde aynı).
|
||||||
|
- Auth: JWT (HS256, jsonwebtoken `rust_crypto`) `kakeibo_session` cookie'de; HttpOnly, SameSite=Lax, Secure yalnızca APP_URL https ise. Beni hatırla → 30 gün Max-Age; değilse 12 saat JWT + oturum cookie'si. Kayıttan sonra 30 gün.
|
||||||
|
- Giriş sınırı: e-posta başına 15 dk'da 5 hatalı deneme (bellek içi, `auth/limiter.rs`). Olmayan kullanıcıda da sahte argon2 doğrulaması (zamanlama sızıntısı yok).
|
||||||
|
- Hata kodları: `{error:{code,message}}`; `invalid_credentials`, `unauthorized`, `bad_request`, `conflict`, `too_many_requests`.
|
||||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7.
|
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7.
|
||||||
|
|
||||||
## ❓ Açık Konular
|
## ❓ Açık Konular
|
||||||
@@ -32,3 +35,4 @@
|
|||||||
### 2026-11-11 — Faz 1
|
### 2026-11-11 — Faz 1
|
||||||
- **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti.
|
- **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti.
|
||||||
- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi.
|
- **Adım 2:** `db/` modülü: `col` sabitleri, `migrations.rs` (`schema_migrations`, v1 = koleksiyonları oluştur), `indexes.rs` (DATA_MODEL §4 + tüm hane koleksiyonlarına `(household_id, updated_at)`). `models/common.rs`: UUID v7, `new_meta`, `scope`, `touch`. Açılışta `db::prepare`. 4 birim testi; idempotency ve tek açık dönem indeksi elle doğrulandı. `uuid` (v7) eklendi.
|
||||||
|
- **Adım 3:** Kimlik doğrulama: `auth/` (password argon2id + spawn_blocking, jwt, limiter, session cookie + `AuthUser` extractor), `extract.rs` (`ValidJson` = JSON + validator, Türkçe 400), `services/account.rs` (kayıt tek transaction: hane + owner + 13 kategori + 4 ödeme türü), `services/seed.rs`, `routes/auth.rs` (register/login/logout/me). Yeni crate'ler: argon2, jsonwebtoken, axum-extra(cookie), validator, time. 11 birim testi; 13 senaryo curl ile, rollback DB'de doğrulandı.
|
||||||
|
|||||||
+2
-2
@@ -27,6 +27,6 @@
|
|||||||
## Faz 1 Adımları
|
## Faz 1 Adımları
|
||||||
- ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu
|
- ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu
|
||||||
- ✅ 2. Modeller, migration mekanizması ve indeksler
|
- ✅ 2. Modeller, migration mekanizması ve indeksler
|
||||||
- 🔄 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
|
- ✅ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
|
||||||
- ⏳ 4. Hane izolasyonu (middleware), üye davet kodu
|
- 🔄 4. Hane izolasyonu (middleware), üye davet kodu
|
||||||
- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`
|
- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`
|
||||||
|
|||||||
Reference in New Issue
Block a user