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
396 lines
13 KiB
Python
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() |