Authentication & Security
MeshDash uses a JWT-in-cookie authentication model with optional TOTP-based MFA. There is no refresh-token endpoint — sessions are long-lived JWTs that auto-renew on status polling when less than half their lifetime remains.
PUBLIC_MODE=True (no auth required). Set PUBLIC_MODE=False and create users via the Setup Wizard or C2 provisioning to activate authentication.Architecture Overview
| Component | File | Purpose |
|---|---|---|
| Auth core | core/auth.py | JWT creation/verification, password hashing, TOTP/backup-code verification, CSRF generation |
| Auth routes | core/routes/auth_routes.py | Login/logout, TOTP setup/confirm/disable, account info |
| Web routes | core/routes/web_routes.py | Login page serving, login POST with brute-force protection, TOTP verification |
| Admin routes | core/routes/admin_routes.py | User CRUD, role/suspend/MFA management, initial setup |
| Middleware | core/middleware.py | Security headers, CSRF cookie injection, login lockout |
| Database | core/database.py | User persistence, TOTP state, backup codes |
Login Flow — Standard (No MFA)
Browser Server
| |
| GET /login |
| <--- login.html --------|
| |
| POST /login |
| {username, password} |
| |
| (validate credentials) |
| (check brute-force) |
| (check disabled) |
| (check MFA flags) |
| |
| <-- 302 redirect / -----| + Set-Cookie: access_token=Bearer <jwt>; httponly; samesite=strict
| |
- POST /login with
username+password(form-encoded). - Server checks brute-force lockout (
_check_login_not_locked). - Looks up user in DB, verifies bcrypt hash.
- Checks
disabledflag → returns error if suspended. - Checks
totp_enabled→ if true, enters MFA flow. - Checks
must_setup_mfaorforce_mfa && !totp_enabled→ if true, enters forced MFA setup. - Otherwise: records login, clears lockout, creates JWT, sets cookie, redirects to
/.
Login Flow — MFA (TOTP)
Browser Server
| |
| POST /login |
| {username, password} |
| |
| <-- 200 JSON -----------| {mfa_required: true, preauth_token: <jwt>}
| |
| POST /login/verify-totp |
| {preauth_token, totp_code}
| |
| (validate preauth token)|
| (verify TOTP or backup) |
| |
| <-- 200 JSON -----------| {success: true, redirect: "/"} + Set-Cookie: access_token + csrf-token
- After successful password check, if
totp_enabled, server returns a preauth token (90s JWT, claimpreauth: true) instead of the session cookie. - Frontend prompts for 6-digit TOTP code.
- POST /login/verify-totp with
preauth_token+totp_code. - Server validates preauth token, verifies TOTP code (with 1-window clock drift tolerance), or falls back to backup code verification.
- On success: records login, creates session JWT, sets
access_token+csrf-tokencookies, returns JSON success.
Forced MFA Setup
When a user has must_setup_mfa=True or force_mfa=True and TOTP is not yet enabled:
- After password check, server returns
{mfa_setup_required: true, preauth_token}. - Frontend enters MFA setup flow using preauth token.
- POST /login/setup-totp → generates secret + QR code (base64 PNG) and manual-entry key.
- POST /login/confirm-totp-setup → verifies code, enables TOTP, clears
must_setup_mfa, issues backup codes, sets session cookie. - User cannot access the dashboard until MFA is configured.
Decision Tree: Login / MFA / Forced MFA Flows
User posts username + password
│
├─ Disabled? → "Account suspended. Contact your administrator."
├─ Brute-force locked? → "Too many login attempts. Try again in X minutes."
├─ Password wrong? → "Invalid Credentials" (no username existence leak)
│
├─ Password accepted:
│ ├─ must_setup_mfa or (force_mfa && !totp_enabled)?
│ │ → MFA SETUP FLOW: Setup TOTP → Confirm → Session
│ ├─ totp_enabled?
│ │ → MFA VERIFY FLOW: Enter TOTP or backup code → Session
│ └─ Neither?
│ → STANDARD LOGIN: Session cookie set → Redirect
JWT Token Structure
Session Token
{
"sub": "<username>", // subject = username
"exp": <expiry_timestamp>, // UTC epoch
"iat": <issued_at> // UTC epoch
}
- Algorithm: HS256
- Signing key:
AUTH_SECRET_KEY(generated viasecrets.token_hex(32)by default) - Default expiry:
AUTH_TOKEN_EXPIRE_MINUTES = 10080(7 days)
Preauth Token
{
"sub": "<username>",
"preauth": true,
"exp": <now + 90 seconds>,
"iat": <now>
}
- Lifetime: 90 seconds
- Purpose: bridges the gap between password validation and TOTP verification
CSRF Protection — Double-Submit Cookie Pattern
csrf-token — generated via secrets.token_urlsafe(32), auto-set by middleware if missing. Flags: httponly; samesite=strict; path=/.<meta name="csrf-token" content="<token>"> + window._csrfToken JS global.x-csrf-token header on all state-changing requests. Returns 403 on mismatch or missing.Protected endpoints include all admin user management, TOTP setup/confirm/disable, and password changes.
TOTP/MFA System
Setup Flow
POST /api/totp/setup → {secret, qr_code (data:image/png;base64,...), provisioning_uri}
POST /api/totp/confirm → {enabled: true, backup_codes: [...], message}
- Setup generates
pyotp.random_base32()secret, stores in DB. - Confirm verifies the first TOTP code. If valid, generates 8 backup codes (bcrypt-hashed for storage), enables TOTP, returns plaintext codes (shown once).
Backup Codes
- Count: 8 codes by default.
- Format: 8-char uppercase hex strings, e.g.
A1B2C3D4. - Storage: bcrypt-hashed JSON array in
backup_codescolumn. - Consumption: atomically consumed — code removed from stored list on use.
Prerequisites
pip install pyotp qrcode[pil]
If not installed, _HAS_TOTP = False and all TOTP endpoints return 501.
User Roles
| Role | ID | Label | Capabilities |
|---|---|---|---|
| Admin | 0 | Admin | Full system access: user management, config, MFA enforcement, C2 access, all data |
| Operator | 1 | Operator | Standard dashboard access: messages, node config, map, packets (default) |
| Spectator | 2 | Spectator | Read-only access to dashboard views |
User Management (Admin Only)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/users | List all users with full details |
| POST | /api/users | Create user. Body: {username, password, role, force_mfa}. Min 2 char username, min 6 char password. |
| PUT | /api/users/{username}/role | Change role. Body: {role: 0|1|2} |
| PUT | /api/users/{username}/password | Reset password. Body: {password} |
| PUT | /api/users/{username}/suspend | Suspend/reactivate. Body: {suspended: true|false}. Cannot suspend self. |
| PUT | /api/users/{username}/force-mfa | Force MFA. Body: {force_mfa: true|false}. Sets must_setup_mfa=true if unset. |
| DELETE | /api/users/{username}/mfa | Reset MFA for a user. Disables TOTP, clears backup codes. |
| DELETE | /api/users/{username} | Delete user permanently. Cannot delete self. |
| POST | /api/users/generate-password | Generate a random 22-char password. |
Session Management
- Auto-renewal:
GET /api/statuschecks remaining token lifetime. If below half-life (default: <3.5 days for 7-day token), a new JWT is issued. - No server-side session store: JWTs are valid until expiry. Suspending/deleting a user prevents new token issuance but does not invalidate existing tokens.
- Logout:
GET /logoutdeletesaccess_tokencookie and redirects to/login.
Brute-Force Protection
| Parameter | Value |
|---|---|
| Max login attempts | 5 (_MAX_LOGIN_ATTEMPTS) |
| Lockout duration | 300 seconds / 5 minutes (_LOCKOUT_SECONDS) |
| Scope | Per-username, in-memory (lost on server restart) |
Login errors return generic "Invalid Credentials" — no username existence leak.
First-Run Setup
On first boot with no users:
- C2-provisioned: If
INITIAL_ADMIN_USERNAME+INITIAL_ADMIN_PASSWORDare in config (set by C2 wizard), admin user is created and credentials are removed from config. - Interactive setup: Otherwise,
setup.flagis created.GET /setupserves the Setup Wizard UI.POST /api/system/config/initial-setupcreates the first admin atomically. - No
securecookie flag — tokens transmitted over plain HTTP on LAN. Acceptable for local deployments; add a reverse proxy with HTTPS if exposed to the internet. - No session revocation — JWTs are valid until expiry. Suspending a user doesn't invalidate already-issued tokens.
- Brute-force protection is in-memory — lockout state is lost on server restart.
- Preauth tokens are not single-use — 90s window allows replay, but short lifetime mitigates this.
unsafe-inlinein CSP — required for current inline script/style patterns. Limits XSS protection.- Backup codes are consumed atomically — no race condition on concurrent use.
- Initial setup is race-safe —
atomic_setup_useruses a DB transaction to count+insert, preventing two concurrent setups from creating duplicate admins.
There are no hardcoded credentials — the system starts empty and requires explicit admin creation.
Security Headers
Set by _security_headers middleware on every response:
Content-Security-PolicyCache-Controlno-store, no-cache, must-revalidate, proxy-revalidateX-Content-Type-OptionsnosniffStrict-Transport-Securitymax-age=31536000; includeSubDomainsX-Frame-OptionsSAMEORIGINReferrer-Policystrict-origin-when-cross-originX-Robots-Tagnoindex, nofollowPermissions-Policycamera=(), microphone=(), geolocation=()Cookie Settings
| Cookie | Flags | Set By |
|---|---|---|
access_token | httponly, samesite=strict | Login, setup, TOTP verify endpoints |
csrf-token | httponly, samesite=strict, path=/ | Security middleware (auto-inject if missing), login responses |
secure flag is not set on cookies — tokens are sent over plain HTTP for LAN deployments. If exposing to the internet, add a reverse proxy with HTTPS.Public Mode
| Setting | PUBLIC_MODE=True (default) | PUBLIC_MODE=False |
|---|---|---|
| Authentication | No auth required — everyone has access | JWT authentication active |
| Login page | GET /login → 302 redirect to / | Login page served |
| User management | Not available | Available to Admins |
| Setup wizard | Skipped | Active until first user created |
| Database | In-memory (:memory:), ephemeral | Persistent SQLite |
Auth API Endpoints Reference
Login / Logout
| Method | Endpoint | Auth | CSRF | Description |
|---|---|---|---|---|
| GET | /login | No | No | HTML login page (302 redirects if PUBLIC_MODE) |
| POST | /login | No | No | Form: username, password. Returns 302 + cookie, or 200 {mfa_required, preauth_token} |
| POST | /login/verify-totp | No | No | Form: preauth_token, totp_code. Returns 200 + cookies, or 401 |
| POST | /login/setup-totp | No | No | Form: preauth_token. Returns {secret, qr_code} |
| POST | /login/confirm-totp-setup | No | No | Form: preauth_token, totp_code. Returns {success, backup_codes} + cookie |
| GET | /logout | No | No | Deletes cookie, redirects to /login |
Account / MFA
| Method | Endpoint | Auth | CSRF | Description |
|---|---|---|---|---|
| GET | /api/account/me | Yes | No | User profile: username, role, totp_enabled, force_mfa, backup_codes_remaining, last_login, login_count, created_at |
| GET | /api/totp/status | Yes | No | {totp_available, totp_enabled, backup_codes_remaining} |
| POST | /api/totp/setup | Yes | Yes | {secret, qr_code, provisioning_uri} |
| POST | /api/totp/confirm | Yes | Yes | Form: code. Returns {enabled, backup_codes, message} |
| POST | /api/totp/disable | Yes | Yes | Form: password. Returns {enabled: false, message} |
User Management (Admin Only)
| Method | Endpoint | CSRF | Description |
|---|---|---|---|
| GET | /api/users | No | List all users with full details |
| POST | /api/users | Yes | JSON: {username, password, role, force_mfa} |
| PUT | /api/users/{username}/role | Yes | JSON: {role: 0|1|2} |
| PUT | /api/users/{username}/password | Yes | JSON: {password} |
| PUT | /api/users/{username}/suspend | Yes | JSON: {suspended: true|false}. Cannot suspend self. |
| PUT | /api/users/{username}/force-mfa | Yes | JSON: {force_mfa: true|false} |
| DELETE | /api/users/{username}/mfa | Yes | Reset MFA — disables TOTP, clears backup codes |
| DELETE | /api/users/{username} | Yes | Delete user permanently. Cannot delete self. |
| POST | /api/users/generate-password | Yes | Generates random 22-char password |
Setup
| Method | Endpoint | Auth | CSRF | Description |
|---|---|---|---|---|
| GET | /setup | No | No | HTML setup wizard (only if no users exist) |
| POST | /api/system/config/initial-setup | No | No | JSON: {adminUser: {username, password}, configValues: {...}, rawSelections: {...}}. Atomic user creation — no race condition on concurrent setups. |
C2 Bridge Token (Internal)
The C2 bridge uses an internal service token for proxied remote requests. Requests from the C2 relay arrive with a short-lived (30-second) internal JWT signed with AUTH_SECRET_KEY. The sub claim is "__c2_bridge__" with "internal": true. This token is validated by get_current_active_user() which returns a synthetic user object — no DB lookup, no disable mechanism. This is a backdoor-by-design for the C2 proxy system.
Security Observations
Database Schema — Users Table
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
hashed_password TEXT,
disabled BOOLEAN DEFAULT FALSE,
role INTEGER DEFAULT 1, -- 0=Admin, 1=Operator, 2=Spectator
force_mfa BOOLEAN DEFAULT FALSE, -- admin-enforced MFA requirement
must_setup_mfa BOOLEAN DEFAULT FALSE, -- pending MFA setup flag
totp_secret TEXT DEFAULT NULL, -- base32 TOTP secret
totp_enabled BOOLEAN DEFAULT FALSE, -- MFA active
backup_codes TEXT DEFAULT NULL, -- JSON array of bcrypt-hashed backup codes
last_login DATETIME DEFAULT NULL, -- timestamp of last successful login
login_count INTEGER DEFAULT 0, -- total successful logins
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
See also: Radio Connection for connection security. Configuration Reference for auth-related config keys.