77d7a081b3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
3.8 KiB
Rust
109 lines
3.8 KiB
Rust
//! Oturum cookie'si ve `AuthUser` extractor'ı.
|
||
//!
|
||
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce:
|
||
//! 1. cookie'deki JWT'nin imzasını ve süresini doğrular,
|
||
//! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder
|
||
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur),
|
||
//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`).
|
||
//!
|
||
//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner.
|
||
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
|
||
//! asla istekten (URL/gövde) almaz.
|
||
|
||
use std::time::Duration;
|
||
|
||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||
use mongodb::bson::{Uuid, doc};
|
||
use serde::Deserialize;
|
||
|
||
use crate::{db::col, 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,
|
||
/// Veritabanındaki güncel rol (JWT'deki değil)
|
||
pub role: Role,
|
||
}
|
||
|
||
impl AuthUser {
|
||
/// Yalnızca hane sahibinin yapabileceği işlemler için.
|
||
pub fn require_owner(&self) -> Result<(), AppError> {
|
||
match self.role {
|
||
Role::Owner => Ok(()),
|
||
Role::Member => Err(AppError::Forbidden),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct RoleOnly {
|
||
role: Role,
|
||
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
|
||
#[serde(default)]
|
||
sessions_valid_from: Option<i64>,
|
||
}
|
||
|
||
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);
|
||
let user_id = parse(&claims.sub)?;
|
||
let household_id = parse(&claims.hid)?;
|
||
|
||
let current = state
|
||
.db
|
||
.collection::<RoleOnly>(col::USERS)
|
||
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
|
||
.projection(doc! { "role": 1, "sessions_valid_from": 1 })
|
||
.await?
|
||
.ok_or(AppError::Unauthorized)?;
|
||
if current
|
||
.sessions_valid_from
|
||
.is_some_and(|from| (claims.iat as i64) < from)
|
||
{
|
||
return Err(AppError::Unauthorized);
|
||
}
|
||
|
||
Ok(Self {
|
||
user_id,
|
||
household_id,
|
||
role: current.role,
|
||
})
|
||
}
|
||
}
|