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:
parent
12e8edba93
commit
d2c9a48cc2
315
app/main.py
315
app/main.py
@ -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()
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user