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
+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.
mod auth;
mod health;
use axum::{Router, routing::get};
use axum::{
Router,
routing::{get, post},
};
use crate::state::AppState;
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)
}