6989a98d75
- 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
227 lines
6.9 KiB
Python
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() |