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
@@ -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()