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():
""", + 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řihlaste se interním účtem AppFactory.
+ {error_html} + + +{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