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 = """ + + """ 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} +