From 9916e4d833821b2cc83249c5799a486618f0c554 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:21:29 +0200 Subject: [PATCH] =?UTF-8?q?Zm=C4=9Bny:=20P=C5=99idan=C3=A1=20centr=C3=A1ln?= =?UTF-8?q?=C3=AD=20runtime=20konfigurace=20public=20URL,=20HTTPS=20a=20Go?= =?UTF-8?q?ogle=20OAuth=20readiness=20v=20config.py=20(line=2037).=20Googl?= =?UTF-8?q?e=20OAuth=20p=C5=99idan=C3=BD=20jako=20voliteln=C3=A1=20dal?= =?UTF-8?q?=C5=A1=C3=AD=20mo=C5=BEnost=20p=C5=99ihl=C3=A1=C5=A1en=C3=AD,?= =?UTF-8?q?=20bez=20nahrazen=C3=AD=20lok=C3=A1ln=C3=ADho/Gitea=20loginu,?= =?UTF-8?q?=20v=20auth.py=20(line=20128).=20Default=20Google=20redirect=20?= =?UTF-8?q?URI=20se=20skl=C3=A1d=C3=A1=20z=20APPFACTORY=5FPORTAL=5FPUBLIC?= =?UTF-8?q?=5FURL=20+=20/auth/google/callback,=20bez=20hardcoded=20IP/loca?= =?UTF-8?q?lhost/dom=C3=A9ny.=20Readiness=20JSON=20endpoint=20p=C5=99id?= =?UTF-8?q?=C3=A1n=20na=20/portal/migration-readiness/auth-domain-config;?= =?UTF-8?q?=20vrac=C3=AD=20jen=20boolean=20hodnoty,=20=C5=BE=C3=A1dn=C3=A9?= =?UTF-8?q?=20secrety,=20v=20migration=5Freadiness.py=20(line=20112).=20UI?= =?UTF-8?q?=20sekce=20Auth=20&=20Domain=20Readiness=20p=C5=99idan=C3=A1=20?= =?UTF-8?q?do=20Migration=20Readiness=20v=20migration=5Freadiness.py=20(li?= =?UTF-8?q?ne=2041).=20Audit=20event=20auth=5Fdomain=5Freadiness.viewed=20?= =?UTF-8?q?se=20zapisuje=20p=C5=99i=20zobrazen=C3=AD=20readiness=20str?= =?UTF-8?q?=C3=A1nky=20v=20migration=5Freadiness.py=20(line=20133).=20APPF?= =?UTF-8?q?ACTORY=5FENABLE=5FHTTPS=20se=20prom=C3=ADt=C3=A1=20i=20do=20sec?= =?UTF-8?q?ure=20session=20cookies=20v=20main.py=20(line=2019).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 55 +++++++++++ app/main.py | 4 +- app/routes/auth.py | 156 +++++++++++++++++++++++++++++- app/routes/migration_readiness.py | 56 ++++++++++- 4 files changed, 267 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 8c78165..e93bd6b 100644 --- a/app/config.py +++ b/app/config.py @@ -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", "") + ) diff --git a/app/main.py b/app/main.py index aac7938..ecdd5fe 100644 --- a/app/main.py +++ b/app/main.py @@ -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" diff --git a/app/routes/auth.py b/app/routes/auth.py index 7ee612e..b318a0c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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'

{html.escape(error)}

' + google_login_html = "" + if is_google_oauth_button_enabled(): + google_login_html = """ +

+ Sign in with Google +

+ """ return page( "Přihlášení", @@ -218,6 +370,8 @@ def _render_login(error: str | None = None) -> str: Přihlásit přes Gitea

+ {google_login_html} +

Nouzové lokální přihlášení

diff --git a/app/routes/migration_readiness.py b/app/routes/migration_readiness.py index c211d4e..3295f30 100644 --- a/app/routes/migration_readiness.py +++ b/app/routes/migration_readiness.py @@ -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 'NOT READY' +def render_boolean_pill(enabled: bool) -> str: + if enabled: + return 'YES' + return 'NO' + + +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 = """ +

+ Google OAuth is enabled but domain readiness is incomplete. +

Set APPFACTORY_PORTAL_PUBLIC_URL or APPFACTORY_ENABLE_HTTPS=true with APPFACTORY_PORTAL_DOMAIN, and make sure a Google redirect URI is available.

+
+ """ + + return f""" +
+

Auth & Domain Readiness

+ + + + + + +
Portal public URL{html.escape(public_url)}
HTTPS enabled{render_boolean_pill(readiness["https_enabled"])}
Google OAuth enabled{render_boolean_pill(readiness["google_oauth_enabled"])}
Google OAuth configured{render_boolean_pill(readiness["google_oauth_configured"])}
Google redirect URI effective value{html.escape(redirect_uri)}
+ {warning} +
+ """ + + def render_script_result(title: str, script_name: str, summary: dict) -> str: job = summary.get("job") job_rows = f'Zatím není uložený žádný výsledek skriptu {html.escape(script_name)}.' @@ -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 = 'Zatím není uložený žádný výsledek preflight kontroly.' if job: @@ -147,6 +200,8 @@ def migration_readiness_page(request: Request, user=Depends(require_user)):
FAIL{html.escape(str(summary["fail_count"]))}
+ {render_auth_domain_readiness()} +

Poslední preflight úloha

@@ -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) -