Files
appfactory-portal/app/routes/auth.py
T
JiriUhlir b1263edac6 Změny:
users migrace má nové sloupce gitea_user_id, gitea_sync_status, gitea_sync_error, gitea_synced_at.
Přidal jsem gitea_provisioning.py, který přes Gitea API vytváří, aktivuje, deaktivuje a synchronizuje účty.
Role viewer Gitea účet nemá; developer a admin účet mají.
Změny role a enable/disable v administraci spouští sync do Gitea.
Admin UI v routes/users.py zobrazuje stav Gitea synchronizace a má ruční Sync Gitea.
Google login po přihlášení dorovnává Gitea stav podle portálového uživatele.
Gitea OAuth login je v portálu vypnutý; endpointy zůstaly, ale vrací hlášku, že se má použít Google.
Lokální login se zobrazuje/povolí jen při explicitním AUTH_MODE=local.
Gitea API token se čte z GITEA_ADMIN_TOKEN, případně fallback GITEA_API_TOKEN nebo GITEA_TOKEN. Podřízené Gitea username je deterministicky portal-{user_id}, portálová tabulka zůstává zdroj pravdy.
Ověření: python -m py_compile prošel pro všechny upravené Python soubory.
2026-06-10 09:34:56 +02:00

472 lines
16 KiB
Python

