Files
boc/docs/auth/passwordless-architecture.md
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

23 KiB

quiXzoom — Passwordless Cross-Device Authentication

Arkitektur för att logga in på webb (quixzoom.se) utan lösenord, via en redan inloggad mobilapp. Version: 1.0 | Datum: 2026-07-07


1. Översikt

Aspekt Beskrivning
Mål Användare med quiXzoom-appen ska kunna logga in på webb utan att ange lösenord
Flöde Webb visar QR-kod → App skannar/godkänner → Webb loggas in automatiskt
Backend Samma JWT-issuer (api.quixzoom.com) som appen redan använder
Säkerhetsmodell Kryptografiska signaturer, engångstokens, tidsbegränsning, device-binding

2. Sekvensdiagram

┌─────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────────┐     ┌──────────────┐
│  Webb   │     │  quixzoom.se │     │ quiXzoom App │     │ api.quixzoom.com │     │   Redis      │
│ (Browser)│     │  (Frontend)  │     │  (Mobile)    │     │   (Backend)      │     │  (Sessions)  │
└────┬────┘     └──────┬──────┘     └──────┬──────┘     └────────┬────────┘     └──────┬───────┘
     │                 │                   │                      │                     │
     │  1. Klickar "Logga in med app"     │                      │                     │
     │────────────────>│                   │                      │                     │
     │                 │  2. POST /auth/passwordless/initiate     │                     │
     │                 │──────────────────────────────────────────>│                     │
     │                 │                   │                      │  3. Genererar       │
     │                 │                   │                      │     session_id      │
     │                 │                   │                      │     + request_token │
     │                 │                   │                      │────────────────────>│
     │                 │  4. Returnerar    │                      │                     │
     │                 │     {session_id,  │                      │                     │
     │                 │      qr_data,     │                      │                     │
     │                 │      expires_at}  │                      │                     │
     │                 │<──────────────────────────────────────────│                     │
     │  5. Visar QR-kod│                   │                      │                     │
     │<────────────────│                   │                      │                     │
     │                 │                   │  6. Skannar QR-kod   │                     │
     │                 │                   │  (eller trycker deep link)                 │
     │                 │                   │                      │                     │
     │                 │                   │  7. POST /auth/passwordless/approve        │
     │                 │                   │  Headers: Authorization: Bearer <app-JWT>  │
     │                 │                   │  Body: {session_id, request_token, signature}│
     │                 │                   │───────────────────────────────────────────>│
     │                 │                   │                      │  8. Verifierar      │
     │                 │                   │                      │     • JWT giltigt   │
     │                 │                   │                      │     • request_token │
     │                 │                   │                      │       matchar Redis │
     │                 │                   │                      │     • signatur OK   │
     │                 │                   │                      │  9. Uppdaterar      │
     │                 │                   │                      │     Redis: approved │
     │                 │                   │                      │────────────────────>│
     │                 │                   │  10. 200 OK          │                     │
     │                 │                   │<───────────────────────────────────────────│
     │                 │                   │                      │                     │
     │  11. Polling:   │                   │                      │                     │
     │      GET /auth/passwordless/status?session_id=xxx        │                     │
     │────────────────>│                   │                      │                     │
     │                 │  12. Proxy till backend                  │                     │
     │                 │──────────────────────────────────────────>│                     │
     │                 │                   │                      │  13. Kollar Redis   │
     │                 │                   │                      │────────────────────>│
     │                 │                   │                      │<────────────────────│
     │                 │  14. 200 OK       │                      │                     │
     │                 │     {status:"approved",                   │                     │
     │                 │      access_token, refresh_token}         │                     │
     │                 │<──────────────────────────────────────────│                     │
     │  15. Sätter     │                   │                      │                     │
     │      cookies    │                   │                      │                     │
     │<────────────────│                   │                      │                     │
     │  16. Omdirigerar│                   │                      │                     │
     │      till /dashboard                │                      │                     │
     │<────────────────│                   │                      │                     │
     │                 │                   │                      │                     │

3. Komponentarkitektur

┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│                                     TRUST ZONES                                              │
├─────────────────────────────┬─────────────────────────────┬─────────────────────────────────┤
│      UNTRUSTED              │        SEMI-TRUSTED         │           TRUSTED               │
│    (User Browser)           │      (quixzoom.se CDN)      │      (api.quixzoom.com)         │
│                             │                             │                                 │
│  ┌─────────────────────┐   │   ┌─────────────────────┐   │   ┌─────────────────────────┐   │
│  │  Webb-frontend      │   │   │  Static assets      │   │   │  Passwordless Service   │   │
│  │  • QR-visning       │   │   │  • QR-kod render    │   │   │  • /initiate            │   │
│  │  • Polling-loop     │   │   │  • Polling proxy    │   │   │  • /approve             │   │
│  │  • Cookie-hantering │   │   │                     │   │   │  • /status              │   │
│  └─────────────────────┘   │   └─────────────────────┘   │   │  • /cancel              │   │
│                            │                             │   └─────────────────────────┘   │
│                            │                             │   ┌─────────────────────────┐   │
│                            │                             │   │  JWT Auth Service       │   │
│                            │                             │   │  (befintlig)            │   │
│                            │                             │   └─────────────────────────┘   │
│                            │                             │   ┌─────────────────────────┐   │
│                            │                             │   │  Redis Cluster          │   │
│                            │                             │   │  • Sessions             │   │
│                            │                             │   │  • Rate limits          │   │
│                            │                             │   └─────────────────────────┘   │
│                            │                             │   ┌─────────────────────────┐   │
│                            │                             │   │  PostgreSQL             │   │
│                            │                             │   │  • Users                │   │
│                            │                             │   │  • Devices              │   │
│                            │                             │   │  • Audit logs           │   │
│                            │                             │   └─────────────────────────┘   │
└─────────────────────────────┴─────────────────────────────┴─────────────────────────────────┘

4. API-specifikation

4.1 POST /auth/passwordless/initiate

Initierar en ny passwordless-inloggningssession.

Request:

POST https://api.quixzoom.com/v1/auth/passwordless/initiate
Content-Type: application/json

{
  "client_id": "web-dashboard",      // Identifierar webbklienten
  "redirect_url": "https://quixzoom.se/dashboard",
  "device_info": {
    "user_agent": "Mozilla/5.0...",
    "ip": "203.0.113.42"
  }
}

Response (201 Created):

{
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "request_token": "a1b2c3d4e5f6...",   // 32 bytes, base64url
  "qr_data": "quixzoom://auth?sid=pls_2vPqN5L8xT9wR3mK&token=a1b2...",
  "expires_at": "2026-07-07T07:11:00Z",  // 15 minuter
  "poll_interval_ms": 2000
}

Fel:

  • 429 Too Many Requests — rate limit per IP
  • 400 Bad Request — ogiltig redirect_url

4.2 POST /auth/passwordless/approve

Appen godkänner inloggningen. Kräver giltig app-JWT.

Request:

POST https://api.quixzoom.com/v1/auth/passwordless/approve
Content-Type: application/json
Authorization: Bearer <app-access-token>

{
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "request_token": "a1b2c3d4e5f6...",
  "signature": "base64url(SHA256(session_id + request_token + timestamp))",
  "timestamp": "2026-07-07T06:58:00Z",
  "approving_device_id": "dev_abc123"
}

Response (200 OK):

{
  "status": "approved",
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "approved_at": "2026-07-07T06:58:00Z",
  "web_session": {
    "user_agent_hash": "sha256:...",
    "ip_hash": "sha256:..."
  }
}

Fel:

  • 401 Unauthorized — ogiltig eller utgången app-JWT
  • 403 Forbidden — felaktig signatur eller request_token
  • 410 Gone — sessionen har utgått eller redan använts
  • 409 Conflict — sessionen redan godkänd/avbruten

4.3 GET /auth/passwordless/status

Webbklienten pollar status på sessionen.

Request:

GET https://api.quixzoom.com/v1/auth/passwordless/status?session_id=pls_2vPqN5L8xT9wR3mK

Response (200 OK) — pending:

{
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "status": "pending",           // pending | approved | rejected | expired | cancelled
  "expires_at": "2026-07-07T07:11:00Z",
  "remaining_seconds": 780
}

Response (200 OK) — approved:

{
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "status": "approved",
  "approved_at": "2026-07-07T06:58:00Z",
  "tokens": {
    "access_token": "eyJhbG...",
    "refresh_token": "dGhpcyB...",
    "token_type": "Bearer",
    "expires_in": 3600
  }
}

Fel:

  • 404 Not Found — okänd session_id
  • 410 Gone — sessionen utgången

4.4 POST /auth/passwordless/cancel

Avbryter en pågående session (t.ex. användaren stänger webbläsaren).

Request:

POST https://api.quixzoom.com/v1/auth/passwordless/cancel
Content-Type: application/json

{
  "session_id": "pls_2vPqN5L8xT9wR3mK"
}

