Google Sign-In auth + email whitelist + session cookies

WHY: Doslej basic auth z deljenim AUTH_USER/AUTH_PASS — tvegan za delitev
z drugimi (urednika ipd.). Z Google Sign-In dobi vsak uporabnik svoj
account, dostop pa nadzira whitelist v env.

NEW BACKEND:
- GET  /login              — login stran z Google Sign-In gumbom (GSI popup)
- POST /auth/google/callback  — verify Google ID token + set session cookie
- GET  /auth/me            — vrne email + auth method (za frontend header)
- GET  /logout             — pobriše cookie + redirect /login
- POST /logout

AUTH FLOW:
1. Browser GET /              → check_auth() → ni session → redirect /login
2. /login                     → Google Sign-In popup (preko gsi/client.js)
3. User izbere Google account → JS pošlje credential na /auth/google/callback
4. Server verifies token (oauth2.googleapis.com/tokeninfo)
5. Email mora biti v ALLOWED_EMAILS env
6. Set HttpOnly+Secure session cookie (HMAC-SHA256, 30 dni)
7. Redirect /

SECURITY:
- Session token: base64url(email|expiry|HMAC). Server ne ranji ne hrani.
- HMAC z SESSION_SECRET (auto-derived iz AUTH_USER+AUTH_PASS če ni nastavljen)
- Cookie HttpOnly + Secure + SameSite=lax
- Token verify: aud check, iss check, exp check, email_verified check

BACKWARD COMPAT:
- HTTPBasic auth še vedno deluje (cron, scripte, API klici)
- check_auth() probaj prvo cookie, potem basic
- Brez GOOGLE_CLIENT_ID env: vse še vedno dela na basic auth

ENV VARS (treba dodati v Coolify):
- GOOGLE_CLIENT_ID=938379241163-pvb328plec2207rbtufic8u5fgb6mkn9.apps.googleusercontent.com
- ALLOWED_EMAILS=sebastjan.artic@gmail.com,ales.cadez@gmail.com
- SESSION_SECRET=<random 64-char hex> (optional — defaultni se izračuna)

FRONTEND:
- Header: 👤 email + ↪ Odjava gumb (samo ko je prijavljen)
- DOMContentLoaded fetcha /auth/me, prikaže email
This commit is contained in:
Claude 2026-05-04 12:26:53 +00:00
parent 12e8edba93
commit d2c9a48cc2
2 changed files with 324 additions and 15 deletions

View File

