From 09c742fefbaeadabf3ead9d653ed8308bee0dad2 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 28 May 2026 11:40:49 +0200 Subject: [PATCH] auth via DB --- app/auth.py | 93 +++++++++++++++++++++++++++++++ app/main.py | 15 ++++- app/routes/apps.py | 28 ++++++++-- app/routes/auth.py | 67 ++++++++++++++++++++++ app/routes/backups.py | 11 ++-- app/routes/deployments.py | 9 ++- app/scripts/__init__.py | 1 + app/scripts/set_admin_password.py | 54 ++++++++++++++++++ app/static/styles.css | 38 +++++++++++++ app/templates/layout.py | 37 +++++++++--- requirements.txt | 5 +- 11 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 app/auth.py create mode 100644 app/routes/auth.py create mode 100644 app/scripts/__init__.py create mode 100644 app/scripts/set_admin_password.py diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..a8f88fe --- /dev/null +++ b/app/auth.py @@ -0,0 +1,93 @@ +from typing import Any + +from fastapi import HTTPException, Request, status +from passlib.context import CryptContext + +from app.db.database import get_connection + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, password_hash: str) -> bool: + if not password_hash: + return False + + return pwd_context.verify(plain_password, password_hash) + + +def get_user_by_username(username: str) -> dict[str, Any] | None: + con = get_connection() + + row = con.execute( + """ + SELECT id, username, display_name, email, password_hash, role, is_active, created_at, updated_at + FROM users + WHERE username = ? + """, + (username,), + ).fetchone() + + con.close() + return dict(row) if row else None + + +def get_user_by_id(user_id: int) -> dict[str, Any] | None: + con = get_connection() + + row = con.execute( + """ + SELECT id, username, display_name, email, password_hash, role, is_active, created_at, updated_at + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + + con.close() + return dict(row) if row else None + + +def authenticate_user(username: str, password: str) -> dict[str, Any] | None: + user = get_user_by_username(username.strip()) + + if not user or not user.get("is_active"): + return None + + if not verify_password(password, user.get("password_hash") or ""): + return None + + return user + + +def current_user(request: Request) -> dict[str, Any] | None: + user_id = request.session.get("user_id") + if not user_id: + return None + + try: + user_id = int(user_id) + except (TypeError, ValueError): + request.session.pop("user_id", None) + return None + + user = get_user_by_id(user_id) + if not user or not user.get("is_active"): + request.session.pop("user_id", None) + return None + + return user + + +def require_user(request: Request) -> dict[str, Any]: + user = current_user(request) + if user: + return user + + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/portal/login"}, + ) diff --git a/app/main.py b/app/main.py index ad7a578..a36118d 100644 --- a/app/main.py +++ b/app/main.py @@ -2,17 +2,28 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware -from .routes import apps, backups, deployments, health +from .config import read_env_value +from .routes import apps, auth, backups, deployments, health def create_app() -> FastAPI: app = FastAPI(title="AppFactory Portal") + session_secret = read_env_value("PORTAL_SESSION_SECRET", "") or "dev-only-appfactory-session-secret" + + app.add_middleware( + SessionMiddleware, + secret_key=session_secret, + same_site="lax", + https_only=False, + ) static_dir = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(health.router) + app.include_router(auth.router) app.include_router(apps.router) app.include_router(backups.router) app.include_router(deployments.router) @@ -20,4 +31,4 @@ def create_app() -> FastAPI: return app -app = create_app() \ No newline at end of file +app = create_app() diff --git a/app/routes/apps.py b/app/routes/apps.py index 95e1dd3..e74eadd 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -1,8 +1,9 @@ import html -from fastapi import APIRouter, Form +from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse +from ..auth import require_user from ..config import ( DEFAULT_APPFACTORY_HOST, DEFAULT_GITEA_ORG, @@ -20,7 +21,7 @@ router = APIRouter() @router.get("/", response_class=HTMLResponse) -def index(): +def index(request: Request, user=Depends(require_user)): apps = get_apps() gitea_url = read_env_value("GITEA_URL", "") @@ -147,11 +148,12 @@ def index(): """, + user=user, ) @router.get("/new-app", response_class=HTMLResponse) -def new_app_form(): +def new_app_form(request: Request, user=Depends(require_user)): return page( "Nová aplikace", """ @@ -183,11 +185,17 @@ def new_app_form():

← Zpět

""", + user=user, ) @router.post("/new-app", response_class=HTMLResponse) -def create_app(app_id: str = Form(...), app_name: str = Form(...), template: str = Form(...)): +def create_app( + app_id: str = Form(...), + app_name: str = Form(...), + template: str = Form(...), + user=Depends(require_user), +): if template != "python-fastapi": return HTMLResponse("Nepodporovaná šablona", status_code=400) @@ -207,11 +215,12 @@ def create_app(app_id: str = Form(...), app_name: str = Form(...), template: str ], extra_link=f"/apps/{html.escape(app_id)}/docs", extra_label="Otevřít Swagger", + user=user, ) @router.post("/delete-app", response_class=HTMLResponse) -def delete_app(app_id: str = Form(...)): +def delete_app(app_id: str = Form(...), user=Depends(require_user)): result = run_command([DELETE_APP_SCRIPT, app_id]) status = "OK" if result.returncode == 0 else "FAILED" @@ -219,11 +228,17 @@ def delete_app(app_id: str = Form(...)): title=f"Smazání aplikace: {status}", back_url="/portal", sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], + user=user, ) @router.post("/update-resources", response_class=HTMLResponse) -def update_resources(app_id: str = Form(...), memory: str = Form(""), cpus: str = Form("")): +def update_resources( + app_id: str = Form(...), + memory: str = Form(""), + cpus: str = Form(""), + user=Depends(require_user), +): memory = memory.strip() cpus = cpus.strip() @@ -250,4 +265,5 @@ def update_resources(app_id: str = Form(...), memory: str = Form(""), cpus: str ("Výstup nasazení", deploy_result.stdout), ("Chyba nasazení", deploy_result.stderr), ], + user=user, ) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..e695e60 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,67 @@ +import html + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth import authenticate_user, current_user +from app.templates.layout import page + +router = APIRouter() + + +@router.get("/login", response_class=HTMLResponse) +def login_form(request: Request): + if current_user(request): + return RedirectResponse(url="/portal", status_code=303) + + return _render_login() + + +@router.post("/login", response_class=HTMLResponse) +def login(request: Request, username: str = Form(...), password: str = Form(...)): + user = authenticate_user(username, password) + + if not user: + return _render_login("Neplatné přihlašovací údaje.") + + request.session.clear() + request.session["user_id"] = user["id"] + + return RedirectResponse(url="/portal", status_code=303) + + +@router.post("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/portal/login", status_code=303) + + +def _render_login(error: str | None = None) -> str: + error_html = "" + if error: + error_html = f'

