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

@@ -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}