From 272b1eb883edf79d08d3ba11dd376877ab7931c7 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:37:42 +0200 Subject: [PATCH] =?UTF-8?q?Upravil=20jsem=20Google=20OAuth=20tak,=20=C5=BE?= =?UTF-8?q?e=20Google=20u=C5=BEivatel=20se=20hled=C3=A1/vytv=C3=A1=C5=99?= =?UTF-8?q?=C3=AD=20p=C5=99es=20email,=20ukl=C3=A1d=C3=A1=20auth=5Fprovide?= =?UTF-8?q?r=3D'google',=20provider=5Fsubject,=20display=5Fname,=20avatar?= =?UTF-8?q?=5Furl=20a=20po=20=C3=BAsp=C4=9B=C5=A1n=C3=A9m=20loginu=20last?= =?UTF-8?q?=5Flogin=5Fat.=20Prvn=C3=AD=20Google=20u=C5=BEivatel=20v=20pr?= =?UTF-8?q?=C3=A1zdn=C3=A9=20users=20tabulce=20dostane=20admin,=20dal?= =?UTF-8?q?=C5=A1=C3=AD=20nov=C3=AD=20Google=20u=C5=BEivatel=C3=A9=20viewe?= =?UTF-8?q?r.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Přidal jsem AUTH_MODE=google|mixed|local: google: jen Google login, lokální/Gitea nejdou ani přes přímý endpoint mixed: Google + lokální/Gitea fallback local: jen lokální login Správa uživatelů zůstává admin-only, guard posledního admina platí pro odebrání role i deaktivaci. Přidal jsem do ní i přehled oprávnění rolí a zobrazení provider/avatar metadat. DB migrace teď přes PRAGMA table_info(users) doplňuje chybějící sloupce bez mazání/recreate existující tabulky; pokud users vůbec neexistuje, bezpečně ji vytvoří. Podporované sloupce zahrnují email, display_name, role, is_enabled, created_at, updated_at, last_login_at, auth_provider, provider_subject, plus kompatibilní sloupce pro lokální login. --- app/auth.py | 108 +++++++++++++++++++++++++++++++++++++++++- app/config.py | 12 +++++ app/db/migrations.py | 53 ++++++++++++++++++--- app/db/users.py | 17 ++++++- app/routes/auth.py | 74 +++++++++++++++++++++++++++-- app/routes/users.py | 38 ++++++++++++++- app/static/styles.css | 9 ++++ 7 files changed, 295 insertions(+), 16 deletions(-) diff --git a/app/auth.py b/app/auth.py index 2511657..ea1df49 100644 --- a/app/auth.py +++ b/app/auth.py @@ -17,10 +17,21 @@ def _table_columns(con, table_name: str) -> set[str]: def _user_select_columns(con) -> str: columns = _table_columns(con, "users") enabled_select = "COALESCE(is_enabled, 1) AS is_enabled" if "is_enabled" in columns else "1 AS is_enabled" - return f"id, username, display_name, email, password_hash, role, is_active, {enabled_select}, created_at, updated_at" + optional_columns = [] + for column in ("last_login_at", "auth_provider", "provider_subject", "avatar_url"): + if column in columns: + optional_columns.append(column) + else: + optional_columns.append(f"NULL AS {column}") + return ( + "id, username, display_name, email, password_hash, role, " + f"is_active, {enabled_select}, created_at, updated_at, " + + ", ".join(optional_columns) + ) def mark_last_login(user_id: int) -> None: + run_migrations() con = get_connection() columns = _table_columns(con, "users") if "last_login_at" in columns: @@ -125,6 +136,101 @@ def find_or_create_oauth_user(username: str, display_name: str, email: str) -> d return dict(user) +def find_or_create_google_user(email: str, display_name: str, provider_subject: str, avatar_url: str = "") -> tuple[dict[str, Any], bool]: + email = email.strip().lower() + display_name = (display_name or email).strip() or email + provider_subject = (provider_subject or "").strip() + avatar_url = (avatar_url or "").strip() + + run_migrations() + con = get_connection() + columns = _table_columns(con, "users") + params: list[Any] = [email, email] + provider_clause = "" + if provider_subject: + provider_clause = " OR (auth_provider = 'google' AND provider_subject = ?)" + params.append(provider_subject) + + row = con.execute( + f""" + SELECT {_user_select_columns(con)} + FROM users + WHERE LOWER(COALESCE(email, '')) = ? + OR LOWER(COALESCE(username, '')) = ? + {provider_clause} + ORDER BY + CASE + WHEN LOWER(COALESCE(email, '')) = ? THEN 0 + WHEN LOWER(COALESCE(username, '')) = ? THEN 1 + ELSE 2 + END, + id + LIMIT 1 + """, + (*params, email, email), + ).fetchone() + + if row: + updates = [ + "display_name = ?", + "email = ?", + "auth_provider = 'google'", + "provider_subject = ?", + "updated_at = CURRENT_TIMESTAMP", + ] + values: list[Any] = [display_name, email, provider_subject] + if "avatar_url" in columns: + updates.append("avatar_url = ?") + values.append(avatar_url) + values.append(row["id"]) + con.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", values) + created = False + user_id = row["id"] + else: + role = "admin" if con.execute("SELECT COUNT(*) AS count FROM users").fetchone()["count"] == 0 else "viewer" + insert_columns = [ + "username", + "display_name", + "email", + "password_hash", + "role", + "is_active", + "is_enabled", + "auth_provider", + "provider_subject", + "created_at", + "updated_at", + ] + values = [email, display_name, email, "", role, 1, 1, "google", provider_subject] + placeholders = ["?", "?", "?", "?", "?", "?", "?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"] + if "avatar_url" in columns: + insert_columns.append("avatar_url") + values.append(avatar_url) + placeholders.append("?") + con.execute( + f""" + INSERT INTO users ({', '.join(insert_columns)}) + VALUES ({', '.join(placeholders)}) + """, + values, + ) + created = True + user_id = con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + + con.commit() + user = con.execute( + f""" + SELECT {_user_select_columns(con)} + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + + con.close() + return dict(user), created + + def get_user_by_id(user_id: int) -> dict[str, Any] | None: run_migrations() con = get_connection() diff --git a/app/config.py b/app/config.py index d5b2a85..223fcfa 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,7 @@ DEFAULT_GITEA_ORG = "appfactory" INTERNAL_GITEA_URL = "http://appfactory-gitea:3000" PORTAL_PREFIX = "/portal" +AUTH_MODES = {"google", "mixed", "local"} def read_env_value(key: str, default: str = "") -> str: @@ -107,6 +108,9 @@ def get_auth_domain_readiness() -> dict[str, bool]: return { "portal_public_url_configured": bool(get_portal_public_url()), + "auth_mode_google": get_auth_mode() == "google", + "auth_mode_mixed": get_auth_mode() == "mixed", + "auth_mode_local": get_auth_mode() == "local", "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), @@ -114,9 +118,17 @@ def get_auth_domain_readiness() -> dict[str, bool]: } +def get_auth_mode() -> str: + mode = read_env_value("AUTH_MODE", "mixed").strip().lower() + if mode in AUTH_MODES: + return mode + return "mixed" + + 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", "") + and get_google_redirect_uri() ) diff --git a/app/db/migrations.py b/app/db/migrations.py index 2287d1f..1d132f3 100644 --- a/app/db/migrations.py +++ b/app/db/migrations.py @@ -7,18 +7,57 @@ def _table_columns(con, table_name: str) -> set[str]: def ensure_user_columns(con) -> None: + con.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + display_name TEXT, + email TEXT, + password_hash TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'viewer', + is_active INTEGER NOT NULL DEFAULT 1, + is_enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at TEXT, + auth_provider TEXT, + provider_subject TEXT, + avatar_url TEXT + ) + """ + ) columns = _table_columns(con, "users") if not columns: return - if "is_enabled" not in columns: - default_expression = "COALESCE(is_active, 1)" if "is_active" in columns else "1" - con.execute("ALTER TABLE users ADD COLUMN is_enabled INTEGER NOT NULL DEFAULT 1") - con.execute(f"UPDATE users SET is_enabled = {default_expression} WHERE is_enabled IS NULL OR is_enabled = 1") + defaults = { + "username": "TEXT", + "display_name": "TEXT", + "email": "TEXT", + "password_hash": "TEXT NOT NULL DEFAULT ''", + "role": "TEXT NOT NULL DEFAULT 'viewer'", + "is_active": "INTEGER NOT NULL DEFAULT 1", + "is_enabled": "INTEGER NOT NULL DEFAULT 1", + "created_at": "TEXT", + "updated_at": "TEXT", + "last_login_at": "TEXT", + "auth_provider": "TEXT", + "provider_subject": "TEXT", + "avatar_url": "TEXT", + } + for column, definition in defaults.items(): + if column not in columns: + con.execute(f"ALTER TABLE users ADD COLUMN {column} {definition}") - if "updated_at" not in columns: - con.execute("ALTER TABLE users ADD COLUMN updated_at TEXT") - con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL") + columns = _table_columns(con, "users") + if "is_active" in columns and "is_enabled" in columns: + con.execute("UPDATE users SET is_enabled = COALESCE(is_active, 1) WHERE is_enabled IS NULL") + con.execute("UPDATE users SET role = 'viewer' WHERE role IS NULL OR role = ''") + con.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL OR created_at = ''") + con.execute("UPDATE users SET updated_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL OR updated_at = ''") + con.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") + con.execute("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(auth_provider, provider_subject)") def run_migrations(): diff --git a/app/db/users.py b/app/db/users.py index f48326c..33bdbd4 100644 --- a/app/db/users.py +++ b/app/db/users.py @@ -16,6 +16,9 @@ def list_users() -> list[dict[str, Any]]: con = get_connection() columns = _table_columns(con, "users") last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at" + auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider" + provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject" + avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url" rows = con.execute( f""" @@ -28,7 +31,10 @@ def list_users() -> list[dict[str, Any]]: COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at, - {last_login_select} + {last_login_select}, + {auth_provider_select}, + {provider_subject_select}, + {avatar_url_select} FROM users ORDER BY id """ @@ -43,6 +49,9 @@ def get_user(user_id: int) -> dict[str, Any] | None: con = get_connection() columns = _table_columns(con, "users") last_login_select = "last_login_at" if "last_login_at" in columns else "NULL AS last_login_at" + auth_provider_select = "auth_provider" if "auth_provider" in columns else "NULL AS auth_provider" + provider_subject_select = "provider_subject" if "provider_subject" in columns else "NULL AS provider_subject" + avatar_url_select = "avatar_url" if "avatar_url" in columns else "NULL AS avatar_url" row = con.execute( f""" @@ -55,7 +64,10 @@ def get_user(user_id: int) -> dict[str, Any] | None: COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at, - {last_login_select} + {last_login_select}, + {auth_provider_select}, + {provider_subject_select}, + {avatar_url_select} FROM users WHERE id = ? """, @@ -81,6 +93,7 @@ def count_enabled_admins(excluding_user_id: int | None = None) -> int: FROM users WHERE LOWER(COALESCE(role, '')) = 'admin' AND COALESCE(is_enabled, 1) = 1 + AND COALESCE(is_active, 1) = 1 {exclusion} """, params, diff --git a/app/routes/auth.py b/app/routes/auth.py index d5bfae3..d5f08c1 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -9,8 +9,9 @@ from urllib.request import urlopen 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, mark_last_login +from app.auth import authenticate_user, current_user, find_or_create_google_user, find_or_create_oauth_user, mark_last_login from app.config import ( + get_auth_mode, get_gitea_public_url, get_gitea_redirect_uri, get_gitea_server_url, @@ -38,6 +39,9 @@ def login_form(request: Request): @router.post("/login", response_class=HTMLResponse) def login(request: Request, username: str = Form(...), password: str = Form(...)): + if get_auth_mode() == "google": + return _render_login("Local login is disabled. Use Google sign in.") + user = authenticate_user(username, password) if not user: @@ -60,6 +64,8 @@ def login(request: Request, username: str = Form(...), password: str = Form(...) def gitea_login(request: Request): if current_user(request): return RedirectResponse(url="/portal/operations", status_code=303) + if get_auth_mode() != "mixed": + return _render_login("Gitea login is not enabled in this auth mode.") gitea_url = get_gitea_public_url() client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "") @@ -92,6 +98,9 @@ def gitea_login(request: Request): @router.get("/auth/gitea/callback", response_class=HTMLResponse) def gitea_callback(request: Request, code: str = "", state: str = "", error: str = ""): + if get_auth_mode() != "mixed": + return _render_login("Gitea login is not enabled in this auth mode.") + expected_state = request.session.pop("gitea_oauth_state", None) if error: _log_gitea_failure("provider_error", error=error) @@ -141,6 +150,8 @@ def gitea_callback(request: Request, code: str = "", state: str = "", error: str def google_login(request: Request): if current_user(request): return RedirectResponse(url="/portal/operations", status_code=303) + if get_auth_mode() == "local": + return _render_login("Google login is not enabled in local auth mode.") if not is_google_oauth_button_enabled(): _log_google_failure("missing_oauth_config") @@ -176,6 +187,9 @@ def google_login(request: Request): @router.get("/auth/google/callback", response_class=HTMLResponse) def google_callback(request: Request, code: str = "", state: str = "", error: str = ""): + if get_auth_mode() == "local": + return _render_login("Google login is not enabled in local auth mode.") + expected_state = request.session.pop("google_oauth_state", None) if error: _log_google_failure("provider_error", error=error) @@ -204,7 +218,9 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st username = email display_name = (google_user.get("name") or email).strip() - user = find_or_create_oauth_user(username, display_name, email) + provider_subject = (google_user.get("sub") or "").strip() + avatar_url = (google_user.get("picture") or "").strip() + user, created = find_or_create_google_user(email, display_name, provider_subject, avatar_url) if not user.get("is_enabled", 1): _log_google_failure("disabled_user", username=username) return _render_login("User is disabled in the portal.") @@ -215,6 +231,19 @@ def google_callback(request: Request, code: str = "", state: str = "", error: st request.session.clear() request.session["user_id"] = user["id"] mark_last_login(int(user["id"])) + if created: + log_audit_event( + user, + action="auth.google.user.created", + target_type="user", + target_id=user.get("id"), + metadata={ + "username": user.get("username"), + "email": user.get("email"), + "role": user.get("role"), + "provider": "google", + }, + ) log_audit_event( user, action="auth.google.login.success", @@ -366,20 +395,55 @@ def _render_login(error: str | None = None) -> str: error_html = "" if error: error_html = f'

{html.escape(error)}

' + auth_mode = get_auth_mode() gitea_login_html = "" - if is_gitea_oauth_button_enabled(): + if auth_mode == "mixed" and is_gitea_oauth_button_enabled(): gitea_login_html = """

- Sign in with Gitea + Sign in with Gitea

""" google_login_html = "" - if is_google_oauth_button_enabled(): + if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled(): google_login_html = """

Sign in with Google

""" + elif auth_mode in {"google", "mixed"}: + google_login_html = '

Google login is not fully configured.

' + + local_login_html = "" + if auth_mode in {"mixed", "local"}: + local_login_html = """ +
+

Local login

+

+
+ +

+ +

+
+ +

+ + +
+ """ + + return page( + "Sign in", + f""" +
+

CSBot Services Portal

+ {error_html} + {google_login_html} + {gitea_login_html} + {local_login_html} +
+ """, + ) return page( "Přihlášení", diff --git a/app/routes/users.py b/app/routes/users.py index d26d66e..53b3e26 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -31,6 +31,34 @@ def render_enabled_pill(enabled) -> str: return 'disabled' +def render_role_overview() -> str: + role_rows = ( + ("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."), + ("developer", "Operational portal access for application and service workflows. No access to Administration / Users."), + ("viewer", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."), + ) + rows = "" + for role, description in role_rows: + rows += f""" + + {html.escape(role)} + {html.escape(description)} + + """ + return f""" +
+

Role Permissions

+ + + + + + {rows} +
RoleAccess
+
+ """ + + @router.get("/admin/users", response_class=HTMLResponse) def users_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)): require_admin(user) @@ -49,6 +77,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend display_name = html.escape(managed_user.get("display_name", "") or "") email = html.escape(managed_user.get("email", "") or "") role = html.escape(managed_user.get("role", "") or "") + auth_provider = html.escape(managed_user.get("auth_provider", "") or "") + avatar_url = html.escape(managed_user.get("avatar_url", "") or "", quote=True) + avatar = f'' if avatar_url else "" created_at = html.escape(managed_user.get("created_at", "") or "") last_login_at = html.escape(managed_user.get("last_login_at", "") or "") enabled = bool(managed_user.get("is_enabled", True)) @@ -60,7 +91,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend {user_id_html} {username} {display_name} + {avatar} {email} + {auth_provider}
@@ -79,7 +112,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend """ if not rows: - rows = 'No users found.' + rows = 'No users found.' return page( "Users", @@ -97,7 +130,9 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend ID Username Display name + Avatar Email + Provider Role Status Created @@ -107,6 +142,7 @@ def users_page(request: Request, message: str = "", error: str = "", user=Depend {rows} + {render_role_overview()} """, user=user, ) diff --git a/app/static/styles.css b/app/static/styles.css index 427c54c..44d4af5 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -168,6 +168,15 @@ nav a:hover, margin: 0; } +.user-avatar { + width: 32px; + height: 32px; + border-radius: 8px; + object-fit: cover; + background: #eef6f9; + border: 1px solid var(--border); +} + .auth-card { max-width: 420px; margin: 48px auto 24px;