@ -15,22 +15,26 @@ Endpoints:
"""
import asyncio
import json
import hashlib
import hmac
import os
import secrets
import shutil
import subprocess
import threading
import time
import urllib.parse
import uuid
from pathlib import Path
from typing import Optional
from fastapi import (
FastAPI, UploadFile, File, Form, HTTPException, Depends,
BackgroundTasks, Request, status
BackgroundTasks, Request, Response as FastAPIResponse, status, Cookie
)
from fastapi.responses import (
FileResponse, HTMLResponse, StreamingResponse, JSONResponse, Response
FileResponse, HTMLResponse, StreamingResponse, JSONResponse, Response,
RedirectResponse
)
from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBasic, HTTPBasicCredentials
@ -223,6 +227,15 @@ def dedup_remove(filename: str, tv_station: str):
AUTH_USER = os.environ.get("AUTH_USER", "sebastjan")
AUTH_PASS = os.environ.get("AUTH_PASS", "change-me-in-coolify-env")
# Google Sign-In (https://developers.google.com/identity/gsi/web)
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
ALLOWED_EMAILS = [e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()]
SESSION_SECRET = os.environ.get("SESSION_SECRET", "").strip() or hashlib.sha256(
f"{AUTH_USER}|{AUTH_PASS}|reels-app-default-secret".encode()
).hexdigest()
SESSION_COOKIE_NAME = "reels_session"
SESSION_TTL_SECONDS = 30 * 24 * 3600 # 30 dni
MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "2000"))
# Nextcloud upload (folxspeed/REELS/)
@ -233,21 +246,114 @@ NEXTCLOUD_FOLDER = os.environ.get("NEXTCLOUD_FOLDER", "folxspeed/REELS")
# ────────────────────────────────────────────────────────────────
# Auth
# Auth — Google Sign-In + basic auth fallback (za API/cron)
# ────────────────────────────────────────────────────────────────
security = HTTPBasic()
security = HTTPBasic(auto_error=False)
def check_auth(creds: HTTPBasicCredentials = Depends(security)):
correct_user = secrets.compare_digest(creds.username, AUTH_USER)
correct_pass = secrets.compare_digest(creds.password, AUTH_PASS)
if not (correct_user and correct_pass):
def _email_allowed(email: str) -> bool:
"""Email mora biti v ALLOWED_EMAILS whitelistu."""
if not email:
return False
return email.lower().strip() in ALLOWED_EMAILS
def _make_session_token(email: str) -> str:
"""Sign session: base64url(email|expiry|hmac_sha256)."""
import base64
expiry = int(time.time()) + SESSION_TTL_SECONDS
payload = f"{email}|{expiry}"
sig = hmac.new(SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
full = f"{payload}|{sig}"
return base64.urlsafe_b64encode(full.encode()).decode().rstrip("=")
def _verify_session_token(token: str) -> Optional[str]:
"""Returns email if valid+not expired, else None."""
if not token:
return None
try:
import base64
pad = len(token) % 4
if pad:
token += "=" * (4 - pad)
decoded = base64.urlsafe_b64decode(token.encode()).decode()
parts = decoded.split("|")
if len(parts) != 3:
return None
email, expiry, sig = parts
expected = hmac.new(SESSION_SECRET.encode(), f"{email}|{expiry}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
if int(expiry) < int(time.time()):
return None
return email
except Exception:
return None
def _verify_google_id_token(id_token: str) -> Optional[dict]:
"""Verify Google ID token via tokeninfo endpoint. Returns claims dict or None."""
if not id_token or not GOOGLE_CLIENT_ID:
return None
try:
import urllib.request, urllib.error, json as _json
url = f"https://oauth2.googleapis.com/tokeninfo?id_token={urllib.parse.quote(id_token)}"
with urllib.request.urlopen(url, timeout=10) as resp:
data = _json.loads(resp.read().decode())
if data.get("aud") != GOOGLE_CLIENT_ID:
print(f"⚠️ Google token aud mismatch: {data.get('aud')!r}", flush=True)
return None
if data.get("iss") not in ("accounts.google.com", "https://accounts.google.com"):
return None
if int(data.get("exp", 0)) < int(time.time()):
return None
if data.get("email_verified") not in (True, "true"):
return None
return data
except Exception as e:
print(f"⚠️ Google token verify error: {e}", flush=True)
return None
def check_auth(
request: Request,
creds: Optional[HTTPBasicCredentials] = Depends(security),
):
"""Auth dependency. Accepts (in order):
1) Valid session cookie (set by Google Sign-In flow)
2) HTTP Basic with AUTH_USER/AUTH_PASS (legacy + cron + scripts)
On failure: redirect browser GETs to /login, return 401 to API clients.
"""
# 1) Session cookie
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
email = _verify_session_token(token)
if email and _email_allowed(email):
return email
# 2) Basic auth fallback
if creds and creds.username and creds.password:
correct_user = secrets.compare_digest(creds.username, AUTH_USER)
correct_pass = secrets.compare_digest(creds.password, AUTH_PASS)
if correct_user and correct_pass:
return creds.username
# 3) Failure — redirect browsers, 401 API
accept = request.headers.get("accept", "")
is_browser_get = request.method == "GET" and "text/html" in accept
if is_browser_get and GOOGLE_CLIENT_ID:
# Browser → /login (raise s 303 ne dela v Depends, zato uporabljamo HTTPException)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Napačno geslo",
headers={"WWW-Authenticate": "Basic"},
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/login"},
)
return creds.username
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Basic"},
)
# ────────────────────────────────────────────────────────────────
@ -1285,6 +1391,191 @@ async def _resume_job_async(job_id):
print(f" ❌ Resume failed for {job_id}: {e}")
# ────────────────────────────────────────────────────────────────
# Google Sign-In endpointi
# ────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
"""Login stran z Google Sign-In gumbom."""
# Če že prijavljen, redirect na /
token = request.cookies.get(SESSION_COOKIE_NAME)
if token and _verify_session_token(token):
email = _verify_session_token(token)
if email and _email_allowed(email):
return RedirectResponse(url="/", status_code=303)
error_html = ""
if error == "not_allowed":
error_html = '<div class="error">❌ Tvoj račun nima dostopa. Kontaktiraj Sebastjana, da te doda.</div>'
elif error == "invalid_token":
error_html = '<div class="error">❌ Neveljaven Google token. Probaj še enkrat.</div>'
elif error == "no_client_id":
error_html = '<div class="error">⚠️ Google Sign-In ni konfiguriran. Nastavi GOOGLE_CLIENT_ID env var.</div>'
if not GOOGLE_CLIENT_ID:
gsi_html = '<div class="error">⚠️ Google Sign-In ni konfiguriran (GOOGLE_CLIENT_ID manjka).</div>'
else:
gsi_html = f"""
<div id="g_id_onload"
data-client_id="{GOOGLE_CLIENT_ID}"
data-callback="handleCredentialResponse"
data-auto_prompt="true"
data-cancel_on_tap_outside="false"
data-context="signin"
data-ux_mode="popup"></div>
<div class="g_id_signin"
data-type="standard"
data-shape="pill"
data-theme="filled_blue"
data-text="signin_with"
data-size="large"
data-logo_alignment="left"></div>
"""
html = f"""<!DOCTYPE html>
<html lang="sl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prijava reels.biba.live</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<style>
* {{ box-sizing: border-box; }}
body {{
margin: 0; min-height: 100vh;
background: #1a1a1a; color: #eee;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center;
padding: 20px;
}}
.login-card {{
background: #242424; border: 1px solid #333; border-radius: 12px;
padding: 40px 32px; max-width: 400px; width: 100%;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
text-align: center;
}}
h1 {{ margin: 0 0 8px; font-size: 24px; color: #fff; }}
.subtitle {{ color: #888; font-size: 14px; margin-bottom: 32px; }}
.gsi-wrap {{ display: flex; justify-content: center; margin: 24px 0; }}
.error {{
background: rgba(255,107,107,0.12); border: 1px solid #ff6b6b;
color: #ff9999; padding: 10px 14px; border-radius: 8px;
font-size: 13px; margin-bottom: 20px; text-align: left;
}}
.footer {{ margin-top: 32px; color: #666; font-size: 12px; }}
.footer a {{ color: #888; text-decoration: none; border-bottom: 1px dashed #555; }}
</style>
</head>
<body>
<div class="login-card">
<h1>🎬 reels.biba.live</h1>
<div class="subtitle">Prijava z Google računom</div>
{error_html}
<div class="gsi-wrap">
{gsi_html}
</div>
<div class="footer">
Težave z dostopom?<br>
Kontaktiraj <a href="mailto:sebastjan.artic@gmail.com">Sebastjana</a>
</div>
</div>
<script>
function handleCredentialResponse(response) {{
fetch("/auth/google/callback", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify({{ credential: response.credential }})
}}).then(r => {{
if (r.ok) {{
window.location.href = "/";
}} else {{
return r.json().then(err => {{
window.location.href = "/login?error=" + (err.detail || "invalid_token");
}});
}}
}}).catch(err => {{
console.error(err);
window.location.href = "/login?error=invalid_token";
}});
}}
</script>
</body>
</html>"""
return html
class GoogleCallbackRequest(BaseModel):
credential: str # Google ID token (JWT)
@app.post("/auth/google/callback")
async def google_callback(payload: GoogleCallbackRequest):
"""Verify Google ID token in set session cookie."""
if not GOOGLE_CLIENT_ID:
raise HTTPException(503, "no_client_id")
claims = _verify_google_id_token(payload.credential)
if not claims:
raise HTTPException(401, "invalid_token")
email = claims.get("email", "").lower()
if not _email_allowed(email):
raise HTTPException(403, "not_allowed")
# Set session cookie
token = _make_session_token(email)
resp = JSONResponse({"ok": True, "email": email, "name": claims.get("name", "")})
resp.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
max_age=SESSION_TTL_SECONDS,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
print(f"✅ Login: {email} ({claims.get('name','?')})", flush=True)
return resp
@app.get("/auth/me")
async def auth_me(request: Request):
"""Vrne trenutno prijavljenega uporabnika (za frontend header)."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
email = _verify_session_token(token)
if email and _email_allowed(email):
return {"email": email, "method": "google"}
# Tudi basic auth (če cron uporablja)
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Basic "):
try:
import base64
decoded = base64.b64decode(auth_header[6:]).decode()
user, _, pwd = decoded.partition(":")
if secrets.compare_digest(user, AUTH_USER) and secrets.compare_digest(pwd, AUTH_PASS):
return {"email": user, "method": "basic"}
except Exception:
pass
raise HTTPException(401, "Not logged in")
@app.post("/logout")
async def logout():
"""Pobriše session cookie + redirect na /login."""
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
return resp
@app.get("/logout")
async def logout_get():
"""GET varianta za <a href> linke."""
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
return resp
@app.get("/", response_class=HTMLResponse)
async def index(user: str = Depends(check_auth)):
html = (Path(__file__).parent.parent / "templates" / "index.html").read_text()

View File

@ -313,9 +313,15 @@
</style>
</head>
<body>
<header>
<h1><span class="accent-mark">1]</span> reels clipper</h1>
<span style="color: var(--muted); font-size: 13px;">biba.live</span>
<header style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div style="display:flex; align-items:baseline; gap:12px;">
<h1 style="margin:0;"><span class="accent-mark">1]</span> reels clipper</h1>
<span style="color: var(--muted); font-size: 13px;">biba.live</span>
</div>
<div id="user-info" style="display:none; align-items:center; gap:10px; font-size:13px;">
<span style="color:var(--muted);">👤 <span id="user-email"></span></span>
<a href="/logout" style="color:var(--muted); text-decoration:none; padding:4px 10px; border:1px solid #444; border-radius:4px; font-size:12px;" title="Odjava">↪ Odjava</a>
</div>
</header>
<main>
@ -1163,6 +1169,18 @@
// Toggle pokaži/skrij že naložene + iskalnik
document.addEventListener("DOMContentLoaded", () => {
// Naloži user info v header
fetch("/auth/me").then(r => r.ok ? r.json() : null).then(u => {
if (u && u.email) {
const ui = document.getElementById("user-info");
const ue = document.getElementById("user-email");
if (ui && ue) {
ue.textContent = u.email;
ui.style.display = "flex";
}
}
}).catch(() => {});
const toggle = $("#show-uploaded");
if (toggle) toggle.addEventListener("change", refreshJobs);
const search = $("#jobs-search");