Files
flowdeck/app/routers/auth.py
T
bruno 7f5ad2aaca
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
fix: login/logout flow — protected routes + redirect to login on logout
- Logout now redirects to /auth/login?provider=local instead of /
- Dashboard / and /workspace check auth and redirect to login if no session
- _get_user_or_redirect() helper added for reusable auth checks
- No more fallback admin user on dashboard — explicit login required
- Routes without session redirect to login page
2026-07-10 15:57:14 -04:00

289 lines
14 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 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
.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</label><input type="email" id="email" required></div>
<div class="form-group"><label>Password</label><input type="password" id="password" required minlength="6"></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">
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 Login with Gitea</button>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 Login 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';}
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='/';}}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)
if provider == "local":
from fastapi.responses import HTMLResponse
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:
# Fallback: no OAuth configured, create admin session
from app.db import get_conn
with get_conn() as conn:
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
if not user:
conn.execute(
"INSERT INTO users (login, full_name, email, avatar_url, is_active) "
"VALUES ('admin', 'Admin', 'admin@localhost', '', 1)"
)
conn.commit()
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
user_data = dict(user)
session = SessionManager.create_session(user_data)
response = RedirectResponse(url="/", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
return response
state = secrets.token_hex(32)
request.session["oauth_state"] = state
request.session["oauth_provider"] = provider
auth_url = oauth_provider.get_authorize_url(state)
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)
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash) VALUES (?, ?, ?, ?)",
(email, name, email, hash_password(password)),
)
conn.commit()
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
user_data = dict(user)
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")
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)
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")
return response
@router.get("/callback")
async def callback(
request: Request,
code: str = Query(...),
state: str = Query(...),
):
"""Handle OAuth2 callback from Gitea."""
# Validate state
expected_state = request.session.get("oauth_state", "")
provider_name = request.session.get("oauth_provider", "gitea")
if not expected_state or state != expected_state:
return HTMLResponse("<h1>Invalid state</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
token_data = await oauth_provider.exchange_code(code)
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)
# 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)
VALUES (?, ?, ?, ?)
ON CONFLICT(login)
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
(login_id, oauth_user.get("full_name", ""), oauth_user.get("email", ""), oauth_user.get("avatar_url", "")),
)
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)
response = RedirectResponse(url="/", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
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}