1a12fb870b
- New /developers/ page with API docs, SDKs, pricing, use cases - OpenAPI 3.0 spec for Orders, Missions, Photos, Analytics - Case study: Glasskiosken i Flatenbadet — complete ROI analysis - Updated /order/ with recurring missions and frequency dropdown
429 lines
12 KiB
Python
429 lines
12 KiB
Python
"""
|
|
quiXzoom SSO Auth Service
|
|
Cross-domain single sign-on with silent login
|
|
"""
|
|
|
|
from fastapi import FastAPI, HTTPException, Response, Request, Depends
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from pydantic import BaseModel, EmailStr
|
|
from datetime import datetime, timedelta
|
|
import jwt
|
|
import redis
|
|
import uuid
|
|
import os
|
|
from typing import Optional
|
|
|
|
app = FastAPI(title="quiXzoom SSO Auth", version="2.0.0")
|
|
|
|
# CORS for cross-domain
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"https://quixzoom.com",
|
|
"https://app.quixzoom.com",
|
|
"https://www.quixzoom.com",
|
|
"https://quixzoom.se",
|
|
"https://quixzoom.de",
|
|
"https://quixzoom.fr",
|
|
"https://quixzoom.nl",
|
|
"https://quixzoom.co.uk",
|
|
"https://quixzoom.asia",
|
|
"http://localhost:3000",
|
|
"http://localhost:3003",
|
|
"http://localhost:5173",
|
|
],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Redis for session storage
|
|
redis_client = redis.Redis(
|
|
host=os.getenv('REDIS_HOST', 'localhost'),
|
|
port=int(os.getenv('REDIS_PORT', 6379)),
|
|
db=0,
|
|
decode_responses=True
|
|
)
|
|
|
|
# JWT config
|
|
JWT_SECRET = os.getenv('JWT_SECRET', 'quixzoom-sso-secret-key-2026')
|
|
JWT_ALGORITHM = 'HS256'
|
|
ACCESS_TOKEN_EXPIRE = 15 # minutes
|
|
REFRESH_TOKEN_EXPIRE = 30 # days
|
|
|
|
# Cookie config for cross-domain
|
|
COOKIE_DOMAIN = None # Allow localhost
|
|
COOKIE_SECURE = False # Allow HTTP for localhost
|
|
COOKIE_SAMESITE = "lax"
|
|
|
|
class UserLogin(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
remember_me: bool = False
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
expires_in: int
|
|
user: dict
|
|
|
|
class UserProfile(BaseModel):
|
|
id: str
|
|
email: str
|
|
name: str
|
|
avatar: Optional[str] = None
|
|
role: str = "zoomer"
|
|
|
|
# Mock users database (replace with real DB)
|
|
USERS = {
|
|
"erik@landvex.com": {
|
|
"id": "usr_001",
|
|
"email": "erik@landvex.com",
|
|
"name": "Erik Svensson",
|
|
"password": "***", # In production: hashed
|
|
"role": "admin",
|
|
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=Erik",
|
|
},
|
|
"demo@quixzoom.com": {
|
|
"id": "usr_002",
|
|
"email": "demo@quixzoom.com",
|
|
"name": "Demo Zoomer",
|
|
"password": "demo123",
|
|
"role": "zoomer",
|
|
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=Demo",
|
|
}
|
|
}
|
|
|
|
def create_access_token(user_id: str, email: str, role: str) -> str:
|
|
"""Create short-lived access token"""
|
|
now = datetime.utcnow()
|
|
payload = {
|
|
"sub": user_id,
|
|
"email": email,
|
|
"role": role,
|
|
"iat": now,
|
|
"exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE),
|
|
"type": "access",
|
|
"jti": str(uuid.uuid4()),
|
|
}
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
def create_refresh_token(user_id: str) -> str:
|
|
"""Create long-lived refresh token"""
|
|
now = datetime.utcnow()
|
|
token_id = str(uuid.uuid4())
|
|
payload = {
|
|
"sub": user_id,
|
|
"iat": now,
|
|
"exp": now + timedelta(days=REFRESH_TOKEN_EXPIRE),
|
|
"type": "refresh",
|
|
"jti": token_id,
|
|
}
|
|
# Store in Redis for revocation
|
|
redis_client.setex(
|
|
f"refresh:{token_id}",
|
|
timedelta(days=REFRESH_TOKEN_EXPIRE),
|
|
user_id
|
|
)
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
|
|
"""Verify JWT token"""
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
if payload.get("type") != token_type:
|
|
return None
|
|
|
|
# Check revocation for refresh tokens
|
|
if token_type == "refresh":
|
|
jti = payload.get("jti")
|
|
if not redis_client.exists(f"refresh:{jti}"):
|
|
return None
|
|
|
|
return payload
|
|
except jwt.ExpiredSignatureError:
|
|
return None
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
|
"""Set cross-domain auth cookies"""
|
|
# Access token cookie (short-lived, HttpOnly)
|
|
response.set_cookie(
|
|
key="qz_access_token",
|
|
value=access_token,
|
|
max_age=ACCESS_TOKEN_EXPIRE * 60,
|
|
httponly=True,
|
|
secure=False,
|
|
samesite="lax",
|
|
domain=None,
|
|
)
|
|
|
|
# Refresh token cookie (long-lived, HttpOnly)
|
|
response.set_cookie(
|
|
key="qz_refresh_token",
|
|
value=refresh_token,
|
|
max_age=REFRESH_TOKEN_EXPIRE * 24 * 60 * 60,
|
|
httponly=True,
|
|
secure=False,
|
|
samesite="lax",
|
|
domain=None,
|
|
)
|
|
|
|
# Non-HttpOnly cookie for JS detection (no sensitive data)
|
|
response.set_cookie(
|
|
key="qz_auth",
|
|
value="1",
|
|
max_age=REFRESH_TOKEN_EXPIRE * 24 * 60 * 60,
|
|
httponly=False,
|
|
secure=False,
|
|
samesite="lax",
|
|
domain=None,
|
|
)
|
|
|
|
def clear_auth_cookies(response: Response):
|
|
"""Clear all auth cookies"""
|
|
for cookie_name in ["qz_access_token", "qz_refresh_token", "qz_auth"]:
|
|
response.delete_cookie(key=cookie_name)
|
|
|
|
@app.post("/auth/login", response_model=TokenResponse)
|
|
async def login(credentials: UserLogin, response: Response):
|
|
"""Login and set cross-domain cookies"""
|
|
user = USERS.get(credentials.email)
|
|
|
|
if not user or user["password"] != credentials.password:
|
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
|
|
# Create tokens
|
|
access_token = create_access_token(user["id"], user["email"], user["role"])
|
|
refresh_token = create_refresh_token(user["id"])
|
|
|
|
# Set cookies
|
|
set_auth_cookies(response, access_token, refresh_token)
|
|
|
|
# Store session in Redis
|
|
session_id = str(uuid.uuid4())
|
|
redis_client.setex(
|
|
f"session:{session_id}",
|
|
timedelta(days=REFRESH_TOKEN_EXPIRE),
|
|
user["id"]
|
|
)
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=ACCESS_TOKEN_EXPIRE * 60,
|
|
user={
|
|
"id": user["id"],
|
|
"email": user["email"],
|
|
"name": user["name"],
|
|
"role": user["role"],
|
|
"avatar": user.get("avatar"),
|
|
}
|
|
)
|
|
|
|
@app.post("/auth/refresh")
|
|
async def refresh_token(request: Request, response: Response):
|
|
"""Refresh access token using refresh token cookie"""
|
|
refresh_token = request.cookies.get("qz_refresh_token")
|
|
|
|
if not refresh_token:
|
|
raise HTTPException(status_code=401, detail="No refresh token")
|
|
|
|
payload = verify_token(refresh_token, "refresh")
|
|
if not payload:
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
|
|
user_id = payload["sub"]
|
|
|
|
# Get user from DB
|
|
user = next((u for u in USERS.values() if u["id"] == user_id), None)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
# Create new tokens
|
|
new_access = create_access_token(user["id"], user["email"], user["role"])
|
|
new_refresh = create_refresh_token(user["id"])
|
|
|
|
# Revoke old refresh token
|
|
old_jti = payload.get("jti")
|
|
if old_jti:
|
|
redis_client.delete(f"refresh:{old_jti}")
|
|
|
|
# Set new cookies
|
|
set_auth_cookies(response, new_access, new_refresh)
|
|
|
|
return {
|
|
"access_token": new_access,
|
|
"refresh_token": new_refresh,
|
|
"expires_in": ACCESS_TOKEN_EXPIRE * 60,
|
|
}
|
|
|
|
@app.post("/auth/logout")
|
|
async def logout(request: Request, response: Response):
|
|
"""Logout and clear all cookies"""
|
|
refresh_token = request.cookies.get("qz_refresh_token")
|
|
|
|
if refresh_token:
|
|
payload = verify_token(refresh_token, "refresh")
|
|
if payload:
|
|
# Revoke refresh token
|
|
jti = payload.get("jti")
|
|
if jti:
|
|
redis_client.delete(f"refresh:{jti}")
|
|
|
|
clear_auth_cookies(response)
|
|
|
|
return {"message": "Logged out successfully"}
|
|
|
|
@app.get("/auth/me")
|
|
async def get_current_user(request: Request):
|
|
"""Get current user from access token"""
|
|
# Try Authorization header first
|
|
auth_header = request.headers.get("Authorization")
|
|
token = None
|
|
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
token = auth_header[7:]
|
|
else:
|
|
# Fall back to cookie
|
|
token = request.cookies.get("qz_access_token")
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
payload = verify_token(token, "access")
|
|
if not payload:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
user_id = payload["sub"]
|
|
user = next((u for u in USERS.values() if u["id"] == user_id), None)
|
|
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
return {
|
|
"id": user["id"],
|
|
"email": user["email"],
|
|
"name": user["name"],
|
|
"role": user["role"],
|
|
"avatar": user.get("avatar"),
|
|
}
|
|
|
|
@app.get("/auth/check")
|
|
async def check_auth(request: Request):
|
|
"""Silent auth check — returns user if logged in, null if not"""
|
|
token = request.cookies.get("qz_access_token")
|
|
|
|
if not token:
|
|
return {"authenticated": False, "user": None}
|
|
|
|
payload = verify_token(token, "access")
|
|
if not payload:
|
|
# Token expired but refresh token might exist
|
|
refresh = request.cookies.get("qz_refresh_token")
|
|
if refresh and verify_token(refresh, "refresh"):
|
|
return {
|
|
"authenticated": True,
|
|
"user": None,
|
|
"needs_refresh": True,
|
|
}
|
|
return {"authenticated": False, "user": None}
|
|
|
|
user_id = payload["sub"]
|
|
user = next((u for u in USERS.values() if u["id"] == user_id), None)
|
|
|
|
if not user:
|
|
return {"authenticated": False, "user": None}
|
|
|
|
return {
|
|
"authenticated": True,
|
|
"user": {
|
|
"id": user["id"],
|
|
"email": user["email"],
|
|
"name": user["name"],
|
|
"role": user["role"],
|
|
"avatar": user.get("avatar"),
|
|
},
|
|
"needs_refresh": False,
|
|
}
|
|
|
|
@app.get("/auth/silent")
|
|
async def silent_login(request: Request, response: Response):
|
|
"""
|
|
Silent login endpoint — attempts to refresh token without user interaction
|
|
Used by apps/sites to maintain session across domains
|
|
"""
|
|
refresh_token = request.cookies.get("qz_refresh_token")
|
|
|
|
if not refresh_token:
|
|
return {"authenticated": False}
|
|
|
|
payload = verify_token(refresh_token, "refresh")
|
|
if not payload:
|
|
clear_auth_cookies(response)
|
|
return {"authenticated": False}
|
|
|
|
user_id = payload["sub"]
|
|
user = next((u for u in USERS.values() if u["id"] == user_id), None)
|
|
|
|
if not user:
|
|
clear_auth_cookies(response)
|
|
return {"authenticated": False}
|
|
|
|
# Create new access token
|
|
new_access = create_access_token(user["id"], user["email"], user["role"])
|
|
|
|
# Set new access cookie
|
|
response.set_cookie(
|
|
key="qz_access_token",
|
|
value=new_access,
|
|
max_age=ACCESS_TOKEN_EXPIRE * 60,
|
|
httponly=True,
|
|
secure=COOKIE_SECURE,
|
|
samesite=COOKIE_SAMESITE,
|
|
domain=None,
|
|
)
|
|
|
|
return {
|
|
"authenticated": True,
|
|
"user": {
|
|
"id": user["id"],
|
|
"email": user["email"],
|
|
"name": user["name"],
|
|
"role": user["role"],
|
|
"avatar": user.get("avatar"),
|
|
},
|
|
}
|
|
|
|
@app.get("/auth/session-info")
|
|
async def session_info(request: Request):
|
|
"""Debug endpoint — show session cookies"""
|
|
return {
|
|
"cookies_present": {
|
|
"qz_access_token": bool(request.cookies.get("qz_access_token")),
|
|
"qz_refresh_token": bool(request.cookies.get("qz_refresh_token")),
|
|
"qz_auth": bool(request.cookies.get("qz_auth")),
|
|
},
|
|
"headers": {
|
|
"origin": request.headers.get("origin"),
|
|
"referer": request.headers.get("referer"),
|
|
}
|
|
}
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
"""Health check"""
|
|
return {
|
|
"status": "ok",
|
|
"service": "quixzoom-sso-auth",
|
|
"version": "2.0.0",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|