Změny:
Přidaná centrální runtime konfigurace public URL, HTTPS a Google OAuth readiness v config.py (line 37). Google OAuth přidaný jako volitelná další možnost přihlášení, bez nahrazení lokálního/Gitea loginu, v auth.py (line 128). Default Google redirect URI se skládá z APPFACTORY_PORTAL_PUBLIC_URL + /auth/google/callback, bez hardcoded IP/localhost/domény. Readiness JSON endpoint přidán na /portal/migration-readiness/auth-domain-config; vrací jen boolean hodnoty, žádné secrety, v migration_readiness.py (line 112). UI sekce Auth & Domain Readiness přidaná do Migration Readiness v migration_readiness.py (line 41). Audit event auth_domain_readiness.viewed se zapisuje při zobrazení readiness stránky v migration_readiness.py (line 133). APPFACTORY_ENABLE_HTTPS se promítá i do secure session cookies v main.py (line 19).
This commit is contained in:
@@ -24,3 +24,58 @@ def read_env_value(key: str, default: str = "") -> str:
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def read_env_bool(key: str, default: bool = False) -> bool:
|
||||
value = read_env_value(key, "")
|
||||
if value == "":
|
||||
return default
|
||||
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def get_portal_public_url() -> str:
|
||||
public_url = read_env_value("APPFACTORY_PORTAL_PUBLIC_URL", "").rstrip("/")
|
||||
if public_url:
|
||||
return public_url
|
||||
|
||||
domain = read_env_value("APPFACTORY_PORTAL_DOMAIN", "").strip()
|
||||
if read_env_bool("APPFACTORY_ENABLE_HTTPS") and domain:
|
||||
return f"https://{domain}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_google_redirect_uri() -> str:
|
||||
redirect_uri = read_env_value("GOOGLE_REDIRECT_URI", "").strip()
|
||||
if redirect_uri:
|
||||
return redirect_uri
|
||||
|
||||
public_url = get_portal_public_url()
|
||||
if public_url:
|
||||
return f"{public_url}/auth/google/callback"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_auth_domain_readiness() -> dict[str, bool]:
|
||||
google_enabled = read_env_bool("GOOGLE_OAUTH_ENABLED")
|
||||
google_client_id = read_env_value("GOOGLE_CLIENT_ID", "")
|
||||
google_client_secret = read_env_value("GOOGLE_CLIENT_SECRET", "")
|
||||
google_redirect_uri = get_google_redirect_uri()
|
||||
|
||||
return {
|
||||
"portal_public_url_configured": bool(get_portal_public_url()),
|
||||
"google_oauth_enabled": google_enabled,
|
||||
"google_oauth_configured": bool(google_client_id and google_client_secret and google_redirect_uri),
|
||||
"google_redirect_uri_configured": bool(google_redirect_uri),
|
||||
"https_enabled": read_env_bool("APPFACTORY_ENABLE_HTTPS"),
|
||||
}
|
||||
|
||||
|
||||
def is_google_oauth_button_enabled() -> bool:
|
||||
return bool(
|
||||
read_env_bool("GOOGLE_OAUTH_ENABLED")
|
||||
and read_env_value("GOOGLE_CLIENT_ID", "")
|
||||
and read_env_value("GOOGLE_CLIENT_SECRET", "")
|
||||
)
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .config import read_env_value
|
||||
from .config import read_env_bool, read_env_value
|
||||
from .routes import alerting, apps, audit, auth, backups, deployments, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, workers
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def create_app() -> FastAPI:
|
||||
SessionMiddleware,
|
||||
secret_key=session_secret,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
https_only=read_env_bool("APPFACTORY_ENABLE_HTTPS"),
|
||||
)
|
||||
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
|
||||
+155
-1
@@ -10,12 +10,15 @@ from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth import authenticate_user, current_user, find_or_create_oauth_user
|
||||
from app.config import read_env_value
|
||||
from app.config import get_google_redirect_uri, is_google_oauth_button_enabled, read_env_value
|
||||
from app.db.audit import log_audit_event
|
||||
from app.templates.layout import page
|
||||
|
||||
router = APIRouter()
|
||||
DEFAULT_GITEA_URL = "http://192.168.66.130:3000"
|
||||
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
@@ -122,6 +125,93 @@ def gitea_callback(request: Request, code: str = "", state: str = "", error: str
|
||||
return _render_login("Přihlášení přes Gitea se nepodařilo.")
|
||||
|
||||
|
||||
@router.get("/auth/google/login")
|
||||
def google_login(request: Request):
|
||||
if current_user(request):
|
||||
return RedirectResponse(url="/portal/operations", status_code=303)
|
||||
|
||||
if not is_google_oauth_button_enabled():
|
||||
_log_google_failure("missing_oauth_config")
|
||||
return _render_login("Google login is not configured.")
|
||||
|
||||
redirect_uri = get_google_redirect_uri()
|
||||
if not redirect_uri:
|
||||
_log_google_failure("missing_redirect_uri")
|
||||
return _render_login("Google login is missing redirect URI.")
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
request.session["google_oauth_state"] = state
|
||||
log_audit_event(
|
||||
None,
|
||||
action="auth.google.login.started",
|
||||
target_type="auth",
|
||||
metadata={"provider": "google"},
|
||||
)
|
||||
|
||||
params = urlencode(
|
||||
{
|
||||
"client_id": read_env_value("GOOGLE_CLIENT_ID", ""),
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "online",
|
||||
"prompt": "select_account",
|
||||
}
|
||||
)
|
||||
return RedirectResponse(url=f"{GOOGLE_AUTH_URL}?{params}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/auth/google/callback", response_class=HTMLResponse)
|
||||
def google_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
||||
expected_state = request.session.pop("google_oauth_state", None)
|
||||
if error:
|
||||
_log_google_failure("provider_error", error=error)
|
||||
return _render_login("Google login failed.")
|
||||
if not expected_state or not state or not secrets.compare_digest(expected_state, state):
|
||||
_log_google_failure("invalid_state")
|
||||
return _render_login("Google login failed.")
|
||||
if not code:
|
||||
_log_google_failure("missing_code")
|
||||
return _render_login("Google login failed.")
|
||||
|
||||
try:
|
||||
token = _exchange_google_code(code)
|
||||
google_user = _fetch_google_user(token)
|
||||
email = (google_user.get("email") or "").strip().lower()
|
||||
if not email:
|
||||
_log_google_failure("missing_email")
|
||||
return _render_login("Google account did not return an email address.")
|
||||
|
||||
allowed_domain = read_env_value("GOOGLE_ALLOWED_DOMAIN", "").strip().lower()
|
||||
email_domain = email.rsplit("@", 1)[1] if "@" in email else ""
|
||||
hosted_domain = (google_user.get("hd") or "").strip().lower()
|
||||
if allowed_domain and allowed_domain not in {email_domain, hosted_domain}:
|
||||
_log_google_failure("domain_not_allowed", email_domain=email_domain)
|
||||
return _render_login("Google account is not allowed for this portal.")
|
||||
|
||||
username = email
|
||||
display_name = (google_user.get("name") or email).strip()
|
||||
user = find_or_create_oauth_user(username, display_name, email)
|
||||
if not user.get("is_active"):
|
||||
_log_google_failure("disabled_user", username=username)
|
||||
return _render_login("Uživatel je v portálu vypnutý.")
|
||||
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user["id"]
|
||||
log_audit_event(
|
||||
user,
|
||||
action="auth.google.login.success",
|
||||
target_type="user",
|
||||
target_id=user.get("id"),
|
||||
metadata={"username": user.get("username"), "provider": "google"},
|
||||
)
|
||||
return RedirectResponse(url="/portal", status_code=303)
|
||||
except Exception:
|
||||
_log_google_failure("callback_failed")
|
||||
return _render_login("Google login failed.")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
user = current_user(request)
|
||||
@@ -182,6 +272,50 @@ def _fetch_gitea_user(access_token: str) -> dict:
|
||||
return _read_json(request)
|
||||
|
||||
|
||||
def _exchange_google_code(code: str) -> str:
|
||||
client_id = read_env_value("GOOGLE_CLIENT_ID", "")
|
||||
client_secret = read_env_value("GOOGLE_CLIENT_SECRET", "")
|
||||
redirect_uri = get_google_redirect_uri()
|
||||
if not client_id or not client_secret or not redirect_uri:
|
||||
raise RuntimeError("Missing Google OAuth configuration")
|
||||
|
||||
payload = urlencode(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = UrlRequest(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data=payload,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
data = _read_json(request)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError("Google OAuth token response did not contain access_token")
|
||||
return token
|
||||
|
||||
|
||||
def _fetch_google_user(access_token: str) -> dict:
|
||||
request = UrlRequest(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
return _read_json(request)
|
||||
|
||||
|
||||
def _read_json(request: UrlRequest) -> dict:
|
||||
try:
|
||||
with urlopen(request, timeout=10) as response:
|
||||
@@ -201,10 +335,28 @@ def _log_gitea_failure(reason: str, **metadata):
|
||||
)
|
||||
|
||||
|
||||
def _log_google_failure(reason: str, **metadata):
|
||||
data = {"reason": reason, "provider": "google"}
|
||||
data.update(metadata)
|
||||
log_audit_event(
|
||||
None,
|
||||
action="auth.google.login.failed",
|
||||
target_type="auth",
|
||||
metadata=data,
|
||||
)
|
||||
|
||||
|
||||
def _render_login(error: str | None = None) -> str:
|
||||
error_html = ""
|
||||
if error:
|
||||
error_html = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
||||
google_login_html = ""
|
||||
if is_google_oauth_button_enabled():
|
||||
google_login_html = """
|
||||
<p>
|
||||
<a class="btn" href="/portal/auth/google/login">Sign in with Google</a>
|
||||
</p>
|
||||
"""
|
||||
|
||||
return page(
|
||||
"Přihlášení",
|
||||
@@ -218,6 +370,8 @@ def _render_login(error: str | None = None) -> str:
|
||||
<a class="btn" href="/portal/auth/gitea/login">Přihlásit přes Gitea</a>
|
||||
</p>
|
||||
|
||||
{google_login_html}
|
||||
|
||||
<form method="post" action="/portal/login">
|
||||
<h3>Nouzové lokální přihlášení</h3>
|
||||
<p>
|
||||
|
||||
@@ -4,6 +4,11 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth import require_user
|
||||
from app.config import (
|
||||
get_auth_domain_readiness,
|
||||
get_google_redirect_uri,
|
||||
get_portal_public_url,
|
||||
)
|
||||
from app.db.audit import log_audit_event
|
||||
from app.db.jobs import create_job
|
||||
from app.db.migration_readiness import (
|
||||
@@ -27,6 +32,43 @@ def render_readiness_pill(ready: bool) -> str:
|
||||
return '<span class="pill pill-danger">NOT READY</span>'
|
||||
|
||||
|
||||
def render_boolean_pill(enabled: bool) -> str:
|
||||
if enabled:
|
||||
return '<span class="pill pill-success">YES</span>'
|
||||
return '<span class="pill">NO</span>'
|
||||
|
||||
|
||||
def render_auth_domain_readiness() -> str:
|
||||
readiness = get_auth_domain_readiness()
|
||||
public_url = get_portal_public_url() or "relative/proxy mode"
|
||||
redirect_uri = get_google_redirect_uri() or "not configured"
|
||||
warning = ""
|
||||
|
||||
if readiness["google_oauth_enabled"] and (
|
||||
not readiness["portal_public_url_configured"] or not readiness["google_redirect_uri_configured"]
|
||||
):
|
||||
warning = """
|
||||
<div class="alert alert-danger">
|
||||
<strong>Google OAuth is enabled but domain readiness is incomplete.</strong>
|
||||
<p>Set APPFACTORY_PORTAL_PUBLIC_URL or APPFACTORY_ENABLE_HTTPS=true with APPFACTORY_PORTAL_DOMAIN, and make sure a Google redirect URI is available.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div class="card">
|
||||
<h2>Auth & Domain Readiness</h2>
|
||||
<table>
|
||||
<tr><th>Portal public URL</th><td>{html.escape(public_url)}</td></tr>
|
||||
<tr><th>HTTPS enabled</th><td>{render_boolean_pill(readiness["https_enabled"])}</td></tr>
|
||||
<tr><th>Google OAuth enabled</th><td>{render_boolean_pill(readiness["google_oauth_enabled"])}</td></tr>
|
||||
<tr><th>Google OAuth configured</th><td>{render_boolean_pill(readiness["google_oauth_configured"])}</td></tr>
|
||||
<tr><th>Google redirect URI effective value</th><td>{html.escape(redirect_uri)}</td></tr>
|
||||
</table>
|
||||
{warning}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_script_result(title: str, script_name: str, summary: dict) -> str:
|
||||
job = summary.get("job")
|
||||
job_rows = f'<tr><td colspan="2">Zatím není uložený žádný výsledek skriptu {html.escape(script_name)}.</td></tr>'
|
||||
@@ -67,6 +109,11 @@ def render_script_result(title: str, script_name: str, summary: dict) -> str:
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/migration-readiness/auth-domain-config")
|
||||
def auth_domain_readiness_config(user=Depends(require_user)):
|
||||
return get_auth_domain_readiness()
|
||||
|
||||
|
||||
@router.get("/migration-readiness", response_class=HTMLResponse)
|
||||
def migration_readiness_page(request: Request, user=Depends(require_user)):
|
||||
latest_job = get_latest_preflight_job()
|
||||
@@ -81,6 +128,12 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
|
||||
target_id=PREFLIGHT_SCRIPT,
|
||||
metadata={"job_id": job.get("id") if job else None},
|
||||
)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="auth_domain_readiness.viewed",
|
||||
target_type="auth_domain_readiness",
|
||||
metadata=get_auth_domain_readiness(),
|
||||
)
|
||||
|
||||
job_rows = '<tr><td colspan="2">Zatím není uložený žádný výsledek preflight kontroly.</td></tr>'
|
||||
if job:
|
||||
@@ -147,6 +200,8 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
|
||||
<div class="stat-card stat-danger"><span>FAIL</span><strong>{html.escape(str(summary["fail_count"]))}</strong></div>
|
||||
</div>
|
||||
|
||||
{render_auth_domain_readiness()}
|
||||
|
||||
<div class="card">
|
||||
<h2>Poslední preflight úloha</h2>
|
||||
<table>
|
||||
@@ -204,4 +259,3 @@ def run_bootstrap_v2(mode: str = Form(...), user=Depends(require_user)):
|
||||
source="portal_manual",
|
||||
)
|
||||
return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user