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:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user