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 - Services
"""
from .device_service import DeviceService
from .auth_service import AuthService
from .session_service import SessionService
__all__ = [
"DeviceService",
"AuthService",
"SessionService",
]
@@ -0,0 +1,396 @@
"""
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()
@@ -0,0 +1,253 @@
"""
Device Service - Manages device registration and operations
"""
from typing import Optional, List
from datetime import datetime
from models.device import (
Device, DeviceCreate, DeviceUpdate, DeviceResponse,
DeviceStatus, DevicePlatform
)
from utils.security import security_manager
from utils.device_fingerprint import DeviceFingerprint
class DeviceService:
"""
Service for managing registered devices
Handles:
- Device registration
- Device lookup
- Device revocation
- Device fingerprint validation
"""
def __init__(self, db_pool=None):
self.db = db_pool
async def register_device(self, device_data: DeviceCreate) -> Device:
"""
Register a new device
Args:
device_data: Device registration data
Returns:
Device: Created device
"""
# Hash the device fingerprint for storage
fingerprint_hash = security_manager.hash_fingerprint(device_data.device_fingerprint)
device = Device(
user_id=device_data.user_id,
device_name=device_data.device_name,
platform=device_data.platform,
public_key=device_data.public_key,
device_fingerprint=fingerprint_hash,
push_token=device_data.push_token,
status=DeviceStatus.ACTIVE,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Store in database
if self.db:
await self._save_device_to_db(device)
return device
async def get_device(self, device_id: str) -> Optional[Device]:
"""Get device by ID"""
if not self.db:
return None
row = await self.db.fetchrow(
"""
SELECT * FROM devices WHERE id = $1
""",
device_id
)
if row:
return Device(**dict(row))
return None
async def get_device_by_fingerprint(
self,
user_id: str,
fingerprint: str
) -> Optional[Device]:
"""Get device by user ID and fingerprint"""
if not self.db:
return None
fingerprint_hash = security_manager.hash_fingerprint(fingerprint)
row = await self.db.fetchrow(
"""
SELECT * FROM devices
WHERE user_id = $1 AND device_fingerprint = $2 AND status = 'active'
""",
user_id,
fingerprint_hash
)
if row:
return Device(**dict(row))
return None
async def get_user_devices(self, user_id: str) -> List[DeviceResponse]:
"""Get all devices for a user"""
if not self.db:
return []
rows = await self.db.fetch(
"""
SELECT id, user_id, device_name, platform, status, created_at, last_used_at
FROM devices
WHERE user_id = $1
ORDER BY last_used_at DESC NULLS LAST
""",
user_id
)
return [DeviceResponse(**dict(row)) for row in rows]
async def update_device(
self,
device_id: str,
update_data: DeviceUpdate
) -> Optional[Device]:
"""Update device information"""
device = await self.get_device(device_id)
if not device:
return None
# Update fields
if update_data.device_name:
device.device_name = update_data.device_name
if update_data.push_token is not None:
device.push_token = update_data.push_token
if update_data.status:
device.status = update_data.status
device.updated_at = datetime.utcnow()
if self.db:
await self._update_device_in_db(device)
return device
async def revoke_device(self, device_id: str, reason: str = "") -> bool:
"""
Revoke a device
Args:
device_id: Device to revoke
reason: Reason for revocation
Returns:
bool: True if revoked successfully
"""
device = await self.get_device(device_id)
if not device:
return False
device.status = DeviceStatus.REVOKED
device.updated_at = datetime.utcnow()
if self.db:
await self.db.execute(
"""
UPDATE devices
SET status = 'revoked', updated_at = NOW()
WHERE id = $1
""",
device_id
)
return True
async def validate_device(
self,
device_id: str,
fingerprint: Optional[str] = None
) -> bool:
"""
Validate device is active and optionally check fingerprint
Args:
device_id: Device ID
fingerprint: Optional fingerprint to validate
Returns:
bool: True if device is valid
"""
device = await self.get_device(device_id)
if not device:
return False
if device.status != DeviceStatus.ACTIVE:
return False
if fingerprint:
fingerprint_hash = security_manager.hash_fingerprint(fingerprint)
if device.device_fingerprint != fingerprint_hash:
return False
return True
async def update_last_used(self, device_id: str, ip_address: Optional[str] = None):
"""Update last used timestamp"""
if self.db:
await self.db.execute(
"""
UPDATE devices
SET last_used_at = NOW(), last_ip = $2
WHERE id = $1
""",
device_id,
ip_address
)
async def _save_device_to_db(self, device: Device):
"""Save device to database"""
await self.db.execute(
"""
INSERT INTO devices (
id, user_id, device_name, platform, public_key,
device_fingerprint, push_token, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
""",
device.id,
device.user_id,
device.device_name,
device.platform.value,
device.public_key,
device.device_fingerprint,
device.push_token,
device.status.value,
device.created_at,
device.updated_at
)
async def _update_device_in_db(self, device: Device):
"""Update device in database"""
await self.db.execute(
"""
UPDATE devices
SET device_name = $2, push_token = $3, status = $4, updated_at = $5
WHERE id = $1
""",
device.id,
device.device_name,
device.push_token,
device.status.value,
device.updated_at
)
# Global device service instance
device_service = DeviceService()