import html
import json
import secrets
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request as UrlRequest
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_google_user, mark_last_login
from app.config import (
get_auth_mode,
get_gitea_public_url,
get_gitea_redirect_uri,
get_gitea_server_url,
get_google_redirect_uri,
is_gitea_oauth_button_enabled,
is_google_oauth_button_enabled,
read_env_value,
)
from app.db.audit import log_audit_event
from app.gitea_provisioning import sync_gitea_user
from app.templates.layout import page
router = APIRouter()
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)
def login_form(request: Request):
if current_user(request):
return RedirectResponse(url="/portal/operations", status_code=303)
return _render_login()
@router.post("/login", response_class=HTMLResponse)
def login(request: Request, username: str = Form(...), password: str = Form(...)):
if get_auth_mode() != "local":
return _render_login("Local login is disabled. Use Google sign in.")
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"]
log_audit_event(
user,
action="login_success",
target_type="user",
target_id=user.get("id"),
metadata={"username": user.get("username")},
)
return RedirectResponse(url="/portal/operations", status_code=303)
@router.get("/auth/gitea/login")
def gitea_login(request: Request):
if current_user(request):
return RedirectResponse(url="/portal/operations", status_code=303)
return _render_login("Gitea login is disabled. Use Google sign in.")
gitea_url = get_gitea_public_url()
client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "")
client_secret = read_env_value("GITEA_OAUTH_CLIENT_SECRET", "")
redirect_uri = get_gitea_redirect_uri()
if not gitea_url or not client_id or not client_secret or not redirect_uri:
_log_gitea_failure("missing_oauth_config")
return _render_login("Gitea login is not fully configured.")
state = secrets.token_urlsafe(32)
request.session["gitea_oauth_state"] = state
log_audit_event(
None,
action="auth.gitea.login.started",
target_type="auth",
metadata={"provider": "gitea"},
)
params = urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "read:user",
"state": state,
}
)
return RedirectResponse(url=f"{gitea_url}/login/oauth/authorize?{params}", status_code=303)
@router.get("/auth/gitea/callback", response_class=HTMLResponse)
def gitea_callback(request: Request, code: str = "", state: str = "", error: str = ""):
return _render_login("Gitea login is disabled. Use Google sign in.")
expected_state = request.session.pop("gitea_oauth_state", None)
if error:
_log_gitea_failure("provider_error", error=error)
return _render_login("Přihlášení přes Gitea se nepodařilo.")
if not expected_state or not state or not secrets.compare_digest(expected_state, state):
_log_gitea_failure("invalid_state")
return _render_login("Přihlášení přes Gitea se nepodařilo.")
if not code:
_log_gitea_failure("missing_code")
return _render_login("Přihlášení přes Gitea se nepodařilo.")
try:
token = _exchange_gitea_code(code)
gitea_user = _fetch_gitea_user(token)
username = (gitea_user.get("login") or gitea_user.get("username") or "").strip()
if not username:
_log_gitea_failure("missing_username")
return _render_login("Přihlášení přes Gitea se nepodařilo.")
display_name = (gitea_user.get("full_name") or username).strip()
email = (gitea_user.get("email") or "").strip()
user = find_or_create_oauth_user(username, display_name, email)
if not user.get("is_enabled", 1):
_log_gitea_failure("disabled_user", username=username)
return _render_login("User is disabled in the portal.")
if not user.get("is_active"):
_log_gitea_failure("disabled_user", username=username)
return _render_login("Uživatel je v portálu vypnutý.")
request.session.clear()
request.session["user_id"] = user["id"]
mark_last_login(int(user["id"]))
log_audit_event(
user,
action="auth.gitea.login.success",
target_type="user",
target_id=user.get("id"),
metadata={"username": user.get("username"), "provider": "gitea"},
)
return RedirectResponse(url="/portal", status_code=303)
except Exception:
_log_gitea_failure("callback_failed")
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 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")
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 = ""):
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)
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()
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.")
if not user.get("is_active"):
_log_google_failure("disabled_user", username=username)
return _render_login("Uživatel je v portálu vypnutý.")
sync_result = sync_gitea_user(user)
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",
"gitea_sync_status": sync_result.get("status"),
},
)
log_audit_event(
user,
action="auth.google.login.success",
target_type="user",
target_id=user.get("id"),
metadata={"username": user.get("username"), "provider": "google", "gitea_sync_status": sync_result.get("status")},
)
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)
log_audit_event(
user,
action="logout",
target_type="user",
target_id=user.get("id") if user else None,
metadata={"username": user.get("username")} if user else {},
)
request.session.clear()
return RedirectResponse(url="/portal/login", status_code=303)
def _exchange_gitea_code(code: str) -> str:
gitea_url = get_gitea_server_url()
client_id = read_env_value("GITEA_OAUTH_CLIENT_ID", "")
client_secret = read_env_value("GITEA_OAUTH_CLIENT_SECRET", "")
redirect_uri = get_gitea_redirect_uri()
if not client_id or not client_secret or not redirect_uri:
raise RuntimeError("Missing Gitea 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(
f"{gitea_url}/login/oauth/access_token",
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("Gitea OAuth token response did not contain access_token")
return token
def _fetch_gitea_user(access_token: str) -> dict:
gitea_url = get_gitea_server_url()
request = UrlRequest(
f"{gitea_url}/api/v1/user",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {access_token}",
},
method="GET",
)
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:
return json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
raise RuntimeError("Gitea OAuth request failed") from exc
def _log_gitea_failure(reason: str, **metadata):
data = {"reason": reason, "provider": "gitea"}
data.update(metadata)
log_audit_event(
None,
action="auth.gitea.login.failed",
target_type="auth",
metadata=data,
)
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>'
auth_mode = get_auth_mode()
gitea_login_html = ""
google_login_html = ""
if auth_mode in {"google", "mixed"} and is_google_oauth_button_enabled():
google_login_html = """
<p>
<a class="btn" href="/portal/auth/google/login">Sign in with Google</a>
</p>
"""
elif auth_mode in {"google", "mixed"}:
google_login_html = '<p class="alert alert-danger">Google login is not fully configured.</p>'
local_login_html = ""
if auth_mode == "local":
local_login_html = """
<form method="post" action="/portal/login">
<h3>Local login</h3>
<p>
<label>Username</label><br>
<input name="username" autocomplete="username" required autofocus>
</p>
<p>
<label>Password</label><br>
<input type="password" name="password" autocomplete="current-password" required>
</p>
<button type="submit" class="btn-secondary">Sign in locally</button>
</form>
"""
return page(
"Sign in",
f"""
<div class="auth-card card">
<h2>CSBot Services Portal</h2>
{error_html}
{google_login_html}
{gitea_login_html}
{local_login_html}
</div>
""",
)
return page(
"Přihlášení",
f"""
<div class="auth-card card">
<h2>CSBot Services Portal</h2>
<p class="muted">Doporučené přihlášení je přes Gitea. Lokální účet slouží pouze jako nouzový administrátorský přístup.</p>
{error_html}
{gitea_login_html}
{google_login_html}
<form method="post" action="/portal/login">
<h3>Nouzové lokální přihlášení</h3>
<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" class="btn-secondary">Přihlásit lokálně</button>
</form>
</div>
""",
)