feat: M1 foundation — compose stack, FastAPI, Google OAuth, encrypted tokens
- docker-compose with Postgres 16 + slim Python API image - FastAPI app with session middleware, health endpoint, static login page - Google OAuth (Authlib) with email invite-list whitelist; admin role support - User + OAuthToken models; refresh tokens encrypted at rest (Fernet) - Alembic migrations, run automatically on container startup - Postgres backup/restore scripts for portability between machines
This commit is contained in:
commit
3a5209e96c
27 changed files with 775 additions and 0 deletions
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
108
backend/app/auth.py
Normal file
108
backend/app/auth.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import OAuthToken, User
|
||||
from app.security import encrypt
|
||||
|
||||
# Single YouTube scope that allows reading subscriptions/playlists AND writing
|
||||
# (unsubscribe, playlist export). openid/email/profile give us the account identity.
|
||||
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube"
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="google",
|
||||
client_id=settings.google_client_id,
|
||||
client_secret=settings.google_client_secret,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={
|
||||
"scope": SCOPES,
|
||||
# access_type=offline + prompt=consent ensures we receive a refresh_token.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
return await oauth.google.authorize_redirect(request, settings.oauth_redirect_url)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
token = await oauth.google.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo or not userinfo.get("sub"):
|
||||
raise HTTPException(status_code=400, detail="No user info returned by Google")
|
||||
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
if not email or email not in settings.allowed_email_set:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="This Google account is not on the invite list."
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
|
||||
if user is None:
|
||||
user = User(google_sub=userinfo["sub"], email=email)
|
||||
db.add(user)
|
||||
user.email = email
|
||||
user.display_name = userinfo.get("name")
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
user.role = "admin" if email in settings.admin_email_set else "user"
|
||||
db.flush()
|
||||
|
||||
tok = user.token or OAuthToken(user=user)
|
||||
# Google only returns a refresh_token on (re)consent; keep the previous one otherwise.
|
||||
if token.get("refresh_token"):
|
||||
tok.refresh_token_enc = encrypt(token["refresh_token"])
|
||||
tok.access_token = token.get("access_token")
|
||||
expires_at = token.get("expires_at")
|
||||
tok.expiry = (
|
||||
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||
)
|
||||
tok.scopes = token.get("scope") or SCOPES
|
||||
db.add(tok)
|
||||
db.commit()
|
||||
|
||||
request.session["user_id"] = user.id
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(current_user)) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
}
|
||||
36
backend/app/config.py
Normal file
36
backend/app/config.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
app_name: str = "Subfeed"
|
||||
|
||||
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
|
||||
|
||||
# Session cookie signing key.
|
||||
secret_key: str = "change-me-session-key"
|
||||
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
|
||||
token_encryption_key: str = ""
|
||||
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
oauth_redirect_url: str = "http://localhost:8080/auth/callback"
|
||||
|
||||
# Comma-separated invite list / admin list of Google account emails.
|
||||
allowed_emails: str = ""
|
||||
admin_emails: str = ""
|
||||
|
||||
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||
frontend_origin: str = ""
|
||||
|
||||
@property
|
||||
def allowed_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||
|
||||
@property
|
||||
def admin_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
22
backend/app/db.py
Normal file
22
backend/app/db.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
40
backend/app/main.py
Normal file
40
backend/app/main.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app import auth
|
||||
from app.config import settings
|
||||
from app.routes import health
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
if settings.frontend_origin:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
43
backend/app/models.py
Normal file
43
backend/app/models.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
token: Mapped["OAuthToken | None"] = relationship(
|
||||
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class OAuthToken(Base):
|
||||
__tablename__ = "oauth_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
# Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk.
|
||||
refresh_token_enc: Mapped[str | None] = mapped_column(String)
|
||||
access_token: Mapped[str | None] = mapped_column(String)
|
||||
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
scopes: Mapped[str | None] = mapped_column(String)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="token")
|
||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
13
backend/app/routes/health.py
Normal file
13
backend/app/routes/health.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def healthz(db: Session = Depends(get_db)) -> dict:
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "ok"}
|
||||
25
backend/app/security.py
Normal file
25
backend/app/security.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_fernet: Fernet | None = (
|
||||
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
||||
)
|
||||
|
||||
|
||||
def _require_fernet() -> Fernet:
|
||||
if _fernet is None:
|
||||
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _require_fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _require_fernet().decrypt(value.encode()).decode()
|
||||
0
backend/app/static/assets/.gitkeep
Normal file
0
backend/app/static/assets/.gitkeep
Normal file
58
backend/app/static/index.html
Normal file
58
backend/app/static/index.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!doctype html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Subfeed</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #f4f4f5;
|
||||
background: radial-gradient(1200px 800px at 20% 10%, #1f2a44 0%, #0b0f1a 55%, #07090f 100%);
|
||||
}
|
||||
.card {
|
||||
width: min(92vw, 420px);
|
||||
padding: 40px 36px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
}
|
||||
.logo { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.logo span { color: #ff4d4f; }
|
||||
p { color: #a1a1aa; line-height: 1.5; margin: 14px 0 28px; }
|
||||
a.btn {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
padding: 12px 20px; border-radius: 12px; text-decoration: none;
|
||||
font-weight: 600; color: #0b0f1a; background: #f4f4f5;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
a.btn:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
|
||||
.foot { margin-top: 24px; font-size: 12px; color: #71717a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Sub<span>feed</span></div>
|
||||
<p>A te feliratkozásaid, a te szűrőid szerint.<br />Jelentkezz be a Google-fiókoddal.</p>
|
||||
<a class="btn" href="/auth/login">
|
||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9.1 3.6l6.8-6.8C35.9 2.4 30.4 0 24 0 14.6 0 6.4 5.4 2.5 13.3l7.9 6.1C12.3 13.2 17.7 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.1 24.6c0-1.6-.1-3.1-.4-4.6H24v9.1h12.4c-.5 2.9-2.1 5.3-4.6 6.9l7.1 5.5c4.2-3.9 6.2-9.6 6.2-16.9z"/>
|
||||
<path fill="#FBBC05" d="M10.4 28.6c-.5-1.4-.8-2.9-.8-4.6s.3-3.2.8-4.6l-7.9-6.1C.9 16.5 0 20.1 0 24s.9 7.5 2.5 10.7l7.9-6.1z"/>
|
||||
<path fill="#34A853" d="M24 48c6.4 0 11.9-2.1 15.9-5.8l-7.1-5.5c-2 1.3-4.5 2.1-8.8 2.1-6.3 0-11.7-3.7-13.6-9.9l-7.9 6.1C6.4 42.6 14.6 48 24 48z"/>
|
||||
</svg>
|
||||
Bejelentkezés Google-fiókkal
|
||||
</a>
|
||||
<div class="foot">Csak meghívott fiókok léphetnek be.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue