From 4765360a45f43e5734764d96579ca48e1daf8362 Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Tue, 2 Dec 2025 16:43:56 +0100 Subject: [PATCH] Replaced config auth with db auth --- src/alpine_bits_python/alpinebits_server.py | 41 +++++++++++++-------- src/alpine_bits_python/api.py | 35 +++++++++++++----- src/alpine_bits_python/free_rooms_action.py | 3 +- src/alpine_bits_python/hotel_service.py | 23 ++++++++++++ tests/test_api_freerooms.py | 3 +- tests/test_free_rooms_action.py | 3 +- tests/test_webhook_duplicates.py | 5 ++- 7 files changed, 83 insertions(+), 30 deletions(-) diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index 91ee96c..bb292c0 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -33,6 +33,7 @@ from .generated.alpinebits import ( OtaReadRq, WarningStatus, ) +from .hotel_service import HotelService from .reservation_service import ReservationService # Configure logging @@ -413,20 +414,24 @@ def strip_control_chars(s): return re.sub(r"[\x00-\x1F\x7F]", "", s) -def validate_hotel_authentication( - username: str, password: str, hotelid: str, config: dict +async def validate_hotel_authentication( + username: str, + password: str, + hotelid: str, + config: dict, + dbsession=None, ) -> bool: - """Validate hotel authentication based on username, password, and hotel ID. + """Validate hotel authentication against the database (fallback to config).""" + if dbsession is not None: + hotel_service = HotelService(dbsession) + hotel = await hotel_service.authenticate_hotel(username, password) + if hotel: + return hotel.hotel_id == hotelid - Example config - alpine_bits_auth: - - hotel_id: "123" - hotel_name: "Frangart Inn" - username: "alice" - password: !secret ALICE_PASSWORD - """ + # Fallback to config for legacy scenarios (e.g., during migration) if not config or "alpine_bits_auth" not in config: return False + auth_list = config["alpine_bits_auth"] for auth in auth_list: if ( @@ -488,8 +493,12 @@ class ReadAction(AlpineBitsAction): HttpStatusCode.UNAUTHORIZED, ) - if not validate_hotel_authentication( - client_info.username, client_info.password, hotelid, self.config + if not await validate_hotel_authentication( + client_info.username, + client_info.password, + hotelid, + self.config, + dbsession, ): return AlpineBitsResponse( f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", @@ -522,7 +531,7 @@ class ReadAction(AlpineBitsAction): await reservation_service.get_unacknowledged_reservations( username=client_info.username, client_id=client_info.client_id, - hotel_code=hotelid + hotel_code=hotelid, ) ) else: @@ -619,7 +628,9 @@ class NotifReportReadAction(AlpineBitsAction): ): # type: ignore md5_unique_id = entry.unique_id.id await reservation_service.record_acknowledgement( - client_id=client_info.client_id, unique_id=md5_unique_id, username=client_info.username + client_id=client_info.client_id, + unique_id=md5_unique_id, + username=client_info.username, ) return AlpineBitsResponse(response_xml, HttpStatusCode.OK) @@ -826,4 +837,4 @@ class AlpineBitsServer: # Ensure FreeRoomsAction is registered with ServerCapabilities discovery -#from .free_rooms_action import FreeRoomsAction # noqa: E402,F401 disable for now +# from .free_rooms_action import FreeRoomsAction diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index faab88f..7b07e09 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -664,7 +664,8 @@ async def detect_language( async def validate_basic_auth( credentials: HTTPBasicCredentials = Depends(security_basic), -) -> str: + db_session=Depends(get_async_session), +) -> tuple[str, str]: """Validate basic authentication for AlpineBits protocol. Returns username if valid, raises HTTPException if not. @@ -676,26 +677,40 @@ async def validate_basic_auth( detail="ERROR: Authentication required", headers={"WWW-Authenticate": "Basic"}, ) - valid = False - config = app.state.config + hotel_service = HotelService(db_session) + hotel = await hotel_service.authenticate_hotel( + credentials.username, credentials.password + ) - for entry in config["alpine_bits_auth"]: + if hotel: + _LOGGER.info( + "AlpineBits authentication successful for user: %s (from database)", + credentials.username, + ) + return credentials.username, credentials.password + + # Fallback to config-defined credentials for legacy scenarios + config = app.state.config + valid = False + for entry in config.get("alpine_bits_auth", []): if ( - credentials.username == entry["username"] - and credentials.password == entry["password"] + credentials.username == entry.get("username") + and credentials.password == entry.get("password") ): valid = True + _LOGGER.warning( + "AlpineBits authentication for user %s matched legacy config entry", + credentials.username, + ) break + if not valid: raise HTTPException( status_code=401, detail="ERROR: Invalid credentials", headers={"WWW-Authenticate": "Basic"}, ) - _LOGGER.info( - "AlpineBits authentication successful for user: %s (from config)", - credentials.username, - ) + return credentials.username, credentials.password diff --git a/src/alpine_bits_python/free_rooms_action.py b/src/alpine_bits_python/free_rooms_action.py index 840f7b9..42f5786 100644 --- a/src/alpine_bits_python/free_rooms_action.py +++ b/src/alpine_bits_python/free_rooms_action.py @@ -125,11 +125,12 @@ class FreeRoomsAction(AlpineBitsAction): code="401", ) - if not validate_hotel_authentication( + if not await validate_hotel_authentication( client_info.username, client_info.password, hotel_code, self.config, + dbsession, ): raise FreeRoomsProcessingError( f"Unauthorized FreeRooms notification for hotel {hotel_code}", diff --git a/src/alpine_bits_python/hotel_service.py b/src/alpine_bits_python/hotel_service.py index 0b5c27f..07c1aa2 100644 --- a/src/alpine_bits_python/hotel_service.py +++ b/src/alpine_bits_python/hotel_service.py @@ -244,3 +244,26 @@ class HotelService: ) ) return result.scalar_one_or_none() + + async def authenticate_hotel(self, username: str, password: str) -> Hotel | None: + """Authenticate a hotel using username and password. + + Args: + username: AlpineBits username + password: Plain text password submitted via HTTP basic auth + + Returns: + Hotel instance if the credentials are valid and the hotel is active, + otherwise None. + """ + hotel = await self.get_hotel_by_username(username) + if not hotel: + return None + + if not password: + return None + + if verify_password(password, hotel.password_hash): + return hotel + + return None diff --git a/tests/test_api_freerooms.py b/tests/test_api_freerooms.py index b988d5a..322b1e4 100644 --- a/tests/test_api_freerooms.py +++ b/tests/test_api_freerooms.py @@ -17,6 +17,7 @@ from alpine_bits_python.alpinebits_server import AlpineBitsServer from alpine_bits_python.api import app from alpine_bits_python.const import HttpStatusCode from alpine_bits_python.db import Base, Hotel, RoomAvailability +from alpine_bits_python.hotel_service import hash_password def build_request_xml(body: str, include_unique_id: bool = True) -> str: @@ -118,7 +119,7 @@ def seed_hotel_if_missing(client: TestClient): hotel_id="HOTEL123", hotel_name="Integration Hotel", username="testuser", - password_hash="integration-hash", + password_hash=hash_password("testpass"), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), is_active=True, diff --git a/tests/test_free_rooms_action.py b/tests/test_free_rooms_action.py index baa0f62..8b67fd9 100644 --- a/tests/test_free_rooms_action.py +++ b/tests/test_free_rooms_action.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo, Version from alpine_bits_python.const import HttpStatusCode from alpine_bits_python.db import Base, Hotel, HotelInventory, RoomAvailability +from alpine_bits_python.hotel_service import hash_password from alpine_bits_python.free_rooms_action import FreeRoomsAction @@ -78,7 +79,7 @@ async def insert_test_hotel(session: AsyncSession, hotel_id: str = "TESTHOTEL"): hotel_id=hotel_id, hotel_name="Unit Test Hotel", username="testuser", - password_hash="bcrypt-hash", + password_hash=hash_password("testpass"), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), is_active=True, diff --git a/tests/test_webhook_duplicates.py b/tests/test_webhook_duplicates.py index 57580d7..cb23bf3 100644 --- a/tests/test_webhook_duplicates.py +++ b/tests/test_webhook_duplicates.py @@ -23,6 +23,7 @@ from alpine_bits_python.api import app from alpine_bits_python.const import WebhookStatus from alpine_bits_python.db import Base, Reservation, WebhookRequest from alpine_bits_python.db_setup import reprocess_stuck_webhooks +from alpine_bits_python.hotel_service import hash_password from alpine_bits_python.schemas import WebhookRequestData from alpine_bits_python.webhook_processor import initialize_webhook_processors, webhook_registry @@ -206,7 +207,7 @@ class TestWebhookReprocessing: hotel_id="HOTEL123", hotel_name="Test Hotel", username="testuser", - password_hash="dummy", + password_hash=hash_password("testpass"), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), is_active=True, @@ -291,7 +292,7 @@ class TestWebhookReprocessingNeverBlocksStartup: hotel_id="HOTEL123", hotel_name="Test Hotel", username="testuser", - password_hash="dummy", + password_hash=hash_password("testpass"), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), is_active=True,