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