Files
flowdeck/app/routers/auth.py
T
bruno 7cefc08abc
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
fix: Gitea tree loading + auth system overhaul (v2.5.0)
ROOT CAUSE: Local accounts ([email protected]) had no Gitea token linked,
so /api/gitea/.../tree returned 401 → loadGiteaTree() failed silently.

FIXES:
1. _require_gitea() now falls back to admin token for read ops
   → Any logged-in user can browse Gitea repos without linking account
2. get_user_gitea_client() filters by provider='gitea'
   → Prevents using wrong token if user has GitHub+Gitrea linked
3. OAuth callback now stores auth_method correctly
   → gitea_bruno gets auth_method='gitea' instead of 'local'
4. Linked Gitea token to [email protected] (user_id=127)
   → Local account can now use personal token for Gitea API

PREVIOUS FIXES (from prior commit):
- loadGiteaTree: skip if giteaWorkspace already loaded, error logging, .bind(this)
- gitea_workspace.html: use captured 'self' instead of querySelector('[x-data]')
- 12 test assertions updated to reflect admin fallback behavior
2026-07-15 18:17:49 -04:00

360 lines
18 KiB
Python

"""FlowDeck — Auth routes: login, callback, logout."""
from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Request, Query
from fastapi.responses import RedirectResponse, HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
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="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>
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("/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
# Dynamic redirect URI based on incoming Host header
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_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
import json
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 user is admin
user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
is_admin = 1 if 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)
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."""
from app.db import get_conn
from app.password_utils import verify_password, is_locked
from fastapi.responses import JSONResponse
import json, time
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)
_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 — use dynamic redirect URI matching the authorize step
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_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)
_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