Compare commits
2 Commits
38a2023ca3
...
cad528d732
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad528d732 | ||
|
|
c4590d1442 |
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Callable, Awaitable, Sequence
|
||||
|
||||
@@ -107,6 +108,29 @@ def create_base_app(
|
||||
from shared.infrastructure.oauth import create_oauth_blueprint
|
||||
app.register_blueprint(create_oauth_blueprint(name))
|
||||
|
||||
# --- device id (all apps, including account) ---
|
||||
_did_cookie = f"{name}_did"
|
||||
|
||||
@app.before_request
|
||||
async def _init_device_id():
|
||||
did = request.cookies.get(_did_cookie)
|
||||
if did:
|
||||
g.device_id = did
|
||||
g._new_device_id = False
|
||||
else:
|
||||
g.device_id = secrets.token_urlsafe(32)
|
||||
g._new_device_id = True
|
||||
|
||||
@app.after_request
|
||||
async def _set_device_cookie(response):
|
||||
if getattr(g, "_new_device_id", False):
|
||||
response.set_cookie(
|
||||
_did_cookie, g.device_id,
|
||||
max_age=30 * 24 * 3600,
|
||||
secure=True, samesite="Lax", httponly=True,
|
||||
)
|
||||
return response
|
||||
|
||||
# --- before-request hooks ---
|
||||
@app.before_request
|
||||
async def _route_log():
|
||||
@@ -123,7 +147,7 @@ def create_base_app(
|
||||
for fn in before_request_fns:
|
||||
app.before_request(fn)
|
||||
|
||||
# Auth state check via device cookie + account internal endpoint
|
||||
# Auth state check via grant verification + silent OAuth handshake
|
||||
if name != "account":
|
||||
@app.before_request
|
||||
async def _check_auth_state():
|
||||
@@ -134,14 +158,11 @@ def create_base_app(
|
||||
|
||||
uid = qs.get("uid")
|
||||
grant_token = qs.get("grant_token")
|
||||
device_id = request.cookies.get(f"{name}_did")
|
||||
|
||||
import os, aiohttp
|
||||
from shared.browser.app.redis_cacher import get_redis
|
||||
redis = get_redis()
|
||||
account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
|
||||
|
||||
# Case 1: logged in locally — verify grant still valid
|
||||
# Case 1: logged in — verify grant still valid (direct DB, cached)
|
||||
if uid and grant_token:
|
||||
cache_key = f"grant:{grant_token}"
|
||||
if redis:
|
||||
@@ -154,17 +175,17 @@ def create_base_app(
|
||||
qs.pop("cart_sid", None)
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from shared.db.session import get_session
|
||||
from shared.models.oauth_grant import OAuthGrant
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{account_internal}/auth/internal/verify-grant",
|
||||
params={"token": grant_token},
|
||||
timeout=aiohttp.ClientTimeout(total=3),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
valid = data.get("valid", False)
|
||||
async with get_session() as s:
|
||||
grant = await s.scalar(
|
||||
select(OAuthGrant).where(OAuthGrant.token == grant_token)
|
||||
)
|
||||
valid = grant is not None and grant.revoked_at is None
|
||||
except Exception:
|
||||
return # account unreachable — don't log user out
|
||||
return # DB error — don't log user out
|
||||
|
||||
if redis:
|
||||
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
||||
@@ -174,33 +195,35 @@ def create_base_app(
|
||||
qs.pop("cart_sid", None)
|
||||
return
|
||||
|
||||
# Case 2: not logged in but device cookie exists — check for auth change
|
||||
if not uid and device_id:
|
||||
cache_key = f"device:{name}:{device_id}"
|
||||
if redis:
|
||||
cached = await redis.get(cache_key)
|
||||
# Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only)
|
||||
if not uid and request.method == "GET":
|
||||
if request.headers.get("HX-Request"):
|
||||
return
|
||||
import time as _time
|
||||
now = _time.time()
|
||||
pnone_at = qs.get("_pnone_at")
|
||||
|
||||
# Check if account signalled a login after we cached "not logged in"
|
||||
account_did = qs.get("_account_did")
|
||||
if account_did and redis and pnone_at:
|
||||
auth_ts = await redis.get(f"did_auth:{account_did}")
|
||||
if auth_ts:
|
||||
try:
|
||||
if float(auth_ts) > pnone_at:
|
||||
# Login on account after our cache — re-check now
|
||||
qs.pop("_pnone_at", None)
|
||||
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if pnone_at and (now - pnone_at) < 300:
|
||||
return
|
||||
device_id = g.device_id
|
||||
if device_id and redis:
|
||||
cached = await redis.get(f"prompt:{name}:{device_id}")
|
||||
if cached == b"none":
|
||||
return # recently checked, no auth
|
||||
if cached == b"active":
|
||||
# Auth available — trigger OAuth
|
||||
return redirect(f"/auth/login/?next={_quote(request.url, safe='')}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{account_internal}/auth/internal/check-device",
|
||||
params={"device_id": device_id, "app": name},
|
||||
timeout=aiohttp.ClientTimeout(total=3),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
active = data.get("active", False)
|
||||
except Exception:
|
||||
return # account unreachable — stay anonymous
|
||||
|
||||
if redis:
|
||||
await redis.set(cache_key, b"active" if active else b"none", ex=60)
|
||||
if active:
|
||||
return redirect(f"/auth/login/?next={_quote(request.url, safe='')}")
|
||||
return
|
||||
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
||||
|
||||
@app.before_request
|
||||
async def _csrf_protect():
|
||||
|
||||
@@ -8,7 +8,6 @@ client apps can detect login/logout without cross-domain cookies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -24,7 +23,6 @@ from quart import (
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.db.session import get_session
|
||||
from shared.models import User
|
||||
from shared.models.oauth_code import OAuthCode
|
||||
from shared.infrastructure.urls import account_url, app_url
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
@@ -34,52 +32,57 @@ SESSION_USER_KEY = "uid"
|
||||
GRANT_TOKEN_KEY = "grant_token"
|
||||
|
||||
|
||||
def _device_cookie_name(app_name: str) -> str:
|
||||
return f"{app_name}_did"
|
||||
|
||||
|
||||
def _internal_account_url() -> str:
|
||||
return (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
|
||||
|
||||
|
||||
def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
"""Return an OAuth client blueprint for *app_name*."""
|
||||
bp = Blueprint("oauth_auth", __name__, url_prefix="/auth")
|
||||
cookie_name = _device_cookie_name(app_name)
|
||||
|
||||
# Ensure device cookie exists on every response
|
||||
@bp.after_app_request
|
||||
async def _ensure_device_cookie(response):
|
||||
if not request.cookies.get(cookie_name):
|
||||
did = secrets.token_urlsafe(32)
|
||||
response.set_cookie(
|
||||
cookie_name, did,
|
||||
max_age=30 * 24 * 3600,
|
||||
secure=True, samesite="Lax", httponly=True,
|
||||
)
|
||||
return response
|
||||
|
||||
@bp.get("/login")
|
||||
@bp.get("/login/")
|
||||
async def login():
|
||||
next_url = request.args.get("next", "/")
|
||||
prompt = request.args.get("prompt", "")
|
||||
state = secrets.token_urlsafe(32)
|
||||
qsession["oauth_state"] = state
|
||||
qsession["oauth_next"] = next_url
|
||||
|
||||
device_id = request.cookies.get(cookie_name, "")
|
||||
device_id = g.device_id
|
||||
redirect_uri = app_url(app_name, "/auth/callback")
|
||||
authorize_url = account_url(
|
||||
f"/auth/oauth/authorize?client_id={app_name}"
|
||||
params = (
|
||||
f"?client_id={app_name}"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
f"&device_id={device_id}"
|
||||
f"&state={state}"
|
||||
)
|
||||
if prompt:
|
||||
params += f"&prompt={prompt}"
|
||||
authorize_url = account_url(f"/auth/oauth/authorize{params}")
|
||||
return redirect(authorize_url)
|
||||
|
||||
@bp.get("/callback")
|
||||
@bp.get("/callback/")
|
||||
async def callback():
|
||||
# Always store account_did when account passes it back
|
||||
account_did = request.args.get("account_did", "")
|
||||
if account_did:
|
||||
qsession["_account_did"] = account_did
|
||||
|
||||
# Handle prompt=none error (user not logged in on account)
|
||||
error = request.args.get("error")
|
||||
if error == "login_required":
|
||||
next_url = qsession.pop("oauth_next", "/")
|
||||
qsession.pop("oauth_state", None)
|
||||
import time as _time
|
||||
qsession["_pnone_at"] = _time.time()
|
||||
device_id = g.device_id
|
||||
if device_id:
|
||||
from shared.browser.app.redis_cacher import get_redis
|
||||
_redis = get_redis()
|
||||
if _redis:
|
||||
await _redis.set(
|
||||
f"prompt:{app_name}:{device_id}", b"none", ex=300
|
||||
)
|
||||
return redirect(next_url)
|
||||
|
||||
code = request.args.get("code")
|
||||
state = request.args.get("state")
|
||||
expected_state = qsession.pop("oauth_state", None)
|
||||
@@ -129,6 +132,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
qsession[SESSION_USER_KEY] = user_id
|
||||
if grant_token:
|
||||
qsession[GRANT_TOKEN_KEY] = grant_token
|
||||
qsession.pop("_pnone_at", None)
|
||||
|
||||
# Emit login activity for cart adoption
|
||||
ident = current_cart_identity()
|
||||
@@ -159,7 +163,7 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
qsession.clear()
|
||||
resp = await make_response(redirect("/"))
|
||||
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
||||
resp.delete_cookie(cookie_name, path="/")
|
||||
resp.delete_cookie(f"{app_name}_did", path="/")
|
||||
return resp
|
||||
|
||||
@bp.post("/logout")
|
||||
@@ -168,6 +172,8 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
qsession.pop(SESSION_USER_KEY, None)
|
||||
qsession.pop(GRANT_TOKEN_KEY, None)
|
||||
qsession.pop("cart_sid", None)
|
||||
qsession.pop("_pnone_at", None)
|
||||
qsession.pop("_account_did", None)
|
||||
# Redirect through account to revoke grants + clear account session
|
||||
return redirect(account_url("/auth/sso-logout/"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user