feat(caldav): app-specific passwords so MFA accounts can use CalDAV

CalDAV clients send only user+password over Basic Auth and can't provide a TOTP
code, so account passwords would bypass 2FA. Add revocable app passwords:

- models: AppPassword table (bcrypt hash, label, last_used); auto-created via
  create_all
- profile_router: GET/POST/DELETE /profile/app-passwords (plaintext shown once)
- dav_router: Basic Auth accepts any app password; the account password is
  accepted only when 2FA is disabled
- frontend: "App-Passwörter (CalDAV)" section in the profile modal (create/show-
  once/copy/revoke) + i18n (de/en); login hint now says app password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-01 13:15:35 +02:00
parent fb32f0424f
commit f662163185
8 changed files with 213 additions and 9 deletions

View File

@@ -114,6 +114,26 @@ class UserSettings(Base):
user = relationship("User", back_populates="settings") 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): class LocalCalendar(Base):
__tablename__ = "local_calendars" __tablename__ = "local_calendars"

View File

@@ -21,6 +21,7 @@ from __future__ import annotations
import base64 import base64
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from xml.sax.saxutils import escape as xml_escape 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: 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", "") hdr = request.headers.get("Authorization", "")
if not hdr.lower().startswith("basic "): if not hdr.lower().startswith("basic "):
return None 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() user = db.query(models.User).filter(models.User.username == username).first()
if not user: if not user:
return None 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 return None
except Exception: return None
return None
return user
def _unauthorized() -> Response: def _unauthorized() -> Response:

View File

@@ -1,6 +1,8 @@
import io import io
import re import re
import base64 import base64
import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -264,3 +266,74 @@ def disable_totp(
current_user.totp_enabled = False current_user.totp_enabled = False
db.commit() db.commit()
return {"ok": True} 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}

View File

@@ -1472,6 +1472,12 @@ a { color: var(--primary); text-decoration: none; }
background: var(--surface-2); color: var(--text-1); background: var(--surface-2); color: var(--text-1);
} }
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; } .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, .ct-bell { opacity: .45; transition: opacity .15s; }
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; } .ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
.ct-eye:hover, .ct-bell:hover { opacity: 1; } .ct-eye:hover, .ct-bell:hover { opacity: 1; }

View File

@@ -995,6 +995,26 @@
</div> </div>
</div> </div>
<!-- App passwords (CalDAV) -->
<div class="settings-section">
<h4 data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
<p class="text-muted" data-i18n="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.</p>
<div class="form-group app-pw-create">
<input type="text" id="app-pw-label" data-i18n-placeholder="app_pw_label_ph" placeholder="Name (z.B. iPhone)" maxlength="100" />
<button class="btn btn-primary btn-sm" id="app-pw-create-btn" data-i18n="app_pw_create">Erstellen</button>
</div>
<div id="app-pw-new" class="app-pw-new hidden">
<label data-i18n="app_pw_new_label">Neues App-Passwort (nur jetzt sichtbar):</label>
<div class="totp-secret-row">
<code id="app-pw-new-value"></code>
<button class="btn btn-ghost btn-sm" id="app-pw-copy" title="Kopieren">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button>
</div>
</div>
<div id="app-pw-list"></div>
</div>
<!-- Calendars --> <!-- Calendars -->
<div class="settings-section"> <div class="settings-section">
<h4>Meine Kalender</h4> <h4>Meine Kalender</h4>

View File

@@ -4144,6 +4144,50 @@ function bindProfileModal() {
document.getElementById('2fa-disable-pw').value = ''; document.getElementById('2fa-disable-pw').value = '';
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}; };
// ── App passwords (CalDAV) ──
const appPwList = document.getElementById('app-pw-list');
document.getElementById('app-pw-new').classList.add('hidden');
document.getElementById('app-pw-new-value').textContent = '';
async function loadAppPasswords() {
try {
const rows = await api.get('/profile/app-passwords');
appPwList.innerHTML = rows.length
? rows.map(r => `<div class="app-pw-item">
<span class="app-pw-name">${escHtml(r.label)}</span>
<span class="app-pw-meta">${r.last_used_at ? t('app_pw_last_used') + ' ' + new Date(r.last_used_at).toLocaleDateString() : t('app_pw_never_used')}</span>
<button class="btn btn-ghost btn-sm app-pw-del" data-id="${r.id}">${t('app_pw_revoke')}</button>
</div>`).join('')
: `<p class="text-muted">${t('app_pw_none')}</p>`;
} 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) { function updateTopbarAvatar(hasAvatar) {

View File

@@ -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_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_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_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', share: 'Teilen',
import: 'Importieren', import: 'Importieren',
export: 'Exportieren', 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_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_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_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', share: 'Share',
import: 'Import', import: 'Import',
export: 'Export', export: 'Export',

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v67'; export const APP_VERSION = 'v68';