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
497 lines
23 KiB
Markdown
497 lines
23 KiB
Markdown
# 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:**
|
|
```http
|
|
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):**
|
|
```json
|
|
{
|
|
"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:**
|
|
```http
|
|
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):**
|
|
```json
|
|
{
|
|
"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:**
|
|
```http
|
|
GET https://api.quixzoom.com/v1/auth/passwordless/status?session_id=pls_2vPqN5L8xT9wR3mK
|
|
```
|
|
|
|
**Response (200 OK) — pending:**
|
|
```json
|
|
{
|
|
"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:**
|
|
```json
|
|
{
|
|
"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:**
|
|
```http
|
|
POST https://api.quixzoom.com/v1/auth/passwordless/cancel
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"session_id": "pls_2vPqN5L8xT9wR3mK"
|
|
}
|
|
```
|
|
|
|
**Response (200 OK):**
|
|
```json
|
|
{
|
|
"status": "cancelled"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.5 POST /auth/passwordless/reject
|
|
|
|
Appen aktivt avvisar inloggningsförsöket.
|
|
|
|
**Request:**
|
|
```http
|
|
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
|
|
|
|
```sql
|
|
-- 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 |
|
|
|-----|-------------|-----------|
|
|
| **S**poofing | Angripare förfalskar appens signatur | Ed25519-signatur verifieras mot registrerad public key |
|
|
| **T**ampering | Manipulering av request_token | Token genereras kryptografiskt säkert (32 bytes, CSPRNG) |
|
|
| **R**epudiation | Användare förnekar inloggning | Audit-logg i PostgreSQL med signatur och tidsstämplar |
|
|
| **I**nformation Disclosure | Session ID avlyssnas | HTTPS överallt; session_id är engångs; kort TTL |
|
|
| **D**enial of Service | Överbelastning av /initiate | Rate limiting per IP (10/min) och per användare (5/min) |
|
|
| **E**levation 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
|
|
|
|
```json
|
|
{
|
|
"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.*
|