Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication & User Management

MyAstroBoard uses a simple session-based multi-user system with three roles.

Module: backend/utils/auth.py


Default credentials

On first startup, a single admin account is created automatically:

UsernamePassword
adminadmin

Change the default password immediately after first login via My settings -> Security -> Change Password, or via the admin user management panel.


Roles

RoleDescriptionPermissions
adminFull administratorAll features + user management, system config, admin API endpoints
userStandard userAll observatory features (observe, plan, record, edit own data)
read-onlyView-only accessBrowse Astrodex, SkyTonight, and weather; cannot write any data

Role comparison

Actionadminuserread-only
View dashboard, weather, SkyTonight✅✅✅
View Astrodex✅✅✅
Add/edit Astrodex items✅✅❌
Plan My Night (read)✅✅✅
Plan My Night (create/edit)✅✅❌
Manage own equipment✅✅❌
Change own password/preferences✅✅✅
Manage all users✅❌❌
Change system configuration✅❌❌
View metrics and logs✅❌❌
Trigger SkyTonight recalculation✅❌❌

User management (admin)

The App settings -> Users panel (admin only) allows:

  • Create user: username, password, role, and optionally the account scope (see Local vs. global accounts).
  • Edit user: change username, password, role, or account scope.
  • Usernames ignore case: “Emeric” and “emeric” are the same name, so the second cannot be created, and either spelling signs in.
  • Delete user: removes the account and every per-user file (Astrodex and pictures, observation sessions and attachments, equipment, plans, wishlist) - see Privacy & GDPR.
  • Export data: downloads the same personal-data ZIP the user gets from My settings -> Security -> Your data, to answer an access or portability request.
  • Reset 2FA: shown only when the user has two-factor authentication active. Clears it unconditionally, no password check - see Lost-authenticator recovery.

API endpoints (admin only):

MethodPathDescription
GET/api/usersList all users (without password hashes or TOTP secrets); each entry includes account_scope and totp_enabled
POST/api/usersCreate a new user (account_scope optional, defaults to global)
PUT/api/users/<user_id>Update username, password, role, or account_scope
DELETE/api/users/<user_id>Delete a user
DELETE/api/users/<user_id>/2faClear a user’s two-factor authentication (admin only, no password check)
GET/api/users/<user_id>/exportZIP of everything stored about a user (see Privacy & GDPR)

Two-factor authentication

Optional TOTP (Time-based One-Time Password) second factor, compatible with any standard authenticator app (Google Authenticator, Authy, 1Password, etc.). Off by default; an admin must enable it instance-wide before any individual user can turn it on for their own account.

Dependency: pyotp (pure Python, no transitive dependencies) generates secrets, verifies codes, and builds the otpauth:// provisioning URI. QR codes are rendered client-side from a small vendored JS library (static/vendor/qrcode/qrcode.min.js) - the backend never renders an image, so no qrcode/Pillow-style dependency was added for this.

Two levels of control

  1. Instance switch (security settings, two_factor_enabled): an admin turns 2FA on or off for the whole install. Requires at least one trusted network (see Trusted networks). Managed in App settings -> Users.
  2. Per-user opt-in (account, totp_enabled): once the instance switch is on, each user individually enables 2FA for their own account in My settings -> Security. A user whose account was never opted in is never challenged at login, even with the instance switch on.

Trusted-network bypass

2FA is skipped entirely - no code is asked for - when the login request’s client IP falls inside a trusted network (including the always-trusted loopback ranges). This is deliberate: the trusted network already establishes who is signing in, so the second factor would be redundant from there.

Setup flow (My settings -> Security)

  1. Enable 2FA -> POST /api/auth/2fa/setup generates a TOTP secret and persists it immediately with totp_enabled: false. It is not held in server memory or the Flask session: the container runs gunicorn -w 2 (two worker processes), so an in-memory pending secret would randomly fail confirmation depending on which worker handled it.
  2. The setup modal shows:
    • A QR code (rendered client-side) - scan it from a different device than the one showing it.
    • The raw secret as selectable text - the reliable fallback when the authenticator app is on the same phone as the browser (QR scanning is then impossible).
    • An otpauth:// link/button to open directly in an authenticator app - best effort, not every app registers the URI scheme as a system handler.
  3. Confirm with a 6-digit code -> POST /api/auth/2fa/confirm verifies it against the just-generated secret (pyotp.TOTP(secret).verify(code, valid_window=1), tolerating +/-30 seconds of clock drift) and sets totp_enabled: true, totp_confirmed_at to the current timestamp.
  4. Disable (self-service) -> POST /api/auth/2fa/disable requires the current password (re-authentication) and clears totp_secret, totp_enabled, totp_confirmed_at.

