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
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,
})
}
}