Skip to content

Add authentication with TOTP two-factor to the admin panel - #898

Open
sven-n wants to merge 3 commits into
masterfrom
claude/adminpanel-auth-no-refresh-1l6ayl
Open

Add authentication with TOTP two-factor to the admin panel#898
sven-n wants to merge 3 commits into
masterfrom
claude/adminpanel-auth-no-refresh-1l6ayl

Conversation

@sven-n

@sven-n sven-n commented Aug 25, 2026

Copy link
Copy Markdown
Member

Motivation

The admin panel had no authentication of its own. It relied on the basic authentication of the reverse proxy against an .htpasswd file (NginxHtpasswdFileUserService), which meant there was no logout, no roles, no audit trail — and no protection at all when the panel was started without a proxy in front of it (dev.sh, the standalone Startup host, and the Dapr AdminPanel.Host, which has no nginx).

This adds authentication to the panel itself, including a second factor that works with the Microsoft Authenticator app, without requiring a page reload for the login.

How the login works without a page reload

A cookie can only be set on an http response, which an interactive Blazor component doesn't have — its response was sent when the circuit started. That's why the stock Identity template puts the login on a static-SSR page and does a full page load.

Instead:

  1. The login component validates the credentials inside the circuit.
  2. If the user has a second factor, the form switches to the code input in place — no navigation, no lost state.
  3. Only once everything checked out, the circuit issues a single-use ticket which the browser posts to /auth/complete in the background (fetch). That request is a normal http request and can set the cookie.
  4. The new state is pushed into the running circuit, so every AuthorizeView re-renders where it stands.

A cookie is only ever issued after the second factor was verified, so an authenticated session always implies the second factor was used when the user has one — there is no partial-auth state to defend against.

Where the credentials are stored

A new admin schema with its own AdminPanelContext, its own migration history and its own migration — deliberately not the Account table of the game:

  • A game password is typed into the game client and travels over the game protocol, while an admin panel user can restart servers, edit the whole game configuration and read the logs. Sharing one secret means a leaked game password grants server administration.
  • Every game server connects with the account database role and could otherwise read and overwrite admin password hashes. The admin schema is not in the role-grant map of MyNpgsqlMigrationsSqlGenerator, so none of the game server roles (account, config, guild, friend) can reach it.
  • The panel is the tool which creates the game database, so its users can't live inside it — otherwise a fresh install could never be set up.

Passwords are hashed with BCrypt (consistent with the rest of the project), TOTP secrets are encrypted with data protection, and recovery codes are stored as hashes.

Identity Core, not the full Identity stack

AddIdentityCore provides the TOTP validation, recovery codes, lockout and the security stamp. Its SignInManager cookie flow and IdentityDbContext are not used: a custom IUserStore implementation keeps everything in a single AdminUser table instead of the eight tables of the Identity EF store, which avoids reconciling Identity's migrations with this repo's per-schema role grants.

Two-factor authentication

  • Standard TOTP: SHA-1, 6 digits, 30 seconds. The Microsoft Authenticator app ignores deviating algorithm/digits values in the otpauth uri and computes the defaults anyway, so a "stronger" configuration would just produce codes that never validate. This is noted in the code so it doesn't get "improved" later.
  • The second factor is only enabled after the user entered a code its app produced, so a failed scan can't lock anybody out of their own panel.
  • The QR code is rendered as inline SVG via QRCoder — no JS library, no image dependency.
  • Ten recovery codes are handed out once and stored as hashes.
  • The time step of the last accepted code is remembered, so an observed code can't be replayed within its time step.
  • Failed code entries count towards the lockout, so a correct password does not buy unlimited 6-digit guesses.

Bootstrap user and initial setup mode

Until any user exists, the panel stays reachable without a login and says so — otherwise a fresh installation could never reach /setup to create the database. To close that window, a bootstrap user can be configured which works without any database:

OPENMU_ADMIN_USER=admin
OPENMU_ADMIN_PASSWORD=<a long password>
OPENMU_ADMIN_TOTP_SECRET=<optional base32 secret>

It is also the way back in when the last stored user lost its authenticator. Its state is only kept in memory, which is documented.

Also in this change

  • Three roles which build up on each other (Viewer, Operator, Administrator). Setup, plugins, updates, log files and user management require Administrator.
  • ServerController and the other API endpoints are behind authorization now (MapControllers().RequireAuthorization()), with the theme and culture controllers explicitly anonymous so the login page keeps working.
  • The /logs static file mount is served through an authorization check — static files are middleware, not endpoints, so endpoint authorization does not cover them.
  • The Users page now manages AdminUsers (role, password, second-factor reset) instead of editing .htpasswd. IUserService, UserServiceBase and NginxHtpasswdFileUserService are removed.
  • The basic authentication and the .htpasswd mounts are removed from the nginx and Traefik deployments, which instead persist the data protection key ring in an adminpanel-keys volume. Without that, a container restart would invalidate every session and make every stored TOTP secret unreadable.
  • src/Web/AdminPanel/Readme.md documents all of the above.

