- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table) - Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens - Active sessions management: list/revoke via /api/settings/sessions with device info - Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project) - Automatic daily backups: backup_db(), prune, scheduler + admin API - Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects() - GitHubAdapter implements ForgeAdapter contract, transport injection for mocking - Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs - Linting config: ruff (Python) + eslint (JS) - Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky) - Bumped version to 5.9.1 Co-authored-by: Bruno <[email protected]>
407 lines
20 KiB
Python
407 lines
20 KiB
Python
"""FlowDeck — Auth routes: login, callback, logout."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, Query, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["auth"], prefix="/auth")
|
|
|
|
|
|
def get_redirect_uri(request: Request) -> str:
|
|
"""OAuth redirect URI for this request.
|
|
|
|
Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the
|
|
provider's OAuth app). Otherwise it is derived from the request so it always
|
|
matches the URL the user actually used: scheme from `X-Forwarded-Proto`
|
|
(reverse proxies) falling back to the request scheme, host from
|
|
`X-Forwarded-Host` falling back to the `Host` header.
|
|
"""
|
|
if settings.oauth_redirect_uri:
|
|
return settings.oauth_redirect_uri
|
|
proto = request.headers.get("x-forwarded-proto", "")
|
|
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
|
|
fwd_host = request.headers.get("x-forwarded-host", "")
|
|
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
|
return f"{scheme}://{host}/auth/callback"
|
|
|
|
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>FlowDeck — Login</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box;}
|
|
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
|
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
|
.login-box h1{font-size:24px;margin-bottom:8px;}
|
|
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
|
.form-group{margin-bottom:16px;}
|
|
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
|
.form-group input{width:100%;padding:10px 36px 10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
|
|
.pw-wrapper{position:relative;}
|
|
.pw-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:16px;padding:4px;line-height:1;}
|
|
.pw-toggle:hover{color:rgba(255,255,255,.8);}
|
|
.form-group input:focus{border-color:#2383E2;box-shadow:0 0 0 1px #2383E2;}
|
|
.btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;margin-top:8px;}
|
|
.btn-primary{background:#2383E2;color:#fff;}
|
|
.btn-primary:hover{background:#2C8CEB;}
|
|
.btn-secondary{background:#333;color:#fff;margin-top:12px;}
|
|
.btn-secondary:hover{background:#444;}
|
|
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,.08);}
|
|
.tab{flex:1;text-align:center;padding:12px;cursor:pointer;font-size:14px;color:rgba(255,255,255,.5);border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none;}
|
|
.tab.active{color:#fff;border-bottom-color:#2383E2;}
|
|
.error{background:rgba(255,80,80,.15);color:#ff5050;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
|
.success{background:rgba(80,255,80,.15);color:#50ff50;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
|
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
|
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
|
.oauth-btn:hover{background:#333;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h1>FlowDeck</h1>
|
|
<p>Login or create an account to continue</p>
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
|
|
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
|
|
</div>
|
|
<div id="expired-msg" class="success" style="display:none">⚠️ Your session has expired. Please log in again.</div>
|
|
<div id="error-msg" class="error"></div>
|
|
<div id="success-msg" class="success"></div>
|
|
<form id="login-form" onsubmit="handleLogin(event)">
|
|
<div class="form-group"><label>Email or username</label><input type="text" id="email" required autocomplete="username"></div>
|
|
<div class="form-group">
|
|
<label>Password</label>
|
|
<div class="pw-wrapper">
|
|
<input type="password" id="password" required minlength="6" autocomplete="current-password">
|
|
<button type="button" class="pw-toggle" onclick="togglePassword()" title="Show password">👁</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
|
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
|
</form>
|
|
<div class="oauth-section">
|
|
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
// Show session expired banner if ?expired=1 in URL
|
|
(function(){if(location.search.includes('expired=1')){var el=document.getElementById('expired-msg');if(el)el.style.display='block';}})();
|
|
let mode='login';
|
|
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';var lbl=t==='register'?'Register':'Login';var e1=document.getElementById('gitea-btn-label');var e2=document.getElementById('github-btn-label');if(e1)e1.textContent=lbl;if(e2)e2.textContent=lbl;}
|
|
function togglePassword(){var pw=document.getElementById('password');var btn=pw.parentElement.querySelector('.pw-toggle');if(pw.type==='password'){pw.type='text';btn.textContent='🙈';}else{pw.type='password';btn.textContent='👁';}}
|
|
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/workspaces';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@router.get("/register")
|
|
async def register_page(request: Request):
|
|
"""Show the registration page (local login page with register tab active)."""
|
|
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
|
'class="tab active" onclick="switchTab(\'login\')"',
|
|
'class="tab" onclick="switchTab(\'login\')"'
|
|
).replace(
|
|
'class="tab" onclick="switchTab(\'register\')"',
|
|
'class="tab active" onclick="switchTab(\'register\')"'
|
|
).replace(
|
|
'let mode=\'login\';',
|
|
'let mode=\'register\';'
|
|
).replace(
|
|
'id="name-group" style="display:none"',
|
|
'id="name-group" style="display:block"'
|
|
).replace(
|
|
'id="submit-btn">Login<',
|
|
'id="submit-btn">Register<'
|
|
), status_code=200)
|
|
|
|
|
|
@router.get("/login")
|
|
async def login(request: Request, provider: str = Query("gitea")):
|
|
"""Redirect to OAuth2 authorize page or show local login page."""
|
|
# Local login page (POST handled by /auth/local-login)
|
|
from fastapi.responses import HTMLResponse
|
|
if provider == "local":
|
|
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
|
|
|
# OAuth flow — check if provider is configured
|
|
from app.auth.providers import get_provider
|
|
oauth_provider = get_provider(provider)
|
|
if not oauth_provider:
|
|
# OAuth provider not configured — show error instead of auto-creating admin
|
|
return HTMLResponse(
|
|
f"""<!DOCTYPE html><html><head><title>FlowDeck</title><style>
|
|
body{{background:#191919;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center;}}
|
|
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;max-width:420px;}}
|
|
h1{{font-size:20px;margin-bottom:12px;}}p{{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:16px;}}
|
|
a{{color:#2383E2;}}
|
|
</style></head><body><div class="box">
|
|
<h1>⚠️ {provider.title()} OAuth not configured</h1>
|
|
<p>The {provider} integration has not been set up by the server administrator.</p>
|
|
<p><a href="/auth/login?provider=local">↩ Use local login</a></p>
|
|
</div></body></html>""",
|
|
status_code=200,
|
|
)
|
|
|
|
state = secrets.token_hex(32)
|
|
request.session["oauth_state"] = state
|
|
request.session["oauth_provider"] = provider
|
|
# Link mode: connect OAuth to current local account instead of creating new user
|
|
mode = request.query_params.get("mode", "")
|
|
# Encode auth mode in state to survive session loss during OAuth redirect
|
|
signed_state = f"{state}:{mode}" if mode else state
|
|
request.session["oauth_mode"] = mode
|
|
# Redirect URI derived from the incoming request (scheme-aware); stored in
|
|
# session so the callback reuses the EXACT same URI for token exchange
|
|
redirect_uri = get_redirect_uri(request)
|
|
request.session["oauth_redirect_uri"] = redirect_uri
|
|
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link"))
|
|
return RedirectResponse(url=auth_url, status_code=302)
|
|
|
|
|
|
@router.post("/register")
|
|
async def register(request: Request):
|
|
"""Register a new local account."""
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import hash_password
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
email = body.get("email", "").strip()
|
|
password = body.get("password", "").strip()
|
|
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
|
|
|
if not email or not password:
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
|
if len(password) < 6:
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
|
|
with get_conn() as conn:
|
|
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
|
if existing:
|
|
from fastapi.responses import JSONResponse
|
|
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
|
# First real user (excluding default admin with no password) is admin
|
|
real_user_count = conn.execute(
|
|
"SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL AND password_hash != ''"
|
|
).fetchone()[0]
|
|
is_admin = 1 if real_user_count == 0 else 0
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
|
(email, name, email, hash_password(password), is_admin),
|
|
)
|
|
conn.commit()
|
|
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
|
user_data = dict(user)
|
|
# Log login
|
|
_log_login(user_data["id"], request)
|
|
session = SessionManager.create_session(user_data, request)
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
@router.post("/local-login")
|
|
async def local_login(request: Request):
|
|
"""Login with email + password."""
|
|
import time
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.db import get_conn
|
|
from app.password_utils import is_locked, verify_password
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
email = body.get("email", "").strip()
|
|
password = body.get("password", "").strip()
|
|
|
|
if not email or not password:
|
|
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
|
|
|
with get_conn() as conn:
|
|
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
|
if not user:
|
|
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
|
|
|
ud = dict(user)
|
|
if not ud.get("is_active"):
|
|
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
|
if is_locked(ud.get("locked_until")):
|
|
return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
|
|
|
|
if not verify_password(password, ud.get("password_hash", "")):
|
|
with get_conn() as conn:
|
|
attempts = (ud.get("login_attempts", 0) or 0) + 1
|
|
lock = None
|
|
if attempts >= 5:
|
|
lock = str(time.time() + 900) # 15 min lock
|
|
conn.execute(
|
|
"UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
|
|
(attempts, lock, ud["id"]),
|
|
)
|
|
conn.commit()
|
|
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
|
|
|
# Successful login
|
|
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()
|
|
session = SessionManager.create_session(ud, request)
|
|
_log_login(ud["id"], request)
|
|
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
@router.get("/callback")
|
|
async def callback(
|
|
request: Request,
|
|
code: str = Query(...),
|
|
state: str = Query(...),
|
|
):
|
|
"""Handle OAuth2 callback from Gitea."""
|
|
# Recover mode from state suffix (state:mode format), then validate
|
|
expected_state = request.session.get("oauth_state", "")
|
|
provider_name = request.session.get("oauth_provider", "gitea")
|
|
|
|
auth_mode = ""
|
|
raw_state = state
|
|
if ":" in state:
|
|
raw_state, auth_mode = state.rsplit(":", 1)
|
|
if auth_mode:
|
|
request.session["oauth_mode"] = auth_mode
|
|
|
|
# Validate: state token must match session, unless session lost and mode present
|
|
if expected_state and raw_state != expected_state:
|
|
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
|
if not expected_state and not auth_mode:
|
|
return HTMLResponse("<h1>Session expired — please try connecting again</h1>", status_code=400)
|
|
|
|
from app.auth.providers import get_provider
|
|
oauth_provider = get_provider(provider_name)
|
|
if not oauth_provider:
|
|
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
|
|
|
|
# Exchange code for token — reuse the redirect URI from the authorize step
|
|
# (stored in session), falling back to deriving it from this request
|
|
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
|
|
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
|
|
if not token_data:
|
|
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
|
|
|
access_token = token_data.get("access_token")
|
|
if not access_token:
|
|
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
|
|
|
# Get user info
|
|
oauth_user = await oauth_provider.get_user(access_token)
|
|
if not oauth_user:
|
|
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
|
|
|
# Link mode: connect OAuth to current session user (for Settings → Integrations)
|
|
oauth_mode = request.session.pop("oauth_mode", "")
|
|
if oauth_mode == "link":
|
|
from app.auth.session import get_current_user as gcu
|
|
current = await gcu(request)
|
|
if not current:
|
|
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
|
from app.db import get_conn as _gc
|
|
with _gc() as conn:
|
|
conn.execute(
|
|
"""INSERT OR REPLACE INTO user_oauth_tokens
|
|
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
|
(current["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
|
)
|
|
conn.commit()
|
|
return RedirectResponse(url="/settings#integrations", status_code=302)
|
|
|
|
# Store user in DB
|
|
from app.db import get_conn
|
|
login_id = f"{provider_name}_{oauth_user['login']}"
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO users (login, full_name, email, avatar_url, auth_method)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(login)
|
|
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url, auth_method=excluded.auth_method""",
|
|
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", ""), provider_name),
|
|
)
|
|
conn.commit()
|
|
# Store OAuth token
|
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login_id,)).fetchone()
|
|
if uid:
|
|
conn.execute(
|
|
"""INSERT OR REPLACE INTO user_oauth_tokens
|
|
(user_id, provider, access_token, refresh_token, expires_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
|
|
(uid["id"], provider_name, access_token, token_data.get("refresh_token"), token_data.get("expires_at")),
|
|
)
|
|
conn.commit()
|
|
user = conn.execute("SELECT * FROM users WHERE id=?", (uid["id"],)).fetchone()
|
|
else:
|
|
user = conn.execute("SELECT * FROM users WHERE login=?", (login_id,)).fetchone()
|
|
user_data = dict(user) if user else oauth_user
|
|
|
|
# Create session
|
|
session = SessionManager.create_session(user_data, request)
|
|
_log_login(user_data["id"], request)
|
|
response = RedirectResponse(url="/workspaces", status_code=302)
|
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
@router.get("/logout")
|
|
async def logout():
|
|
"""Clear session and redirect to login page."""
|
|
response = RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
|
response.delete_cookie("flowdeck_session")
|
|
return response
|
|
|
|
|
|
@router.get("/user")
|
|
async def current_user(request: Request):
|
|
"""Return current user info as JSON."""
|
|
from app.auth.session import get_current_user as gcu
|
|
user = await gcu(request)
|
|
if not user:
|
|
return {"authenticated": False}
|
|
return {"authenticated": True, "user": user}
|
|
|
|
# ── Helpers ──
|
|
def _log_login(user_id: int, request: Request):
|
|
"""Record login in history."""
|
|
try:
|
|
from app.db import get_conn
|
|
ip = request.client.host if request.client else ''
|
|
ua = request.headers.get('user-agent', '')[:500]
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO login_history (user_id, ip_address, user_agent) VALUES (?, ?, ?)",
|
|
(user_id, ip, ua),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|