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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user