From 69f5789e2dc665d40c2ad6c7cd79dddb42633e06 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 21 Apr 2026 11:02:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Home=20Assistant=20Benutzername/Passwor?= =?UTF-8?q?t-Authentifizierung=20Erg=C3=A4nzt=20die=20HA-Integration=20um?= =?UTF-8?q?=20Password-Grant=20OAuth2:=20Nutzer=20k=C3=B6nnen=20sich=20nun?= =?UTF-8?q?=20wahlweise=20mit=20einem=20Long-Lived=20Token=20oder=20mit=20?= =?UTF-8?q?Benutzername/Passwort=20anmelden.=20Access=20Tokens=20werden=20?= =?UTF-8?q?automatisch=20per=20Refresh-Token=20erneuert.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 15 ++++ backend/models.py | 3 + backend/routers/caldav_router.py | 2 +- backend/routers/homeassistant_router.py | 103 ++++++++++++++++++++++-- frontend/index.html | 21 +++++ frontend/js/calendar.js | 45 ++++++++++- 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/backend/main.py b/backend/main.py index ef673d0..ca24b29 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,6 +63,21 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'")) + conn.commit() + except Exception: + pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT")) + conn.commit() + except Exception: + pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME")) + conn.commit() + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 4f85b31..4772ef0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -189,6 +189,9 @@ class HomeAssistantAccount(Base): name = Column(String(100), nullable=False) url = Column(String(500), nullable=False) token = Column(Text, nullable=False) + auth_method = Column(String(20), default="token") + refresh_token = Column(Text, nullable=True) + token_expiry = Column(DateTime, nullable=True) user = relationship("User", back_populates="homeassistant_accounts") calendars = relationship( diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index d555487..c5f3167 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -364,7 +364,7 @@ def get_events( ) for ha_acc in ha_accounts: try: - all_events.extend(get_ha_events(ha_acc, start_dt, end_dt)) + all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db)) except Exception as exc: logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc) diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py index 3bffdf2..28a3fee 100644 --- a/backend/routers/homeassistant_router.py +++ b/backend/routers/homeassistant_router.py @@ -15,6 +15,70 @@ logger = logging.getLogger(__name__) router = APIRouter() HA_DEFAULT_COLOR = "#03a9f4" +HA_CLIENT_ID = "http://localhost/" + + +# ── Auth helpers ────────────────────────────────────────── + +def _ha_login(url: str, username: str, password: str) -> tuple: + """Password grant → (access_token, refresh_token, expires_in)""" + try: + resp = http_requests.post( + f"{url.rstrip('/')}/auth/token", + data={ + "grant_type": "password", + "username": username, + "password": password, + "client_id": HA_CLIENT_ID, + }, + timeout=10, + verify=False, + ) + except http_requests.exceptions.ConnectionError: + raise HTTPException(503, "Home Assistant nicht erreichbar") + except http_requests.exceptions.Timeout: + raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)") + if resp.status_code in (400, 401): + raise HTTPException(401, "Ungültige Anmeldedaten") + resp.raise_for_status() + data = resp.json() + return data["access_token"], data.get("refresh_token", ""), data.get("expires_in", 1800) + + +def _ha_refresh(url: str, refresh_token: str) -> tuple: + """Refresh grant → (access_token, expires_in)""" + resp = http_requests.post( + f"{url.rstrip('/')}/auth/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": HA_CLIENT_ID, + }, + timeout=10, + verify=False, + ) + resp.raise_for_status() + data = resp.json() + return data["access_token"], data.get("expires_in", 1800) + + +def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str: + """Return a valid access token, refreshing if necessary.""" + if account.auth_method != "password": + return account.token # Long-Lived Token läuft nicht ab + now = datetime.now(timezone.utc) + if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now: + return account.token + # Needs refresh + try: + access_token, expires_in = _ha_refresh(account.url, account.refresh_token) + except Exception as exc: + logger.error("HA token refresh failed for %s: %s", account.name, exc) + raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden") + account.token = access_token + account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) + db.commit() + return access_token # ── HA API helpers ──────────────────────────────────────── @@ -77,13 +141,18 @@ def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> } -def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list: +def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime, db: Session) -> list: all_events = [] + try: + token = _get_valid_token(account, db) + except Exception as exc: + logger.error("HA token error for %s: %s", account.name, exc) + raise for cal in account.calendars: if not cal.enabled or cal.sidebar_hidden: continue try: - raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt) + raw = _ha_get_events(account.url, token, cal.entity_id, start_dt, end_dt) color = cal.color or HA_DEFAULT_COLOR for ev in raw: all_events.append(_parse_ha_event(ev, cal.id, cal.name, color)) @@ -99,6 +168,7 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: "id": a.id, "name": a.name, "url": a.url, + "auth_method": a.auth_method or "token", "calendars": [ { "id": c.id, @@ -118,7 +188,9 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: class HAAccountCreate(BaseModel): name: str url: str - token: str + token: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None class HACalendarUpdate(BaseModel): @@ -149,13 +221,31 @@ def add_account( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - remote_cals = _ha_get_calendars(data.url, data.token) + now = datetime.now(timezone.utc) + + if data.username and data.password: + access_token, refresh_tok, expires_in = _ha_login(data.url, data.username, data.password) + auth_method = "password" + stored_refresh = refresh_tok + token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc) + elif data.token: + access_token = data.token + auth_method = "token" + stored_refresh = None + token_expiry = None + else: + raise HTTPException(400, "Token oder Benutzername/Passwort erforderlich") + + remote_cals = _ha_get_calendars(data.url, access_token) account = models.HomeAssistantAccount( user_id=current_user.id, name=data.name, url=data.url, - token=data.token, + token=access_token, + auth_method=auth_method, + refresh_token=stored_refresh, + token_expiry=token_expiry, ) db.add(account) db.flush() @@ -215,7 +305,8 @@ def sync_account( if not acc: raise HTTPException(404, "Account not found") - remote_cals = _ha_get_calendars(acc.url, acc.token) + token = _get_valid_token(acc, db) + remote_cals = _ha_get_calendars(acc.url, token) existing = {c.entity_id: c for c in acc.calendars} for cal in remote_cals: diff --git a/frontend/index.html b/frontend/index.html index 8497872..b9685bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -417,9 +417,30 @@
+ +
+ + +
+
+
+