Authenticatie (Backend)
Technisch Ontwerp
De backend gebruikt JWT-tokens (SimpleJWT) met tenant-claims om strikte scheiding tussen apotheken (tenants) af te dwingen. Een gebruiker kan lid zijn van meerdere apotheken: het lidmaatschap staat in UserTenantMembership en het token is altijd gescoped op precies een apotheek.
De login is single-factor met een verplichte, vooraf gekoppelde tweede factor. POST /api/auth/login/password/ valideert e-mail en wachtwoord en geeft alleen tokens uit als de gebruiker een bevestigde TOTP-device heeft. Er is geen losse login/totp/-stap meer: de TOTP-koppeling gebeurt eenmalig tijdens de onboarding (uitnodigingsflow), niet bij elke login. Naast wachtwoord-login bestaan er flows voor vertrouwde apparaten (HMAC challenge-response), web-inlog via QR, en OIDC (ZORG-ID, Google, Apple).
Gevoelige acties zijn niet afhankelijk van een tweede login-stap maar van een aparte step-up: een verse her-authenticatie via TOTP of biometrisch apparaatbewijs, die server-side een 30-minuten-venster opent.
- Tenant enforcement:
common/authentication.pybevatTenantEnforcedJWTAuthentication. De klasse hergebruikt het token dat al doorTenantFromJWTSchemaMiddlewareis gevalideerd, controleert dattenant_id/tenant_schemaop het token horen bij een actiefUserTenantMembershipvan de gebruiker, en weigert geblacklistete tokens. Eensupport_mode-claim laat superusers toe om tijdelijk in een andere tenant te werken. - Twee-factor (TOTP): loopt via
django_otp.plugins.otp_totp. De device wordt tijdens onboarding bevestigd. Wachtwoord-login enswitch-tenant/weigeren fail-closed wanneer er geen bevestigde TOTP-device is, met een uniforme foutmelding tegen user-enumeration. - Step-up (
auth/views/step_up.py,common/stepup/):RequiresFreshStepUpis de DRF-permissie die patientdata, beheeracties, OIDC-koppelen/ontkoppelen, e-mailwijziging en erasure afschermt. Step-up gebeurt met TOTP of met een biometrische challenge-response op een step-up-bevoegd apparaat; de elevatie staat per(user, device_label)30 minuten in Redis. - Brute-force-bescherming: een Redis-counter per e-mailadres (
auth/services/password_lockout.py) blokkeert na het ingestelde aantal mislukte pogingen (default 5) voorPASSWORD_LOGIN_LOCKOUT_SECONDS(default 15 minuten). Er wordt altijd een dummy-hash gecheckt om timing-side-channels op user-enumeration te voorkomen. - Vertrouwde apparaten: een geregistreerd toestel krijgt eenmalig een HMAC-sleutel (
auth/services/device_crypto.py) die versleuteld wordt opgeslagen. Het toestel logt in via challenge-response (POST /api/auth/device-login/challenge/gevolgd doorPOST /api/auth/device-login/). Alleen een toestel metcan_step_up=True(gekoppeld na TOTP-bevestiging) levert een biometrische factor die step-up kan openen; een toestel dat via gewone login is gebootstrapt kan dat niet. - Web-inlog via QR: een
WebLoginIntentmet challenge en korte TTL wordt door de desktop gestart; de mobiele client bevestigt via sessie of via apparaat-HMAC; de desktop wisselt een eenmaligeexchange_codeom voor JWT-tokens. Hetzelfde mechanisme bedient een step-up-intent waarmee de telefoon een ingelogde websessie verhoogt. - OIDC: ZORG-ID, Google en Apple worden vastgelegd als provider-agnostische
UserOidcIdentity. Koppelen gebeurt step-up-gated; inloggen met een nog niet gekoppelde identiteit vraagt eenmalig e-mail plus wachtwoord om de identiteit aan een account te binden. - Sessies: SimpleJWT's
OutstandingToken/BlacklistedTokentonen en trekken actieve refresh-sessies in. Bij wachtwoordwijziging of 2FA-reset worden alle refresh-tokens geblacklist en alle vertrouwde apparaten ingetrokken.
flowchart TD
A[Client: POST /api/auth/login/password/] --> B{password_lockout.is_locked?}
B -- ja --> B1[401 uniforme fout]
B -- nee --> C[authenticate email + password]
C -- fail --> C1[dummy-hash + record_failure + 401]
C -- ok --> D{bevestigde TOTP-device?}
D -- nee --> D1[401 uniforme fout, fail-closed]
D -- ja --> E[services.complete_login]
E --> F{aantal actieve lidmaatschappen?}
F -- 1 --> G[issue_tokens_for_user + set_auth_cookies]
F -- meerdere --> H[200 requires_tenant_choice + login_state]
H --> I[Client: POST /api/auth/switch-tenant/ met tenant_id]
I --> G
G --> J[ip_tracking.check_and_record + _rid cookie]
J --> K[200 access + refresh]
Datamodel (ERD)
Het User-model leeft in het publieke schema. Er is geen directe tenant-FK op User: de koppeling apotheek-gebruiker loopt via UserTenantMembership. TOTP-devices komen uit django_otp en sessies (OutstandingToken, BlacklistedToken) uit SimpleJWT; beide staan niet in het ERD. Het model is gesplitst in drie diagrammen: kernidentiteit, login-apparaten en onboarding/AVG.
Kernidentiteit, OIDC en lidmaatschap
erDiagram
Apotheek ||--o{ UserTenantMembership : "lidmaatschap"
User ||--o{ UserTenantMembership : "lid van"
Role ||--o{ UserTenantMembership : "pending_role"
User ||--o{ UserOidcIdentity : "gekoppelde identiteiten"
Apotheek {
int id PK
string name
string schema_name
}
User {
int id PK
string email "unique-partial, live rows"
string first_name
string last_name
string phone_number "encrypted"
date birth_date "encrypted, nullable"
boolean is_active
boolean is_staff
boolean is_superuser
string preferred_model
boolean has_completed_global_walkthrough
boolean has_completed_patient_walkthrough
boolean has_completed_review_walkthrough
datetime date_joined
datetime deleted_at "nullable, soft-delete"
}
UserOidcIdentity {
int id PK
int user_id FK
string provider "zorgid|google|apple|dezi"
string subject "encrypted"
string subject_hash "sha256, indexed, unique per provider"
datetime created_at
}
UserTenantMembership {
int id PK
int user_id FK
int tenant_id FK
string status "invited|active"
int pending_role_id FK "nullable"
string accept_token_hash "nullable"
datetime accept_token_expires_at "nullable"
datetime created_at
}
Login-apparaten en web-login
erDiagram
User ||--o{ TrustedDevice : "bezit"
TrustedDevice ||--o{ DeviceKnownIP : "ip-historie"
User ||--o{ UserKnownIP : "logt in vanaf"
User ||--o{ UserKnownBrowser : "browser-cookies"
User ||--o{ WebLoginIntent : "approved_by / target_user"
User {
int id PK
string email
boolean is_active
}
TrustedDevice {
uuid id PK
int user_id FK
string name
string platform
string device_info
string install_id_hash "nullable, unique per user"
text hmac_key_enc "encrypted"
boolean can_step_up
int failed_attempts
datetime locked_until "nullable"
datetime created_at
datetime last_seen_at
datetime revoked_at "nullable"
}
DeviceKnownIP {
uuid id PK
uuid device_id FK
string ip_address
datetime first_seen_at
datetime last_seen_at
}
UserKnownIP {
uuid id PK
int user_id FK
string ip_address
datetime first_seen_at
datetime last_seen_at
}
UserKnownBrowser {
uuid id PK
int user_id FK
uuid browser_id
datetime first_seen_at
datetime last_seen_at
}
WebLoginIntent {
uuid id PK
string challenge
string status "pending|approved|denied|expired|consumed"
string purpose "login|step_up"
int target_user_id FK "nullable"
string target_device_label "nullable"
int approved_by_id FK "nullable"
string approved_via "biometric|session|password"
datetime approved_at "nullable"
string exchange_code_hash "nullable"
datetime exchange_code_expires_at "nullable"
text user_agent
string ip_hash
datetime created_at
datetime expires_at
datetime consumed_at "nullable"
}
Onboarding, reset en AVG
erDiagram
Apotheek ||--o{ UserInvitation : "verstuurt"
Apotheek ||--o{ PasswordResetToken : "scope"
Apotheek ||--o{ TotpAdminResetToken : "scope"
User ||--o{ PasswordResetToken : "reset"
User ||--o{ TotpAdminResetToken : "2fa-reset"
User ||--|| ErasureRequest : "erasure"
User ||--o{ DSARDownloadToken : "data-export"
UserInvitation {
uuid id PK
int tenant_id FK
string email
string first_name
string last_name
string phone_number "encrypted, admin-vast"
datetime sms_confirmed_at "nullable"
date birth_date "encrypted, nullable"
string invited_given_names
string invited_initials
string invited_tussenvoegsel
string invited_surname
int role_id FK
string token_hash "sha256, unique"
datetime created_at
datetime expires_at
datetime used_at "nullable"
datetime cancelled_at "nullable"
int invited_by_id FK
}
SmsOtpChallenge {
int id PK
string subject "unique"
string code_hash "pbkdf2"
smallint attempts
datetime created_at
datetime expires_at
}
PasswordResetToken {
uuid id PK
int tenant_id FK
int user_id FK
string token_hash "sha256"
datetime created_at
datetime expires_at
datetime used_at "nullable"
}
TotpAdminResetToken {
uuid id PK
int tenant_id FK
int user_id FK
int reset_by_id FK
string token_hash "sha256"
datetime created_at
datetime expires_at
datetime used_at "nullable"
}
ErasureRequest {
uuid id PK
int user_id FK "one-to-one"
int requested_by_id FK
string status "pending|cancelled|executed"
string reason
datetime requested_at
datetime scheduled_at
datetime executed_at "nullable"
datetime cancelled_at "nullable"
}
DSARDownloadToken {
uuid token PK
int user_id FK
string s3_key
string filename
datetime created_at
datetime expires_at
datetime downloaded_at "nullable"
}
API & Communicatie
De endpoints staan onder /api/auth/. JWT-tokens worden als JSON-payload en via secure cookies (access / refresh) verstuurd; het refresh-cookie is scoped op de refresh-route.
-
Login en step-up:
POST /api/auth/login/password/: valideert e-mail en wachtwoord. Vereist een bevestigde TOTP-device (anders uniforme 401). Bij een enkel lidmaatschap volgt{ access, refresh, is_superuser? }; bij meerdere lidmaatschappen{ requires_tenant_choice: true, login_state, memberships }.POST /api/auth/switch-tenant/: kies een apotheek na login (metlogin_state) of wissel als ingelogde gebruiker van apotheek. Geeft een nieuw tenant-gescoped tokenpaar terug.POST /api/auth/step-up/: verse her-authenticatie metcode(TOTP) of metdevice_id+challenge+signature(biometrisch). Opent een 30-minuten elevatie-venster.POST /api/auth/token/refresh/: vernieuwt het access token (en roteert het refresh token). Leest het refresh-cookie als er geen body is.POST /api/auth/logout/: blacklist refresh- en access-token, wist de elevatie en de auth-cookies.
-
Web-inlog via QR:
POST /api/auth/weblogin/start/: start een login-intent; geeftintent_id+challenge(TTL 120s).POST /api/auth/weblogin/step-up/start/: start een step-up-intent voor de eigen ingelogde websessie.GET /api/auth/weblogin/status/{intent_id}/: poll-fallback voor de WebSocket-status.POST /api/auth/weblogin/confirm/: goedkeuren/weigeren vanuit een ingelogde mobiele sessie (approved_via=session).POST /api/auth/weblogin/confirm-device/: goedkeuren via vertrouwd apparaat (HMAC);approved_via=biometricalleen bijcan_step_up.POST /api/auth/weblogin/fetch-code/: desktop claimt na approval de eenmaligeexchange_code.POST /api/auth/weblogin/exchange/: desktop wisseltexchange_codeom voor tokens.
-
Vertrouwde apparaten:
GET /api/auth/devices/: eigen toestellen (of alle tenant-toestellen metsettings.manage_users).POST /api/auth/devices/register/: koppel een toestel (step-up-gated); idempotent opinstall_id. Retourneert eenmalig{ device_id, device_secret }en zetcan_step_up=True.POST /api/auth/devices/revoke/: trek een toestel in.POST /api/auth/device-login/challenge/: start een HMAC-challenge.POST /api/auth/device-login/: device-login metdevice_id,challenge,signature.
-
OIDC (ZORG-ID, Google, Apple):
GET /api/auth/zorgid/authorize/enGET|POST /api/auth/zorgid/callback/: de bij VZVZ geregistreerde ZORG-ID-route.GET /api/auth/oauth/{provider}/authorize/enGET|POST /api/auth/oauth/{provider}/callback/: generieke provider-route.POST /api/auth/oauth/link/start/: start het koppelen van een provider aan het ingelogde account (step-up-gated).GET /api/auth/oauth/identities/enDELETE /api/auth/oauth/identities/{provider}/: gekoppelde providers tonen en ontkoppelen (ontkoppelen step-up-gated).POST /api/auth/oauth/exchange/: wissel de eenmalige callback-code om voor tokens.POST /api/auth/oauth/link-login/: bind een nog niet gekoppelde identiteit aan een account via e-mail + wachtwoord.POST /api/auth/oauth/apple/native/enPOST /api/auth/oauth/apple/native/link/: native Apple-login en -koppeling op iOS.
-
Profiel en preferences:
GET /api/auth/me/: profiel, actieve tenant, rollen, permissies,enabled_modules, walkthrough-state en support-vlaggen.POST /api/auth/me/email-change/confirm/: bevestig een e-mailwijziging (step-up-gated).GET /api/auth/memberships/: actieve lidmaatschappen van de gebruiker.PATCH /api/auth/preferences/: wijzigpreferred_model(prooffast).POST /api/auth/walkthrough/{global|patient|review}/{complete|reset}/: walkthrough-status.
-
Sessies en security:
GET /api/auth/sessions/enPOST /api/auth/sessions/revoke/: actieve refresh-sessies tonen en intrekken.POST /api/auth/security/password/change/: wachtwoordwijziging; revoket alle refresh-tokens en vertrouwde apparaten.POST /api/auth/security/2fa/reset/: zelf-reset van 2FA.POST /api/auth/security/csp-report/: ontvangt CSP-violation reports (geen persistentie).
-
Onboarding, uitnodigingen en reset (geen auth):
GET /api/auth/invite/{token}/validate/: valideer uitnodiging en geef de stap terug.POST /api/auth/invite/{token}/sms/start/en.../sms/verify/: sms-bevestiging eerst;verifymint een onboarding-grant.POST /api/auth/invite/{token}/accept/: stel het wachtwoord in (vereist live grant); maakt de gebruiker + onbevestigde TOTP-device aan.POST /api/auth/invite/{token}/complete/: bevestig TOTP, ken het lidmaatschap + rol toe en geef tokens uit.GET /api/auth/invite-membership/{token}/validate/,.../accept/,.../decline/: tweede-apotheek-uitnodiging voor een bestaande gebruiker.POST /api/auth/password-reset/request/,GET .../{token}/validate/,POST .../{token}/confirm/: wachtwoord-reset (antwoord altijd 200 tegen enumeration).GET /api/auth/totp-admin-reset/{token}/validate/,POST .../confirm/: door een beheerder geinitieerde 2FA-reset.POST /api/auth/totp-help-request/: een gebruiker op het TOTP-scherm vraagt de beheerders om een 2FA-reset. Identificatie vialogin_state(bewijst dat het wachtwoord klopte); enumeration-veilig (antwoordt altijd met een neutrale bevestiging).
-
Beheer (
settings.manage_users, step-up-gated):GET /api/auth/admin/users/enGET /api/auth/admin/users/{user_id}/: lijst en detail.POST /api/auth/admin/users/{user_id}/totp-reset/: stuur een 2FA-resetlink.POST /api/auth/admin/users/{user_id}/erasure/enGET /api/auth/admin/erasure-requests/: erasure plannen en openstaande verzoeken tonen.GET /api/auth/admin/pending-invitations/,POST /api/auth/admin/invitations/{id}/resend/,DELETE /api/auth/admin/invitations/{id}/: openstaande uitnodigingen beheren.GET /api/auth/admin/audit-log/: audit-log-viewer.
-
AVG (zelfservice):
POST /api/auth/me/data-export/enGET /api/auth/dsar/download/{token}/: data-export (DSAR, art. 15).POST /api/auth/me/erasure-request/start/enGET|POST /api/auth/me/erasure-request/: eigen erasure aanvragen of annuleren binnen de respijtperiode (art. 17).
-
Support (alleen superuser):
GET /api/auth/support/tenants/enPOST /api/auth/support/enter-tenant/: tijdelijke support-toegang met eensupport_mode-token.
WebSocket: het kanaal weblogin_<intent_id> (Channels) duwt de approve/deny-status naar de wachtende desktop.
Foutafhandeling & Statuscodes
400 Bad Request: ontbrekende of ongeldige velden, ongeldige TOTP-code, verlopen of ongeldigelogin_state, mismatchende wachtwoordbevestiging.401 Unauthorized: ongeldige inloggegevens, ontbrekende bevestigde TOTP-device (fail-closed, uniform), ongeldig/verlopen/geblacklist JWT, mislukte HMAC-validatie bij device-login ofweblogin/confirm-device/, ingetrokken trusted device, lockout na te veel pogingen.403 Forbidden: ontbrekende permissie, resource buiten de eigen tenant, of ontbrekende step-up-elevatie op een doorRequiresFreshStepUpafgeschermde actie (patientdata, beheer, OIDC-koppelen, erasure).404 Not Found: onbekende user, intent, sessie, uitnodiging of token.410 Gone: uitnodiging, membership-uitnodiging of resetlink is gebruikt, ingetrokken of verlopen.429 Too Many Requests: device-lockout (DEVICE_LOGIN_MAX_FAILURES/DEVICE_LOGIN_LOCKOUT_SECONDS), sms-throttle of een overschredenScopedRateThrottle. Wachtwoord-lockout wordt als uniforme 401 teruggegeven om enumeration te voorkomen.
Specifieke error_code-velden: device_revoked (toestel ingetrokken, biometrie opnieuw koppelen) en tenant_mismatch / no_membership (tenant-claim hoort niet bij een actief lidmaatschap).
Autorisatie & Beveiliging
- JWT-claims: tokens dragen
tenant_id,tenant_schema,device_labelen eenauth_origin.TenantFromJWTSchemaMiddlewarezet het django-tenants-schema;TenantEnforcedJWTAuthenticationvalideert dat de gebruiker een actiefUserTenantMembershipvoor die tenant heeft. - Lidmaatschap-invariant: elke rol-toekenning vereist een actief lidmaatschap voor
(user, tenant).auth/services/membership.pyhoudt rollen en lidmaatschappen synchroon; het intrekken van een lidmaatschap trekt de rollen in die tenant mee in. - Token-lifetimes:
ACCESS_TOKEN_LIFETIME = 30 minuten,REFRESH_TOKEN_LIFETIME = 12 uur(sliding),ROTATE_REFRESH_TOKENS = True,BLACKLIST_AFTER_ROTATION = True. Een absolute bovengrens (REFRESH_ABSOLUTE_MAX_SECONDS, 7 dagen) dwingt na een doorlopende sessie van maximaal 7 dagen een nieuwe login af. Patientdata is los hiervan afgeschermd door step-up, dus een werkdaglange sessie is aanvaardbaar. - Step-up:
RequiresFreshStepUp(common/stepup/permissions.py) controleert de Redis-elevatie per(user, device_label). Step-up gebeurt met TOTP of met een biometrische challenge-response op eencan_step_up-toestel. Een toestel krijgtcan_step_upalleen na TOTP-bevestiging (onboarding) of na een verse step-up, nooit vanuit een gewone wachtwoord-login. - Wachtwoordbeleid: minimaal 10 tekens, minstens 1 cijfer en 1 speciaal teken, geen veelgebruikt of numeriek-only wachtwoord, niet lijkend op user-attributen, en gecontroleerd tegen HaveIBeenPwned via k-anonymity (
common/validators/password.py, fail-open bij netwerkfouten). Hashing volgt de Django-default (PBKDF2-SHA256). - Cookies: access- en refresh-cookie zijn
Secure,HttpOnlyenSameSite; het refresh-cookie ispath-scoped op de refresh-endpoint. - Encryptie:
phone_number,birth_date(opUserenUserInvitation),UserOidcIdentity.subjectenTrustedDevice.hmac_key_encworden met Fernet versleuteld. Tokens (token_hash,subject_hash,accept_token_hash) worden gehasht opgeslagen; de OIDC-subject wordt opgezocht viasubject_hash, nooit via de plaintext. - IP- en device-tracking:
auth/services/ip_tracking.pylegt nieuwe IP's per gebruiker en per device vast en triggert bij een nieuw-apparaat-en-nieuw-IP-combinatie een audit-event plus waarschuwingsmail. - OIDC-binding: ZORG-ID, Google en Apple zijn optionele identiteitskoppelingen in
UserOidcIdentity. Koppelen en ontkoppelen zijn step-up-gated; inloggen met een nog niet gekoppelde identiteit vereist eenmalig e-mail + wachtwoord. Een unieksubject_hashvoorkomt dat dezelfde provider-identiteit aan meerdere accounts hangt. Geen Vektis. - AVG:
dsar.pybouwt een cross-tenant export (profiel, audit, agenda, chat, review-metadata, sessies);erasure.pydoet soft-delete plus pseudonimisering (sentinel-e-mail op@invalid.local, naam/telefoon/geboortedatum geleegd) na een respijtperiode en trekt apparaten, tokens en OIDC-identiteiten in. - Audit logging: alle gevoelige events (login, logout, step-up, device-register/revoke/lock, weblogin-approve/deny, OIDC link/unlink, tenant-switch, erasure, DSAR) gaan via
common/audit/service.pymet expliciete event-namen uitcommon/audit/events.py.
Bestandsstructuur & Verantwoordelijkheden
backend/auth/models.py:User,UserOidcIdentity,UserTenantMembership,TrustedDevice,DeviceKnownIP,UserKnownIP,UserKnownBrowser,WebLoginIntent,UserInvitation,SmsOtpChallenge,TotpAdminResetToken,PasswordResetToken,ErasureRequest,DSARDownloadToken.backend/auth/managers.py:UserManager(filtert soft-deleted users) enAllObjectsManager.backend/auth/views/auth.py:PasswordLoginStartView,SwitchTenantView,RefreshView,LogoutView.backend/auth/views/step_up.py:StepUpViewvoor verse her-authenticatie.backend/auth/views/weblogin.py: alle web-inlog endpoints (start, step-up start, status, confirm, confirm-device, fetch-code, exchange).backend/auth/views/devices.pyendevice_login.py/device_challenge.py: apparaatbeheer en HMAC-login.backend/auth/views/zorgid.py:OidcAuthorizeView,OidcCallbackView(ZORG-ID en generieke providers).backend/auth/views/oauth.py: link/exchange/link-login/identities en native Apple-login.backend/auth/views/me.py:MeView,PreferencesView,EmailChangeConfirmView, membership-views.backend/auth/views/sessions.py,security.py,password_reset.py,invite_accept.py,invite_membership.py,totp_admin_reset.py,admin_users.py,erasure.py,dsar.py,audit_log.py,support.py,walkthrough.py.backend/auth/services/jwt_tokens.py: tenant-gescoped tokens uitgeven, refresh-tokens revoken, device-label resolven.backend/auth/services/membership.py: lidmaatschappen en de rol-invariant.backend/auth/services/weblogin.py: lifecycle vanWebLoginIntent.backend/auth/services/oauth_identity.pyenoauth_bridge.py: OIDC-identiteiten resolven/koppelen en eenmalige code-/ticket-uitwisseling.backend/auth/services/password_lockout.py,device_crypto.py,ip_tracking.py,login_state.py,onboarding.py,onboarding_grant.py,dsar.py,erasure.py,email.py.backend/auth/consumers.py: WebSocket-consumer voorweblogin_<intent_id>.backend/common/authentication.py:TenantEnforcedJWTAuthentication.backend/common/stepup/: step-up-permissies en het Redis-elevatie-venster.backend/common/auth_cookies.py: secure access- en refresh-cookies.backend/common/validators/password.py: wachtwoordvalidators incl. HIBP.backend/rbac/: rollen, permissies enHasPermissionCode(apart gedocumenteerd).
Belangrijke bestanden
backend/auth/views/auth.pybackend/auth/views/step_up.pybackend/auth/views/weblogin.pybackend/auth/views/zorgid.pybackend/auth/services/jwt_tokens.pybackend/auth/services/membership.pybackend/common/authentication.pybackend/common/stepup/permissions.pybackend/auth/models.py