diff --git a/app/db.py b/app/db.py index 483e998..26c3fd2 100644 --- a/app/db.py +++ b/app/db.py @@ -27,7 +27,12 @@ def init_db(): full_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', avatar_url TEXT NOT NULL DEFAULT '', - is_admin BOOLEAN NOT NULL DEFAULT 0, + password_hash TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + last_login TIMESTAMP, + login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -39,6 +44,19 @@ def init_db(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS user_oauth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + provider TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + expires_at TIMESTAMP, + instance_url TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, provider) + ); + CREATE TABLE IF NOT EXISTS boards ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_owner TEXT NOT NULL, @@ -304,6 +322,12 @@ def init_db(): conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0") except sqlite3.OperationalError: pass + # v2.2: Auth locale + for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}") + except sqlite3.OperationalError: + pass conn.commit() diff --git a/app/password_utils.py b/app/password_utils.py new file mode 100644 index 0000000..78ec1fd --- /dev/null +++ b/app/password_utils.py @@ -0,0 +1,35 @@ +"""Password hashing and login security utilities.""" + +import hashlib +import secrets +import time + + +def hash_password(password: str) -> str: + """Hash a password using SHA-256 + random salt (16 bytes). + Format: salt_hex:hash_hex (64 + 64 = 128 chars) + Fallback for bcrypt — we use SHA-256 for SQLite simplicity + but with proper salt per password.""" + salt = secrets.token_hex(16) + h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest() + return f"{salt}:{h}" + + +def verify_password(password: str, stored: str) -> bool: + """Verify a password against its stored hash.""" + try: + salt, h = stored.split(":", 1) + expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest() + return h == expected + except (ValueError, AttributeError): + return False + + +def is_locked(locked_until: str | None) -> bool: + """Check if account is temporarily locked.""" + if not locked_until: + return False + try: + return float(locked_until) > time.time() + except (ValueError, TypeError): + return False diff --git a/app/routers/auth.py b/app/routers/auth.py index b7a2f2c..b175723 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -14,10 +14,75 @@ from app.config import settings logger = logging.getLogger(__name__) router = APIRouter(tags=["auth"], prefix="/auth") +LOCAL_LOGIN_HTML = """ + + + + +FlowDeck — Login + + + +
+

FlowDeck

+

Login or create an account to continue

+
+ + +
+
+
+
+
+
+ + +
+
+ +
+
+ + +""" + @router.get("/login") -async def login(request: Request): - """Redirect to Gitea OAuth2 authorize page.""" +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 if not gitea_oauth.enabled: # Fallback: use global token, create a fake session from app.db import get_conn @@ -39,6 +104,100 @@ async def login(request: Request): 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, diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..e7caff4 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,189 @@ +{% extends "base.html" %} +{% block page_title %}Account Settings{% endblock %} +{% block page_icon %}⚙️{% endblock %} + +{% block topbar %} +
+
+ + Settings + + Account Settings +
+
+
+{% endblock %} + +{% block content %} + + +
+ + +
+

Profile

+
+
+
+
Email
+
Used for login and notifications
+
+
{{ user.email or user.login or '' }}
+
+
+
+
Name
+
Display name
+
+
+ + +
+
+
+
+
Password
+
Change your password
+
+
+ + +
+
+
+
+ + +
+

Connected Forges

+
+
+
+
🔗 Gitea
+
Connect to your Gitea instance
+
+ {% if gitea_connected %} + ✅ Connected + + {% else %} + + {% endif %} +
+
+
+
🐙 GitHub
+
Connect to GitHub
+
+ {% if github_connected %} + ✅ Connected + + {% else %} + + {% endif %} +
+
+
+ + +
+

API Tokens

+
+
+
+
Generate API Token
+
For programmatic access to FlowDeck API
+
+ +
+ +
+
+ + +
+

Active Sessions

+
+
+
+
Current session
+
You are logged in
+
+ +
+
+
+ +
+ + +{% endblock %}