Refactor SSO authentication to support multiple providers and enhance error handling

This commit is contained in:
b267a 2026-01-22 16:17:05 +01:00
parent 862518a45f
commit c4cef0d9b5
10 changed files with 233 additions and 66 deletions

4
.gitignore vendored
View file

@ -1,2 +1,4 @@
/.idea /.idea
*.env *.env
*pyc
__pycache__/

View file

@ -1,17 +1,17 @@
# 📚 KIT Anny Booking Automation # 📚 Anny Booking Automation
This Python project automates booking of study spaces or resources via the [anny.eu](https://anny.eu) platform used by the KIT (Karlsruhe Institute of Technology) library system. It logs in automatically using KIT SAML SSO, searches for available slots, and makes bookings without user interaction — ideal for recurring reservations. This Python project automates booking of study spaces or resources via the [anny.eu](https://anny.eu) platform used by university library systems. It logs in automatically using SAML SSO (with pluggable provider support), searches for available slots, and makes bookings without user interaction — ideal for recurring reservations.
--- ---
## ⚙️ Features ## ⚙️ Features
- 🔐 Automatic KIT login via SAML SSO - 🔐 Pluggable SSO providers (KIT included, easily extendable for other universities)
- 📆 Configurable 3-days-ahead reservation window - 📆 Configurable 3-days-ahead reservation window
- 🔎 Auto-detection of available time slots - 🔎 Auto-detection of available time slots
- ⏳ Pre-login shortly before midnight to instantly book a slot at 00:00 - ⏳ Smart midnight wait: only waits if within 10 minutes of midnight, otherwise executes immediately
- 🛠️ Clean and modular object-oriented codebase - 🛠️ Clean and modular object-oriented codebase
- 🔁 Fully automated execution using [cron-job.org](https://cron-job.org) + GitHub API - 🔁 Fully automated execution using [cron-job.org](https://cron-job.org) + GitHub API
- 📦 Easy to extend and maintain - 📦 Easy to extend and maintain
--- ---
@ -25,13 +25,17 @@ anny_booking/
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── auth/ ├── auth/
│ └── session.py # AnnySession class (login logic) │ ├── session.py # AnnySession class (login logic)
│ └── providers/ # SSO provider implementations
│ ├── __init__.py # Provider registry
│ ├── base.py # Abstract base class for providers
│ └── kit.py # KIT (Karlsruhe) SSO provider
├── booking/ ├── booking/
│ └── client.py # BookingClient class (resource booking) │ └── client.py # BookingClient class (resource booking)
├── config/ ├── config/
│ └── constants.py # API URLs and shared constants │ └── constants.py # API URLs, timezone, SSO provider, and shared constants
├── utils/ ├── utils/
│ └── helpers.py # Utility functions │ └── helpers.py # Utility functions
@ -71,6 +75,46 @@ PASSWORD=your_kit_password
> 🔒 Never commit this file to version control! > 🔒 Never commit this file to version control!
### 4. Configure SSO provider
In `config/constants.py`, set your SSO provider:
```python
SSO_PROVIDER = "kit" # Available: kit (add more in auth/providers/)
```
---
## 🔌 Adding a New SSO Provider
To add support for another university (e.g., TUM), create a new file `auth/providers/tum.py`:
```python
from auth.providers.base import SSOProvider
from utils.helpers import extract_html_value
class TUMProvider(SSOProvider):
name = "TUM"
domain = "tum.de"
def authenticate(self) -> str:
# Implement TUM-specific SAML authentication
# Use self.session, self.redirect_response, self.username, self.password
# Return the HTML containing the SAMLResponse
pass
```
Then register it in `auth/providers/__init__.py`:
```python
from auth.providers.tum import TUMProvider
PROVIDERS: dict[str, type[SSOProvider]] = {
"kit": KITProvider,
"tum": TUMProvider,
}
```
--- ---
## ⏱️ Automated Execution via cron-job.org + GitHub API ## ⏱️ Automated Execution via cron-job.org + GitHub API
@ -103,9 +147,9 @@ This ensures your booking script runs exactly when needed — already logged in
### How it works ### How it works
1. `cron-job.org` triggers the GitHub Actions workflow at **23:58** (Europe/Berlin). 1. `cron-job.org` triggers the GitHub Actions workflow at **23:58** (Europe/Berlin).
2. The script logs in via KIT SAML SSO and maintains an active session. 2. The script logs in via your configured SSO provider and maintains an active session.
3. It waits internally until **00:00**. 3. If within 10 minutes of midnight, it waits until **00:00**. Otherwise, it executes immediately (useful for testing or manual runs).
4. As soon as new booking slots are released, it instantly reserves the first suitable slot. 4. As soon as new booking slots are released, it instantly reserves the first suitable slot.
--- ---
@ -161,7 +205,7 @@ jobs:
python main.py python main.py
``` ```
> 💡 Store your KIT credentials securely as [GitHub Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets): `USERNAME` and `PASSWORD`. > 💡 Store your university credentials securely as [GitHub Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets): `USERNAME` and `PASSWORD`.
--- ---

View file

@ -0,0 +1,16 @@
from auth.providers.base import SSOProvider
from auth.providers.kit import KITProvider
# Registry of available SSO providers
PROVIDERS: dict[str, type[SSOProvider]] = {
"kit": KITProvider,
}
def get_provider(name: str) -> type[SSOProvider]:
"""Get an SSO provider class by name."""
provider = PROVIDERS.get(name.lower())
if not provider:
available = ", ".join(PROVIDERS.keys())
raise ValueError(f"Unknown SSO provider: {name}. Available: {available}")
return provider

35
auth/providers/base.py Normal file
View file

@ -0,0 +1,35 @@
from abc import ABC, abstractmethod
import requests
class SSOProvider(ABC):
"""Base class for SSO authentication providers."""
# Override these in subclasses
name: str = "base"
domain: str = ""
def __init__(self, username: str, password: str):
self.username = username
self.password = password
self.session: requests.Session = None
self.redirect_response: requests.Response = None
self.saml_response_html: str = None
def set_session(self, session: requests.Session):
"""Set the shared session from AnnySession."""
self.session = session
def set_redirect_response(self, response: requests.Response):
"""Set the redirect response from Anny SSO initiation."""
self.redirect_response = response
@abstractmethod
def authenticate(self) -> str:
"""
Perform institution-specific authentication.
Returns:
The HTML containing the SAML response, or raises an exception on failure.
"""
pass

36
auth/providers/kit.py Normal file
View file

@ -0,0 +1,36 @@
import html
from auth.providers.base import SSOProvider
from utils.helpers import extract_html_value
class KITProvider(SSOProvider):
"""SSO provider for Karlsruhe Institute of Technology (KIT)."""
name = "KIT"
domain = "kit.edu"
def authenticate(self) -> str:
self.session.headers.pop('x-requested-with', None)
self.session.headers.pop('x-inertia', None)
self.session.headers.pop('x-inertia-version', None)
csrf_token = extract_html_value(
self.redirect_response.text,
r'name="csrf_token" value="([^"]+)"'
)
response = self.session.post(
'https://idp.scc.kit.edu/idp/profile/SAML2/Redirect/SSO?execution=e1s1',
data={
'csrf_token': csrf_token,
'j_username': self.username,
'j_password': self.password,
'_eventId_proceed': '',
'fudis_web_authn_assertion_input': '',
}
)
if "/consume" not in html.unescape(response.text):
raise ValueError("KIT authentication failed - invalid credentials or SSO error")
return response.text

View file

@ -1,27 +1,38 @@
import requests import requests
import urllib.parse import urllib.parse
import re import re
import html
from config.constants import AUTH_BASE_URL, ANNY_BASE_URL, DEFAULT_HEADERS from config.constants import AUTH_BASE_URL, ANNY_BASE_URL, DEFAULT_HEADERS
from utils.helpers import extract_html_value from utils.helpers import extract_html_value
from auth.providers import get_provider, SSOProvider
class AnnySession: class AnnySession:
def __init__(self, username, password): def __init__(self, username: str, password: str, provider_name: str = "kit"):
self.session = requests.Session() self.session = requests.Session()
self.username = username self.username = username
self.password = password self.password = password
# Initialize the SSO provider
provider_class = get_provider(provider_name)
self.provider: SSOProvider = provider_class(username, password)
def login(self): def login(self):
try: try:
self._init_headers() self._init_headers()
self._sso_login() self._sso_login()
self._kit_auth() self._provider_auth()
self._consume_saml() self._consume_saml()
print("✅ Login successful.") print(f"✅ Login successful via {self.provider.name}.")
return self.session.cookies return self.session.cookies
except Exception as e: except requests.RequestException as e:
print(f"[Login Error] Network error: {type(e).__name__}")
return None
except ValueError as e:
print(f"[Login Error] {e}") print(f"[Login Error] {e}")
return None return None
except KeyError as e:
print(f"[Login Error] Missing expected field: {e}")
return None
def _init_headers(self): def _init_headers(self):
self.session.headers.update({ self.session.headers.update({
@ -45,32 +56,17 @@ class AnnySession:
'x-inertia-version': x_inertia_version 'x-inertia-version': x_inertia_version
}) })
r2 = self.session.post(f"{AUTH_BASE_URL}/login/sso", json={"domain": "kit.edu"}) r2 = self.session.post(f"{AUTH_BASE_URL}/login/sso", json={"domain": self.provider.domain})
redirect_url = r2.headers['x-inertia-location'] redirect_url = r2.headers['x-inertia-location']
self.redirect_response = self.session.get(redirect_url) redirect_response = self.session.get(redirect_url)
def _kit_auth(self): # Pass session and redirect response to provider
self.session.headers.pop('x-requested-with', None) self.provider.set_session(self.session)
self.session.headers.pop('x-inertia', None) self.provider.set_redirect_response(redirect_response)
self.session.headers.pop('x-inertia-version', None)
csrf_token = extract_html_value(self.redirect_response.text, r'name="csrf_token" value="([^"]+)"') def _provider_auth(self):
"""Delegate authentication to the SSO provider."""
r4 = self.session.post( self.saml_response_html = self.provider.authenticate()
'https://idp.scc.kit.edu/idp/profile/SAML2/Redirect/SSO?execution=e1s1',
data={
'csrf_token': csrf_token,
'j_username': self.username,
'j_password': self.password,
'_eventId_proceed': '',
'fudis_web_authn_assertion_input': '',
}
)
if "/consume" not in html.unescape(r4.text):
raise Exception("KIT authentication failed")
self.saml_response_html = r4.text
def _consume_saml(self): def _consume_saml(self):
consume_url = extract_html_value(self.saml_response_html, r'form action="([^"]+)"') consume_url = extract_html_value(self.saml_response_html, r'form action="([^"]+)"')

View file

@ -1,6 +1,6 @@
import requests import requests
from requests.exceptions import JSONDecodeError
from config.constants import RESOURCE_URL, BOOKING_API_BASE, CHECKOUT_FORM_API, ANNY_BASE_URL, SERVICE_ID from config.constants import RESOURCE_URL, BOOKING_API_BASE, CHECKOUT_FORM_API, ANNY_BASE_URL, SERVICE_ID
from utils.helpers import extract_html_value
class BookingClient: class BookingClient:
def __init__(self, cookies): def __init__(self, cookies):
@ -30,7 +30,14 @@ class BookingClient:
'filter[pre_order_ids]': '', 'filter[pre_order_ids]': '',
'sort': 'name' 'sort': 'name'
}) })
resources = response.json().get('data', []) if not response.ok:
print(f"❌ Failed to fetch resources: HTTP {response.status_code}")
return None
try:
resources = response.json().get('data', [])
except (ValueError, JSONDecodeError):
print(f"❌ Invalid JSON response when fetching resources: {response.text[:200]}")
return None
return resources[0]['id'] if resources else None return resources[0]['id'] if resources else None
def reserve(self, resource_id, start, end): def reserve(self, resource_id, start, end):
@ -50,15 +57,32 @@ class BookingClient:
) )
if not booking.ok: if not booking.ok:
print("❌ Slot already taken.") print(f"❌ Booking failed: HTTP {booking.status_code}")
return False
try:
data = booking.json().get("data", {})
except (ValueError, JSONDecodeError):
print(f"❌ Invalid JSON response from booking request: {booking.text[:200]}")
return False return False
data = booking.json().get("data", {})
oid = data.get("id") oid = data.get("id")
oat = data.get("attributes", {}).get("access_token") oat = data.get("attributes", {}).get("access_token")
if not oid or not oat:
print("❌ Missing booking ID or access token in response")
return False
checkout = self.session.get(f"{CHECKOUT_FORM_API}?oid={oid}&oat={oat}&stateless=1") checkout = self.session.get(f"{CHECKOUT_FORM_API}?oid={oid}&oat={oat}&stateless=1")
customer = checkout.json().get("default", {}).get("customer", {}) if not checkout.ok:
print(f"❌ Checkout form failed: HTTP {checkout.status_code}")
return False
try:
customer = checkout.json().get("default", {}).get("customer", {})
except (ValueError, JSONDecodeError):
print(f"❌ Invalid JSON response from checkout form: {checkout.text[:200]}")
return False
final = self.session.post( final = self.session.post(
f"{BOOKING_API_BASE}/order?oid={oid}&oat={oat}&stateless=1", f"{BOOKING_API_BASE}/order?oid={oid}&oat={oat}&stateless=1",

View file

@ -4,7 +4,9 @@ BOOKING_API_BASE = "https://b.anny.eu/api/v1"
CHECKOUT_FORM_API = "https://b.anny.eu/api/ui/checkout-form" CHECKOUT_FORM_API = "https://b.anny.eu/api/ui/checkout-form"
RESOURCE_URL = f"{BOOKING_API_BASE}/resources/1-lehrbuchsammlung-eg-und-1-og/children" RESOURCE_URL = f"{BOOKING_API_BASE}/resources/1-lehrbuchsammlung-eg-und-1-og/children"
SERVICE_ID = "449" SERVICE_ID = "449"
RESSOURCE_ID = "5993" # None Will be set dynamically if None, else use the given ID RESOURCE_ID = "5960" # Will be set dynamically if None, else use the given ID
TIMEZONE = "Europe/Berlin"
SSO_PROVIDER = "kit" # Available: kit (add more in auth/providers/)
DEFAULT_HEADERS = { DEFAULT_HEADERS = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',

43
main.py
View file

@ -7,20 +7,20 @@ from auth.session import AnnySession
from booking.client import BookingClient from booking.client import BookingClient
from utils.helpers import get_future_datetime from utils.helpers import get_future_datetime
import pytz import pytz
from config.constants import RESSOURCE_ID from config.constants import RESOURCE_ID, TIMEZONE, SSO_PROVIDER
def main(): def main():
load_dotenv('.env', override=True) load_dotenv('.env', override=True)
username = os.getenv("USERNAME") username = os.getenv("USERNAME")
password = os.getenv("PASSWORD") password = os.getenv("PASSWORD")
start_time = datetime.datetime.now(pytz.timezone('Europe/Berlin')) tz = pytz.timezone(TIMEZONE)
if not username or not password: if not username or not password:
print("❌ Missing USERNAME or PASSWORD in .env") print("❌ Missing USERNAME or PASSWORD in .env")
return return
session = AnnySession(username, password) session = AnnySession(username, password, provider_name=SSO_PROVIDER)
cookies = session.login() cookies = session.login()
if not cookies: if not cookies:
@ -28,8 +28,17 @@ def main():
booking = BookingClient(cookies) booking = BookingClient(cookies)
while start_time.day == datetime.datetime.now(pytz.timezone('Europe/Berlin')).day: # Only wait for midnight if within 10 minutes, otherwise execute immediately
time.sleep(1) now = datetime.datetime.now(tz)
midnight = (now + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
seconds_until_midnight = (midnight - now).total_seconds()
max_wait_seconds = 10 * 60 # 10 minutes
if 0 < seconds_until_midnight <= max_wait_seconds:
print(f"⏳ Waiting {seconds_until_midnight:.0f} seconds until midnight...")
time.sleep(seconds_until_midnight)
elif seconds_until_midnight > max_wait_seconds:
print(f"⚡ More than 10 min until midnight, executing immediately...")
times = [ times = [
{ {
@ -47,19 +56,21 @@ def main():
] ]
for time_ in times: for time_ in times:
try:
start = get_future_datetime(hour=time_['start'])
end = get_future_datetime(hour=time_['end'])
start = get_future_datetime(hour=time_['start']) if RESOURCE_ID:
end = get_future_datetime(hour=time_['end']) resource_id = RESOURCE_ID
else:
resource_id = booking.find_available_resource(start, end)
if RESSOURCE_ID: if resource_id:
resource_id = RESSOURCE_ID booking.reserve(resource_id, start, end)
else: else:
resource_id = booking.find_available_resource(start, end) print("⚠️ No available slots found.")
except Exception as e:
if resource_id: print(f"❌ Error booking slot {time_['start']}-{time_['end']}: {e}")
booking.reserve(resource_id, start, end)
else:
print("⚠️ No available slots found.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -2,9 +2,10 @@ import re
import html import html
import datetime import datetime
import pytz import pytz
from config.constants import TIMEZONE
def get_future_datetime(days_ahead=3, hour="13:00:00"): def get_future_datetime(days_ahead=3, hour="13:00:00"):
dt = datetime.datetime.now(pytz.timezone('Europe/Berlin')) + datetime.timedelta(days=days_ahead) dt = datetime.datetime.now(pytz.timezone(TIMEZONE)) + datetime.timedelta(days=days_ahead)
return dt.strftime(f"%Y-%m-%dT{hour}+02:00") return dt.strftime(f"%Y-%m-%dT{hour}+02:00")
def extract_html_value(text, pattern): def extract_html_value(text, pattern):