Passkeys / Biometric Authentication - CreaRack Pro
Passkeys / Biometric Authentication - CreaRack Pro
Version: v1.0.2 Last updated: 19-02-2026 Status: Production (hardened)
Summary
CreaRack Pro implements biometric authentication (Passkeys/WebAuthn) allowing users to sign in with Windows Hello, Touch ID, Face ID, or physical security keys. The system is built on django-allauth’s MFA module with three custom API endpoints for login-page passkey setup.
Key features:
- Passwordless login with registered passkeys (discoverable credentials)
- Passkey setup directly from login page (no prior login required)
- Windows Hello support (fingerprint, face, PIN)
- TOTP + WebAuthn coexistence with priority toggle on MFA page
- MFA device management from User Management (admin)
- Audit logging of all auth events via allauth signals
- Forgot Password flow via allauth built-in views
- POST-only logout (anti-CSRF)
Architecture
Tech Stack
| Component | Technology |
|---|---|
| Backend MFA | django-allauth + allauth.mfa |
| Protocol | WebAuthn / FIDO2 |
| Python lib | fido2 >= 1.1.0, < 1.2.0 |
| JS lib | webauthn-json (bundled with allauth) |
| Storage | PostgreSQL (Authenticator model) |
| Cache | Valkey (setup session tokens, 5-min TTL) |
Key Files
core/
auth_api.py # 3 endpoints: passkey_login_options, validate_and_setup_biometric,
# complete_biometric_setup (C1-C5 hardened)
adapters.py # CreaRackAccountAdapter (MFA enforcement redirect)
# CreaRackMFAAdapter (dynamic RP ID from request host)
signals.py # Auth audit signals -> SystemLog (login, failed, logout)
context_processors.py # PASSKEY_LOGIN_ENABLED template variable
apps.py # Signal registration via ready()
models.py # User (email unique constraint), SystemLog
config/
settings/base.py # MFA/WebAuthn/Account config
settings/production.py # Rate limits, verify_origin=strict, CSP
urls.py # Auth routes (3 custom + allauth)
templates/
account/login.html # Login + passkey setup + forgot password
account/base.html # Auth base template
mfa/authenticate.html # MFA challenge page (TOTP/WebAuthn toggle)
mfa/webauthn/snippets/scripts.html # Loads allauth native WebAuthn JS
static/js/
auth.js # Logout (POST), setup_admin
session_timeout.js # Inactivity logout (POST)
Current Flow (v1.0.2)
Flow 1: Login with Passkey (user has passkey, username filled)
1. User enters username on login page
2. Clicks "Sign in with Passkey"
3. JS calls GET /api/auth/passkey-options/?username=xxx
4. Server looks up user, calls allauth begin_authentication(user)
-> Returns WebAuthn challenge WITH allowCredentials for that user
5. webauthnJSON.get(options) triggers Windows Hello directly (no account picker)
6. Credential submitted via hidden POST form to allauth's mfa_login_webauthn endpoint
7. User is logged in and redirected to dashboard
Key detail: Passing the user object to begin_authentication() populates allowCredentials, which tells the browser to use a specific credential. Windows Hello shows the biometric prompt directly without an account picker dialog.
Flow 2: Login with Passkey (no username filled)
1. User clicks "Sign in with Passkey" without filling username
2. JS calls GET /accounts/2fa/webauthn/login/ (allauth native endpoint)
-> Returns challenge WITHOUT allowCredentials (discoverable credentials mode)
3. Windows Security dialog shows account picker
4. User selects account -> Windows Hello verifies
5. Credential submitted via hidden POST form to allauth's mfa_login_webauthn
6. User is logged in
Flow 3: Passkey Setup (new user, no TOTP, no passkey)
1. User fills username + password, clicks "Sign in with Passkey"
2. JS first tries WebAuthn passwordless (Flow 1/2) -> fails (no passkey exists)
- If user cancelled Windows Hello (NotAllowedError), stop here
3. JS checks skipPasskeySetup in localStorage (30-day expiry)
- If skipped recently, submit standard login form instead
4. JS calls POST /api/auth/validate-and-setup-biometric/
- Server authenticates credentials
- Server checks: has_passkey? has_totp? (see blocked flows below)
- Returns creation_options + session_token (Valkey, 5-min TTL)
- creation_options has residentKey: "required", authenticatorAttachment: "platform"
5. UI switches from login form to "Set Up Passkey" section
6. User names the device (e.g., "Home PC"), clicks "Set Up Now"
7. webauthnJSON.create(creationOptions) triggers Windows Hello
- Creates a discoverable credential on the platform authenticator
8. JS calls POST /api/auth/complete-biometric-setup/ with session_token + credential + name
- Token consumed BEFORE processing (single-use, C3)
- Origin verified against RP ID (production, C2)
- fido2 server.register_complete() validates the attestation
- Passkey saved via allauth's WebAuthn.add()
- User logged in via django.contrib.auth.login()
- Event logged to SystemLog
9. User redirected to dashboard
Flow 4: Passkey Setup Blocked (user has TOTP)
1. User fills credentials, clicks "Sign in with Passkey"
2. WebAuthn passwordless attempt fails (no passkey)
3. POST /api/auth/validate-and-setup-biometric/ detects has_totp=True (C4)
4. Returns message: "You have TOTP enabled. Set up passkeys from account settings."
5. User must sign in with TOTP first, then configure passkeys from /accounts/2fa/
Rationale (C4): A user with TOTP active has already completed MFA setup. Allowing passkey registration from the unauthenticated login page would bypass the TOTP verification step. They should sign in fully first, then add passkeys from authenticated account settings.
Flow 5: User with Passkey Enters Credentials
1. User fills username + password, clicks "Sign in with Passkey"
2. WebAuthn passwordless succeeds (user has passkey) -> Flow 1 completes
OR
3. If WebAuthn fails for non-cancellation reason, JS calls validate_and_setup_biometric
4. Server detects has_passkey=True, returns {has_passkey: true} WITHOUT logging in (C1)
5. JS sets sessionStorage.preferPasskey = '1'
6. JS submits the standard login form (username + password)
7. Allauth authenticates credentials, triggers MFA challenge
8. User lands on /accounts/2fa/authenticate/ -> WebAuthn shown as primary (due to preferPasskey)
9. User completes WebAuthn challenge -> logged in
Flow 6: MFA Authenticate Page (fallback)
When a user reaches /accounts/2fa/authenticate/ via standard login:
1. Page renders with both TOTP and WebAuthn sections
2. If sessionStorage.preferPasskey is set:
- WebAuthn section shown as primary (TOTP hidden)
- sessionStorage.preferPasskey cleared
3. Otherwise:
- TOTP form shown as primary with autofocus on code input
- "Use Biometric Sign-In" button switches to WebAuthn
4. WebAuthn uses allauth native JS:
- Data serialized via Django's json_script filter (XSS-safe)
- allauth.webauthn.forms.authenticateForm() handles the challenge
5. TOTP form has auto-submit on 6 digits (keyup listener)
6. "Cancel and sign out" button available (POST logout)
Flow 7: Forgot Password
1. User clicks "Forgot password?" on login page
2. Redirected to /accounts/password/reset/ (allauth built-in)
3. Enters email address
4. Allauth sends reset email (requires SMTP configuration)
5. User clicks link -> sets new password
Security Measures (v1.0.2 Hardening)
Fixes Applied
| ID | Severity | Description |
|---|---|---|
| C1 | Critical | No login bypass — validate_and_setup_biometric() returns {has_passkey: true} without calling login(). User must complete WebAuthn challenge via allauth native flow. |
| C2 | Critical | verify_origin enforced — Fido2Server validates origin against https://{RP_ID} in production. Dev mode (MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN=True) allows any origin. |
| C3 | Critical | Single-use tokens — complete_biometric_setup() deletes cache token BEFORE processing (consume-then-process). If registration fails, token cannot be reused. |
| C4 | Critical | TOTP blocks login-page setup — Users with TOTP active cannot register passkeys from the login page. They must sign in with TOTP first, then add passkeys from account settings. |
| C5 | Medium | Challenge stored as bytes — Challenge bytes stored in hex format in Valkey for fido2 compatibility (avoids base64 encoding/decoding issues). |
| H4 | Medium | excludeCredentials — Existing passkey credential IDs sent in creation options to prevent duplicate registration on the same authenticator. |
| H5 | Medium | Auto-clear sensitive data — JS variables sessionToken and creationOptions auto-cleared after 5 minutes via setTimeout. |
| H6 | Medium | is_active check — Both validate_and_setup_biometric and complete_biometric_setup verify user.is_active before proceeding. |
| M1 | Medium | Auth auditing — allauth signals (user_login_failed, user_logged_in, user_logged_out) connected to SystemLog with IP addresses. |
| M3 | Medium | POST logout — ACCOUNT_LOGOUT_ON_GET=False. JS uses POST with CSRF token. Prevents CSRF logout attacks. |
| M4 | Medium | Rate limits — Auth endpoints have strict per-endpoint rate limits in production (see table below). |
| M5 | Medium | Skip expiry — skipPasskeySetup localStorage uses timestamp with 30-day expiration instead of permanent boolean. |
| M6 | Medium | Forgot Password — ACCOUNT_EMAIL_REQUIRED=True + allauth built-in password reset flow. |
| M7 | Medium | Email unique — UniqueConstraint on email when non-empty. Prevents duplicate accounts. |
| L1 | Low | setup_admin CSRF — Replaced @csrf_exempt with @ensure_csrf_cookie. |
| L2 | Low | Device name sanitized — HTML tags stripped, limited to [\w\s\-.,()], max 64 chars. |
Rate Limits (Production)
RATE_LIMIT_ENDPOINTS = {
'/accounts/login/': {'requests': 5, 'window': 60},
'/api/auth/passkey-options/': {'requests': 10, 'window': 60},
'/api/auth/validate-and-setup-biometric/': {'requests': 5, 'window': 60},
'/api/auth/complete-biometric-setup/': {'requests': 5, 'window': 60},
'/admin/login/': {'requests': 5, 'window': 60},
}
Production vs Development
| Setting | Development | Production |
|---|---|---|
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN | True | False |
verify_origin in Fido2Server | lambda o: True | Validates against https://{RP_ID} |
RATE_LIMIT_ENABLED | False | True |
SESSION_COOKIE_SECURE | False | True |
CSRF_COOKIE_SECURE | False | True |
SECURE_PROXY_SSL_HEADER | Not set | HTTP_X_FORWARDED_PROTO: https |
Configuration
Settings (config/settings/base.py)
# Account
ACCOUNT_ADAPTER = "core.adapters.CreaRackAccountAdapter"
ACCOUNT_LOGIN_METHODS = {"username"}
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_LOGOUT_ON_GET = False
ACCOUNT_PRESERVE_USERNAME_CASING = False
ACCOUNT_SESSION_REMEMBER = True
# MFA / Passkeys
MFA_ADAPTER = "core.adapters.CreaRackMFAAdapter"
MFA_SUPPORTED_TYPES = ["webauthn", "totp", "recovery_codes"]
MFA_PASSKEY_LOGIN_ENABLED = True
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True # Dev only, False in production
MFA_WEBAUTHN_RP_NAME = "CreaRack Pro"
MFA_RECOVERY_CODE_COUNT = 10
URLs (config/urls.py)
# Custom passkey API (login page setup flow)
path('api/auth/passkey-options/', auth_api.passkey_login_options),
path('api/auth/validate-and-setup-biometric/', auth_api.validate_and_setup_biometric),
path('api/auth/complete-biometric-setup/', auth_api.complete_biometric_setup),
# Authentication (django-allauth with Passkeys/MFA)
path('accounts/', include('allauth.urls')),
Context Processor
PASSKEY_LOGIN_ENABLED is injected into all templates via core/context_processors.py:
'PASSKEY_LOGIN_ENABLED': getattr(settings, 'MFA_PASSKEY_LOGIN_ENABLED', False),
Used in login.html to conditionally show the “Sign in with Passkey” button.
API Endpoints
GET /api/auth/passkey-options/
Returns WebAuthn assertion options for passwordless login. If ?username= is provided and the user has passkeys, returns options WITH allowCredentials so Windows Hello shows directly (no account picker). Otherwise returns discoverable credentials mode.
Query params: username (optional)
Response (200):
{
"request_options": {
"publicKey": {
"rpId": "localhost",
"challenge": "...",
"allowCredentials": [{"type": "public-key", "id": "..."}],
"userVerification": "required",
"timeout": 60000
}
}
}
Fallback: If user lookup fails or user has no passkeys, returns discoverable mode (empty allowCredentials). If WebAuthn is not in MFA_SUPPORTED_TYPES, returns 404.
POST /api/auth/validate-and-setup-biometric/
Validates credentials and returns WebAuthn creation options for passkey setup.
Request:
{
"username": "admin",
"password": "secret"
}
Response (no passkey, no TOTP — setup flow):
{
"success": true,
"has_passkey": false,
"session_token": "abc123...",
"creation_options": {
"publicKey": {
"rp": {"id": "localhost", "name": "CreaRack Pro"},
"user": {"id": "...", "name": "admin", "displayName": "admin"},
"challenge": "...",
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257}
],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"residentKey": "required",
"requireResidentKey": true,
"userVerification": "required"
},
"excludeCredentials": [],
"attestation": "none",
"timeout": 60000
}
}
}
Response (has passkey — redirect to standard login):
{
"success": true,
"has_passkey": true,
"redirect": "/accounts/login/"
}
Response (has TOTP — setup blocked, C4):
{
"success": true,
"has_totp_only": true,
"message": "You have TOTP enabled. Set up passkeys from your account settings after signing in."
}
POST /api/auth/complete-biometric-setup/
Completes WebAuthn registration and logs the user in.
Request:
{
"session_token": "abc123...",
"credential": { "id": "...", "response": {"clientDataJSON": "...", "attestationObject": "..."} },
"name": "Home PC"
}
Response (200):
{
"success": true,
"redirect": "/"
}
Error cases: 400 (missing data, expired session, user not found), 401 (invalid credentials), 403 (account disabled), 500 (unexpected error).
Custom Adapters (core/adapters.py)
CreaRackAccountAdapter
Extends DefaultAccountAdapter. After login, checks if user has any MFA authenticator. If not, redirects to /accounts/2fa/ for MFA setup instead of the dashboard.
CreaRackMFAAdapter
Extends DefaultMFAAdapter. Dynamically sets the Relying Party ID from the current request host (strips port). Falls back to "localhost" when no request context is available.
Auth Auditing (core/signals.py)
All auth events are automatically logged to SystemLog via allauth signals:
| Event | Signal | Level | Example |
|---|---|---|---|
| Login success | user_logged_in | INFO | “User logged in: admin” |
| Login failed | user_login_failed | WARNING | “Failed login: baduser” |
| Logout | user_logged_out | INFO | “User logged out: admin” |
| Passkey registered | (manual in auth_api) | INFO | “Passkey registered: Home PC” |
| Biometric setup failed | (manual in auth_api) | WARNING | “Failed biometric setup: admin” |
All entries include client IP address (supports X-Forwarded-For for reverse proxies).
View logs at: Dashboard -> Config -> System Logs.
MFA Management (Admin)
Access
- Dashboard -> Config -> User Management
- Users table shows MFA column with device count
- Green badge = MFA active, Gray = No MFA
Admin Actions
| Action | Description |
|---|---|
| View MFA status | MFA column shows device count per user |
| “MFA” button | Opens modal with device list (HTMX loaded) |
| “Remove” | Deletes a specific MFA device |
| “Remove All” | Bulk remove all MFA devices for a user |
| “Edit” | Change password or role |
MFA API
GET /api/users/mfa-status # All users with MFA status
GET /api/users/me/mfa # My MFA devices
GET /api/users/{id}/mfa # User's devices (admin only)
DELETE /api/users/{id}/mfa/{auth_id} # Remove specific device (admin only)
User Roles
| Role | Value | Permissions |
|---|---|---|
| Admin | admin | Full access, user management, MFA management |
| Operator | operator | Create/edit racks, devices, maps |
| Read Only | readonly | View only |
Troubleshooting
“Sign in with Passkey” button not visible
Cause: PASSKEY_LOGIN_ENABLED not in template context.
Fix: Verify core.context_processors.app_context is in TEMPLATES[0]['OPTIONS']['context_processors'] in base.py, and that MFA_PASSKEY_LOGIN_ENABLED = True.
Error: “Invalid domain”
Cause: RP ID does not match current domain.
Fix: Access from localhost (not 127.0.0.1) in development. In production, ensure the domain matches the RP ID derived from the request host.
Error: “Session expired”
Cause: Setup token consumed or expired (5-min TTL, single-use). Fix: Start the setup flow again from the login page.
Windows Hello not appearing
Cause: Stale browser credentials or platform authenticator not available. Fix: Clear passkeys from the browser:
- Chrome:
chrome://settings/passkeys - Edge:
edge://settings/passwords-> Passkeys
Forgot Password not working
Cause: SMTP not configured or user has no email address.
Fix: Configure EMAIL_HOST_* settings in production environment variables.
Login audit not showing
Cause: Signals not loaded.
Fix: Verify core/apps.py has ready() importing core.signals.
MFA modal not loading in User Management
Cause: HTMX loading issue.
Fix: MFA modal uses htmx.ajax() to load content (not trigger('revealed')). Check browser console for HTMX errors.
Dependencies
# requirements.txt
django-allauth[mfa]>=65.4.0
fido2>=1.1.0,<1.2.0
Maintained by: CreaRack Team
Véase también
- [[crearack-tech—agents—dev-core]] — agente técnico del módulo core
- [[crearack-tech—guides—security-guide]] — guía de seguridad
- [[crearack-tech—reports—security-audit]] — auditoría de seguridad global
- [[entity—core—model—user]] — modelo User
- [[entity—core—model—storedcredential]] — modelo StoredCredential (passkey)
- [[crearack—conceptos—usuarios-y-permisos]] — usuarios, roles y permisos en la app