Files
family_budget/backend/src/lib.rs
arrelin ccac6a4d2f fix: add auth layer to family protected routes
Fix authentication for /families/:id/members endpoint by adding
auth_layer instead of just session_layer to family_protected_routes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 17:28:31 +03:00

256 lines
11 KiB
Rust

use axum::{
routing::{get, post, put, delete},
Router, middleware as axum_middleware,
};
use sea_orm::{sqlx, Database, DatabaseConnection, DbErr};
use sea_orm_migration::prelude::*;
use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite};
use tower_sessions_sqlx_store::PostgresStore;
use axum_login::AuthManagerLayerBuilder;
use time::Duration;
use tower_http::cors::CorsLayer;
use axum::http::{Method, HeaderValue};
pub mod models;
pub mod services;
pub mod migration;
pub mod routes;
pub mod auth;
pub mod middleware;
pub use auth::AuthBackend;
pub use middleware::{require_admin, require_family_access};
#[derive(OpenApi)]
#[openapi(
paths(
routes::auth::login,
routes::auth::logout,
routes::auth::me,
routes::auth::family_login,
routes::oauth::google_auth,
routes::oauth::google_callback,
routes::family::create_family,
routes::family::create_my_family,
routes::family::get_family,
routes::family::get_all_families,
routes::family::update_family,
routes::family::delete_family,
routes::category::create_category,
routes::category::get_category,
routes::category::get_categories_by_family,
routes::category::update_category,
routes::category::delete_category,
routes::expense::create_expense,
routes::expense::get_expense,
routes::expense::get_expenses_by_category,
routes::expense::update_expense,
routes::expense::delete_expense,
routes::expense::get_remaining_limit,
routes::shopping_item::create_shopping_item,
routes::shopping_item::get_shopping_items_by_family,
routes::shopping_item::get_shopping_item,
routes::shopping_item::update_shopping_item,
routes::shopping_item::delete_shopping_item,
routes::shopping_item::mark_as_purchased,
routes::shopping_item::mark_all_as_purchased,
routes::shopping_item::clear_all,
routes::invite_link::create_invite_link,
routes::invite_link::get_my_invite_links,
routes::invite_link::delete_invite_link,
routes::invite_link::validate_invite_link,
routes::invite_link::join_family_via_invite,
routes::user::leave_family,
routes::user::get_family_members,
),
components(
schemas(
models::family::Model,
models::category::Model,
models::expense::Model,
models::shopping_item::Model,
routes::auth::LoginRequest,
routes::auth::LoginResponse,
routes::auth::MeResponse,
routes::auth::FamilyLoginRequest,
routes::auth::FamilyLoginResponse,
routes::oauth::OAuthUrlResponse,
routes::family::CreateFamilyRequest,
routes::family::CreateMyFamilyRequest,
routes::family::CreateMyFamilyResponse,
routes::family::UpdateFamilyRequest,
routes::category::CreateCategoryRequest,
routes::category::UpdateCategoryRequest,
routes::expense::CreateExpenseRequest,
routes::expense::UpdateExpenseRequest,
routes::expense::RemainingLimitResponse,
routes::shopping_item::CreateShoppingItemRequest,
routes::shopping_item::UpdateShoppingItemRequest,
routes::shopping_item::MarkAsPurchasedRequest,
routes::shopping_item::BulkOperationResponse,
models::invite_link::Model,
routes::invite_link::CreateInviteLinkRequest,
routes::invite_link::InviteLinkResponse,
routes::invite_link::ValidateInviteResponse,
routes::invite_link::JoinFamilyResponse,
routes::user::LeaveFamilyResponse,
routes::user::FamilyMember,
)
),
tags(
(name = "auth", description = "Authentication endpoints"),
(name = "families", description = "Family management endpoints"),
(name = "categories", description = "Category management endpoints"),
(name = "expenses", description = "Expense management endpoints"),
(name = "shopping-items", description = "Shopping list management endpoints"),
(name = "invite-links", description = "Family invite link management endpoints"),
(name = "user", description = "User profile management endpoints")
),
info(
title = "Family Budget API",
version = "0.1.0",
description = "REST API"
)
)]
struct ApiDoc;
pub async fn establish_connection() -> Result<DatabaseConnection, DbErr> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file");
Database::connect(&database_url).await
}
pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file");
let session_store = PostgresStore::new(
sqlx::PgPool::connect(&database_url)
.await
.expect("Failed to connect to database for sessions"),
);
session_store
.migrate()
.await
.expect("Failed to run session store migrations");
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(true)
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(Duration::days(7)));
let backend = auth::AuthBackend { db: db.clone() };
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer.clone()).build();
let admin_family_routes = Router::new()
.route("/families", post(routes::family::create_family))
.route("/families/:id", delete(routes::family::delete_family))
.route_layer(axum_middleware::from_fn(middleware::require_admin))
.layer(auth_layer.clone())
.with_state(db.clone());
let auth_routes = Router::new()
.route("/login", post(routes::auth::login))
.route("/logout", post(routes::auth::logout))
.route("/me", get(routes::auth::me))
.route("/me/leave-family", post(routes::user::leave_family))
.route("/my-family", post(routes::family::create_my_family))
.route("/auth/family-login", post(routes::auth::family_login))
.layer(auth_layer.clone())
.with_state(db.clone());
let oauth_routes = Router::new()
.route("/auth/google", get(routes::oauth::google_auth))
.route("/auth/google/callback", get(routes::oauth::google_callback))
.layer(auth_layer.clone())
.with_state(db.clone());
let invite_link_routes = Router::new()
.route("/my-family/invite-links", post(routes::invite_link::create_invite_link))
.route("/my-family/invite-links", get(routes::invite_link::get_my_invite_links))
.route("/my-family/invite-links/:token", delete(routes::invite_link::delete_invite_link))
.route("/invite/:token/join", post(routes::invite_link::join_family_via_invite))
.layer(auth_layer.clone())
.with_state(db.clone());
let family_protected_routes = Router::new()
.route("/families/:family_id/categories", post(routes::category::create_category))
.route("/families/:family_id/categories", get(routes::category::get_categories_by_family))
.route("/families/:family_id/categories/:category_id", get(routes::category::get_category))
.route("/families/:family_id/categories/:category_id", put(routes::category::update_category))
.route("/families/:family_id/categories/:category_id", delete(routes::category::delete_category))
.route("/families/:family_id/categories/:category_id/expenses", post(routes::expense::create_expense))
.route("/families/:family_id/categories/:category_id/expenses", get(routes::expense::get_expenses_by_category))
.route("/families/:family_id/categories/:category_id/expenses/:expense_id", get(routes::expense::get_expense))
.route("/families/:family_id/categories/:category_id/expenses/:expense_id", put(routes::expense::update_expense))
.route("/families/:family_id/categories/:category_id/expenses/:expense_id", delete(routes::expense::delete_expense))
.route("/families/:family_id/categories/:category_id/remaining", get(routes::expense::get_remaining_limit))
.route("/families/:family_id/shopping-items", post(routes::shopping_item::create_shopping_item))
.route("/families/:family_id/shopping-items", get(routes::shopping_item::get_shopping_items_by_family))
.route("/families/:family_id/shopping-items/:id", get(routes::shopping_item::get_shopping_item))
.route("/families/:family_id/shopping-items/:id", put(routes::shopping_item::update_shopping_item))
.route("/families/:family_id/shopping-items/:id", delete(routes::shopping_item::delete_shopping_item))
.route("/families/:family_id/shopping-items/:id/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased))
.route("/families/:family_id/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased))
.route("/families/:family_id/shopping-items/clear-all", delete(routes::shopping_item::clear_all))
.route("/families/:family_id/members", get(routes::user::get_family_members))
.route_layer(axum_middleware::from_fn(middleware::require_family_access))
.layer(auth_layer.clone())
.with_state(db.clone());
let public_routes = Router::new()
.route("/families", get(routes::family::get_all_families))
.route("/families/:id", get(routes::family::get_family))
.route("/families/:id", put(routes::family::update_family))
.route("/families/:id/verify", post(routes::family::verify_family_password))
.route("/invite/:token", get(routes::invite_link::validate_invite_link))
.layer(session_layer)
.with_state(db);
let api_routes = Router::new()
.merge(admin_family_routes)
.merge(auth_routes)
.merge(oauth_routes)
.merge(invite_link_routes)
.merge(family_protected_routes)
.merge(public_routes);
let swagger_ui = SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi());
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string());
let origins: Vec<HeaderValue> = allowed_origins
.split(',')
.filter_map(|origin| origin.trim().parse::<HeaderValue>().ok())
.collect();
let cors = CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::header::ACCEPT,
])
.allow_credentials(true);
let app = Router::new()
.nest("/api", api_routes)
.merge(swagger_ui)
.layer(cors);
Ok(app)
}
pub fn server_address() -> SocketAddr {
SocketAddr::from(([0, 0, 0, 0], 8080))
}