Files
boc/iom/quixzoom-auth-service/utils/security.py
T
Bernt 6989a98d75 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
2026-07-07 07:11:50 +00:00

227 lines
6.9 KiB
Python

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