177 lines
6.0 KiB
Python
177 lines
6.0 KiB
Python
|
|
"""
|
|||
|
|
Authentication Endpoints for Mitai Jinkendo
|
|||
|
|
|
|||
|
|
Handles login, logout, password reset, and profile authentication.
|
|||
|
|
"""
|
|||
|
|
import os
|
|||
|
|
import secrets
|
|||
|
|
import smtplib
|
|||
|
|
from typing import Optional
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from email.mime.text import MIMEText
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
|||
|
|
from starlette.requests import Request
|
|||
|
|
from slowapi import Limiter
|
|||
|
|
from slowapi.util import get_remote_address
|
|||
|
|
|
|||
|
|
from db import get_db, get_cursor
|
|||
|
|
from auth import hash_pin, verify_pin, make_token, require_auth
|
|||
|
|
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|||
|
|
limiter = Limiter(key_func=get_remote_address)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/login")
|
|||
|
|
@limiter.limit("5/minute")
|
|||
|
|
async def login(req: LoginRequest, request: Request):
|
|||
|
|
"""Login with email + password."""
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
|
|||
|
|
prof = cur.fetchone()
|
|||
|
|
if not prof:
|
|||
|
|
raise HTTPException(401, "Ungültige Zugangsdaten")
|
|||
|
|
|
|||
|
|
# Verify password
|
|||
|
|
if not verify_pin(req.password, prof['pin_hash']):
|
|||
|
|
raise HTTPException(401, "Ungültige Zugangsdaten")
|
|||
|
|
|
|||
|
|
# Auto-upgrade from SHA256 to bcrypt
|
|||
|
|
if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
|
|||
|
|
new_hash = hash_pin(req.password)
|
|||
|
|
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
|
|||
|
|
|
|||
|
|
# Create session
|
|||
|
|
token = make_token()
|
|||
|
|
session_days = prof.get('session_days', 30)
|
|||
|
|
expires = datetime.now() + timedelta(days=session_days)
|
|||
|
|
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
|
|||
|
|
(token, prof['id'], expires.isoformat()))
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"token": token,
|
|||
|
|
"profile_id": prof['id'],
|
|||
|
|
"name": prof['name'],
|
|||
|
|
"role": prof['role'],
|
|||
|
|
"expires_at": expires.isoformat()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/logout")
|
|||
|
|
def logout(x_auth_token: Optional[str]=Header(default=None)):
|
|||
|
|
"""Logout (delete session)."""
|
|||
|
|
if x_auth_token:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
|
|||
|
|
return {"ok": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/me")
|
|||
|
|
def get_me(session: dict=Depends(require_auth)):
|
|||
|
|
"""Get current user info."""
|
|||
|
|
pid = session['profile_id']
|
|||
|
|
# Import here to avoid circular dependency
|
|||
|
|
from routers.profiles import get_profile
|
|||
|
|
return get_profile(pid, session)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/status")
|
|||
|
|
def auth_status():
|
|||
|
|
"""Health check endpoint."""
|
|||
|
|
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.put("/pin")
|
|||
|
|
def change_pin(req: dict, session: dict=Depends(require_auth)):
|
|||
|
|
"""Change PIN/password for current user."""
|
|||
|
|
pid = session['profile_id']
|
|||
|
|
new_pin = req.get('pin', '')
|
|||
|
|
if len(new_pin) < 4:
|
|||
|
|
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
|
|||
|
|
|
|||
|
|
new_hash = hash_pin(new_pin)
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
|||
|
|
|
|||
|
|
return {"ok": True}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/forgot-password")
|
|||
|
|
@limiter.limit("3/minute")
|
|||
|
|
async def password_reset_request(req: PasswordResetRequest, request: Request):
|
|||
|
|
"""Request password reset email."""
|
|||
|
|
email = req.email.lower().strip()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
|
|||
|
|
prof = cur.fetchone()
|
|||
|
|
if not prof:
|
|||
|
|
# Don't reveal if email exists
|
|||
|
|
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
|||
|
|
|
|||
|
|
# Generate reset token
|
|||
|
|
token = secrets.token_urlsafe(32)
|
|||
|
|
expires = datetime.now() + timedelta(hours=1)
|
|||
|
|
|
|||
|
|
# Store in sessions table (reuse mechanism)
|
|||
|
|
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
|
|||
|
|
(f"reset_{token}", prof['id'], expires.isoformat()))
|
|||
|
|
|
|||
|
|
# Send email
|
|||
|
|
try:
|
|||
|
|
smtp_host = os.getenv("SMTP_HOST")
|
|||
|
|
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
|||
|
|
smtp_user = os.getenv("SMTP_USER")
|
|||
|
|
smtp_pass = os.getenv("SMTP_PASS")
|
|||
|
|
smtp_from = os.getenv("SMTP_FROM")
|
|||
|
|
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
|||
|
|
|
|||
|
|
if smtp_host and smtp_user and smtp_pass:
|
|||
|
|
msg = MIMEText(f"""Hallo {prof['name']},
|
|||
|
|
|
|||
|
|
Du hast einen Passwort-Reset angefordert.
|
|||
|
|
|
|||
|
|
Reset-Link: {app_url}/reset-password?token={token}
|
|||
|
|
|
|||
|
|
Der Link ist 1 Stunde gültig.
|
|||
|
|
|
|||
|
|
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
|
|||
|
|
|
|||
|
|
Dein Mitai Jinkendo Team
|
|||
|
|
""")
|
|||
|
|
msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
|
|||
|
|
msg['From'] = smtp_from
|
|||
|
|
msg['To'] = email
|
|||
|
|
|
|||
|
|
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
|||
|
|
server.starttls()
|
|||
|
|
server.login(smtp_user, smtp_pass)
|
|||
|
|
server.send_message(msg)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Email error: {e}")
|
|||
|
|
|
|||
|
|
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/reset-password")
|
|||
|
|
def password_reset_confirm(req: PasswordResetConfirm):
|
|||
|
|
"""Confirm password reset with token."""
|
|||
|
|
with get_db() as conn:
|
|||
|
|
cur = get_cursor(conn)
|
|||
|
|
cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
|
|||
|
|
(f"reset_{req.token}",))
|
|||
|
|
sess = cur.fetchone()
|
|||
|
|
if not sess:
|
|||
|
|
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
|
|||
|
|
|
|||
|
|
pid = sess['profile_id']
|
|||
|
|
new_hash = hash_pin(req.new_password)
|
|||
|
|
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
|||
|
|
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
|
|||
|
|
|
|||
|
|
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
|