auth via DB

This commit is contained in:
JiriUhlir
2026-05-28 11:40:49 +02:00
parent db039e5988
commit 09c742fefb
11 changed files with 335 additions and 23 deletions
+93
View File
@@ -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"},
)
+12 -1
View File
@@ -2,17 +2,28 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles 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: def create_app() -> FastAPI:
app = FastAPI(title="AppFactory Portal") 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" static_dir = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(backups.router) app.include_router(backups.router)
app.include_router(deployments.router) app.include_router(deployments.router)
+22 -6
View File
@@ -1,8 +1,9 @@
import html import html
from fastapi import APIRouter, Form from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from ..auth import require_user
from ..config import ( from ..config import (
DEFAULT_APPFACTORY_HOST, DEFAULT_APPFACTORY_HOST,
DEFAULT_GITEA_ORG, DEFAULT_GITEA_ORG,
@@ -20,7 +21,7 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def index(): def index(request: Request, user=Depends(require_user)):
apps = get_apps() apps = get_apps()
gitea_url = read_env_value("GITEA_URL", "") gitea_url = read_env_value("GITEA_URL", "")
@@ -147,11 +148,12 @@ def index():
</table> </table>
</div> </div>
""", """,
user=user,
) )
@router.get("/new-app", response_class=HTMLResponse) @router.get("/new-app", response_class=HTMLResponse)
def new_app_form(): def new_app_form(request: Request, user=Depends(require_user)):
return page( return page(
"Nová aplikace", "Nová aplikace",
""" """
@@ -183,11 +185,17 @@ def new_app_form():
<p><a href="/portal">&larr; Zpět</a></p> <p><a href="/portal">&larr; Zpět</a></p>
</div> </div>
""", """,
user=user,
) )
@router.post("/new-app", response_class=HTMLResponse) @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": if template != "python-fastapi":
return HTMLResponse("Nepodporovaná šablona", status_code=400) 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_link=f"/apps/{html.escape(app_id)}/docs",
extra_label="Otevřít Swagger", extra_label="Otevřít Swagger",
user=user,
) )
@router.post("/delete-app", response_class=HTMLResponse) @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]) result = run_command([DELETE_APP_SCRIPT, app_id])
status = "OK" if result.returncode == 0 else "FAILED" 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}", title=f"Smazání aplikace: {status}",
back_url="/portal", back_url="/portal",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
) )
@router.post("/update-resources", response_class=HTMLResponse) @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() memory = memory.strip()
cpus = cpus.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), ("Výstup nasazení", deploy_result.stdout),
("Chyba nasazení", deploy_result.stderr), ("Chyba nasazení", deploy_result.stderr),
], ],
user=user,
) )
+67
View File
@@ -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'<p class="alert alert-danger">{html.escape(error)}</p>'
return page(
"Přihlášení",
f"""
<div class="auth-card card">
<h2>Přihlášení do portálu</h2>
<p class="muted">Přihlaste se interním účtem AppFactory.</p>
{error_html}
<form method="post" action="/portal/login">
<p>
<label>Uživatelské jméno</label><br>
<input name="username" autocomplete="username" required autofocus>
</p>
<p>
<label>Heslo</label><br>
<input type="password" name="password" autocomplete="current-password" required>
</p>
<button type="submit">Přihlásit</button>
</form>
</div>
""",
)
+7 -4
View File
@@ -1,9 +1,10 @@
import html import html
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import require_user
from ..backups import is_backup_path, list_backups from ..backups import is_backup_path, list_backups
from ..config import BACKUP_SCRIPT from ..config import BACKUP_SCRIPT
from ..shell import run_command from ..shell import run_command
@@ -13,7 +14,7 @@ router = APIRouter()
@router.get("/backups", response_class=HTMLResponse) @router.get("/backups", response_class=HTMLResponse)
def backups_page(): def backups_page(request: Request, user=Depends(require_user)):
rows = "" rows = ""
for path in list_backups(): for path in list_backups():
@@ -86,11 +87,12 @@ def backups_page():
</table> </table>
</div> </div>
""", """,
user=user,
) )
@router.post("/backups/create", response_class=HTMLResponse) @router.post("/backups/create", response_class=HTMLResponse)
def create_backup(): def create_backup(user=Depends(require_user)):
result = run_command([BACKUP_SCRIPT]) result = run_command([BACKUP_SCRIPT])
status = "OK" if result.returncode == 0 else "FAILED" status = "OK" if result.returncode == 0 else "FAILED"
@@ -98,11 +100,12 @@ def create_backup():
title=f"Vytvoření zálohy: {status}", title=f"Vytvoření zálohy: {status}",
back_url="/portal/backups", back_url="/portal/backups",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)], sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
) )
@router.post("/backups/delete") @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) target = Path(backup_path)
if not is_backup_path(target): if not is_backup_path(target):
+7 -2
View File
@@ -1,8 +1,9 @@
import html import html
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.apps import get_deployment, get_deployments from app.db.apps import get_deployment, get_deployments
from app.templates.layout import page from app.templates.layout import page
@@ -26,7 +27,7 @@ def render_status_pill(status: str | None) -> str:
@router.get("/deployments", response_class=HTMLResponse) @router.get("/deployments", response_class=HTMLResponse)
async def deployments_page(): async def deployments_page(request: Request, user=Depends(require_user)):
deployments = get_deployments() deployments = get_deployments()
rows = "" rows = ""
@@ -85,12 +86,15 @@ async def deployments_page():
</table> </table>
</div> </div>
""", """,
user=user,
) )
@router.get("/deployments/{deployment_id}", response_class=HTMLResponse) @router.get("/deployments/{deployment_id}", response_class=HTMLResponse)
async def deployment_detail_page( async def deployment_detail_page(
deployment_id: int, deployment_id: int,
request: Request,
user=Depends(require_user),
): ):
deployment = get_deployment(deployment_id) deployment = get_deployment(deployment_id)
@@ -139,4 +143,5 @@ async def deployment_detail_page(
<pre>{stderr}</pre> <pre>{stderr}</pre>
</div> </div>
""", """,
user=user,
) )
+1
View File
@@ -0,0 +1 @@
+54
View File
@@ -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 <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())
+38
View File
@@ -39,6 +39,7 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 18px;
border-bottom: 5px solid var(--primary); border-bottom: 5px solid var(--primary);
box-shadow: 0 10px 26px rgba(30, 81, 107, 0.16); box-shadow: 0 10px 26px rgba(30, 81, 107, 0.16);
} }
@@ -75,6 +76,43 @@ nav a {
border-radius: 8px; 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 { main {
padding: 32px; padding: 32px;
max-width: 1500px; max-width: 1500px;
+29 -8
View File
@@ -3,7 +3,31 @@ import html
from ..config import PORTAL_PREFIX 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 = """
<nav>
<a href="/portal">Aplikace</a>
<a href="/portal/new-app">Nová aplikace</a>
<a href="/portal/deployments">Nasazení</a>
<a href="/portal/backups">Zálohy</a>
</nav>
"""
user_panel = f"""
<div class="user-menu">
<span>{display_name}</span>
<span class="muted">@{username}</span>
<form method="post" action="/portal/logout">
<button type="submit" class="btn-secondary">Odhlásit</button>
</form>
</div>
"""
return f""" return f"""
<html> <html>
<head> <head>
@@ -17,12 +41,8 @@ def page(title: str, body: str) -> str:
<img src="{PORTAL_PREFIX}/static/csbot-logo.svg" alt="CSBOT"> <img src="{PORTAL_PREFIX}/static/csbot-logo.svg" alt="CSBOT">
<span>AppFactory</span> <span>AppFactory</span>
</a> </a>
<nav> {nav}
<a href="/portal">Aplikace</a> {user_panel}
<a href="/portal/new-app">Nová aplikace</a>
<a href="/portal/deployments">Nasazení</a>
<a href="/portal/backups">Zálohy</a>
</nav>
</header> </header>
<main> <main>
{body} {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 = "" rendered_sections = ""
for section_title, content in 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} {rendered_sections}
</div> </div>
""", """,
user=user,
) )
+3
View File
@@ -2,3 +2,6 @@ fastapi
uvicorn[standard] uvicorn[standard]
python-multipart python-multipart
jinja2 jinja2
passlib[bcrypt]
bcrypt<4.1
itsdangerous