Login flow

After password verification (never before - see Security notes) and after the local/global account check:

  1. If the instance switch is on, the user has totp_enabled: true, and the client IP is not trusted: POST /api/auth/login does not set the session. It stores a pending state in the session instead (pending_2fa_user_id, a 5-minute expiry) and returns {"status": "2fa_required"}, 200. The session is never permanent in this pending state, regardless of remember_me - the 30-day cookie only applies once fully authenticated.
  2. The login page shows a second step (6-digit code input). POST /api/auth/login/verify-2fa re-checks the local-account/trusted-network restriction (an admin could have changed it while this login was pending), verifies the code against the pending user’s secret and, on success, finishes the login exactly as a normal password-only login would (same session keys, same response shape).
  3. Brute-force guard: up to 5 incorrect codes per user, and the pending state expires after 5 minutes; either condition forces a fresh /api/auth/login call. The attempt count is tracked server-side, keyed by user ID - not in the session cookie - specifically so that starting a new login (which an attacker who already has the password can always do) does not reset it. A 6-digit TOTP code is a much smaller search space than a password, so this endpoint has a tighter limit than password sign-in (see Security notes).
  4. login_required needs no special handling for the pending state: it requires 'username' in session (and that account to exist), and username stays unset throughout - every existing authenticated route stays correctly locked out mid-2FA.

Admin: forced disable

An admin can clear any user’s 2FA from App settings -> Users (shown only when that user has it active), via DELETE /api/users/<user_id>/2fa. No password check - admin authority is the check. Intended for a lost or reset authenticator device.

Lost-authenticator recovery

If a user loses their authenticator device and no other admin is available to disable 2FA for them via the Users panel, anyone with shell access to the host can turn it off from the command line (DATABASE.md):

docker exec -u appuser myastroboard python backend/db/manage.py disable-2fa <username>

Every worker sees the change on its next request (no restart required) - the user can log in with just their password on the next attempt. reset-password <username> recovers a lost password the same way.


Trusted networks

Module: backend/utils/security_settings.py · Stored as: database setting security_settings

Trusted networks are the shared foundation for both two-factor authentication and local/global accounts below: an admin-managed list of CIDR blocks or bare IP addresses, matched against the client IP of each login attempt. Two independent features read the same list for opposite purposes - two-factor authentication trusts the network to skip a step, local/global accounts trust it to allow a step - but they are otherwise unrelated checks.

{
  "trusted_networks": ["192.168.1.0/24"],
  "two_factor_enabled": false
}
  • Each entry is normalized with Python’s ipaddress.ip_network(value, strict=False), so 192.168.1.5/24 and 192.168.1.0/24 are stored as the same, single entry. Both IPv4 and IPv6 are supported.
  • 127.0.0.0/8 and ::1/128 are always implicitly trusted. They are hardcoded and never written to the file, so clearing the configured list can never lock an admin out of localhost access.
  • The client IP is request.remote_addr, which already resolves the real client IP behind a reverse proxy when trust_proxy_headers is enabled (see Application settings) - no separate configuration is needed for trusted networks to work correctly behind a proxy.
  • Docker Desktop / plain docker-compose without a reverse proxy: when the browser reaches the container through Docker’s own port mapping (ports: - "5000:5000") rather than through a reverse proxy, request.remote_addr is the Docker bridge gateway (typically 172.17.0.1 or 172.18.0.1, one fixed address for every host and every LAN client alike), not the browser’s real address - Docker rewrites the packet’s source before it reaches the container, and there is no X-Forwarded-For header at that point for trust_proxy_headers to read. Trusted networks, the 2FA bypass and local-scope logins will not recognize 127.0.0.1 or a LAN CIDR in this setup. Confirm the address your instance actually sees for a given login attempt in data/myastroboard.log ("Password accepted for user ... from <ip>, awaiting 2FA code"), or docker network inspect <compose_project>_default for the bridge subnet. Adding that one address as a trusted network unblocks testing, but note it then trusts every connection reaching the mapped port, not just localhost - the real fix for a LAN or public deployment is a reverse proxy in front (nginx/Traefik) with trust_proxy_headers = true, per 6.REVERSE_PROXY.md.
  • Not included in backups: the security settings are excluded from both /api/backup/download and /api/config/export, for the same reason as trust_proxy_headers - see docs/CONFIGURATION.md.

Managed in App settings -> Users, in the “Trusted networks and two-factor authentication” panel (admin only), or via GET/POST /api/auth/security-settings.

