Files
kakeibo/backend/src/auth/session.rs
T

109 lines
3.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
})
}
}