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