Validation rules (enforced server-side, in POST /api/auth/security-settings)

  1. Each entry must parse as a valid IP network. The whole save is rejected with 400 and error_key: settings.invalid_trusted_network otherwise, naming the bad entry.
  2. two_factor_enabled cannot be set to true while the resulting trusted_networks list is empty. Rejected with error_key: settings.2fa_requires_trusted_network.
  3. If a save removes the last entry from trusted_networks while two_factor_enabled was previously true, it is silently forced to false as part of the same save (not an error - a documented cascade) and a warning is logged. The admin UI shows a confirmation dialog before this happens when removing the last entry, and again when adding an unusually broad entry (see below).
  4. This invariant (rule 2/3) is also enforced as a defensive backstop directly in save_security_settings() itself, not only in the API handler above - no future caller, now or later, can persist the inconsistent pair to disk even if it skips the handler’s own checks.

A network broader than a plausible home/office LAN (an IPv4 prefix narrower than /8, or an IPv6 prefix narrower than /32) is still accepted - an admin may have a genuine reason - but logs a warning and prompts for confirmation in the admin UI before being added, since it is often a typo in the prefix (/24 losing a digit to become /2, for example) rather than an intentional choice, and a too-broad entry silently makes every login on that IP version bypass 2FA and the local-account restriction alike.


Local vs. global accounts

Each account has an account_scope of global (default - can sign in from anywhere) or local (can only sign in from a trusted network). This reuses the same trusted-network list as two-factor authentication above, but for the opposite purpose: trusting the network to allow the login rather than to skip a step.

  • Managed per user from App settings -> Users (“Scope” column and action button), or via PUT /api/users/<user_id> with {"account_scope": "local"}. POST /api/users also accepts account_scope at creation time.
  • Enforced in POST /api/auth/login, after password verification and before the 2FA check: a local account signing in from outside every trusted network (including loopback) is rejected with 403 and error_key: auth.local_account_network_restricted.
  • Fallback rule: when trusted_networks is empty, the local/global check is not performed at all - a local account can sign in from anywhere in that state. Without any configured network, “local” is undefined, and blocking every non-localhost login for every local account would be a silent full lockout. App settings -> Users shows a warning banner when this fallback is active and at least one account is scoped local.

Application settings (admin)

Stored in the database (setting app_settings) via backend/utils/app_settings.py:

SettingDefaultDescription
vapid_contact_email""Contact email embedded in Web Push VAPID tokens (required for iOS push; see NOTIFICATIONS.md)
trust_proxy_headersfalseSet true when behind a reverse proxy sending X-Forwarded-For / X-Forwarded-Proto headers (see 6.REVERSE_PROXY.md)
session_cookie_securefalseSet true to restrict the session cookie to HTTPS connections only (recommended when using HTTPS)
search_engine_indexingfalseSet true to let search engines crawl and index the login page. Instances are private by default (/robots.txt disallows everything, login page is noindex, nofollow)

These are managed in App settings -> Configuration in the admin UI, or via GET/POST /api/admin/app-settings.


Security notes

  • Session cookies are HttpOnly by default (not accessible by JavaScript).
  • Enable session_cookie_secure = true + trust_proxy_headers = true when deploying with HTTPS behind a reverse proxy.
  • Password sign-in is throttled: after 5 failed attempts for the same username from the same IP address, or 20 failed attempts from one IP address across any usernames, within 15 minutes, POST /api/auth/login answers 429 with a Retry-After header until the window has passed. A successful sign-in resets that username’s counter. Counting per IP + username means a stranger cannot lock a user out from the user’s own address. The counters live in memory per worker process (gunicorn -w N multiplies the ceiling by N) and reset on restart, so a reverse proxy with its own rate limit remains a good complement for public-facing deployments. POST /api/auth/login/verify-2fa has its own, tighter 5-attempt, 5-minute limit (see Two-factor authentication).
  • There is no email-based password reset: an admin must reset passwords via the Users panel.
  • Network and 2FA checks in POST /api/auth/login run strictly after password verification. Checking them first would let an unauthenticated caller probe account existence or configuration (local scope, 2FA status) without a valid credential.
  • Two-factor secrets live directly on the User record, the same record and precedent as password_hash (already a sensitive field included in backups) - not in the separate secrets store used for connector credentials, since that pattern exists specifically to keep those out of backups.

Sessions

MyAstroBoard uses Flask server-side sessions (cookie-based, signed with a persistent SECRET_KEY).

The SECRET_KEY is generated once on first startup and stored in the database. It persists across container restarts so existing sessions remain valid; deleting the database invalidates all active sessions.

The session cookie name is session. Session expiry follows Flask defaults (browser session unless SESSION_COOKIE_SECURE is enabled).

A session is only valid while its account exists. When an admin deletes an account, or a backup restore replaces it, the next request from that browser is answered 401 and its session is cleared: the user is back on the sign-in page and reads nothing more, install-wide data included.


