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,21 @@
"""
QuixZoom Auth Service - Database Models
"""
from .device import Device, DeviceCreate, DeviceResponse
from .auth_request import AuthRequest, AuthRequestCreate, AuthRequestResponse, AuthRequestStatus
from .web_session import WebSession, WebSessionCreate, WebSessionResponse, WebSessionStatus
__all__ = [
"Device",
"DeviceCreate",
"DeviceResponse",
"AuthRequest",
"AuthRequestCreate",
"AuthRequestResponse",
"AuthRequestStatus",
"WebSession",
"WebSessionCreate",
"WebSessionResponse",
"WebSessionStatus",
]
@@ -0,0 +1,129 @@
"""
Auth Request model for passwordless cross-device authentication
Represents an ongoing authentication request between web and device
"""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class AuthRequestStatus(str, Enum):
"""Authentication request status"""
PENDING = "pending" # Waiting for device approval
APPROVED = "approved" # Device approved, ready to complete
REJECTED = "rejected" # Device rejected
EXPIRED = "expired" # Timed out
COMPLETED = "completed" # Successfully completed
CANCELLED = "cancelled" # Cancelled by user
class AuthRequest(BaseModel):
"""Authentication request database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# Request identifiers
request_token: str = Field(..., description="Unique request token (nonce)")
qr_code_data: str = Field(..., description="Data encoded in QR code")
# User and device
user_id: Optional[str] = Field(None, description="User ID if known")
device_id: Optional[str] = Field(None, description="Device that approved/rejected")
# Status tracking
status: AuthRequestStatus = Field(default=AuthRequestStatus.PENDING)
# Security
nonce: str = Field(..., description="Cryptographic nonce for this request")
challenge: str = Field(..., description="Challenge to be signed by device")
challenge_response: Optional[str] = Field(None, description="Device's signed response")
# Device fingerprint at time of request
web_fingerprint: Optional[str] = Field(None, description="Browser fingerprint")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint at approval")
# IP and location info
web_ip: Optional[str] = Field(None, description="Web client IP")
device_ip: Optional[str] = Field(None, description="Device IP at approval")
# Metadata
web_user_agent: Optional[str] = Field(None)
device_user_agent: Optional[str] = Field(None)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime = Field(..., description="Request expiration time")
approved_at: Optional[datetime] = Field(None)
completed_at: Optional[datetime] = Field(None)
# Result
session_token: Optional[str] = Field(None, description="Session token after completion")
error_message: Optional[str] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "auth_req_123",
"request_token": "tkn_abc123",
"qr_code_data": "quixzoom://auth?token=tkn_abc123&nonce=nonce123",
"status": "pending",
"nonce": "nonce123",
"challenge": "ch_abc123...",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-01-15T10:35:00Z"
}
}
class AuthRequestCreate(BaseModel):
"""Request model for initiating authentication"""
user_id: Optional[str] = Field(None, description="User ID if pre-authenticated")
device_fingerprint: Optional[str] = Field(None, description="Browser fingerprint")
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
class AuthRequestApprove(BaseModel):
"""Request model for device approval"""
request_token: str = Field(..., description="Token from QR code")
device_id: str = Field(..., description="Registered device ID")
challenge_response: str = Field(..., description="Signed challenge response")
device_fingerprint: str = Field(..., description="Current device fingerprint")
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
approved: bool = Field(default=True, description="Approve or reject")
class AuthRequestResponse(BaseModel):
"""Response model for auth request"""
id: str
request_token: str
qr_code_data: str
status: AuthRequestStatus
user_id: Optional[str] = None
device_id: Optional[str] = None
created_at: datetime
expires_at: datetime
approved_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
class AuthRequestStatusResponse(BaseModel):
"""Response model for polling auth status"""
status: AuthRequestStatus
request_token: str
device_name: Optional[str] = None
device_platform: Optional[str] = None
approved_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
session_token: Optional[str] = None
error_message: Optional[str] = None
expires_at: datetime
class Config:
from_attributes = True
+101
View File
@@ -0,0 +1,101 @@
"""
Device model for passwordless authentication
Represents a registered mobile device (app)
"""
from datetime import datetime
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class DevicePlatform(str, Enum):
"""Supported device platforms"""
IOS = "ios"
ANDROID = "android"
WEB = "web"
OTHER = "other"
class DeviceStatus(str, Enum):
"""Device status"""
ACTIVE = "active"
REVOKED = "revoked"
SUSPENDED = "suspended"
class Device(BaseModel):
"""Device database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
user_id: str = Field(..., description="Associated user ID")
device_name: str = Field(..., description="Human-readable device name")
platform: DevicePlatform = Field(..., description="Device platform")
# Device fingerprinting
public_key: str = Field(..., description="Device public key for challenge signing")
device_fingerprint: str = Field(..., description="Unique device fingerprint hash")
# Metadata
push_token: Optional[str] = Field(None, description="Push notification token")
last_ip: Optional[str] = Field(None, description="Last known IP address")
last_user_agent: Optional[str] = Field(None, description="Last user agent")
# Status
status: DeviceStatus = Field(default=DeviceStatus.ACTIVE)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
last_used_at: Optional[datetime] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "dev_123456789",
"user_id": "user_abc123",
"device_name": "iPhone 15 Pro",
"platform": "ios",
"public_key": "-----BEGIN PUBLIC KEY-----\n...",
"device_fingerprint": "sha256:abc123...",
"status": "active",
"created_at": "2024-01-15T10:30:00Z"
}
}
class DeviceCreate(BaseModel):
"""Request model for device registration"""
user_id: str = Field(..., description="User ID to associate with device")
device_name: str = Field(..., min_length=1, max_length=100)
platform: DevicePlatform
public_key: str = Field(..., min_length=100, description="Device public key")
device_fingerprint: str = Field(..., min_length=32, description="Device fingerprint")
push_token: Optional[str] = Field(None, max_length=500)
class DeviceUpdate(BaseModel):
"""Request model for device update"""
device_name: Optional[str] = Field(None, min_length=1, max_length=100)
push_token: Optional[str] = Field(None, max_length=500)
status: Optional[DeviceStatus] = None
class DeviceResponse(BaseModel):
"""Response model for device operations"""
id: str
user_id: str
device_name: str
platform: DevicePlatform
status: DeviceStatus
created_at: datetime
last_used_at: Optional[datetime] = None
class Config:
from_attributes = True
class DeviceListResponse(BaseModel):
"""Response model for listing devices"""
devices: list[DeviceResponse]
total: int
@@ -0,0 +1,120 @@
"""
Web Session model for passwordless authentication
Represents an authenticated web session after device approval
"""
from datetime import datetime
from typing import Optional, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field
import uuid
class WebSessionStatus(str, Enum):
"""Web session status"""
ACTIVE = "active"
EXPIRED = "expired"
LOGGED_OUT = "logged_out"
REVOKED = "revoked"
class WebSession(BaseModel):
"""Web session database model"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# Session identifiers
session_token: str = Field(..., description="JWT session token")
refresh_token: str = Field(..., description="Refresh token for session renewal")
# User info
user_id: str = Field(..., description="Authenticated user ID")
auth_request_id: str = Field(..., description="Reference to auth request")
device_id: str = Field(..., description="Device that approved the login")
# Status
status: WebSessionStatus = Field(default=WebSessionStatus.ACTIVE)
# Security context
ip_address: Optional[str] = Field(None)
user_agent: Optional[str] = Field(None)
browser_fingerprint: Optional[str] = Field(None)
device_fingerprint: Optional[str] = Field(None)
# Session metadata
metadata: Dict[str, Any] = Field(default_factory=dict)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime = Field(..., description="Session expiration")
last_activity_at: datetime = Field(default_factory=datetime.utcnow)
logged_out_at: Optional[datetime] = Field(None)
class Config:
json_schema_extra = {
"example": {
"id": "ws_123456",
"session_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "rt_abc123...",
"user_id": "user_abc123",
"auth_request_id": "auth_req_123",
"device_id": "dev_123456789",
"status": "active",
"created_at": "2024-01-15T10:30:00Z",
"expires_at": "2024-01-15T18:30:00Z",
"last_activity_at": "2024-01-15T10:35:00Z"
}
}
class WebSessionCreate(BaseModel):
"""Request model for creating web session"""
user_id: str
auth_request_id: str
device_id: str
ip_address: Optional[str] = None
user_agent: Optional[str] = None
browser_fingerprint: Optional[str] = None
device_fingerprint: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class WebSessionResponse(BaseModel):
"""Response model for web session"""
id: str
user_id: str
device_id: str
status: WebSessionStatus
created_at: datetime
expires_at: datetime
last_activity_at: datetime
class Config:
from_attributes = True
class WebSessionTokenResponse(BaseModel):
"""Response model for session tokens"""
access_token: str = Field(..., description="JWT access token")
refresh_token: str = Field(..., description="Refresh token")
token_type: str = Field(default="Bearer")
expires_in: int = Field(..., description="Token expiration in seconds")
session_id: str
class WebSessionCompleteRequest(BaseModel):
"""Request model for completing web session"""
request_token: str = Field(..., description="Auth request token")
browser_fingerprint: Optional[str] = Field(None)
user_agent: Optional[str] = Field(None)
ip_address: Optional[str] = Field(None)
class WebSessionRefreshRequest(BaseModel):
"""Request model for refreshing session"""
refresh_token: str = Field(..., description="Valid refresh token")
class WebSessionLogoutRequest(BaseModel):
"""Request model for logging out"""
session_token: str = Field(..., description="Session token to invalidate")
all_sessions: bool = Field(default=False, description="Logout all user sessions")