Testing

  • Full solution builds clean: 0 errors, no new warnings.
  • MUnique.OpenMU.Web.Tests: 25/25 pass (14 new).
  • MUnique.OpenMU.Tests: 769/769 pass.

The new tests build a real UserManager over an in-memory repository and drive actual TOTP codes through it (the Identity token provider can only validate codes, so the test project has its own RFC 6238 generator). They cover: login with and without a second factor, wrong password, unknown and disabled users, lockout after repeated failures, a valid authenticator code, a wrong code, replay rejection, recovery-code single use, that the second factor stays off until confirmed, that the authenticator key is not stored in plain text, single-use sign-in tickets, and the role/claim expansion.

Not verified

There is no database or browser in the environment this was developed in, so:

  • The migration has not been applied to a live PostgreSQL instance.
  • The login flow has not been exercised end-to-end in a browser. Worth a manual smoke test: the /auth/complete fetch under UseAntiforgery, and AdminUserRepository.EnsureStorageAsync running MigrateAsync against a real server.

Known limitation

The replay guard blocks reuse of a code within a time step, not a code replayed in the immediately adjacent step — Identity's token provider accepts a ±1 step window but doesn't report which step matched. This is documented where the guard lives. It is still strictly better than stock Identity, which tracks nothing.

🤖 Generated with Claude Code

https://claude.ai/code/session_015MLJ586D4tVVznbF5g7MHp


Generated by Claude Code

claude added 3 commits August 24, 2026 17:14
The admin panel had no authentication of its own. It relied on the basic
authentication of the reverse proxy against an .htpasswd file, which meant
there was no logout, no roles, no audit trail - and no protection at all when
the panel was started without a proxy in front of it.

The panel now authenticates its users itself, without a page reload: the login
component validates the credentials inside the blazor circuit, switches to the
second factor input in place, and only then issues a single use ticket which
the browser exchanges for the authentication cookie in the background. The new
state is pushed into the running circuit, so every AuthorizeView re-renders
without navigating.

ASP.NET Core Identity Core provides the password hashing (BCrypt, like the rest
of the project), the TOTP validation, recovery codes and lockout. Its user
store is implemented over a single table instead of pulling in the eight tables
of the Identity EF store.

Credential storage:
- The users live in an own "admin" schema with an own migration history, not in
  the Account table of the game: a game password travels over the game protocol
  and is typed into the game client, while an admin panel user can restart
  servers and edit the whole configuration. The schema is not granted to any of
  the game server database roles, so a game server can't read or overwrite an
  admin password hash. It also has to work before the game database exists,
  because the panel is the tool which creates it.
- Passwords are hashed with BCrypt, TOTP secrets are encrypted with data
  protection and recovery codes are stored as hashes.

Two-factor authentication:
- Standard TOTP (SHA-1, 6 digits, 30 seconds), so it works with the Microsoft
  Authenticator app, which ignores deviating parameters in the otpauth uri.
- The second factor is only enabled after the user entered a code its app
  produced, so a failed scan can't lock anybody out.
- The time step of the last accepted code is remembered, so an observed code
  can't be replayed within its time step.
- Failed code entries count towards the lockout.

Also:
- A bootstrap user from the configuration or from OPENMU_ADMIN_USER /
  OPENMU_ADMIN_PASSWORD works without any database and closes the window in
  which a fresh installation would be reachable without a login. Until any user
  exists, the panel runs in an initial setup mode and says so.
- Three roles which build up on each other; setup, plugins, updates, log files
  and user management require the administrator role.
- The API controllers and the log file directory are behind authorization now.
- The basic authentication and the .htpasswd mounts are removed from the nginx
  and traefik deployments, which instead persist the data protection key ring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MLJ586D4tVVznbF5g7MHp
…uth-no-refresh-1l6ayl

# Conflicts:
#	deploy/all-in-one-traefik/README.md
#	src/Web/AdminPanel/Readme.md
Master restructured the documentation while this branch was open: the admin
panel documentation moved from the repository readmes into the Docusaurus site.
The authentication section which this branch had added to the readme is now a
page of that site instead, and the pages which still described the old basic
authentication are updated.

- Add "Signing in", which covers the login, the bootstrap user and the initial
  setup mode, the second factor with an authenticator app, recovery codes and
  the roles.
- Rewrite the Users page: it manages admin users with roles and a second factor
  reset now, not entries of an .htpasswd file.
- Drop the "default user admin / password openmu" from the deployment and
  getting-started pages. There is no default user anymore - the panel either has
  a bootstrap user from the environment, or it runs in its initial setup mode
  until the first user was created.
- Remove the basic authentication middleware from the Traefik example and the
  note that Traefik has to be restarted after a user changed, which doesn't
  apply anymore.
- Remove the .htpasswd item from the compose projects, since the file is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MLJ586D4tVVznbF5g7MHp
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 25, 2026

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 45a5704
Status: ✅  Deploy successful!
Preview URL: https://d54a17d8.openmudocs.pages.dev
Branch Preview URL: https://claude-adminpanel-auth-no-re.openmudocs.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants