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
23 KiB
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 IP400 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-JWT403 Forbidden— felaktig signatur eller request_token410 Gone— sessionen har utgått eller redan använts409 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_id410 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
- Transport: Alla anrop över TLS 1.3 (minst 1.2). Certificate pinning i appen.
- Token-återanvändning:
request_tokenkan endast användas en gång. Efter approve/reject/expired är sessionen låst. - Tidsfönster: Max 15 minuter från initiering till godkännande. Webbklienten pollar max 5 minuter efter expiry.
- Device trust: Endast
is_trusted = trueenheter får godkänna passwordless. Trust etableras vid första inloggningen med lösenord + 2FA. - Session binding: Access-token som returneras vid approved status är bunden till webbklientens IP/UA-hash. Vid avvikelse krävs omautentisering.
- QR-säkerhet: QR-koden innehåller endast
session_idochrequest_token, ingen känslig data. Deep link-format:quixzoom://auth?sid=...&token=... - 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
PasswordlessServicei 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.
qrcodenpm-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.