feat: Home Assistant Benutzername/Passwort-Authentifizierung
Ergänzt die HA-Integration um Password-Grant OAuth2: Nutzer können sich nun wahlweise mit einem Long-Lived Token oder mit Benutzername/Passwort anmelden. Access Tokens werden automatisch per Refresh-Token erneuert.
This commit is contained in:
@@ -63,6 +63,21 @@ def _migrate():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_migrate()
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ class HomeAssistantAccount(Base):
|
|||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
url = Column(String(500), nullable=False)
|
url = Column(String(500), nullable=False)
|
||||||
token = Column(Text, 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")
|
user = relationship("User", back_populates="homeassistant_accounts")
|
||||||
calendars = relationship(
|
calendars = relationship(
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ def get_events(
|
|||||||
)
|
)
|
||||||
for ha_acc in ha_accounts:
|
for ha_acc in ha_accounts:
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,70 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
HA_DEFAULT_COLOR = "#03a9f4"
|
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 ────────────────────────────────────────
|
# ── 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 = []
|
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:
|
for cal in account.calendars:
|
||||||
if not cal.enabled or cal.sidebar_hidden:
|
if not cal.enabled or cal.sidebar_hidden:
|
||||||
continue
|
continue
|
||||||
try:
|
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
|
color = cal.color or HA_DEFAULT_COLOR
|
||||||
for ev in raw:
|
for ev in raw:
|
||||||
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
|
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,
|
"id": a.id,
|
||||||
"name": a.name,
|
"name": a.name,
|
||||||
"url": a.url,
|
"url": a.url,
|
||||||
|
"auth_method": a.auth_method or "token",
|
||||||
"calendars": [
|
"calendars": [
|
||||||
{
|
{
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
@@ -118,7 +188,9 @@ def _account_dict(a: models.HomeAssistantAccount) -> dict:
|
|||||||
class HAAccountCreate(BaseModel):
|
class HAAccountCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
token: str
|
token: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class HACalendarUpdate(BaseModel):
|
class HACalendarUpdate(BaseModel):
|
||||||
@@ -149,13 +221,31 @@ def add_account(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user),
|
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(
|
account = models.HomeAssistantAccount(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
url=data.url,
|
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.add(account)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -215,7 +305,8 @@ def sync_account(
|
|||||||
if not acc:
|
if not acc:
|
||||||
raise HTTPException(404, "Account not found")
|
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}
|
existing = {c.entity_id: c for c in acc.calendars}
|
||||||
|
|
||||||
for cal in remote_cals:
|
for cal in remote_cals:
|
||||||
|
|||||||
@@ -417,9 +417,30 @@
|
|||||||
<input type="url" id="ha-account-url" placeholder="http://homeassistant.local:8123" />
|
<input type="url" id="ha-account-url" placeholder="http://homeassistant.local:8123" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
<label>Anmeldemethode</label>
|
||||||
|
<div style="display:flex;gap:16px">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="radio" name="ha-auth-method" value="token" id="ha-auth-token" checked /> Long-Lived Token
|
||||||
|
</label>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="radio" name="ha-auth-method" value="password" id="ha-auth-password" /> Benutzername/Passwort
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="ha-token-group">
|
||||||
<label>Long-Lived Access Token</label>
|
<label>Long-Lived Access Token</label>
|
||||||
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
|
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
<div id="ha-credentials-group" class="hidden">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" id="ha-account-username" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" id="ha-account-userpass" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="ha-account-error" class="form-error hidden"></div>
|
<div id="ha-account-error" class="form-error hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -1266,28 +1266,65 @@ function openHAAccountModal() {
|
|||||||
document.getElementById('ha-account-name').value = '';
|
document.getElementById('ha-account-name').value = '';
|
||||||
document.getElementById('ha-account-url').value = '';
|
document.getElementById('ha-account-url').value = '';
|
||||||
document.getElementById('ha-account-token').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');
|
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');
|
||||||
openModal('modal-ha-account');
|
openModal('modal-ha-account');
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindHAAccountModal() {
|
function bindHAAccountModal() {
|
||||||
|
// Toggle auth method fields
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('ha-account-save').onclick = async () => {
|
document.getElementById('ha-account-save').onclick = async () => {
|
||||||
const name = document.getElementById('ha-account-name').value.trim();
|
const name = document.getElementById('ha-account-name').value.trim();
|
||||||
const url = document.getElementById('ha-account-url').value.trim();
|
const url = document.getElementById('ha-account-url').value.trim();
|
||||||
const token = document.getElementById('ha-account-token').value.trim();
|
const isPw = document.getElementById('ha-auth-password').checked;
|
||||||
const errEl = document.getElementById('ha-account-error');
|
const errEl = document.getElementById('ha-account-error');
|
||||||
if (!name || !url || !token) {
|
|
||||||
errEl.textContent = 'Bitte alle Felder ausfüllen';
|
if (!name || !url) {
|
||||||
|
errEl.textContent = 'Bitte Name und URL ausfüllen';
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
return;
|
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');
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
const saveBtn = document.getElementById('ha-account-save');
|
const saveBtn = document.getElementById('ha-account-save');
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.textContent = 'Verbinde…';
|
saveBtn.textContent = 'Verbinde…';
|
||||||
try {
|
try {
|
||||||
const account = await api.post('/homeassistant/accounts', { name, url, token });
|
const account = await api.post('/homeassistant/accounts', body);
|
||||||
state.haAccounts.push(account);
|
state.haAccounts.push(account);
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
closeModal('modal-ha-account');
|
closeModal('modal-ha-account');
|
||||||
|
|||||||
Reference in New Issue
Block a user