v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
CI / test (push) Failing after 5s
CI / lint (push) Failing after 4s

- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
This commit is contained in:
2026-07-08 09:23:57 -04:00
parent 87862b64b8
commit 5a124d1328
17 changed files with 1390 additions and 91 deletions
+3
View File
@@ -0,0 +1,3 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user
+77
View File
@@ -0,0 +1,77 @@
"""FlowDeck — Gitea OAuth2 client."""
from __future__ import annotations
import logging
from urllib.parse import urlencode
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class GiteaOAuth:
"""Gitea OAuth2 client for user authentication."""
AUTHORIZE_URL = f"{settings.gitea_url}/login/oauth/authorize"
TOKEN_URL = f"{settings.gitea_url}/login/oauth/access_token"
USER_URL = f"{settings.gitea_url}/api/v1/user"
def __init__(self):
self.client_id = settings.gitea_oauth_client_id
self.client_secret = settings.gitea_oauth_client_secret
self.redirect_uri = settings.oauth_redirect_uri
@property
def enabled(self) -> bool:
return bool(self.client_id and self.client_secret)
def get_authorize_url(self, state: str) -> str:
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"state": state,
}
return f"{self.AUTHORIZE_URL}?{urlencode(params)}"
async def exchange_code(self, code: str) -> dict | None:
"""Exchange authorization code for access token."""
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
self.TOKEN_URL,
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self.redirect_uri,
},
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
return data
except Exception as e:
logger.error("OAuth token exchange failed: %s", e)
return None
async def get_user(self, access_token: str) -> dict | None:
"""Get user info from Gitea API."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
self.USER_URL,
headers={"Authorization": f"token {access_token}"},
)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.error("OAuth user fetch failed: %s", e)
return None
# Singleton
gitea_oauth = GiteaOAuth()
+68
View File
@@ -0,0 +1,68 @@
"""FlowDeck — Session management with signed cookies."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
class SessionManager:
"""Manages user sessions via signed cookies."""
@staticmethod
def create_session(user_data: dict) -> str:
"""Create a signed session cookie value."""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
return _serializer.dumps(payload)
@staticmethod
def decode_session(cookie: str) -> dict | None:
"""Decode and validate a session cookie. Returns user data or None."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
return payload.get("user")
except (BadSignature, SignatureExpired):
return None
@staticmethod
def store_token(user_id: int, gitea_token: str) -> None:
"""Store a user's Gitea OAuth token in SQLite."""
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"""INSERT INTO user_tokens (gitea_user_id, gitea_token, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(gitea_user_id)
DO UPDATE SET gitea_token=excluded.gitea_token, updated_at=CURRENT_TIMESTAMP""",
(user_id, gitea_token),
)
conn.commit()
@staticmethod
def get_token(user_id: int) -> str | None:
"""Get a user's stored Gitea token."""
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT gitea_token FROM user_tokens WHERE gitea_user_id=?",
(user_id,),
).fetchone()
return row["gitea_token"] if row else None
# FastAPI dependency
async def get_current_user(request) -> dict | None:
"""FastAPI dependency: extract current user from session cookie."""
from fastapi import Request
session = request.cookies.get("flowdeck_session")
if session:
return SessionManager.decode_session(session)
return None