Files
boc/iom/quixzoom-auth-service/services/auth_service.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

396 lines
13 KiB
Python

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