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
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue