diff --git a/backend/models.py b/backend/models.py index 26f3d7b..9edcc10 100644 --- a/backend/models.py +++ b/backend/models.py @@ -114,6 +114,26 @@ class UserSettings(Base): user = relationship("User", back_populates="settings") +class AppPassword(Base): + """Per-device app-specific password for CalDAV (Basic Auth). + + Keeps MFA intact: accounts with 2FA can't use their normal password over + CalDAV (clients can't send a TOTP code), so they authenticate with one of + these revocable app passwords instead. Only the bcrypt hash is stored. + """ + + __tablename__ = "app_passwords" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + label = Column(String(100), nullable=False) + password_hash = Column(String(255), nullable=False) + created_at = Column(String(50), nullable=True) + last_used_at = Column(String(50), nullable=True) + + user = relationship("User") + + class LocalCalendar(Base): __tablename__ = "local_calendars" diff --git a/backend/routers/dav_router.py b/backend/routers/dav_router.py index b846099..86ce8b7 100644 --- a/backend/routers/dav_router.py +++ b/backend/routers/dav_router.py @@ -21,6 +21,7 @@ from __future__ import annotations import base64 import uuid import xml.etree.ElementTree as ET +from datetime import datetime, timezone from urllib.parse import quote, unquote from xml.sax.saxutils import escape as xml_escape @@ -70,7 +71,12 @@ def _resolve(token: str, db: Session) -> models.LocalCalendar | None: def _basic_auth_user(request: Request, db: Session) -> models.User | None: - """Validate an HTTP Basic Authorization header against a Calendarr account.""" + """Validate an HTTP Basic Authorization header against a Calendarr account. + + Accepts an app-specific password (always) or the account password (only when + MFA is off — otherwise the account password would bypass 2FA, which CalDAV + clients can't satisfy). + """ hdr = request.headers.get("Authorization", "") if not hdr.lower().startswith("basic "): return None @@ -84,12 +90,25 @@ def _basic_auth_user(request: Request, db: Session) -> models.User | None: user = db.query(models.User).filter(models.User.username == username).first() if not user: return None - try: - if not verify_password(password, user.password_hash): + + # 1) App-specific passwords — always allowed, MFA-safe. + for ap in db.query(models.AppPassword).filter(models.AppPassword.user_id == user.id).all(): + try: + if verify_password(password, ap.password_hash): + ap.last_used_at = datetime.now(timezone.utc).isoformat() + db.commit() + return user + except Exception: + continue + + # 2) Account password — only when 2FA is disabled. + if not user.totp_enabled: + try: + if verify_password(password, user.password_hash): + return user + except Exception: return None - except Exception: - return None - return user + return None def _unauthorized() -> Response: diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index 1f16a37..c03eb00 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -1,6 +1,8 @@ import io import re import base64 +import secrets +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -264,3 +266,74 @@ def disable_totp( current_user.totp_enabled = False db.commit() return {"ok": True} + + +# ── App passwords (for CalDAV Basic Auth) ──────────────── +class AppPasswordCreate(BaseModel): + label: str = Field(default="CalDAV", max_length=100) + + +def _app_pw_dict(ap: models.AppPassword) -> dict: + return { + "id": ap.id, + "label": ap.label, + "created_at": ap.created_at, + "last_used_at": ap.last_used_at, + } + + +@router.get("/app-passwords") +def list_app_passwords( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + rows = ( + db.query(models.AppPassword) + .filter(models.AppPassword.user_id == current_user.id) + .order_by(models.AppPassword.id.desc()) + .all() + ) + return [_app_pw_dict(r) for r in rows] + + +@router.post("/app-passwords") +def create_app_password( + data: AppPasswordCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + # Show the plaintext exactly once; only the hash is stored. + password = secrets.token_urlsafe(18) + ap = models.AppPassword( + user_id=current_user.id, + label=(data.label or "CalDAV")[:100], + password_hash=get_password_hash(password), + created_at=datetime.now(timezone.utc).isoformat(), + ) + db.add(ap) + db.commit() + db.refresh(ap) + out = _app_pw_dict(ap) + out["password"] = password + return out + + +@router.delete("/app-passwords/{ap_id}") +def delete_app_password( + ap_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + ap = ( + db.query(models.AppPassword) + .filter( + models.AppPassword.id == ap_id, + models.AppPassword.user_id == current_user.id, + ) + .first() + ) + if not ap: + raise HTTPException(404, "App password not found") + db.delete(ap) + db.commit() + return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index e7b108f..5e34cb4 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -1472,6 +1472,12 @@ a { color: var(--primary); text-decoration: none; } background: var(--surface-2); color: var(--text-1); } .ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; } +.app-pw-create { display: flex; gap: 8px; align-items: center; } +.app-pw-create input { flex: 1; } +.app-pw-new { margin: 8px 0; } +.app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); } +.app-pw-name { font-weight: 500; } +.app-pw-meta { font-size: 11px; color: var(--text-3); margin-left: auto; } .ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; } diff --git a/frontend/index.html b/frontend/index.html index 2d30710..cfcad7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -995,6 +995,26 @@ + +
Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.
+${t('app_pw_none')}
`; + } catch (e) { /* ignore */ } + } + loadAppPasswords(); + + document.getElementById('app-pw-create-btn').onclick = async () => { + const label = document.getElementById('app-pw-label').value.trim() || 'CalDAV'; + try { + const res = await api.post('/profile/app-passwords', { label }); + document.getElementById('app-pw-new-value').textContent = res.password; + document.getElementById('app-pw-new').classList.remove('hidden'); + document.getElementById('app-pw-label').value = ''; + loadAppPasswords(); + } catch (e) { showToast(e.message, true); } + }; + + document.getElementById('app-pw-copy').onclick = () => { + const v = document.getElementById('app-pw-new-value').textContent; + if (v) navigator.clipboard.writeText(v).then(() => showToast(t('app_pw_copied'))); + }; + + appPwList.onclick = async (e) => { + const btn = e.target.closest('.app-pw-del'); + if (!btn) return; + if (!confirm(t('app_pw_revoke_confirm'))) return; + try { + await api.delete(`/profile/app-passwords/${btn.dataset.id}`); + loadAppPasswords(); + } catch (err) { showToast(err.message, true); } + }; } function updateTopbarAvatar(hasAvatar) { diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 9164424..c6a37dc 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -124,7 +124,18 @@ const translations = { caldav_rotate_confirm: 'Neuen Token erzeugen? Die bisherige URL wird ungültig und bestehende Abos müssen mit der neuen URL neu eingerichtet werden.', caldav_hint: 'Jeder mit dieser URL kann diesen Kalender abonnieren und bearbeiten – kein Login nötig. Über CalDAV-fähige Clients (Apple Kalender, Thunderbird, DAVx5) einbinden.', caldav_login_url: 'CalDAV-URL (mit Login):', - caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und Passwort hinzufügen – dann werden alle deine veröffentlichten Kalender gefunden.', + caldav_login_hint: 'Alternativ im Client ein „CalDAV-Konto" mit dieser Server-URL sowie deinem Benutzernamen und App-Passwort hinzufügen – dann werden alle deine veröffentlichten Kalender gefunden.', + app_pw_title: 'App-Passwörter (CalDAV)', + app_pw_desc: 'Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.', + app_pw_label_ph: 'Name (z.B. iPhone)', + app_pw_create: 'Erstellen', + app_pw_new_label: 'Neues App-Passwort (nur jetzt sichtbar):', + app_pw_last_used: 'zuletzt', + app_pw_never_used: 'noch nie genutzt', + app_pw_revoke: 'Widerrufen', + app_pw_none: 'Noch keine App-Passwörter.', + app_pw_copied: 'App-Passwort kopiert', + app_pw_revoke_confirm: 'Dieses App-Passwort widerrufen? Clients, die es nutzen, verlieren den Zugriff.', share: 'Teilen', import: 'Importieren', export: 'Exportieren', @@ -428,7 +439,18 @@ const translations = { caldav_rotate_confirm: 'Generate a new token? The current URL will stop working and existing subscriptions must be re-added with the new URL.', caldav_hint: 'Anyone with this URL can subscribe to and edit this calendar — no login required. Add it in a CalDAV-capable client (Apple Calendar, Thunderbird, DAVx5).', caldav_login_url: 'CalDAV URL (with login):', - caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and password — it will discover all your published calendars.', + caldav_login_hint: 'Alternatively add a "CalDAV account" in your client using this server URL plus your username and app password — it will discover all your published calendars.', + app_pw_title: 'App passwords (CalDAV)', + app_pw_desc: "Dedicated passwords for CalDAV clients. Required when 2FA is on, since apps can't enter a 2FA code. Revocable anytime.", + app_pw_label_ph: 'Name (e.g. iPhone)', + app_pw_create: 'Create', + app_pw_new_label: 'New app password (shown only now):', + app_pw_last_used: 'last used', + app_pw_never_used: 'never used', + app_pw_revoke: 'Revoke', + app_pw_none: 'No app passwords yet.', + app_pw_copied: 'App password copied', + app_pw_revoke_confirm: 'Revoke this app password? Clients using it will lose access.', share: 'Share', import: 'Import', export: 'Export', diff --git a/frontend/js/version.js b/frontend/js/version.js index 4fd4600..8a9ae36 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 = 'v67'; +export const APP_VERSION = 'v68';