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:
2026-11-11 23:37:52 +03:00
parent 4103b90e4b
commit d6136959b1
27 changed files with 1573 additions and 32 deletions
+672 -18
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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"] }
+30
View File
@@ -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
View File
+104
View File
@@ -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());
}
}
+78
View File
@@ -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"));
}
}
+8
View File
@@ -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;
+76
View File
@@ -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()
);
}
}
+65
View File
@@ -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,
})
}
}
-2
View File
@@ -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://")
} }
+14
View File
@@ -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<()> {
+17
View File
@@ -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>;
+31
View File
@@ -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
View File
@@ -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);
+1 -2
View File
@@ -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! {
+19
View File
@@ -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
View File
@@ -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;
+32
View File
@@ -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,
}
+210
View File
@@ -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,
},
})
}
+11 -2
View File
@@ -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)
} }
View File
+94
View File
@@ -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,
})
}
+4
View File
@@ -0,0 +1,4 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod seed;
+75
View File
@@ -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));
}
}
}
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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`