Response (200 OK):

{
  "status": "cancelled"
}

4.5 POST /auth/passwordless/reject

Appen aktivt avvisar inloggningsförsöket.

Request:

POST https://api.quixzoom.com/v1/auth/passwordless/reject
Content-Type: application/json
Authorization: Bearer <app-access-token>

{
  "session_id": "pls_2vPqN5L8xT9wR3mK",
  "request_token": "a1b2c3d4e5f6...",
  "reason": "user_declined"      // user_declined | suspicious | wrong_device
}

5. Databasschema

5.1 PostgreSQL — Persistent lagring

-- Användartabell (befintlig, utökad)
CREATE TABLE users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email           VARCHAR(255) UNIQUE NOT NULL,
    -- ... befintliga kolumner ...
    passwordless_enabled BOOLEAN DEFAULT true,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Registrerade enheter
CREATE TABLE user_devices (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_id       VARCHAR(64) NOT NULL,        -- unikt per app-installation
    device_type     VARCHAR(20) NOT NULL,        -- ios | android
    device_name     VARCHAR(100),
    public_key      TEXT NOT NULL,               -- Ed25519 public key (PEM)
    push_token      VARCHAR(255),                -- FCM/APNs token
    is_trusted      BOOLEAN DEFAULT false,       -- krävs för passwordless
    last_used_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    revoked_at      TIMESTAMPTZ,                 -- NULL = aktiv

    UNIQUE(user_id, device_id)
);

-- Passwordless-sessioner (audit + analytics)
CREATE TABLE passwordless_sessions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id      VARCHAR(32) UNIQUE NOT NULL, -- "pls_" + nanoid
    user_id         UUID REFERENCES users(id),   -- NULL tills godkänd
    status          VARCHAR(20) NOT NULL,        -- pending | approved | rejected | expired | cancelled

    -- Initiering
    client_id       VARCHAR(50) NOT NULL,        -- web-dashboard | web-checkout
    redirect_url    VARCHAR(500),
    web_ip_hash     VARCHAR(64) NOT NULL,        -- SHA256(ip)
    web_ua_hash     VARCHAR(64) NOT NULL,        -- SHA256(user_agent)

    -- Godkännande
    approving_device_id UUID REFERENCES user_devices(id),
    approved_at     TIMESTAMPTZ,
    signature       VARCHAR(128),                -- base64url

    -- Tidsgränser
    initiated_at    TIMESTAMPTZ DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL,

    -- Avvisning/avbrytning
    rejected_reason VARCHAR(50),
    rejected_at     TIMESTAMPTZ,

    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Index för prestanda
CREATE INDEX idx_passwordless_sessions_status ON passwordless_sessions(status, expires_at);
CREATE INDEX idx_passwordless_sessions_user ON passwordless_sessions(user_id, created_at DESC);
CREATE INDEX idx_user_devices_user ON user_devices(user_id, revoked_at);

5.2 Redis — Temporär session-lagring

# Session under pågående inloggning (TTL: 15 min)
KEY:   passwordless:session:{session_id}
TYPE:  Hash
VALUE: {
  "request_token": "a1b2c3d4...",
  "status": "pending",
  "client_id": "web-dashboard",
  "web_ip_hash": "sha256:...",
  "web_ua_hash": "sha256:...",
  "expires_at": "2026-07-07T07:11:00Z"
}
TTL: 900

# Rate limiting per IP
KEY:   passwordless:ratelimit:{ip_hash}
TYPE:  String (counter)
TTL: 3600

# Rate limiting per användare (vid approve)
KEY:   passwordless:ratelimit:user:{user_id}
TYPE:  String (counter)
TTL: 3600

# Blockerade sessioner (efter misslyckade försök)
KEY:   passwordless:blocked:{session_id}
TYPE:  String
TTL: 300

6. Säkerhetsanalys

6.1 Hotmodell (STRIDE)

Hot Beskrivning Motåtgärd
Spoofing Angripare förfalskar appens signatur Ed25519-signatur verifieras mot registrerad public key
Tampering Manipulering av request_token Token genereras kryptografiskt säkert (32 bytes, CSPRNG)
Repudiation Användare förnekar inloggning Audit-logg i PostgreSQL med signatur och tidsstämplar
Information Disclosure Session ID avlyssnas HTTPS överallt; session_id är engångs; kort TTL
Denial of Service Överbelastning av /initiate Rate limiting per IP (10/min) och per användare (5/min)
Elevation of Privilege Session kapas efter godkännande Token binds till webbklientens IP/UA-hash; CSRF-skydd

