diff --git a/backend/main.py b/backend/main.py index ca24b29..bb1327a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -78,6 +78,11 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)")) + conn.commit() + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 4772ef0..36fc462 100644 --- a/backend/models.py +++ b/backend/models.py @@ -192,6 +192,7 @@ class HomeAssistantAccount(Base): auth_method = Column(String(20), default="token") refresh_token = Column(Text, nullable=True) token_expiry = Column(DateTime, nullable=True) + client_id = Column(String(500), nullable=True) user = relationship("User", back_populates="homeassistant_accounts") calendars = relationship( diff --git a/backend/routers/homeassistant_router.py b/backend/routers/homeassistant_router.py index 77f4e05..e6ef38a 100644 --- a/backend/routers/homeassistant_router.py +++ b/backend/routers/homeassistant_router.py @@ -1,9 +1,13 @@ import logging +import secrets +import time from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlencode import requests as http_requests -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session @@ -15,44 +19,27 @@ logger = logging.getLogger(__name__) router = APIRouter() HA_DEFAULT_COLOR = "#03a9f4" -HA_CLIENT_ID = "http://localhost/" + +# In-memory store for pending OAuth states (short-lived, ~10 min TTL) +_pending_oauth: dict[str, dict] = {} + + +def _cleanup_pending(): + now = time.time() + for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]: + _pending_oauth.pop(k, None) # ── 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(400, "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: +def _ha_refresh(url: str, refresh_token: str, client_id: 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, + "client_id": client_id, }, timeout=10, verify=False, @@ -64,14 +51,16 @@ def _ha_refresh(url: str, refresh_token: str) -> tuple: def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str: """Return a valid access token, refreshing if necessary.""" - if account.auth_method != "password": + if account.auth_method != "oauth": 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) + access_token, expires_in = _ha_refresh( + account.url, account.refresh_token, account.client_id or "" + ) 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") @@ -188,9 +177,14 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict: class HAAccountCreate(BaseModel): name: str url: str - token: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None + token: str + + +class HAOAuthStart(BaseModel): + name: str + url: str + client_id: str + redirect_uri: str class HACalendarUpdate(BaseModel): @@ -221,31 +215,17 @@ def add_account( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - 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) + """Create a HA account from a Long-Lived Access Token.""" + remote_cals = _ha_get_calendars(data.url, data.token) account = models.HomeAssistantAccount( user_id=current_user.id, name=data.name, url=data.url, - token=access_token, - auth_method=auth_method, - refresh_token=stored_refresh, - token_expiry=token_expiry, + token=data.token, + auth_method="token", + refresh_token=None, + token_expiry=None, ) db.add(account) db.flush() @@ -267,6 +247,111 @@ def add_account( return _account_dict(account) +@router.post("/auth-url") +def oauth_start( + data: HAOAuthStart, + current_user: models.User = Depends(get_current_user), +): + """Start the OAuth flow: store pending state, return HA authorization URL.""" + _cleanup_pending() + state_token = secrets.token_urlsafe(32) + _pending_oauth[state_token] = { + "user_id": current_user.id, + "ha_url": data.url.rstrip('/'), + "name": data.name, + "client_id": data.client_id, + "redirect_uri": data.redirect_uri, + "expires": time.time() + 600, + } + params = { + "client_id": data.client_id, + "redirect_uri": data.redirect_uri, + "state": state_token, + "response_type": "code", + } + return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"} + + +@router.get("/callback") +def oauth_callback( + request: Request, + code: str = Query(""), + state: str = Query(""), + error: str = Query(""), + db: Session = Depends(get_db), +): + """Callback from Home Assistant after user authorization.""" + if error or not code: + return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302) + + pending = _pending_oauth.pop(state, None) + if not pending or pending["expires"] < time.time(): + return RedirectResponse(url="/?ha_error=state_expired", status_code=302) + + ha_url = pending["ha_url"] + client_id = pending["client_id"] + + # Exchange code for tokens + try: + resp = http_requests.post( + f"{ha_url}/auth/token", + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": client_id, + }, + timeout=15, + verify=False, + ) + except Exception as exc: + logger.error("HA token exchange connection error: %s", exc) + return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302) + + if resp.status_code != 200: + logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text) + return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302) + + tokens = resp.json() + access_token = tokens["access_token"] + refresh_token = tokens.get("refresh_token", "") + expires_in = tokens.get("expires_in", 1800) + now = datetime.now(timezone.utc) + + try: + remote_cals = _ha_get_calendars(ha_url, access_token) + except HTTPException as exc: + logger.error("HA calendar fetch failed after OAuth: %s", exc.detail) + return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302) + + account = models.HomeAssistantAccount( + user_id=pending["user_id"], + name=pending["name"], + url=ha_url, + token=access_token, + auth_method="oauth", + refresh_token=refresh_token, + token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc), + client_id=client_id, + ) + db.add(account) + db.flush() + + for cal in remote_cals: + entity_id = cal.get("entity_id", "") + if not entity_id: + continue + db.add(models.HomeAssistantCalendar( + account_id=account.id, + entity_id=entity_id, + name=cal.get("name") or entity_id, + color=None, + enabled=True, + )) + + db.commit() + return RedirectResponse(url="/?ha_connected=1", status_code=302) + + @router.delete("/accounts/{account_id}") def delete_account( account_id: int, diff --git a/frontend/index.html b/frontend/index.html index b327ee9..f202162 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Calendarr v1 + Calendarr v2 @@ -71,7 +71,7 @@ - + @@ -118,7 +118,7 @@ Profil @@ -173,7 +173,7 @@
- + @@ -208,7 +208,7 @@
- +
@@ -216,7 +216,7 @@
- +
@@ -226,7 +226,7 @@
- +
@@ -234,7 +234,7 @@
- +
@@ -276,10 +276,10 @@ @@ -421,32 +421,25 @@
Anmeldemethode
-
+
+ Du wirst zur Login-Seite deiner Home Assistant Instanz weitergeleitet und nach erfolgreichem Login wieder zurück zu Calendarr. +
+ -
@@ -689,7 +682,7 @@
@@ -765,7 +758,7 @@ scarriffleservices@gmail.com

diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 925526b..5faf66a 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -75,6 +75,32 @@ export async function initCalendar() { bindHAAccountModal(); bindSettingsModal(); bindProfileModal(); + handleHAOAuthReturn(); +} + +function handleHAOAuthReturn() { + const params = new URLSearchParams(window.location.search); + const errMap = { + no_code: 'Home Assistant hat keinen Autorisierungscode zurückgegeben', + state_expired: 'Die Anmeldung ist abgelaufen, bitte erneut versuchen', + ha_unreachable: 'Home Assistant nicht erreichbar', + token_exchange_failed: 'Token-Austausch mit Home Assistant fehlgeschlagen', + calendars_failed: 'Kalender konnten nicht geladen werden', + }; + if (params.has('ha_connected')) { + showToast('Home Assistant verbunden'); + window.history.replaceState({}, '', window.location.pathname); + fetchAndRender(true); + api.get('/homeassistant/accounts').then(accs => { + state.haAccounts = accs || []; + renderCalendarList(); + renderAllAccounts?.(); + }).catch(() => {}); + } else if (params.has('ha_error')) { + const code = params.get('ha_error'); + showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true); + window.history.replaceState({}, '', window.location.pathname); + } } // ── Event cache ─────────────────────────────────────────── @@ -1266,30 +1292,32 @@ function openHAAccountModal() { document.getElementById('ha-account-name').value = ''; document.getElementById('ha-account-url').value = ''; document.getElementById('ha-account-token').value = ''; - document.getElementById('ha-account-username').value = ''; - document.getElementById('ha-account-userpass').value = ''; document.getElementById('ha-account-error').classList.add('hidden'); - // Reset to token method - document.getElementById('ha-auth-token').checked = true; - document.getElementById('ha-token-group').classList.remove('hidden'); - document.getElementById('ha-credentials-group').classList.add('hidden'); + // Reset to OAuth method + document.getElementById('ha-auth-oauth').checked = true; + document.getElementById('ha-oauth-info').classList.remove('hidden'); + document.getElementById('ha-token-group').classList.add('hidden'); + document.getElementById('ha-account-save').textContent = 'Mit Home Assistant anmelden'; openModal('modal-ha-account'); } function bindHAAccountModal() { - // Toggle auth method fields + // Toggle auth method fields + save button label document.querySelectorAll('[name="ha-auth-method"]').forEach(r => { r.addEventListener('change', () => { - const isPw = document.getElementById('ha-auth-password').checked; - document.getElementById('ha-token-group').classList.toggle('hidden', isPw); - document.getElementById('ha-credentials-group').classList.toggle('hidden', !isPw); + const isOAuth = document.getElementById('ha-auth-oauth').checked; + document.getElementById('ha-oauth-info').classList.toggle('hidden', !isOAuth); + document.getElementById('ha-token-group').classList.toggle('hidden', isOAuth); + document.getElementById('ha-account-save').textContent = isOAuth + ? 'Mit Home Assistant anmelden' + : 'Verbinden'; }); }); document.getElementById('ha-account-save').onclick = async () => { const name = document.getElementById('ha-account-name').value.trim(); const url = document.getElementById('ha-account-url').value.trim(); - const isPw = document.getElementById('ha-auth-password').checked; + const isOAuth = document.getElementById('ha-auth-oauth').checked; const errEl = document.getElementById('ha-account-error'); if (!name || !url) { @@ -1297,34 +1325,43 @@ function bindHAAccountModal() { errEl.classList.remove('hidden'); return; } - - const body = { name, url }; - if (isPw) { - const username = document.getElementById('ha-account-username').value.trim(); - const password = document.getElementById('ha-account-userpass').value.trim(); - if (!username || !password) { - errEl.textContent = 'Bitte Benutzername und Passwort ausfüllen'; - errEl.classList.remove('hidden'); - return; - } - body.username = username; - body.password = password; - } else { - const token = document.getElementById('ha-account-token').value.trim(); - if (!token) { - errEl.textContent = 'Bitte Access Token ausfüllen'; - errEl.classList.remove('hidden'); - return; - } - body.token = token; - } errEl.classList.add('hidden'); const saveBtn = document.getElementById('ha-account-save'); saveBtn.disabled = true; + + if (isOAuth) { + saveBtn.textContent = 'Weiterleiten…'; + try { + const base = window.location.origin; + const resp = await api.post('/homeassistant/auth-url', { + name, + url, + client_id: base + '/', + redirect_uri: base + '/api/homeassistant/callback', + }); + if (!resp) return; + window.location.href = resp.url; + } catch (e) { + errEl.textContent = e.message || 'Fehler beim Starten der Anmeldung'; + errEl.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Mit Home Assistant anmelden'; + } + return; + } + + // Long-Lived Token flow + const token = document.getElementById('ha-account-token').value.trim(); + if (!token) { + errEl.textContent = 'Bitte Access Token ausfüllen'; + errEl.classList.remove('hidden'); + saveBtn.disabled = false; + return; + } saveBtn.textContent = 'Verbinde…'; try { - const account = await api.post('/homeassistant/accounts', body); + const account = await api.post('/homeassistant/accounts', { name, url, token }); if (!account) return; state.haAccounts.push(account); renderCalendarList(); diff --git a/frontend/js/version.js b/frontend/js/version.js index c3d71fe..9d3baa6 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v1'; +export const APP_VERSION = 'v2';