{html.escape(error)}

' + + return page( + "Přihlášení", + f""" +
+

Přihlášení do portálu

+

Přihlaste se interním účtem AppFactory.

+ {error_html} + +
+

+
+ +

+ +

+
+ +

+ + +
+
+ """, + ) diff --git a/app/routes/backups.py b/app/routes/backups.py index 5c19111..89b2f81 100644 --- a/app/routes/backups.py +++ b/app/routes/backups.py @@ -1,9 +1,10 @@ import html from pathlib import Path -from fastapi import APIRouter, Form +from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse +from ..auth import require_user from ..backups import is_backup_path, list_backups from ..config import BACKUP_SCRIPT from ..shell import run_command @@ -13,7 +14,7 @@ router = APIRouter() @router.get("/backups", response_class=HTMLResponse) -def backups_page(): +def backups_page(request: Request, user=Depends(require_user)): rows = "" for path in list_backups(): @@ -86,11 +87,12 @@ def backups_page(): """, + user=user, ) @router.post("/backups/create", response_class=HTMLResponse) -def create_backup(): +def create_backup(user=Depends(require_user)): result = run_command([BACKUP_SCRIPT]) status = "OK" if result.returncode == 0 else "FAILED" @@ -98,11 +100,12 @@ def create_backup(): title=f"Vytvoření zálohy: {status}", back_url="/portal/backups", sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], + user=user, ) @router.post("/backups/delete") -def delete_backup(backup_path: str = Form(...)): +def delete_backup(backup_path: str = Form(...), user=Depends(require_user)): target = Path(backup_path) if not is_backup_path(target): diff --git a/app/routes/deployments.py b/app/routes/deployments.py index c203ce2..c99c786 100644 --- a/app/routes/deployments.py +++ b/app/routes/deployments.py @@ -1,8 +1,9 @@ import html -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse +from app.auth import require_user from app.db.apps import get_deployment, get_deployments from app.templates.layout import page @@ -26,7 +27,7 @@ def render_status_pill(status: str | None) -> str: @router.get("/deployments", response_class=HTMLResponse) -async def deployments_page(): +async def deployments_page(request: Request, user=Depends(require_user)): deployments = get_deployments() rows = "" @@ -85,12 +86,15 @@ async def deployments_page(): """, + user=user, ) @router.get("/deployments/{deployment_id}", response_class=HTMLResponse) async def deployment_detail_page( deployment_id: int, + request: Request, + user=Depends(require_user), ): deployment = get_deployment(deployment_id) @@ -139,4 +143,5 @@ async def deployment_detail_page(
{stderr}
""", + user=user, ) diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/app/scripts/set_admin_password.py b/app/scripts/set_admin_password.py new file mode 100644 index 0000000..f563f74 --- /dev/null +++ b/app/scripts/set_admin_password.py @@ -0,0 +1,54 @@ +import sys + +from app.auth import hash_password +from app.db.database import get_connection + + +def main() -> int: + if len(sys.argv) != 2: + print("Použití: python -m app.scripts.set_admin_password ") + return 2 + + password_hash = hash_password(sys.argv[1]) + con = get_connection() + + row = con.execute("SELECT id FROM users WHERE username = ?", ("admin",)).fetchone() + + if row: + con.execute( + """ + UPDATE users + SET password_hash = ?, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + WHERE username = ? + """, + (password_hash, "admin"), + ) + else: + con.execute( + """ + INSERT INTO users ( + username, + display_name, + email, + password_hash, + role, + is_active, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + ("admin", "Admin", "", password_hash, "admin"), + ) + + con.commit() + con.close() + + print("Heslo administrátora bylo nastaveno.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/static/styles.css b/app/static/styles.css index 42e85c2..75982d4 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -39,6 +39,7 @@ header { display: flex; align-items: center; justify-content: space-between; + gap: 18px; border-bottom: 5px solid var(--primary); box-shadow: 0 10px 26px rgba(30, 81, 107, 0.16); } @@ -75,6 +76,43 @@ nav a { border-radius: 8px; } +.user-menu { + display: flex; + align-items: center; + gap: 10px; + color: white; + font-size: 13px; + font-weight: 700; +} + +.user-menu .muted { + color: rgba(255, 255, 255, 0.72); +} + +.user-menu form { + margin: 0; +} + +.auth-card { + max-width: 420px; + margin: 48px auto 24px; +} + +.auth-card input { + width: 100%; +} + +.alert { + border-radius: 8px; + padding: 10px 12px; + font-weight: 700; +} + +.alert-danger { + background: var(--danger-bg); + color: var(--danger); +} + main { padding: 32px; max-width: 1500px; diff --git a/app/templates/layout.py b/app/templates/layout.py index 1030483..169f76d 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -3,7 +3,31 @@ import html from ..config import PORTAL_PREFIX -def page(title: str, body: str) -> str: +def page(title: str, body: str, user=None) -> str: + nav = "" + user_panel = "" + + if user: + username = html.escape(user.get("username", "")) + display_name = html.escape(user.get("display_name") or user.get("username", "")) + nav = """ + + """ + user_panel = f""" +
+ {display_name} + @{username} +
+ +
+
+ """ + return f""" @@ -17,12 +41,8 @@ def page(title: str, body: str) -> str: CSBOT AppFactory - + {nav} + {user_panel}
{body} @@ -32,7 +52,7 @@ def page(title: str, body: str) -> str: """ -def render_result(title, back_url, sections, extra_link=None, extra_label=None): +def render_result(title, back_url, sections, extra_link=None, extra_label=None, user=None): rendered_sections = "" for section_title, content in sections: @@ -57,4 +77,5 @@ def render_result(title, back_url, sections, extra_link=None, extra_label=None): {rendered_sections} """, + user=user, ) diff --git a/requirements.txt b/requirements.txt index 11ffa9a..df82d57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ fastapi uvicorn[standard] python-multipart -jinja2 \ No newline at end of file +jinja2 +passlib[bcrypt] +bcrypt<4.1 +itsdangerous