diff --git a/backend/src/lib.rs b/backend/src/lib.rs index abf06ea..acbb429 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -22,7 +22,7 @@ pub mod auth; pub mod middleware; pub use auth::AuthBackend; -pub use middleware::require_admin; +pub use middleware::{require_admin, require_family_access}; #[derive(OpenApi)] #[openapi( @@ -101,16 +101,16 @@ pub async fn create_app(db: DatabaseConnection) -> Result { let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) - .with_expiry(Expiry::OnInactivity(Duration::days(1))); + .with_expiry(Expiry::OnInactivity(Duration::days(7))); let backend = auth::AuthBackend { db: db.clone() }; - let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); + 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)) - .layer(auth_layer.clone()) .route_layer(axum_middleware::from_fn(middleware::require_admin)) + .layer(auth_layer.clone()) .with_state(db.clone()); let auth_routes = Router::new() @@ -119,10 +119,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .layer(auth_layer) .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)) + 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)) @@ -134,11 +131,22 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .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_layer(axum_middleware::from_fn(middleware::require_family_access)) + .layer(session_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)) + .layer(session_layer) .with_state(db); let api_routes = Router::new() .merge(admin_family_routes) .merge(auth_routes) + .merge(family_protected_routes) .merge(public_routes); let swagger_ui = SwaggerUi::new("/swagger-ui") @@ -148,6 +156,8 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .allow_origin([ "http://localhost:3000".parse::().unwrap(), "http://localhost:5173".parse::().unwrap(), + "http://localhost:5174".parse::().unwrap(), + "http://localhost:5175".parse::().unwrap(), "http://localhost:8080".parse::().unwrap(), ]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs index f5d4ea0..2a2299e 100644 --- a/backend/src/middleware.rs +++ b/backend/src/middleware.rs @@ -5,6 +5,7 @@ use axum::{ response::Response, }; use axum_login::AuthSession; +use tower_sessions::Session; use crate::auth::AuthBackend; @@ -21,3 +22,38 @@ pub async fn require_admin( Ok(next.run(request).await) } + +pub async fn require_family_access( + session: Session, + request: Request, + next: Next, +) -> Result { + let path = request.uri().path(); + + let family_id = extract_family_id_from_path(path) + .ok_or(StatusCode::BAD_REQUEST)?; + + let authorized_families: Vec = session + .get("authorized_families") + .await + .unwrap_or(None) + .unwrap_or_default(); + + if !authorized_families.contains(&family_id) { + return Err(StatusCode::FORBIDDEN); + } + + Ok(next.run(request).await) +} + +fn extract_family_id_from_path(path: &str) -> Option { + let segments: Vec<&str> = path.split('/').collect(); + + if let Some(families_idx) = segments.iter().position(|&s| s == "families") { + if families_idx + 1 < segments.len() { + return segments[families_idx + 1].parse::().ok(); + } + } + + None +} diff --git a/backend/src/migration/m20241215_000001_add_family_password.rs b/backend/src/migration/m20241215_000001_add_family_password.rs new file mode 100644 index 0000000..33c8c60 --- /dev/null +++ b/backend/src/migration/m20241215_000001_add_family_password.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Family::Table) + .add_column( + ColumnDef::new(Family::PasswordHash) + .string() + .not_null() + .default("") + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Family::Table) + .drop_column(Family::PasswordHash) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Family { + Table, + PasswordHash, +} diff --git a/backend/src/migration/mod.rs b/backend/src/migration/mod.rs index 60a373c..694f2bb 100644 --- a/backend/src/migration/mod.rs +++ b/backend/src/migration/mod.rs @@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*; mod m20241209_000001_create_tables; mod m20241209_000002_create_users; mod m20241209_000003_seed_admin; +mod m20241215_000001_add_family_password; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20241209_000001_create_tables::Migration), Box::new(m20241209_000002_create_users::Migration), Box::new(m20241209_000003_seed_admin::Migration), + Box::new(m20241215_000001_add_family_password::Migration), ] } } diff --git a/backend/src/models/family.rs b/backend/src/models/family.rs index b3c23b8..aec6206 100644 --- a/backend/src/models/family.rs +++ b/backend/src/models/family.rs @@ -8,6 +8,8 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub name: String, + #[serde(skip_serializing)] + pub password_hash: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/backend/src/routes/family.rs b/backend/src/routes/family.rs index 5c7fcfe..072ff08 100644 --- a/backend/src/routes/family.rs +++ b/backend/src/routes/family.rs @@ -4,16 +4,30 @@ use axum::{ Json, }; use sea_orm::DatabaseConnection; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use tower_sessions::Session; use crate::models::family::Model as FamilyModel; use crate::services::FamilyService; #[derive(Debug, Deserialize, ToSchema)] -#[schema(example = json!({"name": "Smith Family"}))] +#[schema(example = json!({"name": "Smith Family", "password": "secret123"}))] pub struct CreateFamilyRequest { pub name: String, + pub password: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[schema(example = json!({"password": "secret123"}))] +pub struct VerifyFamilyPasswordRequest { + pub password: String, +} + +#[derive(Debug, Serialize, ToSchema)] +#[schema(example = json!({"valid": true}))] +pub struct VerifyFamilyPasswordResponse { + pub valid: bool, } #[derive(Debug, Deserialize, ToSchema)] @@ -36,7 +50,7 @@ pub async fn create_family( State(db): State, Json(payload): Json, ) -> Result, StatusCode> { - FamilyService::create(&db, payload.name) + FamilyService::create(&db, payload.name, payload.password) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) @@ -129,3 +143,48 @@ pub async fn delete_family( .map(|_| StatusCode::NO_CONTENT) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } + +#[utoipa::path( + post, + path = "/families/{id}/verify", + tag = "families", + params( + ("id" = i32, Path, description = "Family ID") + ), + request_body = VerifyFamilyPasswordRequest, + responses( + (status = 200, description = "Password verified", body = VerifyFamilyPasswordResponse), + (status = 401, description = "Invalid password"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn verify_family_password( + State(db): State, + Path(id): Path, + session: Session, + Json(payload): Json, +) -> Result, StatusCode> { + let valid = FamilyService::verify_password(&db, id, payload.password) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if valid { + let mut authorized_families: Vec = session + .get("authorized_families") + .await + .unwrap_or(None) + .unwrap_or_default(); + + if !authorized_families.contains(&id) { + authorized_families.push(id); + session + .insert("authorized_families", authorized_families) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + + Ok(Json(VerifyFamilyPasswordResponse { valid: true })) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} diff --git a/backend/src/services/family_service.rs b/backend/src/services/family_service.rs index bd3cbab..22c89d6 100644 --- a/backend/src/services/family_service.rs +++ b/backend/src/services/family_service.rs @@ -1,18 +1,44 @@ use sea_orm::*; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString, PasswordVerifier, PasswordHash}, + Argon2, +}; use crate::models::family::{self, Entity as Family, Model as FamilyModel}; pub struct FamilyService; impl FamilyService { - pub async fn create(db: &DatabaseConnection, name: String) -> Result { + pub async fn create(db: &DatabaseConnection, name: String, password: String) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|_| DbErr::Custom("Failed to hash password".to_string()))? + .to_string(); + let family = family::ActiveModel { name: Set(name), + password_hash: Set(password_hash), ..Default::default() }; family.insert(db).await } + pub async fn verify_password(db: &DatabaseConnection, id: i32, password: String) -> Result { + let family = Family::find_by_id(id) + .one(db) + .await? + .ok_or(DbErr::RecordNotFound("Family not found".to_string()))?; + + let parsed_hash = PasswordHash::new(&family.password_hash) + .map_err(|_| DbErr::Custom("Invalid password hash".to_string()))?; + + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) + } + pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result, DbErr> { Family::find_by_id(id).one(db).await } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6e3961f..31f1ae2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,6 +9,8 @@ import type { CreateFamilyRequest, CreateCategoryRequest, CreateExpenseRequest, + VerifyFamilyPasswordRequest, + VerifyFamilyPasswordResponse, } from '../types'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; @@ -41,6 +43,9 @@ export const familyApi = { delete: (id: number) => apiClient.delete(`/families/${id}`), + + verifyPassword: (id: number, data: VerifyFamilyPasswordRequest) => + apiClient.post(`/families/${id}/verify`, data), }; export const categoryApi = { diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 1cca055..324604c 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -13,6 +13,7 @@ export default function AdminPanel() { const [loginError, setLoginError] = useState(''); const [newFamilyName, setNewFamilyName] = useState(''); + const [newFamilyPassword, setNewFamilyPassword] = useState(''); const [families, setFamilies] = useState>([]); useEffect(() => { @@ -64,11 +65,15 @@ export default function AdminPanel() { }; const handleCreateFamily = async () => { - if (!newFamilyName.trim()) return; + if (!newFamilyName.trim() || !newFamilyPassword.trim()) { + alert('Заполните название и пароль семьи'); + return; + } try { - await familyApi.create({ name: newFamilyName }); + await familyApi.create({ name: newFamilyName, password: newFamilyPassword }); setNewFamilyName(''); + setNewFamilyPassword(''); loadFamilies(); } catch (err) { alert('Ошибка создания семьи'); @@ -168,17 +173,24 @@ export default function AdminPanel() { Создать новую семью -
+
setNewFamilyName(e.target.value)} - className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + setNewFamilyPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index a13540d..3fe3913 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -97,18 +97,25 @@ export default function FamilyView() { const handleResetLimit = async (categoryId: number) => { if (!familyId) return; - const newLimit = prompt('Введите новый лимит:'); - if (!newLimit) return; + if (!confirm('Удалить все траты по этой категории?')) return; try { - await categoryApi.resetLimit( + const expensesResponse = await expenseApi.getAllByCategory( parseInt(familyId), - categoryId, - parseFloat(newLimit) + categoryId ); + + for (const expense of expensesResponse.data) { + await expenseApi.delete( + parseInt(familyId), + categoryId, + expense.id + ); + } + loadCategories(); } catch (err) { - alert('Ошибка сброса лимита'); + alert('Ошибка сброса трат'); console.error(err); } }; @@ -280,7 +287,7 @@ export default function FamilyView() { onClick={() => handleResetLimit(category.id)} className="px-4 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600" > - Сбросить лимит + Обнулить траты
)}
+ + {showPasswordDialog && selectedFamilyForAuth && ( +
+
+

+ Введите пароль для семьи +

+

+ {selectedFamilyForAuth.name} +

+ + {passwordError && ( +
+ {passwordError} +
+ )} + + setFamilyPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleVerifyPassword()} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent mb-4" + autoFocus + /> + +
+ + +
+
+
+ )} ); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bd3e85d..f6c17e0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -36,6 +36,15 @@ export interface LoginResponse { export interface CreateFamilyRequest { name: string; + password: string; +} + +export interface VerifyFamilyPasswordRequest { + password: string; +} + +export interface VerifyFamilyPasswordResponse { + valid: boolean; } export interface CreateCategoryRequest {