6.2 Kryptografiska primitiver

Komponent Algoritm Syfte
Signatur Ed25519 Appen signerar godkännande med sin privata nyckel
Request token 32 bytes CSPRNG Engångs-token som appen måste presentera
Hashing SHA-256 IP/UA-hashing för session-binding
Session ID Nanoid (21 chars, alfabete: A-Za-z0-9_-) Unik identifierare, URL-safe

6.3 Säkerhetskrav

  1. Transport: Alla anrop över TLS 1.3 (minst 1.2). Certificate pinning i appen.
  2. Token-återanvändning: request_token kan endast användas en gång. Efter approve/reject/expired är sessionen låst.
  3. Tidsfönster: Max 15 minuter från initiering till godkännande. Webbklienten pollar max 5 minuter efter expiry.
  4. Device trust: Endast is_trusted = true enheter får godkänna passwordless. Trust etableras vid första inloggningen med lösenord + 2FA.
  5. Session binding: Access-token som returneras vid approved status är bunden till webbklientens IP/UA-hash. Vid avvikelse krävs omautentisering.
  6. QR-säkerhet: QR-koden innehåller endast session_id och request_token, ingen känslig data. Deep link-format: quixzoom://auth?sid=...&token=...
  7. Push-notiser: Vid initiering kan push-notis skickas till appen (kräver opt-in) för snabbare upptäckt.

6.4 Riskbedömning

Risk Sannolikhet Påverkan Risknivå Åtgärd
MITM på publikt WiFi Medel Hög Hög TLS 1.3 + certificate pinning
Stulen app-enhet Låg Hög Medel Biometrisk upplåsning i appen; möjlighet att återkalla enhet
QR-kod fotograferas Medel Medel Medel Kort TTL; token kan bara användas en gång; session bound to webb-klient
Brute-force av session_id Låg Medel Låg 21-char nanoid = ~130 bits entropi
Replay-attack Låg Hög Låg Engångstoken + timestamp i signatur
DoS på /initiate Medel Låg Låg Rate limiting + CAPTCHA vid upprepad överbelastning

7. Implementeringschecklista

Backend (api.quixzoom.com)

  • Nytt PasswordlessService i auth-modulen
  • Ed25519-nyckelhantering för enheter (user_devices.public_key)
  • Redis-integration för temporär session-lagring
  • Rate limiting middleware
  • Audit-loggning till PostgreSQL
  • JWT-utgivning med IP/UA-claims för webb-sessioner

Webb (quixzoom.se)

  • QR-kod rendering (t.ex. qrcode npm-paket)
  • Polling-loop med exponential backoff
  • Hantering av alla statusar (approved, rejected, expired, cancelled)
  • Cookie-hantering för access/refresh tokens
  • Deep link-fallback för användare utan app

App (quiXzoom)

  • QR-skanner integration
  • Deep link-hantering (quixzoom://auth?sid=...&token=...)
  • Biometrisk upplåsning innan signering
  • Ed25519-signering av godkännande
  • Push-notis-mottagning för passwordless-requests

DevOps

  • Redis-cluster i produktion (HA)
  • PostgreSQL-backup av passwordless_sessions (audit)
  • Monitoring: alert vid onormalt hög andel rejected/Expired
  • Log-retention: 90 dagar för säkerhetsrelaterade händelser

8. Sekvens — Djup länk (utan QR)

Alternativt flöde när användaren är på mobilen:

1. Användare besöker quixzoom.se på mobil
2. Klickar "Logga in med app"
3. Webb detekterar mobil → visar knapp "Öppna i quiXzoom-appen"
4. Knapp = deep link: quixzoom://auth?sid=...&token=...
5. App öppnas direkt, hoppar över QR-skanner
6. Användare godkänner → samma approve-flöde
7. App redirectar tillbaka till webbläsaren med auth-kod
8. Webb byter auth-kod mot tokens

9. Bilaga: JWT-claims för webb-session

{
  "sub": "user_uuid",
  "iss": "api.quixzoom.com",
  "aud": "quixzoom.se",
  "iat": 1720332000,
  "exp": 1720335600,
  "scope": "web:read web:write",
  "auth_method": "passwordless",
  "session_binding": {
    "ip_hash": "sha256:...",
    "ua_hash": "sha256:..."
  },
  "device_id": "dev_abc123",
  "session_id": "pls_2vPqN5L8xT9wR3mK"
}

Dokumentet är levande — uppdatera vid implementation och säkerhetsgranskning.