feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
This commit is contained in:
@@ -318,12 +318,32 @@ async def local_login(request: Request):
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# v7.2.0: verified domain with SSO enforcement (admins keep local access).
|
||||
if not ud.get("is_admin"):
|
||||
with get_conn() as conn:
|
||||
dom = (ud.get("email") or "").split("@")[-1].lower() if "@" in (ud.get("email") or "") else ""
|
||||
if dom:
|
||||
enforced = conn.execute(
|
||||
"SELECT id FROM domain_claims WHERE domain=? AND verified=1"
|
||||
" AND enforce_sso=1", (dom,)).fetchone()
|
||||
if enforced:
|
||||
return JSONResponse(
|
||||
{"error": "Local login is disabled for your domain — sign in with SSO"},
|
||||
status_code=403)
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# v7.2.0: TOTP 2FA — password OK, but hold the session until code check.
|
||||
from app.services import two_factor as _2fa
|
||||
if _2fa.is_enabled(ud["id"]):
|
||||
return JSONResponse({"status": "2fa_required",
|
||||
"pending": _2fa.mint_pending(ud["id"])})
|
||||
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
@@ -458,6 +478,87 @@ async def current_user(request: Request):
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
if not _2fa.verify_code(user_id, body.get("code", "")):
|
||||
return JSONResponse({"error": "Invalid code"}, status_code=401)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
ud = dict(row)
|
||||
session = SessionManager.create_session(ud, request)
|
||||
_log_login(ud["id"], request)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True,
|
||||
max_age=86400 * 7, samesite="lax", path="/")
|
||||
return response
|
||||
|
||||
|
||||
def _session_user_or_401(request: Request) -> dict:
|
||||
from fastapi import HTTPException
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
"backup_remaining": _2fa.remaining_backup_codes(user["id"])}
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid code — secret not activated"},
|
||||
status_code=400)
|
||||
return {"status": "enabled", "backup_codes": codes}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
return {"status": "disabled"}
|
||||
|
||||
# ── Helpers ──
|
||||
def _log_login(user_id: int, request: Request):
|
||||
"""Record login in history."""
|
||||
|
||||
Reference in New Issue
Block a user