Profilseite mit Avatar, Passwort-Änderung und TOTP 2FA
- Neues Profil-Modal: Avatar-Upload, E-Mail bearbeiten, Kalender-Übersicht - Passwort ändern mit Validierung des aktuellen Passworts - TOTP 2FA: QR-Code + manueller Schlüssel, Aktivierung/Deaktivierung - Login-Flow unterstützt 2FA-Code (neuer JSON-Endpoint /auth/login) - User-Dropdown mit Profil-Link statt confirm()-Dialog - Kalenderfarben in Sidebar editierbar (Color-Picker auf Farbpunkt) - Monatsansicht nutzt volle Höhe (#view-container flex fix) - requirements.txt: passlib durch bcrypt ersetzt, pyotp/qrcode/Pillow hinzugefügt
This commit is contained in:
@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, caldav_router, settings_router, users_router
|
||||
from routers import auth_router, caldav_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -24,6 +24,7 @@ app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
@@ -11,6 +11,9 @@ class User(Base):
|
||||
email = Column(String(100), unique=True, nullable=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
avatar_filename = Column(String(255), nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_enabled = Column(Boolean, default=False)
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
import pyotp
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
@@ -18,6 +19,12 @@ class SetupRequest(BaseModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
|
||||
def _user_dict(user: models.User) -> dict:
|
||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||
|
||||
@@ -59,6 +66,37 @@ def login(
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="2fa_required",
|
||||
)
|
||||
token = create_access_token({"sub": user.username})
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_json(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = (
|
||||
db.query(models.User).filter(models.User.username == req.username).first()
|
||||
)
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Benutzername oder Passwort falsch",
|
||||
)
|
||||
if user.totp_enabled:
|
||||
if not req.totp_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="2fa_required",
|
||||
)
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
if not totp.verify(req.totp_code, valid_window=1):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Ungültiger 2FA-Code",
|
||||
)
|
||||
token = create_access_token({"sub": user.username})
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
@@ -70,4 +108,6 @@ def me(current_user: models.User = Depends(get_current_user)):
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
"totp_enabled": current_user.totp_enabled,
|
||||
}
|
||||
|
||||
203
backend/routers/profile_router.py
Normal file
203
backend/routers/profile_router.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import io
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_user, get_password_hash, verify_password
|
||||
from database import DATA_DIR, get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = DATA_DIR / "avatars"
|
||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2 MB
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────
|
||||
class ProfileUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class TOTPVerify(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class TOTPDisable(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
# ── Profile ───────────────────────────────────────────────
|
||||
@router.get("/")
|
||||
def get_profile(current_user: models.User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
"has_avatar": current_user.avatar_filename is not None,
|
||||
"totp_enabled": current_user.totp_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/")
|
||||
def update_profile(
|
||||
data: ProfileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if data.email is not None:
|
||||
current_user.email = data.email or None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Avatar ────────────────────────────────────────────────
|
||||
@router.post("/avatar")
|
||||
async def upload_avatar(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_AVATAR_SIZE:
|
||||
raise HTTPException(400, "Datei zu groß (max. 2 MB)")
|
||||
|
||||
# Resize to 256x256
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((256, 256))
|
||||
|
||||
filename = f"user_{current_user.id}.jpg"
|
||||
path = AVATAR_DIR / filename
|
||||
img.save(str(path), "JPEG", quality=85)
|
||||
|
||||
current_user.avatar_filename = filename
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
def get_avatar(current_user: models.User = Depends(get_current_user)):
|
||||
if not current_user.avatar_filename:
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
path = AVATAR_DIR / current_user.avatar_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
return FileResponse(str(path), media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/avatar/{user_id}")
|
||||
def get_user_avatar(user_id: int, db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user or not user.avatar_filename:
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
path = AVATAR_DIR / user.avatar_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "Kein Profilbild")
|
||||
return FileResponse(str(path), media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.delete("/avatar")
|
||||
def delete_avatar(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if current_user.avatar_filename:
|
||||
path = AVATAR_DIR / current_user.avatar_filename
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
current_user.avatar_filename = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Password ──────────────────────────────────────────────
|
||||
@router.post("/password")
|
||||
def change_password(
|
||||
data: PasswordChange,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(400, "Aktuelles Passwort ist falsch")
|
||||
if len(data.new_password) < 6:
|
||||
raise HTTPException(400, "Passwort muss mindestens 6 Zeichen haben")
|
||||
current_user.password_hash = get_password_hash(data.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 2FA / TOTP ───────────────────────────────────────────
|
||||
@router.post("/2fa/setup")
|
||||
def setup_totp(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
secret = pyotp.random_base32()
|
||||
current_user.totp_secret = secret
|
||||
db.commit()
|
||||
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(name=current_user.username, issuer_name="Calendarr")
|
||||
|
||||
# Generate QR code as base64
|
||||
qr = qrcode.make(uri, box_size=6, border=2)
|
||||
buf = io.BytesIO()
|
||||
qr.save(buf, format="PNG")
|
||||
qr_b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_code": f"data:image/png;base64,{qr_b64}",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/2fa/enable")
|
||||
def enable_totp(
|
||||
data: TOTPVerify,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(400, "2FA wurde noch nicht eingerichtet")
|
||||
|
||||
totp = pyotp.TOTP(current_user.totp_secret)
|
||||
if not totp.verify(data.code, valid_window=1):
|
||||
raise HTTPException(400, "Ungültiger Code")
|
||||
|
||||
current_user.totp_enabled = True
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
def disable_totp(
|
||||
data: TOTPDisable,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if not verify_password(data.password, current_user.password_hash):
|
||||
raise HTTPException(400, "Passwort ist falsch")
|
||||
|
||||
current_user.totp_secret = None
|
||||
current_user.totp_enabled = False
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user