User preferences

Each user has a preferences object stored on their account. These are saved via PUT /api/auth/preferences. The user-facing description of each setting is in USER_SETTINGS.md.

PreferenceAllowed valuesDefaultDescription
startup_main_tabforecast-astro, forecast-weather, skytonight, spaceflight, astrodex, equipment, my-settings, parametersforecast-astroWhich tab opens on login
startup_subtab(list of valid sub-tab IDs)astro-weatherWhich sub-tab opens on login
time_formatauto, 12h, 24hautoTime display format (auto follows browser locale)
densitycomfortable, compactcomfortableUI row density
theme_modeauto, light, dark, redautoColour theme (auto follows OS preference)
first_day_of_weekmonday, sundaymondayCalendar and date picker start day
languageen, fr, plus community translationsenInterface language
experience_levelbeginner, intermediate, advancedadvancedFilters SkyTonight recommendations and the Beginner Catalog by difficulty (see BEGINNER_EXPERIENCE.md)
beginner_catalog_enabledbooleantrueShows/hides the “Beginner” SkyTonight sub-tab
recommendations_enabledbooleantrueShows the “Tonight for you” panel at the top of SkyTonight -> Deep Sky Objects
mqtt_publish_enabledbooleanfalseThe user’s consent to publish their own activity (Astrodex counters, plan, log totals) through the MQTT connector (see HOME_ASSISTANT.md)
wizardObject (completed, skipped booleans)both falseGuided setup wizard completion state (see BEGINNER_EXPERIENCE.md)
notificationsObject (see NOTIFICATIONS.md)enabled, defaults per triggerPush and in-browser notification settings; includes disabled_location_ids per-location mutes (v1.2)
locationObject (see LOCATIONS.md)empty attribution, install defaultv1.2 multi-location block: attributed_location_ids (admin-set), default_location_id (user’s “what I see when I connect”), active_location_id (session-scoped, reset to default at login), order

Users storage

All user accounts are stored in the database (data/myastroboard.db, users table - see DATABASE.md). Each account holds:

[
  {
    "user_id": "<uuid>",
    "username": "admin",
    "password_hash": "<werkzeug scrypt hash>",
    "role": "admin",
    "created_at": "2025-01-01T00:00:00+00:00",
    "last_login": "2026-06-01T20:00:00+00:00",
    "preferences": { ... },
    "push_subscriptions": [ ... ],
    "account_scope": "global",
    "totp_secret": null,
    "totp_enabled": false,
    "totp_confirmed_at": null
  }
]

Passwords are hashed using Werkzeug’s generate_password_hash (PBKDF2-SHA256 by default). Plain-text passwords are never stored.

account_scope, totp_secret, totp_enabled and totp_confirmed_at are documented in full under Two-factor authentication and Local vs. global accounts above. Entries written before these fields existed load with account_scope: "global" and the three 2FA fields falsy - no migration script is needed.


Authentication API

MethodPathAuthDescription
POST/api/auth/loginPublicSubmit {"username": ..., "password": ...} -> sets session cookie, or returns {"status": "2fa_required"} when a second step is needed (see Two-factor authentication), 403 when a local-scoped account signs in from an untrusted network or, on a demo instance, for the demo account and any account on the default password, or 429 after too many failures (see Security notes)
POST/api/auth/login/verify-2fapending sessionSecond login step: submit {"code": "123456"} to complete a login that returned 2fa_required
POST/api/auth/logoutloginClears session
GET/api/auth/statusPublicReturns {"authenticated": bool, "role": ..., "username": ..., "two_factor_available": bool, "totp_enabled": bool, "account_scope": ..., "demo_mode": bool} (demo_mode is true for a visitor of a demo instance)
POST/api/auth/change-passwordloginChange own password
GET/api/users/me/exportloginZIP of everything stored about the signed-in user (any role)
GET/api/auth/preferencesloginGet current user’s preferences
PUT/api/auth/preferencesloginUpdate preferences (partial update supported). On a demo instance the demo account’s changes go to the visitor’s session, not the account
POST/api/auth/2fa/setuploginGenerate this user’s TOTP secret, returns {"secret": ..., "otpauth_uri": ...}; 400 if 2FA is already active for this user (disable it first)
POST/api/auth/2fa/confirmloginSubmit {"code": "123456"} to activate 2FA after setup
POST/api/auth/2fa/disableloginSubmit {"password": ...} to turn 2FA off (self-service, re-authenticates)
GET/api/auth/security-settingsadminRead the trusted-network list and the instance-wide 2FA switch
POST/api/auth/security-settingsadminSave the trusted-network list and the instance-wide 2FA switch (validation rules under Trusted networks)

Last updated 2026-11-13