Root cause: request.session cookie expires during Gitea OAuth redirect
→ oauth_mode lost → link mode falls through to login mode
→ Creates gitea_bruno user instead of linking to local account
Fix: state now carries mode suffix (state:mode)
Callback recovers mode from state parameter even if session lost
Allows full session loss but still correctly enters link mode
360 lines
18 KiB
Python
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='/';}}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)
|
|
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)
|
|
_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
|