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:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user