feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Expose port
EXPOSE 8080
# Run
CMD ["python", "main.py"]
@@ -0,0 +1,45 @@
version: '3.8'
services:
passwordless-auth:
build: .
container_name: quixzoom-passwordless
ports:
- "8080:8080"
environment:
- PORT=8080
- HOST=0.0.0.0
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- JWT_SECRET=${JWT_SECRET}
depends_on:
- redis
networks:
- quixzoom-auth
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
redis:
image: redis:7-alpine
container_name: quixzoom-auth-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- quixzoom-auth
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
networks:
quixzoom-auth:
driver: bridge
volumes:
redis-data:
+55
View File
@@ -0,0 +1,55 @@
"""
quiXzoom Passwordless Authentication Service
Cross-device login between app and web
"""
import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from routes.passwordless import router as passwordless_router
app = FastAPI(
title="quiXzoom Passwordless Auth",
description="Passwordless cross-device authentication service",
version="1.0.0"
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://quixzoom.se",
"https://www.quixzoom.se",
"https://quixzoom.com",
"https://www.quixzoom.com",
"http://localhost:3000",
"http://localhost:8080",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Error handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)}
)
# Health check
@app.get("/health")
async def health():
return {"status": "ok", "service": "passwordless-auth"}
# Include routes
app.include_router(passwordless_router, prefix="/v1")
if __name__ == "__main__":
port = int(os.getenv('PORT', 8080))
host = os.getenv('HOST', '0.0.0.0')
uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,21 @@
"""
QuixZoom Auth Service - Database Models
"""
from .device import Device, DeviceCreate, DeviceResponse
from .auth_request import AuthRequest, AuthRequestCreate, AuthRequestResponse, AuthRequestStatus
from .web_session import WebSession, WebSessionCreate, WebSessionResponse, WebSessionStatus
__all__ = [
"Device",
"DeviceCreate",
"DeviceResponse",
"AuthRequest",
"AuthRequestCreate",
"AuthRequestResponse",
"AuthRequestStatus",
"WebSession",
"WebSessionCreate",
"WebSessionResponse",
"WebSessionStatus",
]
@@ -0,0 +1,129 @@
"""
Auth Request model for passwordless cross-device authentication
Represents an ongoing authentication request between web and device
"""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class AuthRequestStatus(str, Enum):
"""Authentication request status"""
PENDING = "pending" # Waiting for device approval
APPROVED = "approved" # Device approved, ready to complete
REJECTED = "rejected" # Device rejected
EXPIRED = "expired" # Timed out
COMPLETED = "completed" # Successfully completed
CANCELLED = "cancelled" # Cancelled by user
class AuthRequest(BaseModel):
"""Authentication request database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# Request identifiers
request_token: str = Field(..., description="Unique request token (nonce)")
qr_code_data: str = Field(..., description="Data encoded in QR code")
# User and device
user_id: Optional[str] = Field(None, description="User ID if known")
device_id: Optional[str] = Field(None, description="Device that approved/rejected")
# Status tracking
status: AuthRequestStatus = Field(default=AuthRequestStatus.PENDING)
# Security
nonce: str = Field(..., description="Cryptographic nonce for this request")
challenge: str = Field(..., description="Challenge to be signed by device")
challenge_response: Optional[str] = Field(None, description="Device's signed response")
# Device fingerprint at time of request
web_fingerprint: Optional[str] = Field(None, description="Browser fingerprint")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint at approval")
# IP and location info
web_ip: Optional[str] = Field(None, description="Web client IP")
device_ip: Optional[str] = Field(None, description="Device IP at approval")
# Metadata
web_user_agent: Optional[str] = Field(None)
device_user_agent: Optional[str] = Field(None)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime = Field(..., description="Request expiration time")
approved_at: Optional[datetime] = Field(None)
completed_at: Optional[datetime] = Field(None)
# Result
session_token: Optional[str] = Field(None, description="Session token after completion")
error_message: Optional[str] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "auth_req_123",
"request_token": "tkn_abc123",
"qr_code_data": "quixzoom://auth?token=tkn_abc123&nonce=nonce123",
"status": "pending",
"nonce": "nonce123",
"challenge": "ch_abc123...",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-01-15T10:35:00Z"
}
}
class AuthRequestCreate(BaseModel):
"""Request model for initiating authentication"""
user_id: Optional[str] = Field(None, description="User ID if pre-authenticated")
device_fingerprint: Optional[str] = Field(None, description="Browser fingerprint")
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
class AuthRequestApprove(BaseModel):
"""Request model for device approval"""
request_token: str = Field(..., description="Token from QR code")
device_id: str = Field(..., description="Registered device ID")
challenge_response: str = Field(..., description="Signed challenge response")
device_fingerprint: str = Field(..., description="Current device fingerprint")
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
approved: bool = Field(default=True, description="Approve or reject")
class AuthRequestResponse(BaseModel):
"""Response model for auth request"""
id: str
request_token: str
qr_code_data: str
status: AuthRequestStatus
user_id: Optional[str] = None
device_id: Optional[str] = None
created_at: datetime
expires_at: datetime
approved_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
class AuthRequestStatusResponse(BaseModel):
"""Response model for polling auth status"""
status: AuthRequestStatus
request_token: str
device_name: Optional[str] = None
device_platform: Optional[str] = None
approved_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
session_token: Optional[str] = None
error_message: Optional[str] = None
expires_at: datetime
class Config:
from_attributes = True
+101
View File
@@ -0,0 +1,101 @@
"""
Device model for passwordless authentication
Represents a registered mobile device (app)
"""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class DevicePlatform(str, Enum):
"""Supported device platforms"""
IOS = "ios"
ANDROID = "android"
WEB = "web"
OTHER = "other"
class DeviceStatus(str, Enum):
"""Device status"""
ACTIVE = "active"
REVOKED = "revoked"
SUSPENDED = "suspended"
class Device(BaseModel):
"""Device database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str = Field(..., description="Associated user ID")
device_name: str = Field(..., description="Human-readable device name")
platform: DevicePlatform = Field(..., description="Device platform")
# Device fingerprinting
public_key: str = Field(..., description="Device public key for challenge signing")
device_fingerprint: str = Field(..., description="Unique device fingerprint hash")
# Metadata
push_token: Optional[str] = Field(None, description="Push notification token")
last_ip: Optional[str] = Field(None, description="Last known IP address")
last_user_agent: Optional[str] = Field(None, description="Last user agent")
# Status
status: DeviceStatus = Field(default=DeviceStatus.ACTIVE)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
last_used_at: Optional[datetime] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "dev_123456789",
"user_id": "user_abc123",
"device_name": "iPhone 15 Pro",
"platform": "ios",
"public_key": "-----BEGIN PUBLIC KEY-----\n...",
"device_fingerprint": "sha256:abc123...",
"status": "active",
"created_at": "2024-01-15T10:30:00Z"
}
}
class DeviceCreate(BaseModel):
"""Request model for device registration"""
user_id: str = Field(..., description="User ID to associate with device")
device_name: str = Field(..., min_length=1, max_length=100)
platform: DevicePlatform
public_key: str = Field(..., min_length=100, description="Device public key")
device_fingerprint: str = Field(..., min_length=32, description="Device fingerprint")
push_token: Optional[str] = Field(None, max_length=500)
class DeviceUpdate(BaseModel):
"""Request model for device update"""
device_name: Optional[str] = Field(None, min_length=1, max_length=100)
push_token: Optional[str] = Field(None, max_length=500)
status: Optional[DeviceStatus] = None
class DeviceResponse(BaseModel):
"""Response model for device operations"""
id: str
user_id: str
device_name: str
platform: DevicePlatform
status: DeviceStatus
created_at: datetime
last_used_at: Optional[datetime] = None
class Config:
from_attributes = True
class DeviceListResponse(BaseModel):
"""Response model for listing devices"""
devices: list[DeviceResponse]
total: int
@@ -0,0 +1,120 @@
"""
Web Session model for passwordless authentication
Represents an authenticated web session after device approval
"""
from datetime import datetime
from typing import Optional, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class WebSessionStatus(str, Enum):
"""Web session status"""
ACTIVE = "active"
EXPIRED = "expired"
LOGGED_OUT = "logged_out"
REVOKED = "revoked"
class WebSession(BaseModel):
"""Web session database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# Session identifiers
session_token: str = Field(..., description="JWT session token")
refresh_token: str = Field(..., description="Refresh token for session renewal")
# User info
user_id: str = Field(..., description="Authenticated user ID")
auth_request_id: str = Field(..., description="Reference to auth request")
device_id: str = Field(..., description="Device that approved the login")
# Status
status: WebSessionStatus = Field(default=WebSessionStatus.ACTIVE)
# Security context
ip_address: Optional[str] = Field(None)
user_agent: Optional[str] = Field(None)
browser_fingerprint: Optional[str] = Field(None)
device_fingerprint: Optional[str] = Field(None)
# Session metadata
metadata: Dict[str, Any] = Field(default_factory=dict)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime = Field(..., description="Session expiration")
last_activity_at: datetime = Field(default_factory=datetime.utcnow)
logged_out_at: Optional[datetime] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "ws_123456",
"session_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "rt_abc123...",
"user_id": "user_abc123",
"auth_request_id": "auth_req_123",
"device_id": "dev_123456789",
"status": "active",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-01-15T18:30:00Z",
"last_activity_at": "2024-01-15T10:35:00Z"
}
}
class WebSessionCreate(BaseModel):
"""Request model for creating web session"""
user_id: str
auth_request_id: str
device_id: str
ip_address: Optional[str] = None
user_agent: Optional[str] = None
browser_fingerprint: Optional[str] = None
device_fingerprint: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class WebSessionResponse(BaseModel):
"""Response model for web session"""
id: str
user_id: str
device_id: str
status: WebSessionStatus
created_at: datetime
expires_at: datetime
last_activity_at: datetime
class Config:
from_attributes = True
class WebSessionTokenResponse(BaseModel):
"""Response model for session tokens"""
access_token: str = Field(..., description="JWT access token")
refresh_token: str = Field(..., description="Refresh token")
token_type: str = Field(default="Bearer")
expires_in: int = Field(..., description="Token expiration in seconds")
session_id: str
class WebSessionCompleteRequest(BaseModel):
"""Request model for completing web session"""
request_token: str = Field(..., description="Auth request token")
browser_fingerprint: Optional[str] = Field(None)
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
class WebSessionRefreshRequest(BaseModel):
"""Request model for refreshing session"""
refresh_token: str = Field(..., description="Valid refresh token")
class WebSessionLogoutRequest(BaseModel):
"""Request model for logging out"""
session_token: str = Field(..., description="Session token to invalidate")
all_sessions: bool = Field(default=False, description="Logout all user sessions")
@@ -0,0 +1,5 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
redis>=5.0.0
pydantic>=2.5.0
python-multipart>=0.0.6
@@ -0,0 +1,393 @@
"""
Passwordless authentication routes
Handles cross-device login between app and web
"""
import os
import time
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Header, Request, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
import redis
router = APIRouter(prefix="/auth/passwordless", tags=["passwordless"])
# Redis connection
redis_client = redis.Redis(
host=os.getenv('REDIS_HOST', 'localhost'),
port=int(os.getenv('REDIS_PORT', 6379)),
db=int(os.getenv('REDIS_DB', 0)),
decode_responses=True
)
# Constants
SESSION_TTL_SECONDS = 900 # 15 minutes
POLL_INTERVAL_MS = 2000
RATE_LIMIT_PER_IP = 10 # per minute
RATE_LIMIT_PER_USER = 5 # per minute
# ─── Request/Response Models ────────────────────────────────────────────────
class InitiateRequest(BaseModel):
client_id: str = Field(default="web-dashboard")
redirect_url: Optional[str] = Field(default="https://quixzoom.se/dashboard")
device_info: Optional[dict] = Field(default=None)
class InitiateResponse(BaseModel):
session_id: str
request_token: str
qr_data: str
expires_at: str
poll_interval_ms: int = POLL_INTERVAL_MS
class ApproveRequest(BaseModel):
session_id: str
request_token: str
signature: str
timestamp: str
approving_device_id: str
class ApproveResponse(BaseModel):
status: str
session_id: str
approved_at: str
class StatusResponse(BaseModel):
status: str
session_id: str
request_token: str
expires_at: str
approved_at: Optional[str] = None
completed_at: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
error_message: Optional[str] = None
class RejectRequest(BaseModel):
session_id: str
request_token: str
device_id: str
# ─── Helper Functions ───────────────────────────────────────────────────────
def generate_session_id() -> str:
"""Generate unique session ID"""
return f"pls_{secrets.token_urlsafe(16)}"
def generate_request_token() -> str:
"""Generate cryptographically secure request token"""
return secrets.token_urlsafe(32)
def generate_nonce() -> str:
"""Generate nonce for challenge"""
return secrets.token_hex(16)
def get_client_ip(request: Request) -> str:
"""Extract client IP from request"""
forwarded = request.headers.get('X-Forwarded-For')
if forwarded:
return forwarded.split(',')[0].strip()
return request.client.host if request.client else "unknown"
def check_rate_limit(key: str, limit: int, window: int = 60) -> bool:
"""Check if rate limit is exceeded"""
current = redis_client.get(f"ratelimit:{key}")
if not current:
redis_client.setex(f"ratelimit:{key}", window, 1)
return True
count = int(current)
if count >= limit:
return False
redis_client.incr(f"ratelimit:{key}")
return True
def create_qr_data(session_id: str, request_token: str) -> str:
"""Create QR code data string"""
return f"quixzoom://auth?sid={session_id}&token={request_token}"
def hash_fingerprint(user_agent: str, ip: str) -> str:
"""Create hash of device fingerprint"""
data = f"{user_agent}:{ip}"
return hashlib.sha256(data.encode()).hexdigest()
# ─── Routes ─────────────────────────────────────────────────────────────────
@router.post("/initiate", response_model=InitiateResponse)
async def initiate_passwordless(
request: Request,
body: InitiateRequest
):
"""
Initiate a new passwordless authentication session.
Returns session details including QR code data.
"""
client_ip = get_client_ip(request)
user_agent = request.headers.get('User-Agent', 'unknown')
# Rate limiting
if not check_rate_limit(f"ip:{client_ip}", RATE_LIMIT_PER_IP):
raise HTTPException(
status_code=429,
detail="Too many requests. Please try again later."
)
# Generate session
session_id = generate_session_id()
request_token = generate_request_token()
nonce = generate_nonce()
challenge = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(seconds=SESSION_TTL_SECONDS)
# Store in Redis
session_data = {
"session_id": session_id,
"request_token": request_token,
"nonce": nonce,
"challenge": challenge,
"status": "pending",
"client_id": body.client_id,
"redirect_url": body.redirect_url or "https://quixzoom.se/dashboard",
"web_ip": client_ip,
"web_user_agent": user_agent,
"web_fingerprint": hash_fingerprint(user_agent, client_ip),
"created_at": datetime.utcnow().isoformat(),
"expires_at": expires_at.isoformat(),
}
redis_key = f"passwordless:{session_id}"
redis_client.hset(redis_key, mapping=session_data)
redis_client.expire(redis_key, SESSION_TTL_SECONDS)
# Also store by request_token for lookup
redis_client.setex(
f"passwordless_token:{request_token}",
SESSION_TTL_SECONDS,
session_id
)
return InitiateResponse(
session_id=session_id,
request_token=request_token,
qr_data=create_qr_data(session_id, request_token),
expires_at=expires_at.isoformat() + "Z",
poll_interval_ms=POLL_INTERVAL_MS
)
@router.post("/approve", response_model=ApproveResponse)
async def approve_passwordless(
request: Request,
body: ApproveRequest,
authorization: Optional[str] = Header(None)
):
"""
Approve a passwordless authentication request from the app.
Requires valid app JWT in Authorization header.
"""
# Verify app JWT (simplified - integrate with your JWT validation)
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization")
app_token = authorization.replace("Bearer ", "")
# TODO: Validate app_token against your JWT service
# For now, we'll do basic validation
if len(app_token) < 10:
raise HTTPException(status_code=401, detail="Invalid token")
# Look up session
redis_key = f"passwordless:{body.session_id}"
session_data = redis_client.hgetall(redis_key)
if not session_data:
raise HTTPException(status_code=410, detail="Session not found or expired")
# Verify request_token matches
if session_data.get("request_token") != body.request_token:
raise HTTPException(status_code=403, detail="Invalid request token")
# Check status
if session_data.get("status") != "pending":
raise HTTPException(
status_code=409,
detail=f"Session already {session_data.get('status')}"
)
# Verify timestamp is recent (within 5 minutes)
try:
timestamp = datetime.fromisoformat(body.timestamp.replace('Z', '+00:00'))
if datetime.utcnow() - timestamp > timedelta(minutes=5):
raise HTTPException(status_code=403, detail="Timestamp too old")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid timestamp format")
# TODO: Verify signature
# expected_signature = hmac_sha256(session_id + request_token + timestamp, device_secret)
# For MVP, we'll skip signature verification and rely on JWT + request_token
# Update session
approved_at = datetime.utcnow()
redis_client.hset(redis_key, mapping={
"status": "approved",
"approved_at": approved_at.isoformat(),
"approving_device_id": body.approving_device_id,
"device_ip": get_client_ip(request),
"device_user_agent": request.headers.get('User-Agent', 'unknown'),
})
# Keep TTL but extend slightly for completion
redis_client.expire(redis_key, 300) # 5 more minutes
return ApproveResponse(
status="approved",
session_id=body.session_id,
approved_at=approved_at.isoformat() + "Z"
)
@router.get("/status")
async def check_status(
request: Request,
session_id: str,
request_token: str
):
"""
Check the status of a passwordless authentication session.
Called by web client via polling.
"""
redis_key = f"passwordless:{session_id}"
session_data = redis_client.hgetall(redis_key)
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
# Verify request_token
if session_data.get("request_token") != request_token:
raise HTTPException(status_code=403, detail="Invalid request token")
status = session_data.get("status", "unknown")
response = {
"status": status,
"session_id": session_id,
"request_token": request_token,
"expires_at": session_data.get("expires_at"),
}
if status == "approved":
response["approved_at"] = session_data.get("approved_at")
# Generate web session tokens
# TODO: Integrate with your JWT service to create proper tokens
user_id = "user_from_app_token" # Extract from app JWT
# Create web session
web_session_token = secrets.token_urlsafe(32)
refresh_token = secrets.token_urlsafe(32)
# Store web session
web_session_key = f"web_session:{web_session_token}"
redis_client.hset(web_session_key, mapping={
"user_id": user_id,
"session_id": session_id,
"created_at": datetime.utcnow().isoformat(),
"ip": get_client_ip(request),
"user_agent": request.headers.get('User-Agent', 'unknown'),
})
redis_client.expire(web_session_key, 86400) # 24 hours
# Mark session as completed
redis_client.hset(redis_key, mapping={
"status": "completed",
"completed_at": datetime.utcnow().isoformat(),
"web_session_token": web_session_token,
})
response.update({
"status": "completed",
"completed_at": datetime.utcnow().isoformat() + "Z",
"access_token": web_session_token,
"refresh_token": refresh_token,
})
elif status == "rejected":
response["error_message"] = "Authentication rejected by user"
elif status == "expired":
response["error_message"] = "Session expired"
return StatusResponse(**response)
@router.post("/reject")
async def reject_passwordless(
request: Request,
body: RejectRequest,
authorization: Optional[str] = Header(None)
):
"""
Reject a passwordless authentication request from the app.
"""
# Verify app JWT
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing authorization")
redis_key = f"passwordless:{body.session_id}"
session_data = redis_client.hgetall(redis_key)
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
if session_data.get("request_token") != body.request_token:
raise HTTPException(status_code=403, detail="Invalid request token")
redis_client.hset(redis_key, mapping={
"status": "rejected",
"rejected_at": datetime.utcnow().isoformat(),
"rejecting_device_id": body.device_id,
})
return {"status": "rejected", "session_id": body.session_id}
@router.post("/cancel")
async def cancel_passwordless(
session_id: str,
request_token: str
):
"""
Cancel a pending passwordless authentication request.
Called by web client.
"""
redis_key = f"passwordless:{session_id}"
session_data = redis_client.hgetall(redis_key)
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
if session_data.get("request_token") != request_token:
raise HTTPException(status_code=403, detail="Invalid request token")
if session_data.get("status") != "pending":
raise HTTPException(status_code=409, detail="Session already processed")
redis_client.hset(redis_key, mapping={
"status": "cancelled",
"cancelled_at": datetime.utcnow().isoformat(),
})
return {"status": "cancelled", "session_id": session_id}
@@ -0,0 +1,13 @@
"""
QuixZoom Auth Service - Services
"""
from .device_service import DeviceService
from .auth_service import AuthService
from .session_service import SessionService
__all__ = [
"DeviceService",
"AuthService",
"SessionService",
]
@@ -0,0 +1,396 @@
"""
Auth Service - Core authentication logic for passwordless cross-device flow
"""
from typing import Optional
from datetime import datetime, timedelta
from models.auth_request import (
AuthRequest, AuthRequestCreate, AuthRequestApprove,
AuthRequestStatus, AuthRequestResponse, AuthRequestStatusResponse
)
from models.device import DeviceStatus
from utils.security import security_manager
from utils.tokens import TokenGenerator
from utils.device_fingerprint import DeviceFingerprint
from services.device_service import DeviceService
class AuthService:
"""
Authentication service for passwordless cross-device flow
Flow:
1. Web initiates login -> creates AuthRequest with QR code
2. App scans QR -> gets request_token
3. App approves with signed challenge
4. Web polls status -> gets approved
5. Web completes -> creates session
"""
# Auth request expiration time (5 minutes)
AUTH_REQUEST_EXPIRE_MINUTES = 5
def __init__(self, db_pool=None, device_service: Optional[DeviceService] = None):
self.db = db_pool
self.device_service = device_service or DeviceService(db_pool)
async def initiate_auth(self, request_data: AuthRequestCreate) -> AuthRequest:
"""
Initiate authentication from web
Creates a new auth request with QR code data
Args:
request_data: Initiation data
Returns:
AuthRequest: Created auth request
"""
# Generate secure tokens
request_token = TokenGenerator.generate_request_token()
nonce = TokenGenerator.generate_nonce()
# Generate challenge for device signing
challenge = security_manager.generate_challenge(request_token, nonce)
# Generate QR code data
expires_at = datetime.utcnow() + timedelta(minutes=self.AUTH_REQUEST_EXPIRE_MINUTES)
qr_code_data = TokenGenerator.generate_qr_payload(
request_token=request_token,
nonce=nonce,
expires_at=expires_at
)
# Generate browser fingerprint if data available
web_fingerprint = None
if request_data.device_fingerprint:
web_fingerprint = security_manager.hash_fingerprint(request_data.device_fingerprint)
auth_request = AuthRequest(
request_token=request_token,
qr_code_data=qr_code_data,
user_id=request_data.user_id,
status=AuthRequestStatus.PENDING,
nonce=nonce,
challenge=challenge,
web_fingerprint=web_fingerprint,
web_ip=request_data.ip_address,
web_user_agent=request_data.user_agent,
created_at=datetime.utcnow(),
expires_at=expires_at
)
# Store in database
if self.db:
await self._save_auth_request_to_db(auth_request)
return auth_request
async def approve_auth(self, approve_data: AuthRequestApprove) -> Optional[AuthRequest]:
"""
Approve authentication from device (app)
Args:
approve_data: Approval data from device
Returns:
AuthRequest: Updated auth request or None if invalid
"""
# Get auth request
auth_request = await self.get_auth_request_by_token(approve_data.request_token)
if not auth_request:
return None
# Check if request is still pending
if auth_request.status != AuthRequestStatus.PENDING:
return None
# Check if request has expired
if datetime.utcnow() > auth_request.expires_at:
auth_request.status = AuthRequestStatus.EXPIRED
if self.db:
await self._update_auth_request_status(auth_request)
return None
# Validate device
device = await self.device_service.get_device(approve_data.device_id)
if not device or device.status != DeviceStatus.ACTIVE:
return None
# Verify challenge response (device signature)
if not security_manager.verify_challenge_response(
challenge=auth_request.challenge,
response=approve_data.challenge_response,
public_key_pem=device.public_key
):
return None
# Validate device fingerprint
device_fingerprint_hash = security_manager.hash_fingerprint(approve_data.device_fingerprint)
if device.device_fingerprint != device_fingerprint_hash:
# Fingerprint mismatch - possible attack
return None
# Update auth request
auth_request.status = (
AuthRequestStatus.APPROVED if approve_data.approved
else AuthRequestStatus.REJECTED
)
auth_request.device_id = approve_data.device_id
auth_request.challenge_response = approve_data.challenge_response
auth_request.device_fingerprint = device_fingerprint_hash
auth_request.device_ip = approve_data.ip_address
auth_request.device_user_agent = approve_data.user_agent
auth_request.approved_at = datetime.utcnow()
# Set user_id from device if not already set
if not auth_request.user_id:
auth_request.user_id = device.user_id
# Update device last used
await self.device_service.update_last_used(
device_id=approve_data.device_id,
ip_address=approve_data.ip_address
)
# Save to database
if self.db:
await self._update_auth_request_approval(auth_request)
return auth_request
async def get_auth_status(self, request_token: str) -> Optional[AuthRequestStatusResponse]:
"""
Get authentication status for polling
Args:
request_token: Auth request token
Returns:
AuthRequestStatusResponse: Current status
"""
auth_request = await self.get_auth_request_by_token(request_token)
if not auth_request:
return None
# Check expiration
if (auth_request.status == AuthRequestStatus.PENDING and
datetime.utcnow() > auth_request.expires_at):
auth_request.status = AuthRequestStatus.EXPIRED
if self.db:
await self._update_auth_request_status(auth_request)
# Get device info if approved
device_name = None
device_platform = None
if auth_request.device_id:
device = await self.device_service.get_device(auth_request.device_id)
if device:
device_name = device.device_name
device_platform = device.platform.value
return AuthRequestStatusResponse(
status=auth_request.status,
request_token=auth_request.request_token,
device_name=device_name,
device_platform=device_platform,
approved_at=auth_request.approved_at,
completed_at=auth_request.completed_at,
session_token=auth_request.session_token,
error_message=auth_request.error_message,
expires_at=auth_request.expires_at
)
async def complete_auth(self, request_token: str) -> Optional[AuthRequest]:
"""
Complete authentication (mark as completed)
Called after web session is created
Args:
request_token: Auth request token
Returns:
AuthRequest: Updated auth request
"""
auth_request = await self.get_auth_request_by_token(request_token)
if not auth_request:
return None
if auth_request.status != AuthRequestStatus.APPROVED:
return None
auth_request.status = AuthRequestStatus.COMPLETED
auth_request.completed_at = datetime.utcnow()
if self.db:
await self._update_auth_request_completion(auth_request)
return auth_request
async def cancel_auth(self, request_token: str) -> bool:
"""
Cancel authentication request
Args:
request_token: Auth request token
Returns:
bool: True if cancelled
"""
auth_request = await self.get_auth_request_by_token(request_token)
if not auth_request:
return False
if auth_request.status not in [AuthRequestStatus.PENDING]:
return False
auth_request.status = AuthRequestStatus.CANCELLED
if self.db:
await self._update_auth_request_status(auth_request)
return True
async def get_auth_request(self, auth_request_id: str) -> Optional[AuthRequest]:
"""Get auth request by ID"""
if not self.db:
return None
row = await self.db.fetchrow(
"""
SELECT * FROM auth_requests WHERE id = $1
""",
auth_request_id
)
if row:
return AuthRequest(**dict(row))
return None
async def get_auth_request_by_token(self, request_token: str) -> Optional[AuthRequest]:
"""Get auth request by token"""
if not self.db:
return None
row = await self.db.fetchrow(
"""
SELECT * FROM auth_requests WHERE request_token = $1
""",
request_token
)
if row:
return AuthRequest(**dict(row))
return None
async def cleanup_expired_requests(self) -> int:
"""
Clean up expired pending requests
Returns:
int: Number of requests cleaned up
"""
if not self.db:
return 0
result = await self.db.execute(
"""
UPDATE auth_requests
SET status = 'expired'
WHERE status = 'pending' AND expires_at < NOW()
"""
)
# Parse result (e.g., "UPDATE 5")
try:
return int(result.split()[-1])
except (IndexError, ValueError):
return 0
async def _save_auth_request_to_db(self, auth_request: AuthRequest):
"""Save auth request to database"""
await self.db.execute(
"""
INSERT INTO auth_requests (
id, request_token, qr_code_data, user_id, device_id,
status, nonce, challenge, challenge_response,
web_fingerprint, device_fingerprint,
web_ip, device_ip, web_user_agent, device_user_agent,
created_at, expires_at, approved_at, completed_at,
session_token, error_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
""",
auth_request.id,
auth_request.request_token,
auth_request.qr_code_data,
auth_request.user_id,
auth_request.device_id,
auth_request.status.value,
auth_request.nonce,
auth_request.challenge,
auth_request.challenge_response,
auth_request.web_fingerprint,
auth_request.device_fingerprint,
auth_request.web_ip,
auth_request.device_ip,
auth_request.web_user_agent,
auth_request.device_user_agent,
auth_request.created_at,
auth_request.expires_at,
auth_request.approved_at,
auth_request.completed_at,
auth_request.session_token,
auth_request.error_message
)
async def _update_auth_request_status(self, auth_request: AuthRequest):
"""Update auth request status"""
await self.db.execute(
"""
UPDATE auth_requests
SET status = $2
WHERE id = $1
""",
auth_request.id,
auth_request.status.value
)
async def _update_auth_request_approval(self, auth_request: AuthRequest):
"""Update auth request after approval"""
await self.db.execute(
"""
UPDATE auth_requests
SET status = $2, device_id = $3, challenge_response = $4,
device_fingerprint = $5, device_ip = $6, device_user_agent = $7,
approved_at = $8, user_id = $9
WHERE id = $1
""",
auth_request.id,
auth_request.status.value,
auth_request.device_id,
auth_request.challenge_response,
auth_request.device_fingerprint,
auth_request.device_ip,
auth_request.device_user_agent,
auth_request.approved_at,
auth_request.user_id
)
async def _update_auth_request_completion(self, auth_request: AuthRequest):
"""Update auth request after completion"""
await self.db.execute(
"""
UPDATE auth_requests
SET status = $2, completed_at = $3
WHERE id = $1
""",
auth_request.id,
auth_request.status.value,
auth_request.completed_at
)
# Global auth service instance
auth_service = AuthService()
@@ -0,0 +1,253 @@
"""
Device Service - Manages device registration and operations
"""
from typing import Optional, List
from datetime import datetime
from models.device import (
Device, DeviceCreate, DeviceUpdate, DeviceResponse,
DeviceStatus, DevicePlatform
)
from utils.security import security_manager
from utils.device_fingerprint import DeviceFingerprint
class DeviceService:
"""
Service for managing registered devices
Handles:
- Device registration
- Device lookup
- Device revocation
- Device fingerprint validation
"""
def __init__(self, db_pool=None):
self.db = db_pool
async def register_device(self, device_data: DeviceCreate) -> Device:
"""
Register a new device
Args:
device_data: Device registration data
Returns:
Device: Created device
"""
# Hash the device fingerprint for storage
fingerprint_hash = security_manager.hash_fingerprint(device_data.device_fingerprint)
device = Device(
user_id=device_data.user_id,
device_name=device_data.device_name,
platform=device_data.platform,
public_key=device_data.public_key,
device_fingerprint=fingerprint_hash,
push_token=device_data.push_token,
status=DeviceStatus.ACTIVE,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Store in database
if self.db:
await self._save_device_to_db(device)
return device
async def get_device(self, device_id: str) -> Optional[Device]:
"""Get device by ID"""
if not self.db:
return None
row = await self.db.fetchrow(
"""
SELECT * FROM devices WHERE id = $1
""",
device_id
)
if row:
return Device(**dict(row))
return None
async def get_device_by_fingerprint(
self,
user_id: str,
fingerprint: str
) -> Optional[Device]:
"""Get device by user ID and fingerprint"""
if not self.db:
return None
fingerprint_hash = security_manager.hash_fingerprint(fingerprint)
row = await self.db.fetchrow(
"""
SELECT * FROM devices
WHERE user_id = $1 AND device_fingerprint = $2 AND status = 'active'
""",
user_id,
fingerprint_hash
)
if row:
return Device(**dict(row))
return None
async def get_user_devices(self, user_id: str) -> List[DeviceResponse]:
"""Get all devices for a user"""
if not self.db:
return []
rows = await self.db.fetch(
"""
SELECT id, user_id, device_name, platform, status, created_at, last_used_at
FROM devices
WHERE user_id = $1
ORDER BY last_used_at DESC NULLS LAST
""",
user_id
)
return [DeviceResponse(**dict(row)) for row in rows]
async def update_device(
self,
device_id: str,
update_data: DeviceUpdate
) -> Optional[Device]:
"""Update device information"""
device = await self.get_device(device_id)
if not device:
return None
# Update fields
if update_data.device_name:
device.device_name = update_data.device_name
if update_data.push_token is not None:
device.push_token = update_data.push_token
if update_data.status:
device.status = update_data.status
device.updated_at = datetime.utcnow()
if self.db:
await self._update_device_in_db(device)
return device
async def revoke_device(self, device_id: str, reason: str = "") -> bool:
"""
Revoke a device
Args:
device_id: Device to revoke
reason: Reason for revocation
Returns:
bool: True if revoked successfully
"""
device = await self.get_device(device_id)
if not device:
return False
device.status = DeviceStatus.REVOKED
device.updated_at = datetime.utcnow()
if self.db:
await self.db.execute(
"""
UPDATE devices
SET status = 'revoked', updated_at = NOW()
WHERE id = $1
""",
device_id
)
return True
async def validate_device(
self,
device_id: str,
fingerprint: Optional[str] = None
) -> bool:
"""
Validate device is active and optionally check fingerprint
Args:
device_id: Device ID
fingerprint: Optional fingerprint to validate
Returns:
bool: True if device is valid
"""
device = await self.get_device(device_id)
if not device:
return False
if device.status != DeviceStatus.ACTIVE:
return False
if fingerprint:
fingerprint_hash = security_manager.hash_fingerprint(fingerprint)
if device.device_fingerprint != fingerprint_hash:
return False
return True
async def update_last_used(self, device_id: str, ip_address: Optional[str] = None):
"""Update last used timestamp"""
if self.db:
await self.db.execute(
"""
UPDATE devices
SET last_used_at = NOW(), last_ip = $2
WHERE id = $1
""",
device_id,
ip_address
)
async def _save_device_to_db(self, device: Device):
"""Save device to database"""
await self.db.execute(
"""
INSERT INTO devices (
id, user_id, device_name, platform, public_key,
device_fingerprint, push_token, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
""",
device.id,
device.user_id,
device.device_name,
device.platform.value,
device.public_key,
device.device_fingerprint,
device.push_token,
device.status.value,
device.created_at,
device.updated_at
)
async def _update_device_in_db(self, device: Device):
"""Update device in database"""
await self.db.execute(
"""
UPDATE devices
SET device_name = $2, push_token = $3, status = $4, updated_at = $5
WHERE id = $1
""",
device.id,
device.device_name,
device.push_token,
device.status.value,
device.updated_at
)
# Global device service instance
device_service = DeviceService()
@@ -0,0 +1,13 @@
"""
QuixZoom Auth Service - Utilities
"""
from .security import SecurityManager
from .device_fingerprint import DeviceFingerprint
from .tokens import TokenGenerator
__all__ = [
"SecurityManager",
"DeviceFingerprint",
"TokenGenerator",
]
@@ -0,0 +1,156 @@
"""
Device fingerprinting utilities
Generates unique fingerprints for devices and browsers
"""
import hashlib
import json
from typing import Dict, Optional, Any
class DeviceFingerprint:
"""
Device fingerprinting for security
Creates unique fingerprints from device/browser characteristics
to detect anomalies and prevent replay attacks
"""
@staticmethod
def generate_browser_fingerprint(
user_agent: Optional[str] = None,
accept_language: Optional[str] = None,
screen_resolution: Optional[str] = None,
timezone: Optional[str] = None,
fonts: Optional[list] = None,
canvas_hash: Optional[str] = None,
webgl_hash: Optional[str] = None,
plugins: Optional[list] = None
) -> str:
"""
Generate browser fingerprint from available characteristics
Args:
user_agent: Browser user agent string
accept_language: Accept-Language header
screen_resolution: Screen resolution (e.g., "1920x1080")
timezone: Browser timezone
fonts: List of detected fonts
canvas_hash: Canvas fingerprinting hash
webgl_hash: WebGL fingerprinting hash
plugins: List of browser plugins
Returns:
str: SHA-256 hash of fingerprint components
"""
components = {
"user_agent": user_agent or "",
"accept_language": accept_language or "",
"screen_resolution": screen_resolution or "",
"timezone": timezone or "",
"fonts": sorted(fonts) if fonts else [],
"canvas_hash": canvas_hash or "",
"webgl_hash": webgl_hash or "",
"plugins": sorted(plugins) if plugins else []
}
# Create deterministic JSON string
fingerprint_json = json.dumps(components, sort_keys=True, separators=(',', ':'))
# Hash the fingerprint
return hashlib.sha256(fingerprint_json.encode()).hexdigest()
@staticmethod
def generate_device_fingerprint(
platform: str,
os_version: Optional[str] = None,
device_model: Optional[str] = None,
hardware_id: Optional[str] = None,
app_version: Optional[str] = None,
install_id: Optional[str] = None
) -> str:
"""
Generate mobile device fingerprint
Args:
platform: Device platform (ios/android)
os_version: OS version
device_model: Device model
hardware_id: Hardware identifier
app_version: App version
install_id: Unique installation ID
Returns:
str: SHA-256 hash of device components
"""
components = {
"platform": platform.lower(),
"os_version": os_version or "",
"device_model": device_model or "",
"hardware_id": hardware_id or "",
"app_version": app_version or "",
"install_id": install_id or ""
}
fingerprint_json = json.dumps(components, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(fingerprint_json.encode()).hexdigest()
@staticmethod
def generate_simple_fingerprint(
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
accept_headers: Optional[Dict[str, str]] = None
) -> str:
"""
Generate simple fingerprint from request headers
Used as fallback when detailed fingerprinting is not available
"""
components = {
"user_agent": user_agent or "",
"ip_address": ip_address or "",
"accept": accept_headers.get("accept", "") if accept_headers else "",
"accept_language": accept_headers.get("accept-language", "") if accept_headers else "",
"accept_encoding": accept_headers.get("accept-encoding", "") if accept_headers else ""
}
fingerprint_json = json.dumps(components, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(fingerprint_json.encode()).hexdigest()
@staticmethod
def compare_fingerprints(
fingerprint1: str,
fingerprint2: str,
tolerance: float = 0.0
) -> bool:
"""
Compare two fingerprints
Args:
fingerprint1: First fingerprint
fingerprint2: Second fingerprint
tolerance: Similarity threshold (0.0 = exact match)
Returns:
bool: True if fingerprints match within tolerance
"""
if tolerance == 0.0:
return fingerprint1 == fingerprint2
# For fuzzy matching, we could implement similarity algorithms
# For now, exact match only
return fingerprint1 == fingerprint2
@staticmethod
def extract_fingerprint_components(fingerprint_hash: str) -> Dict[str, Any]:
"""
Extract components from a fingerprint (if stored separately)
Note: This is a placeholder - in production, components would be
stored alongside the hash for analysis
"""
return {
"hash": fingerprint_hash,
"algorithm": "sha256",
"components_stored": False
}
+227
View File
@@ -0,0 +1,227 @@
"""
Security utilities for passwordless authentication
JWT signing, nonce generation, challenge/response
"""
import os
import secrets
import hashlib
import hmac
import base64
from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple
import jwt
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.exceptions import InvalidSignature
class SecurityManager:
"""
Security manager for passwordless authentication
Features:
- JWT token creation/verification
- Cryptographic nonce generation
- Challenge/response for device signing
- Token expiration management
"""
def __init__(self):
self.secret_key = os.getenv("JWT_SECRET", "quixzoom_jwt_secret_change_in_production")
self.algorithm = os.getenv("JWT_ALGORITHM", "HS256")
self.access_token_expire = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
self.refresh_token_expire = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
self.auth_request_expire = int(os.getenv("AUTH_REQUEST_EXPIRE_MINUTES", "5"))
def generate_nonce(self, length: int = 32) -> str:
"""Generate cryptographically secure nonce"""
return secrets.token_urlsafe(length)
def generate_challenge(self, request_token: str, nonce: str) -> str:
"""
Generate a challenge for device signing
Combines request token and nonce with timestamp
"""
timestamp = datetime.utcnow().isoformat()
challenge_data = f"{request_token}:{nonce}:{timestamp}"
# Create HMAC of challenge data
challenge = hmac.new(
self.secret_key.encode(),
challenge_data.encode(),
hashlib.sha256
).hexdigest()
return challenge
def verify_challenge_response(
self,
challenge: str,
response: str,
public_key_pem: str
) -> bool:
"""
Verify device challenge response
Args:
challenge: Original challenge
response: Device's signed response (base64 encoded signature)
public_key_pem: Device's public key in PEM format
Returns:
bool: True if signature is valid
"""
try:
# Load public key
public_key = serialization.load_pem_public_key(
public_key_pem.encode()
)
# Decode signature
signature = base64.b64decode(response)
# Verify signature
public_key.verify(
signature,
challenge.encode(),
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except InvalidSignature:
return False
except Exception:
return False
def create_access_token(
self,
user_id: str,
device_id: str,
session_id: str,
additional_claims: Optional[Dict] = None
) -> str:
"""Create JWT access token"""
now = datetime.utcnow()
expires = now + timedelta(minutes=self.access_token_expire)
payload = {
"sub": user_id,
"device_id": device_id,
"session_id": session_id,
"type": "access",
"iat": now,
"exp": expires,
"jti": self.generate_nonce(16) # Unique token ID
}
if additional_claims:
payload.update(additional_claims)
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def create_refresh_token(
self,
user_id: str,
session_id: str
) -> str:
"""Create JWT refresh token"""
now = datetime.utcnow()
expires = now + timedelta(days=self.refresh_token_expire)
payload = {
"sub": user_id,
"session_id": session_id,
"type": "refresh",
"iat": now,
"exp": expires,
"jti": self.generate_nonce(16)
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def verify_token(self, token: str, expected_type: str = "access") -> Dict:
"""
Verify JWT token
Args:
token: JWT token string
expected_type: Expected token type ("access" or "refresh")
Returns:
Dict: Token payload
Raises:
jwt.ExpiredSignatureError: Token expired
jwt.InvalidTokenError: Invalid token
ValueError: Wrong token type
"""
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
# Check token type
token_type = payload.get("type")
if token_type != expected_type:
raise ValueError(f"Expected {expected_type} token, got {token_type}")
return payload
def create_request_token(self) -> str:
"""Create unique request token for auth flow"""
return f"req_{secrets.token_urlsafe(24)}"
def create_qr_code_data(
self,
request_token: str,
nonce: str,
base_url: str = "quixzoom://auth"
) -> str:
"""
Create QR code data string
Format: quixzoom://auth?token=<token>&nonce=<nonce>
"""
return f"{base_url}?token={request_token}&nonce={nonce}"
def hash_fingerprint(self, fingerprint: str) -> str:
"""Hash device fingerprint for storage"""
return hashlib.sha256(fingerprint.encode()).hexdigest()
def generate_key_pair(self) -> Tuple[str, str]:
"""
Generate RSA key pair for device
Returns:
Tuple of (private_key_pem, public_key_pem)
"""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
).decode()
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode()
return private_pem, public_pem
def is_token_expired(self, token: str) -> bool:
"""Check if token is expired without raising exception"""
try:
jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return False
except jwt.ExpiredSignatureError:
return True
except jwt.InvalidTokenError:
return True
# Global security manager instance
security_manager = SecurityManager()
+133
View File
@@ -0,0 +1,133 @@
"""
Token generation and validation utilities
"""
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional
class TokenGenerator:
"""
Secure token generator for authentication flows
Generates various types of tokens:
- Request tokens (for auth flow)
- Session tokens
- One-time tokens
- Recovery codes
"""
ALPHANUMERIC = string.ascii_letters + string.digits
URLSAFE = string.ascii_letters + string.digits + "-_"
@classmethod
def generate_request_token(cls, length: int = 32) -> str:
"""Generate secure request token"""
return f"req_{secrets.token_urlsafe(length)}"
@classmethod
def generate_session_token(cls, length: int = 32) -> str:
"""Generate secure session token"""
return f"sess_{secrets.token_urlsafe(length)}"
@classmethod
def generate_refresh_token(cls, length: int = 48) -> str:
"""Generate secure refresh token"""
return f"rt_{secrets.token_urlsafe(length)}"
@classmethod
def generate_nonce(cls, length: int = 32) -> str:
"""Generate cryptographic nonce"""
return secrets.token_urlsafe(length)
@classmethod
def generate_pin(cls, length: int = 6) -> str:
"""Generate numeric PIN code"""
return ''.join(secrets.choice(string.digits) for _ in range(length))
@classmethod
def generate_recovery_code(cls, segments: int = 4, segment_length: int = 4) -> str:
"""Generate human-readable recovery code"""
segments_list = [
''.join(secrets.choice(cls.ALPHANUMERIC) for _ in range(segment_length))
for _ in range(segments)
]
return '-'.join(segments_list).upper()
@classmethod
def generate_api_key(cls, prefix: str = "qk", length: int = 32) -> str:
"""Generate API key with prefix"""
return f"{prefix}_{secrets.token_urlsafe(length)}"
@classmethod
def generate_device_id(cls) -> str:
"""Generate unique device identifier"""
return f"dev_{secrets.token_urlsafe(16)}"
@classmethod
def generate_qr_payload(
cls,
request_token: str,
nonce: str,
expires_at: datetime,
base_url: str = "quixzoom://auth"
) -> str:
"""
Generate QR code payload
Format: quixzoom://auth?token=<token>&nonce=<nonce>&exp=<timestamp>
"""
exp_timestamp = int(expires_at.timestamp())
return f"{base_url}?token={request_token}&nonce={nonce}&exp={exp_timestamp}"
@classmethod
def parse_qr_payload(cls, payload: str) -> Optional[dict]:
"""
Parse QR code payload
Returns:
dict with token, nonce, exp or None if invalid
"""
try:
# Remove scheme
if '://' in payload:
payload = payload.split('://', 1)[1]
# Parse query parameters
if '?' not in payload:
return None
query_string = payload.split('?', 1)[1]
params = {}
for param in query_string.split('&'):
if '=' in param:
key, value = param.split('=', 1)
params[key] = value
return {
"token": params.get("token"),
"nonce": params.get("nonce"),
"exp": int(params.get("exp", 0)) if params.get("exp") else None
}
except Exception:
return None
@classmethod
def is_token_format_valid(cls, token: str, prefix: Optional[str] = None) -> bool:
"""Check if token has valid format"""
if not token or len(token) < 10:
return False
if prefix and not token.startswith(prefix):
return False
# Check for valid characters
valid_chars = set(cls.URLSAFE + '_')
return all(c in valid_chars for c in token.replace(prefix + '_', '') if prefix else token)
# Global token generator
token_generator = TokenGenerator()