From 7efa15e5b6ecd17af43d5307a3d72d4c5cc7e615 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:14:59 +0000 Subject: [PATCH 1/3] Add authentication with TOTP two-factor to the admin panel 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 Claude-Session: https://claude.ai/code/session_015MLJ586D4tVVznbF5g7MHp --- deploy/all-in-one-traefik/README.md | 11 +- .../docker-compose.prod.yml | 5 - deploy/all-in-one-traefik/docker-compose.yml | 14 +- deploy/all-in-one/docker-compose.prod.yml | 2 - deploy/all-in-one/docker-compose.yml | 12 +- deploy/all-in-one/nginx/nginx.dev.conf | 3 - deploy/all-in-one/nginx/nginx.prod443.conf | 2 - deploy/distributed/docker-compose.prod.yml | 2 - deploy/distributed/docker-compose.yml | 10 +- deploy/distributed/nginx.dev.conf | 4 - deploy/distributed/nginx.prod443.conf | 3 - src/Dapr/AdminPanel.Host/Program.cs | 8 +- src/Directory.Packages.props | 3 +- src/Persistence/AdminAuth/AdminRoles.cs | 56 +++ src/Persistence/AdminAuth/AdminUser.cs | 110 +++++ .../AdminAuth/IAdminUserRepository.cs | 78 ++++ .../AdminAuthServiceCollectionExtensions.cs | 26 ++ .../AdminAuth/AdminPanelContext.cs | 58 +++ .../AdminAuth/AdminPanelContextFactory.cs | 24 + .../AdminAuth/AdminUserRepository.cs | 157 +++++++ .../EntityFramework/ConnectionConfigurator.cs | 12 + .../EntityFramework/ConnectionSettings.xml | 8 + ...4171349_InitialAdminPanelUsers.Designer.cs | 95 ++++ .../20260824171349_InitialAdminPanelUsers.cs | 59 +++ .../AdminPanelContextModelSnapshot.cs | 92 ++++ .../EntityFramework/SchemaNames.cs | 8 + src/Startup/Program.cs | 4 + .../AdminPanel/Auth/AdminAccessRequirement.cs | 55 +++ .../Auth/AdminAuthenticationDefaults.cs | 66 +++ .../Auth/AdminAuthenticationStateProvider.cs | 109 +++++ src/Web/AdminPanel/Auth/AdminLoginService.cs | 262 +++++++++++ .../Auth/AdminPanelAuthExtensions.cs | 189 ++++++++ .../AdminPanel/Auth/AdminPanelAuthOptions.cs | 68 +++ src/Web/AdminPanel/Auth/AdminPolicies.cs | 26 ++ .../Auth/AdminUserAvailabilityService.cs | 88 ++++ .../Auth/AdminUserSecretProtector.cs | 65 +++ src/Web/AdminPanel/Auth/AdminUserStore.cs | 327 +++++++++++++ src/Web/AdminPanel/Auth/AuthEndpoints.cs | 78 ++++ .../Auth/AuthenticatorSetupService.cs | 152 ++++++ .../AdminPanel/Auth/BCryptPasswordHasher.cs | 40 ++ .../Auth/BootstrapAdminUserProvider.cs | 85 ++++ .../Auth/CurrentAdminUserService.cs | 46 ++ .../AdminPanel/Auth/SignInTicketService.cs | 80 ++++ .../Auth/UnavailableAdminUserRepository.cs | 62 +++ .../Components/Layout/LoginDisplay.razor | 15 + .../Components/Layout/LoginDisplay.razor.cs | 68 +++ .../Components/Layout/LoginLayout.razor | 17 + .../Components/Layout/LoginLayout.razor.css | 3 + .../Components/Layout/MainLayout.razor | 58 ++- .../Components/Layout/NavMenu.razor | 5 +- .../Components/Layout/NavMenu.razor.cs | 3 - .../Components/RedirectToLogin.razor | 13 + src/Web/AdminPanel/Components/Routes.razor | 16 +- .../MUnique.OpenMU.Web.AdminPanel.csproj | 1 + src/Web/AdminPanel/Pages/AccessDenied.razor | 8 + .../AdminPanel/Pages/AccountSecurity.razor | 72 +++ .../AdminPanel/Pages/AccountSecurity.razor.cs | 231 +++++++++ .../Pages/AccountSecurity.razor.css | 4 + src/Web/AdminPanel/Pages/AdminUsers.razor | 101 ++-- src/Web/AdminPanel/Pages/AdminUsers.razor.cs | 79 ++++ src/Web/AdminPanel/Pages/Error.razor | 1 + src/Web/AdminPanel/Pages/LogFiles.razor | 1 + src/Web/AdminPanel/Pages/Login.razor | 58 +++ src/Web/AdminPanel/Pages/Login.razor.cs | 264 +++++++++++ src/Web/AdminPanel/Pages/NotFound.razor | 1 + src/Web/AdminPanel/Pages/Plugins.razor | 3 +- src/Web/AdminPanel/Pages/Setup.razor | 1 + src/Web/AdminPanel/Pages/Updates.razor | 1 + .../Properties/Resources.Designer.cs | 441 ++++++++++++++++++ src/Web/AdminPanel/Properties/Resources.resx | 149 +++++- src/Web/AdminPanel/Readme.md | 94 ++++ .../Services/AdminUserManagementService.cs | 243 ++++++++++ src/Web/AdminPanel/Startup.cs | 12 +- .../AdminPanel/WebApplicationExtensions.cs | 14 +- src/Web/AdminPanel/_Imports.razor | 5 +- src/Web/AdminPanel/wwwroot/js/auth.js | 21 + src/Web/Shared/Services/CultureController.cs | 1 + src/Web/Shared/Services/IUserService.cs | 38 -- .../Services/NginxHtpasswdFileUserService.cs | 101 ---- src/Web/Shared/Services/ThemeController.cs | 3 +- src/Web/Shared/Services/UserServiceBase.cs | 134 ------ .../AdminAuth/AdminAuthenticationTests.cs | 345 ++++++++++++++ .../AdminAuth/InMemoryAdminUserRepository.cs | 55 +++ .../AdminAuth/TestTotpGenerator.cs | 72 +++ 84 files changed, 5051 insertions(+), 365 deletions(-) create mode 100644 src/Persistence/AdminAuth/AdminRoles.cs create mode 100644 src/Persistence/AdminAuth/AdminUser.cs create mode 100644 src/Persistence/AdminAuth/IAdminUserRepository.cs create mode 100644 src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs create mode 100644 src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs create mode 100644 src/Persistence/EntityFramework/AdminAuth/AdminPanelContextFactory.cs create mode 100644 src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs create mode 100644 src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.cs create mode 100644 src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs create mode 100644 src/Web/AdminPanel/Auth/AdminAccessRequirement.cs create mode 100644 src/Web/AdminPanel/Auth/AdminAuthenticationDefaults.cs create mode 100644 src/Web/AdminPanel/Auth/AdminAuthenticationStateProvider.cs create mode 100644 src/Web/AdminPanel/Auth/AdminLoginService.cs create mode 100644 src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs create mode 100644 src/Web/AdminPanel/Auth/AdminPanelAuthOptions.cs create mode 100644 src/Web/AdminPanel/Auth/AdminPolicies.cs create mode 100644 src/Web/AdminPanel/Auth/AdminUserAvailabilityService.cs create mode 100644 src/Web/AdminPanel/Auth/AdminUserSecretProtector.cs create mode 100644 src/Web/AdminPanel/Auth/AdminUserStore.cs create mode 100644 src/Web/AdminPanel/Auth/AuthEndpoints.cs create mode 100644 src/Web/AdminPanel/Auth/AuthenticatorSetupService.cs create mode 100644 src/Web/AdminPanel/Auth/BCryptPasswordHasher.cs create mode 100644 src/Web/AdminPanel/Auth/BootstrapAdminUserProvider.cs create mode 100644 src/Web/AdminPanel/Auth/CurrentAdminUserService.cs create mode 100644 src/Web/AdminPanel/Auth/SignInTicketService.cs create mode 100644 src/Web/AdminPanel/Auth/UnavailableAdminUserRepository.cs create mode 100644 src/Web/AdminPanel/Components/Layout/LoginDisplay.razor create mode 100644 src/Web/AdminPanel/Components/Layout/LoginDisplay.razor.cs create mode 100644 src/Web/AdminPanel/Components/Layout/LoginLayout.razor create mode 100644 src/Web/AdminPanel/Components/Layout/LoginLayout.razor.css create mode 100644 src/Web/AdminPanel/Components/RedirectToLogin.razor create mode 100644 src/Web/AdminPanel/Pages/AccessDenied.razor create mode 100644 src/Web/AdminPanel/Pages/AccountSecurity.razor create mode 100644 src/Web/AdminPanel/Pages/AccountSecurity.razor.cs create mode 100644 src/Web/AdminPanel/Pages/AccountSecurity.razor.css create mode 100644 src/Web/AdminPanel/Pages/AdminUsers.razor.cs create mode 100644 src/Web/AdminPanel/Pages/Login.razor create mode 100644 src/Web/AdminPanel/Pages/Login.razor.cs create mode 100644 src/Web/AdminPanel/Services/AdminUserManagementService.cs create mode 100644 src/Web/AdminPanel/wwwroot/js/auth.js delete mode 100644 src/Web/Shared/Services/IUserService.cs delete mode 100644 src/Web/Shared/Services/NginxHtpasswdFileUserService.cs delete mode 100644 src/Web/Shared/Services/UserServiceBase.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/AdminAuth/AdminAuthenticationTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/AdminAuth/InMemoryAdminUserRepository.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/AdminAuth/TestTotpGenerator.cs diff --git a/deploy/all-in-one-traefik/README.md b/deploy/all-in-one-traefik/README.md index 40ea71799d..818d46acca 100644 --- a/deploy/all-in-one-traefik/README.md +++ b/deploy/all-in-one-traefik/README.md @@ -19,8 +19,6 @@ services: - "traefik.docker.network=proxy" - "traefik.http.routers.adm.entrypoints=websecure" - "traefik.http.routers.adm.rule=Host(`admin.domain.com`)" - - "traefik.http.routers.adm.middlewares=auth" - - "traefik.http.middlewares.auth.basicauth.usersfile=.htpasswd" muonline-website: ... @@ -117,9 +115,12 @@ docker compose -f docker-compose.prod.yml up -d #### Important -Avoid editing the .htpasswd manually. Instead, access the admin panel -and add a new user. If you are using the _all-in-one-traefik_ you -need to restart Traefik after add a new user to it takes effect. +The admin panel authenticates its users itself, so there is no basic +authentication in Traefik anymore. Set `OPENMU_ADMIN_USER` and +`OPENMU_ADMIN_PASSWORD` before the first start - otherwise the panel is +reachable without a login until you created the first user in it. +Users are managed in the admin panel under _Users_, and each of them can +set a second factor up under _Account security_. ## What's next diff --git a/deploy/all-in-one-traefik/docker-compose.prod.yml b/deploy/all-in-one-traefik/docker-compose.prod.yml index 2cff780814..6eec1e2b0b 100644 --- a/deploy/all-in-one-traefik/docker-compose.prod.yml +++ b/deploy/all-in-one-traefik/docker-compose.prod.yml @@ -16,8 +16,6 @@ services: environment: DB_HOST: database working_dir: /app/ - volumes: - - ./.htpasswd:/etc/nginx/.htpasswd depends_on: - database labels: @@ -25,8 +23,6 @@ services: - "traefik.docker.network=proxy" - "traefik.http.routers.openmu.entrypoints=websecure" - "traefik.http.routers.openmu.rule=Host(`${DOMAIN}`)" - - "traefik.http.routers.openmu.middlewares=auth" - - "traefik.http.middlewares.auth.basicauth.usersfile=.htpasswd" database: image: postgres @@ -57,7 +53,6 @@ services: - ./data-traefik/traefik.yml:/traefik.yml:ro - ./data-traefik/acme.json:/acme.json - ./data-traefik/configurations:/configurations - - "./.htpasswd:/.htpasswd" networks: - proxy labels: diff --git a/deploy/all-in-one-traefik/docker-compose.yml b/deploy/all-in-one-traefik/docker-compose.yml index a0fca11493..8fa9e905ad 100644 --- a/deploy/all-in-one-traefik/docker-compose.yml +++ b/deploy/all-in-one-traefik/docker-compose.yml @@ -15,9 +15,15 @@ services: - "55980:55980" environment: DB_HOST: database - working_dir: /app/ + # Optional bootstrap admin panel user. Without it, the admin panel is reachable + # without a login until the first user has been created within the panel itself. + OPENMU_ADMIN_USER: ${OPENMU_ADMIN_USER:-} + OPENMU_ADMIN_PASSWORD: ${OPENMU_ADMIN_PASSWORD:-} + # Optional base32 TOTP secret, if the bootstrap user should require a second factor. + OPENMU_ADMIN_TOTP_SECRET: ${OPENMU_ADMIN_TOTP_SECRET:-} volumes: - - ./.htpasswd:/etc/nginx/.htpasswd + - adminpanel-keys:/app/data-protection-keys + working_dir: /app/ depends_on: - database labels: @@ -25,8 +31,6 @@ services: - "traefik.docker.network=proxy" - "traefik.http.routers.openmu.entrypoints=web" - "traefik.http.routers.openmu.rule=Host(`admin.docker.localhost`)" - - "traefik.http.routers.openmu.middlewares=auth" - - "traefik.http.middlewares.auth.basicauth.usersfile=.htpasswd" database: image: postgres @@ -56,7 +60,6 @@ services: - "80:80" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - - "./.htpasswd:/.htpasswd" networks: - proxy @@ -65,4 +68,5 @@ networks: external: true volumes: + adminpanel-keys: dbdata: diff --git a/deploy/all-in-one/docker-compose.prod.yml b/deploy/all-in-one/docker-compose.prod.yml index 086b12fa04..8df78ef81d 100644 --- a/deploy/all-in-one/docker-compose.prod.yml +++ b/deploy/all-in-one/docker-compose.prod.yml @@ -13,7 +13,6 @@ services: DOMAIN_NAME: ${DOMAIN_NAME} volumes: - ./nginx/nginx.prod80.conf:/etc/nginx/nginx.conf:ro - - ./.htpasswd:/etc/nginx/.htpasswd - ./certbot/www:/var/www/certbot/:ro - ./nginx/templates/nginx.server_name.conf.template:/etc/nginx/templates/nginx.server_name.conf.template:ro @@ -32,7 +31,6 @@ services: - "443:443" volumes: - ./nginx/nginx.prod443.conf:/etc/nginx/nginx.conf:ro - - ./.htpasswd:/etc/nginx/.htpasswd - ./certbot/conf/:/etc/nginx/ssl/:ro - ./nginx/templates/nginx.server_name.conf.template:/etc/nginx/templates/nginx.server_name.conf.template:ro - ./nginx/templates/nginx.prod.certificates.conf.template:/etc/nginx/templates/nginx.prod.certificates.conf.template:ro diff --git a/deploy/all-in-one/docker-compose.yml b/deploy/all-in-one/docker-compose.yml index 725620b1a6..ef48d553b4 100644 --- a/deploy/all-in-one/docker-compose.yml +++ b/deploy/all-in-one/docker-compose.yml @@ -6,7 +6,6 @@ services: - "80:80" volumes: - ./nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro - - ./.htpasswd:/etc/nginx/.htpasswd depends_on: - openmu-startup @@ -27,9 +26,15 @@ services: environment: DB_HOST: database ASPNETCORE_URLS: http://+:8080 - working_dir: /app/ + # Optional bootstrap admin panel user. Without it, the admin panel is reachable + # without a login until the first user has been created within the panel itself. + OPENMU_ADMIN_USER: ${OPENMU_ADMIN_USER:-} + OPENMU_ADMIN_PASSWORD: ${OPENMU_ADMIN_PASSWORD:-} + # Optional base32 TOTP secret, if the bootstrap user should require a second factor. + OPENMU_ADMIN_TOTP_SECRET: ${OPENMU_ADMIN_TOTP_SECRET:-} volumes: - - ./.htpasswd:/etc/nginx/.htpasswd + - adminpanel-keys:/app/data-protection-keys + working_dir: /app/ depends_on: - database @@ -46,4 +51,5 @@ services: - dbdata:/var/lib/postgresql #store data on volume volumes: + adminpanel-keys: dbdata: \ No newline at end of file diff --git a/deploy/all-in-one/nginx/nginx.dev.conf b/deploy/all-in-one/nginx/nginx.dev.conf index 040f106017..47c296b9e3 100644 --- a/deploy/all-in-one/nginx/nginx.dev.conf +++ b/deploy/all-in-one/nginx/nginx.dev.conf @@ -9,9 +9,6 @@ http { } server { - auth_basic "Protected Site"; - auth_basic_user_file /etc/nginx/.htpasswd; - listen 80; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/deploy/all-in-one/nginx/nginx.prod443.conf b/deploy/all-in-one/nginx/nginx.prod443.conf index d6e569a240..460abd3d25 100644 --- a/deploy/all-in-one/nginx/nginx.prod443.conf +++ b/deploy/all-in-one/nginx/nginx.prod443.conf @@ -15,8 +15,6 @@ http { include conf.d/nginx.server_name.conf; include conf.d/nginx.prod.certificates.conf; - auth_basic "Protected Site"; - auth_basic_user_file /etc/nginx/.htpasswd; listen 80; proxy_http_version 1.1; diff --git a/deploy/distributed/docker-compose.prod.yml b/deploy/distributed/docker-compose.prod.yml index 1f8a7b9138..21a3bd4c0a 100644 --- a/deploy/distributed/docker-compose.prod.yml +++ b/deploy/distributed/docker-compose.prod.yml @@ -10,7 +10,6 @@ services: volumes: - ./nginx.server_name.conf.template:/etc/nginx/templates/nginx.server_name.conf.template - ./nginx.prod80.conf:/etc/nginx/nginx.conf:ro - - ./.htpasswd:/etc/nginx/.htpasswd - certbot-www:/var/www/certbot/:ro # We add another nginx here, just for HTTPs. @@ -30,7 +29,6 @@ services: - ./nginx.server_name.conf.template:/etc/nginx/templates/nginx.server_name.conf.template - ./nginx.prod.certificates.conf.template:/etc/nginx/templates/nginx.prod.certificates.conf.template - ./nginx.prod443.conf:/etc/nginx/nginx.conf:ro - - ./.htpasswd:/etc/nginx/.htpasswd - certificates:/etc/nginx/ssl/:ro depends_on: - grafana diff --git a/deploy/distributed/docker-compose.yml b/deploy/distributed/docker-compose.yml index 18c27251a2..f68d1ad78c 100644 --- a/deploy/distributed/docker-compose.yml +++ b/deploy/distributed/docker-compose.yml @@ -1,4 +1,5 @@ volumes: + adminpanel-keys: dbdata: prometheus-data: minio-data: @@ -12,7 +13,6 @@ services: - "80:80" volumes: - ./nginx.dev.conf:/etc/nginx/nginx.conf - - ./.htpasswd:/etc/nginx/.htpasswd depends_on: - grafana - zipkin @@ -256,8 +256,14 @@ services: environment: ASPNETCORE_URLS: http://+:8080 PATH_BASE: /admin/ + # Optional bootstrap admin panel user. Without it, the admin panel is reachable + # without a login until the first user has been created within the panel itself. + OPENMU_ADMIN_USER: ${OPENMU_ADMIN_USER:-} + OPENMU_ADMIN_PASSWORD: ${OPENMU_ADMIN_PASSWORD:-} + # Optional base32 TOTP secret, if the bootstrap user should require a second factor. + OPENMU_ADMIN_TOTP_SECRET: ${OPENMU_ADMIN_TOTP_SECRET:-} volumes: - - ./.htpasswd:/etc/nginx/.htpasswd + - adminpanel-keys:/app/data-protection-keys adminPanel-dapr: image: "daprio/daprd:latest" diff --git a/deploy/distributed/nginx.dev.conf b/deploy/distributed/nginx.dev.conf index b60f1afee8..684cda043b 100644 --- a/deploy/distributed/nginx.dev.conf +++ b/deploy/distributed/nginx.dev.conf @@ -9,9 +9,6 @@ http { } server { - auth_basic "Protected Site"; - auth_basic_user_file /etc/nginx/.htpasswd; - listen 80; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -54,7 +51,6 @@ http { # Public API location ~ (/serverInfo)(.*) { proxy_pass http://connectServer:8080/serverInfo$2; - auth_basic off; } # Game Servers: diff --git a/deploy/distributed/nginx.prod443.conf b/deploy/distributed/nginx.prod443.conf index 3afbfac3c7..2b8e85df19 100644 --- a/deploy/distributed/nginx.prod443.conf +++ b/deploy/distributed/nginx.prod443.conf @@ -15,8 +15,6 @@ http { include conf.d/nginx.server_name.conf; include conf.d/nginx.prod.certificates.conf; - auth_basic "Protected Site"; - auth_basic_user_file /etc/nginx/.htpasswd; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -59,7 +57,6 @@ http { # Public API location ~ (/serverInfo)(.*) { proxy_pass http://connectServer:8080/serverInfo$2; - auth_basic off; } # Game Servers: diff --git a/src/Dapr/AdminPanel.Host/Program.cs b/src/Dapr/AdminPanel.Host/Program.cs index c012b267fa..6035940b37 100644 --- a/src/Dapr/AdminPanel.Host/Program.cs +++ b/src/Dapr/AdminPanel.Host/Program.cs @@ -9,7 +9,9 @@ using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.ServerClients; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; using MUnique.OpenMU.Web.AdminPanel; +using MUnique.OpenMU.Web.AdminPanel.Auth; var builder = DaprService.CreateBuilder("AdminPanel", args); @@ -22,7 +24,8 @@ .AddManageableServerRegistry() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddAdminUserRepository(); builder.AddAdminPanel(); @@ -33,9 +36,12 @@ var app = builder.BuildAndConfigure(false); app.UseStaticFiles(); +app.UseRouting(); +app.UseAdminPanelAuth(); app.UseAntiforgery(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +app.MapAdminPanelAuthEndpoints(); await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false); diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5c2708a952..828c1b308d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,4 +1,4 @@ - + true @@ -43,6 +43,7 @@ + diff --git a/src/Persistence/AdminAuth/AdminRoles.cs b/src/Persistence/AdminAuth/AdminRoles.cs new file mode 100644 index 0000000000..670e8464a3 --- /dev/null +++ b/src/Persistence/AdminAuth/AdminRoles.cs @@ -0,0 +1,56 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// The roles which can be assigned to an . +/// +public static class AdminRoles +{ + /// + /// The role which is allowed to see the state of the servers and the game data, but can't change anything. + /// + public const string Viewer = "Viewer"; + + /// + /// The role which is additionally allowed to operate the servers, e.g. start and stop them, + /// disconnect players and edit accounts. + /// + public const string Operator = "Operator"; + + /// + /// The role which is additionally allowed to change the game configuration, install updates, + /// set the database up and manage the admin panel users. + /// + public const string Administrator = "Administrator"; + + /// + /// Gets all defined roles, from the least to the most privileged one. + /// + public static IReadOnlyList All { get; } = new[] { Viewer, Operator, Administrator }; + + /// + /// Gets the roles which are implied by the specified role, including the role itself. + /// + /// The role. + /// The role itself and all roles which are implied by it. + /// + /// The roles build up on each other, so an is implicitly + /// an and a as well. + /// + public static IEnumerable GetEffectiveRoles(string role) + { + var index = All.ToList().IndexOf(role); + if (index < 0) + { + yield break; + } + + for (var i = 0; i <= index; i++) + { + yield return All[i]; + } + } +} diff --git a/src/Persistence/AdminAuth/AdminUser.cs b/src/Persistence/AdminAuth/AdminUser.cs new file mode 100644 index 0000000000..1a2f9c0f25 --- /dev/null +++ b/src/Persistence/AdminAuth/AdminUser.cs @@ -0,0 +1,110 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// A user which is allowed to log into the admin panel. +/// +/// +/// This is deliberately not related to : +/// A game account 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 logs. +/// Sharing one secret between both would mean that a leaked game password grants server administration. +/// Additionally, the admin panel must be usable before the game database has been initialized, +/// which wouldn't be possible if the credentials were stored in the game data schema. +/// +public class AdminUser +{ + /// + /// Gets or sets the identifier of this user. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the login name. + /// + public string LoginName { get; set; } = string.Empty; + + /// + /// Gets or sets the normalized (upper case, invariant) login name which is used for lookups. + /// + public string NormalizedLoginName { get; set; } = string.Empty; + + /// + /// Gets or sets the hash of the password. + /// + public string PasswordHash { get; set; } = string.Empty; + + /// + /// Gets or sets the security stamp which changes whenever a security relevant property changes. + /// It's used to invalidate all existing sessions of this user. + /// + public string SecurityStamp { get; set; } = string.Empty; + + /// + /// Gets or sets the roles of this user, as a comma separated list. + /// + /// + public string Roles { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the two factor authentication is enabled for this user. + /// + public bool IsTwoFactorEnabled { get; set; } + + /// + /// Gets or sets the data protected authenticator (TOTP) key of this user. + /// + /// + /// The key is password equivalent, so it's never stored in plain text. + /// + public string? ProtectedAuthenticatorKey { get; set; } + + /// + /// Gets or sets the hashes of the still unused recovery codes of this user, separated by semicolons. + /// + /// + /// Only the hashes are stored, so a database dump doesn't hand out usable second factors. + /// The codes themselves are random and long enough to make a fast hash sufficient here. + /// + public string? RecoveryCodeHashes { get; set; } + + /// + /// Gets or sets the last TOTP time step which was accepted for this user. + /// + /// + /// A time based one time password stays valid for a whole validation window. + /// Remembering the last accepted step prevents that an observed code can be replayed within that window. + /// + public long LastAcceptedTotpStep { get; set; } + + /// + /// Gets or sets the number of failed login attempts since the last successful one. + /// + public int AccessFailedCount { get; set; } + + /// + /// Gets or sets the date and time until which this user is locked out. + /// + public DateTimeOffset? LockoutEnd { get; set; } + + /// + /// Gets or sets a value indicating whether this user is disabled and therefore can't log in. + /// + public bool IsDisabled { get; set; } + + /// + /// Gets or sets the date and time when this user has been created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the date and time of the last successful login. + /// + public DateTime? LastLoginAt { get; set; } + + /// + public override string ToString() => this.LoginName; +} diff --git a/src/Persistence/AdminAuth/IAdminUserRepository.cs b/src/Persistence/AdminAuth/IAdminUserRepository.cs new file mode 100644 index 0000000000..4caaf52918 --- /dev/null +++ b/src/Persistence/AdminAuth/IAdminUserRepository.cs @@ -0,0 +1,78 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.AdminAuth; + +using System.Threading; + +/// +/// A repository for the s of the admin panel. +/// +/// +/// The implementation must be usable independently of the game database: +/// The admin panel is the tool which creates the game database in the first place, +/// so its users can't be stored within it. +/// +public interface IAdminUserRepository +{ + /// + /// Ensures that the underlying storage exists and is up to date. + /// + /// The cancellation token. + /// + /// true, if the storage is available; otherwise, false, e.g. when no database server is reachable. + /// + ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the number of stored users. + /// + /// The cancellation token. + /// The number of stored users. + ValueTask GetCountAsync(CancellationToken cancellationToken = default); + + /// + /// Gets all stored users, ordered by their login name. + /// + /// The cancellation token. + /// All stored users. + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the user with the specified identifier. + /// + /// The identifier. + /// The cancellation token. + /// The user, if found; otherwise, null. + ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Gets the user with the specified normalized login name. + /// + /// The normalized login name. + /// The cancellation token. + /// The user, if found; otherwise, null. + ValueTask GetByNormalizedLoginNameAsync(string normalizedLoginName, CancellationToken cancellationToken = default); + + /// + /// Adds the specified user. + /// + /// The user. + /// The cancellation token. + ValueTask AddAsync(AdminUser user, CancellationToken cancellationToken = default); + + /// + /// Updates the specified user. + /// + /// The user. + /// The cancellation token. + ValueTask UpdateAsync(AdminUser user, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified user. + /// + /// The user. + /// The cancellation token. + ValueTask DeleteAsync(AdminUser user, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs b/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs new file mode 100644 index 0000000000..23aa5d380b --- /dev/null +++ b/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Extensions to register the persistence of the admin panel users. +/// +public static class AdminAuthServiceCollectionExtensions +{ + /// + /// Adds the database backed to the service collection. + /// + /// The service collection. + /// The same instance, to allow chaining of further calls. + public static IServiceCollection AddAdminUserRepository(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs new file mode 100644 index 0000000000..74e33b69a6 --- /dev/null +++ b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs @@ -0,0 +1,58 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// The context which holds the users of the admin panel. +/// +/// +/// It uses an own schema, an own migration history and an own set of migrations. +/// That's on purpose: The admin panel needs its users before the game database exists, +/// because it's the tool which creates the game database in the first place. +/// None of the game server database roles (account, config, guild, friend) gets access +/// to this schema, so a game server process can't read or overwrite an admin password hash. +/// +public class AdminPanelContext : DbContext +{ + /// + /// Gets or sets the admin panel users. + /// + public DbSet AdminUsers { get; set; } = null!; + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + this.Configure(optionsBuilder); + + // The migration history of this context lives in its own schema, so it doesn't + // interfere with the migrations of the game database. + optionsBuilder.UseNpgsql( + ConnectionConfigurator.GetConnectionString(), + options => options.MigrationsHistoryTable(HistoryRepository.DefaultTableName, SchemaNames.AdminPanel)); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema(SchemaNames.AdminPanel); + modelBuilder.Entity(entity => + { + entity.ToTable(nameof(AdminUser), SchemaNames.AdminPanel); + entity.HasKey(u => u.Id); + entity.Property(u => u.LoginName).IsRequired().HasMaxLength(100); + entity.Property(u => u.NormalizedLoginName).IsRequired().HasMaxLength(100); + entity.HasIndex(u => u.NormalizedLoginName).IsUnique(); + entity.Property(u => u.PasswordHash).IsRequired(); + entity.Property(u => u.SecurityStamp).IsRequired(); + entity.Property(u => u.Roles).IsRequired().HasMaxLength(200); + }); + } +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminPanelContextFactory.cs b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContextFactory.cs new file mode 100644 index 0000000000..a936061f3a --- /dev/null +++ b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContextFactory.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; + +using Microsoft.EntityFrameworkCore.Design; + +/// +/// Design-time factory for . +/// +public class AdminPanelContextFactory : IDesignTimeDbContextFactory +{ + /// + public AdminPanelContext CreateDbContext(string[] args) + { + if (!ConnectionConfigurator.IsInitialized) + { + ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + } + + return new AdminPanelContext(); + } +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs b/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs new file mode 100644 index 0000000000..90470772dc --- /dev/null +++ b/src/Persistence/EntityFramework/AdminAuth/AdminUserRepository.cs @@ -0,0 +1,157 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; + +using System.Threading; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Persistence.AdminAuth; +using Nito.AsyncEx; + +/// +/// Implementation of the which stores the users +/// in the admin schema of the configured PostgreSQL database. +/// +public class AdminUserRepository : IAdminUserRepository +{ + private readonly ILogger _logger; + private readonly AsyncLock _storageLock = new(); + private bool _isStorageReady; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public AdminUserRepository(ILogger logger) + { + this._logger = logger; + } + + /// + public async ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) + { + if (this._isStorageReady) + { + return true; + } + + using var l = await this._storageLock.LockAsync(cancellationToken).ConfigureAwait(false); + if (this._isStorageReady) + { + return true; + } + + try + { + await using var context = new AdminPanelContext(); + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + this._isStorageReady = true; + } + catch (Exception ex) + { + // This is an expected state before the database server is reachable or the database has been created. + // The admin panel then falls back to the configured bootstrap user. + this._logger.LogInformation(ex, "The admin user storage is not available (yet)."); + } + + return this._isStorageReady; + } + + /// + public async ValueTask GetCountAsync(CancellationToken cancellationToken = default) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return 0; + } + + await using var context = new AdminPanelContext(); + return await context.AdminUsers.CountAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return new List(); + } + + await using var context = new AdminPanelContext(); + return await context.AdminUsers + .AsNoTracking() + .OrderBy(u => u.LoginName) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + await using var context = new AdminPanelContext(); + return await context.AdminUsers + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask GetByNormalizedLoginNameAsync(string normalizedLoginName, CancellationToken cancellationToken = default) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + await using var context = new AdminPanelContext(); + return await context.AdminUsers + .AsNoTracking() + .FirstOrDefaultAsync(u => u.NormalizedLoginName == normalizedLoginName, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask AddAsync(AdminUser user, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.AdminUsers.Add(user); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask UpdateAsync(AdminUser user, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.AdminUsers.Update(user); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask DeleteAsync(AdminUser user, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.AdminUsers.Remove(user); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async ValueTask EnsureAvailableStorageAsync(CancellationToken cancellationToken) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("The admin user storage is not available. Please check the database connection."); + } + } +} diff --git a/src/Persistence/EntityFramework/ConnectionConfigurator.cs b/src/Persistence/EntityFramework/ConnectionConfigurator.cs index d23bb67ef6..e60935238f 100644 --- a/src/Persistence/EntityFramework/ConnectionConfigurator.cs +++ b/src/Persistence/EntityFramework/ConnectionConfigurator.cs @@ -95,6 +95,18 @@ public static string GetRolePassword(DatabaseRole role) return Regex.Match(settings.ConnectionString!, "Password=([^;]+?);").Groups[1].Value; } + /// + /// Gets the configured connection string of the specified context type. + /// + /// The type of the context. + /// The configured connection string of the specified context type. + internal static string GetConnectionString() + where TContext : DbContext + { + Provider.Initialization?.WaitWithoutException(); + return Provider.GetConnectionSetting(typeof(TContext)).ConnectionString!; + } + /// /// Configures the specified options builder. /// diff --git a/src/Persistence/EntityFramework/ConnectionSettings.xml b/src/Persistence/EntityFramework/ConnectionSettings.xml index 3bb7d1e4c4..ea87a8d6a6 100644 --- a/src/Persistence/EntityFramework/ConnectionSettings.xml +++ b/src/Persistence/EntityFramework/ConnectionSettings.xml @@ -7,6 +7,14 @@ Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120; Npgsql + + + MUnique.OpenMU.Persistence.EntityFramework.AdminAuth.AdminPanelContext + Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120; + Npgsql + MUnique.OpenMU.Persistence.EntityFramework.TypedContext diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.Designer.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.Designer.cs new file mode 100644 index 0000000000..ccb7520ccd --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.Designer.cs @@ -0,0 +1,95 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.AdminPanel +{ + [DbContext(typeof(AdminPanelContext))] + [Migration("20260824171349_InitialAdminPanelUsers")] + partial class InitialAdminPanelUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("admin") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.AdminAuth.AdminUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastAcceptedTotpStep") + .HasColumnType("bigint"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedLoginName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProtectedAuthenticatorKey") + .HasColumnType("text"); + + b.Property("RecoveryCodeHashes") + .HasColumnType("text"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedLoginName") + .IsUnique(); + + b.ToTable("AdminUser", "admin"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.cs new file mode 100644 index 0000000000..2aea7452b2 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260824171349_InitialAdminPanelUsers.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.AdminPanel +{ + /// + public partial class InitialAdminPanelUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "admin"); + + migrationBuilder.CreateTable( + name: "AdminUser", + schema: "admin", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + LoginName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + NormalizedLoginName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + SecurityStamp = table.Column(type: "text", nullable: false), + Roles = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsTwoFactorEnabled = table.Column(type: "boolean", nullable: false), + ProtectedAuthenticatorKey = table.Column(type: "text", nullable: true), + RecoveryCodeHashes = table.Column(type: "text", nullable: true), + LastAcceptedTotpStep = table.Column(type: "bigint", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + IsDisabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AdminUser", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AdminUser_NormalizedLoginName", + schema: "admin", + table: "AdminUser", + column: "NormalizedLoginName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AdminUser", + schema: "admin"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs new file mode 100644 index 0000000000..c2f56eafb2 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs @@ -0,0 +1,92 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.AdminPanel +{ + [DbContext(typeof(AdminPanelContext))] + partial class AdminPanelContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("admin") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.AdminAuth.AdminUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastAcceptedTotpStep") + .HasColumnType("bigint"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("LoginName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NormalizedLoginName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProtectedAuthenticatorKey") + .HasColumnType("text"); + + b.Property("RecoveryCodeHashes") + .HasColumnType("text"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedLoginName") + .IsUnique(); + + b.ToTable("AdminUser", "admin"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/SchemaNames.cs b/src/Persistence/EntityFramework/SchemaNames.cs index 7462da6981..b19bbf7470 100644 --- a/src/Persistence/EntityFramework/SchemaNames.cs +++ b/src/Persistence/EntityFramework/SchemaNames.cs @@ -28,4 +28,12 @@ internal static class SchemaNames /// The schema name for the guild server data. /// internal const string Guild = "guild"; + + /// + /// The schema name for the admin panel users. + /// + /// + /// It is deliberately not granted to any of the game server database roles. + /// + internal const string AdminPanel = "admin"; } \ No newline at end of file diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 351eae8e14..10e903c84c 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -26,6 +26,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.Network; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework.Json; using MUnique.OpenMU.Persistence.Initialization; using MUnique.OpenMU.Persistence.Initialization.Version075; @@ -253,6 +254,9 @@ private async Task CreateHostAsync(string[] args) builder.Host.UseSerilog(this._logger); if (addAdminPanel) { + // The storage of the admin panel users has to be registered before the panel itself, + // which only adds a fallback when nothing else is registered. + builder.Services.AddAdminUserRepository(); builder.AddAdminPanel(includeMapApp: true); } diff --git a/src/Web/AdminPanel/Auth/AdminAccessRequirement.cs b/src/Web/AdminPanel/Auth/AdminAccessRequirement.cs new file mode 100644 index 0000000000..3a1493475e --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminAccessRequirement.cs @@ -0,0 +1,55 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using Microsoft.AspNetCore.Authorization; + +/// +/// The requirement to access the admin panel, optionally with a specific role. +/// +/// The role which is required; null, if any authenticated user is allowed. +public record AdminAccessRequirement(string? RequiredRole = null) : IAuthorizationRequirement; + +/// +/// Handles the . +/// +/// +/// As long as no user exists at all, the panel has to stay reachable: it's the tool which creates +/// the database and therefore the first user. That initial setup mode ends as soon as the first +/// user exists, or immediately when a bootstrap user is configured. +/// +public class AdminAccessRequirementHandler : AuthorizationHandler +{ + private readonly AdminUserAvailabilityService _userAvailability; + + /// + /// Initializes a new instance of the class. + /// + /// The service which knows whether any user exists. + public AdminAccessRequirementHandler(AdminUserAvailabilityService userAvailability) + { + this._userAvailability = userAvailability; + } + + /// + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, AdminAccessRequirement requirement) + { + if (!await this._userAvailability.AnyUserExistsAsync().ConfigureAwait(false)) + { + context.Succeed(requirement); + return; + } + + if (context.User.Identity?.IsAuthenticated is not true) + { + return; + } + + if (requirement.RequiredRole is null || context.User.IsInRole(requirement.RequiredRole)) + { + context.Succeed(requirement); + } + } +} diff --git a/src/Web/AdminPanel/Auth/AdminAuthenticationDefaults.cs b/src/Web/AdminPanel/Auth/AdminAuthenticationDefaults.cs new file mode 100644 index 0000000000..bb04a8bf28 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminAuthenticationDefaults.cs @@ -0,0 +1,66 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// Constants of the admin panel authentication. +/// +public static class AdminAuthenticationDefaults +{ + /// + /// The name of the authentication cookie. + /// + public const string CookieName = "OpenMU.AdminPanel.Auth"; + + /// + /// The path of the login page. + /// + public const string LoginPath = "/login"; + + /// + /// The path of the page which shows that the user is missing a permission. + /// + public const string AccessDeniedPath = "/access-denied"; + + /// + /// The path of the page at which a user manages its own second factor. + /// + public const string SecurityPath = "/account/security"; + + /// + /// The endpoint which turns a one time sign in ticket into an authentication cookie. + /// + public const string SignInEndpointPath = "/auth/complete"; + + /// + /// The endpoint which removes the authentication cookie. + /// + public const string SignOutEndpointPath = "/auth/logout"; + + /// + /// The path of the javascript module which talks to the sign in and sign out endpoints. + /// + public const string AuthScriptPath = "./_content/MUnique.OpenMU.Web.AdminPanel/js/auth.js"; + + /// + /// The claim type which holds the security stamp of the user, so sessions can be invalidated. + /// + public const string SecurityStampClaimType = "openmu:security-stamp"; + + /// + /// The claim type which describes how the user authenticated itself. + /// + public const string AuthenticationMethodClaimType = "amr"; + + /// + /// The value of the when a second factor was used. + /// + public const string MultiFactorAuthenticationMethod = "mfa"; + + /// + /// The value of the when only a password was used. + /// + public const string PasswordAuthenticationMethod = "pwd"; +} diff --git a/src/Web/AdminPanel/Auth/AdminAuthenticationStateProvider.cs b/src/Web/AdminPanel/Auth/AdminAuthenticationStateProvider.cs new file mode 100644 index 0000000000..b3b28e804a --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminAuthenticationStateProvider.cs @@ -0,0 +1,109 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Security.Claims; +using System.Threading; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// The authentication state provider of the admin panel. +/// +/// +/// Besides the periodic revalidation, it allows to change the authentication state from within +/// the circuit. That's what makes the login work without a page reload: after the browser +/// exchanged its sign in ticket for a cookie, the new state is pushed into the running circuit +/// and every re-renders in place. +/// +public class AdminAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The service scope factory. + /// The logger. + public AdminAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + ILogger logger) + : base(loggerFactory) + { + this._scopeFactory = scopeFactory; + this._logger = logger; + } + + /// + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(15); + + /// + /// Applies the specified claims as the new authentication state of this circuit. + /// + /// The claims of the now authenticated user. + public void NotifySignedIn(IEnumerable claims) + { + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role); + this.SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity)))); + } + + /// + /// Applies an anonymous authentication state to this circuit. + /// + public void NotifySignedOut() + { + this.SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())))); + } + + /// + protected override async Task ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken) + { + var principal = authenticationState.User; + if (principal.Identity?.IsAuthenticated is not true) + { + return false; + } + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var securityStamp = principal.FindFirstValue(AdminAuthenticationDefaults.SecurityStampClaimType); + if (!Guid.TryParse(userId, out var id) || securityStamp is null) + { + return false; + } + + try + { + await using var scope = this._scopeFactory.CreateAsyncScope(); + var bootstrapUserProvider = scope.ServiceProvider.GetRequiredService(); + AdminUser? user; + if (bootstrapUserProvider.User is { } bootstrapUser && bootstrapUser.Id == id) + { + user = bootstrapUser; + } + else + { + var repository = scope.ServiceProvider.GetRequiredService(); + user = await repository.GetByIdAsync(id, cancellationToken).ConfigureAwait(false); + } + + return user is { IsDisabled: false } + && string.Equals(user.SecurityStamp, securityStamp, StringComparison.Ordinal); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "The authentication state of an admin panel user couldn't be revalidated."); + + // Don't kick the user out just because the database hiccuped. + return true; + } + } +} diff --git a/src/Web/AdminPanel/Auth/AdminLoginService.cs b/src/Web/AdminPanel/Auth/AdminLoginService.cs new file mode 100644 index 0000000000..1e7baa46d0 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminLoginService.cs @@ -0,0 +1,262 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// The result status of a login attempt. +/// +public enum AdminLoginStatus +{ + /// + /// The credentials were wrong or the user is not allowed to log in. + /// + Failed, + + /// + /// The user is locked out because of too many failed attempts. + /// + LockedOut, + + /// + /// The password was correct, but a second factor is required now. + /// + TwoFactorRequired, + + /// + /// The login succeeded. + /// + Succeeded, +} + +/// +/// The result of a login attempt. +/// +/// The status. +/// The sign in ticket, in case the login succeeded. +/// The claims of the authenticated user, in case the login succeeded. +public record AdminLoginResult(AdminLoginStatus Status, string? Ticket = null, IReadOnlyList? Claims = null); + +/// +/// Validates the credentials of an admin panel user. +/// +/// +/// This service runs inside the blazor circuit, so the whole login - including the second factor - +/// happens without leaving the page. Only when everything checked out, a sign in ticket is issued +/// which the browser exchanges for the authentication cookie. +/// +public class AdminLoginService +{ + private const int TotpTimeStepSeconds = 30; + + private readonly UserManager _userManager; + private readonly SignInTicketService _ticketService; + private readonly ILogger _logger; + + private AdminUser? _pendingTwoFactorUser; + private bool _pendingIsPersistent; + + /// + /// Initializes a new instance of the class. + /// + /// The user manager. + /// The sign in ticket service. + /// The logger. + public AdminLoginService(UserManager userManager, SignInTicketService ticketService, ILogger logger) + { + this._userManager = userManager; + this._ticketService = ticketService; + this._logger = logger; + } + + /// + /// Gets the login name of the user which has to provide its second factor now. + /// + public string? PendingTwoFactorLoginName => this._pendingTwoFactorUser?.LoginName; + + /// + /// Checks the specified password and either finishes the login or asks for the second factor. + /// + /// The login name. + /// The password. + /// If set to true, the session survives a browser restart. + /// The result of the attempt. + public async Task CheckPasswordAsync(string loginName, string password, bool isPersistent) + { + this._pendingTwoFactorUser = null; + + var user = await this._userManager.FindByNameAsync(loginName).ConfigureAwait(false); + if (user is null || user.IsDisabled) + { + this._logger.LogWarning("Failed admin panel login attempt for unknown or disabled user '{LoginName}'.", loginName); + return new AdminLoginResult(AdminLoginStatus.Failed); + } + + if (await this._userManager.IsLockedOutAsync(user).ConfigureAwait(false)) + { + this._logger.LogWarning("Admin panel login attempt for locked out user '{LoginName}'.", user.LoginName); + return new AdminLoginResult(AdminLoginStatus.LockedOut); + } + + if (!await this._userManager.CheckPasswordAsync(user, password).ConfigureAwait(false)) + { + await this._userManager.AccessFailedAsync(user).ConfigureAwait(false); + this._logger.LogWarning("Failed admin panel login attempt for user '{LoginName}' (wrong password).", user.LoginName); + return await this.GetFailedResultAsync(user).ConfigureAwait(false); + } + + if (user.IsTwoFactorEnabled) + { + this._pendingTwoFactorUser = user; + this._pendingIsPersistent = isPersistent; + return new AdminLoginResult(AdminLoginStatus.TwoFactorRequired); + } + + return await this.CompleteLoginAsync(user, usedSecondFactor: false, isPersistent).ConfigureAwait(false); + } + + /// + /// Checks the second factor of the user which passed the password check before. + /// + /// The authenticator code or recovery code. + /// If set to true, the code is treated as a recovery code. + /// The result of the attempt. + public async Task CheckTwoFactorAsync(string code, bool isRecoveryCode) + { + if (this._pendingTwoFactorUser is not { } user) + { + return new AdminLoginResult(AdminLoginStatus.Failed); + } + + if (await this._userManager.IsLockedOutAsync(user).ConfigureAwait(false)) + { + return new AdminLoginResult(AdminLoginStatus.LockedOut); + } + + var normalizedCode = code.Replace(" ", string.Empty).Replace("-", string.Empty); + bool isValid; + if (isRecoveryCode) + { + var result = await this._userManager.RedeemTwoFactorRecoveryCodeAsync(user, normalizedCode).ConfigureAwait(false); + isValid = result.Succeeded; + } + else + { + isValid = await this._userManager + .VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, normalizedCode) + .ConfigureAwait(false) + && await this.TryConsumeTimeStepAsync(user).ConfigureAwait(false); + } + + if (!isValid) + { + await this._userManager.AccessFailedAsync(user).ConfigureAwait(false); + this._logger.LogWarning("Failed second factor for admin panel user '{LoginName}'.", user.LoginName); + return await this.GetFailedResultAsync(user).ConfigureAwait(false); + } + + this._pendingTwoFactorUser = null; + return await this.CompleteLoginAsync(user, usedSecondFactor: true, this._pendingIsPersistent).ConfigureAwait(false); + } + + /// + /// Issues a new sign in ticket for an already authenticated user. + /// + /// The user. + /// If set to true, the user authenticated with a second factor. + /// The ticket and the claims it carries. + /// + /// This is needed after a security relevant change of the own user: such a change rotates the + /// security stamp, which would invalidate the running session. Re-issuing the cookie keeps the + /// user signed in without a reload. + /// + public (string Ticket, IReadOnlyList Claims) IssueSessionTicket(AdminUser user, bool usedSecondFactor) + { + var claims = CreateClaims(user, usedSecondFactor); + return (this._ticketService.Issue(claims, false), claims); + } + + /// + /// Builds the claims which describe the specified authenticated user. + /// + /// The user. + /// If set to true, the user authenticated with a second factor. + /// The claims of the user. + public static IReadOnlyList CreateClaims(AdminUser user, bool usedSecondFactor) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.LoginName), + new(AdminAuthenticationDefaults.SecurityStampClaimType, user.SecurityStamp), + new( + AdminAuthenticationDefaults.AuthenticationMethodClaimType, + usedSecondFactor + ? AdminAuthenticationDefaults.MultiFactorAuthenticationMethod + : AdminAuthenticationDefaults.PasswordAuthenticationMethod), + }; + + var assignedRoles = (user.Roles ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var effectiveRoles = assignedRoles + .SelectMany(AdminRoles.GetEffectiveRoles) + .Distinct(StringComparer.OrdinalIgnoreCase); + claims.AddRange(effectiveRoles.Select(role => new Claim(ClaimTypes.Role, role))); + + return claims; + } + + private async Task GetFailedResultAsync(AdminUser user) + { + return await this._userManager.IsLockedOutAsync(user).ConfigureAwait(false) + ? new AdminLoginResult(AdminLoginStatus.LockedOut) + : new AdminLoginResult(AdminLoginStatus.Failed); + } + + private async Task CompleteLoginAsync(AdminUser user, bool usedSecondFactor, bool isPersistent) + { + await this._userManager.ResetAccessFailedCountAsync(user).ConfigureAwait(false); + user.LastLoginAt = DateTime.UtcNow; + await this._userManager.UpdateAsync(user).ConfigureAwait(false); + + this._logger.LogInformation( + "Admin panel user '{LoginName}' logged in (second factor: {UsedSecondFactor}).", + user.LoginName, + usedSecondFactor); + + var claims = CreateClaims(user, usedSecondFactor); + var ticket = this._ticketService.Issue(claims, isPersistent); + return new AdminLoginResult(AdminLoginStatus.Succeeded, ticket, claims); + } + + /// + /// Makes sure that an observed authenticator code can't be used a second time within its validation window. + /// + /// + /// The token provider of ASP.NET Core Identity accepts a code of the current and of the adjacent + /// time steps, but it doesn't tell which step matched and it doesn't remember used codes. + /// Remembering the time step of the last successful validation at least prevents that the same + /// code is accepted twice within the same time step. + /// + private async Task TryConsumeTimeStepAsync(AdminUser user) + { + var currentStep = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TotpTimeStepSeconds; + if (currentStep <= user.LastAcceptedTotpStep) + { + this._logger.LogWarning( + "Rejected an authenticator code of admin panel user '{LoginName}', because a code of the same time step was already used.", + user.LoginName); + return false; + } + + user.LastAcceptedTotpStep = currentStep; + await this._userManager.UpdateAsync(user).ConfigureAwait(false); + return true; + } +} diff --git a/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs new file mode 100644 index 0000000000..c2cf9058ed --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs @@ -0,0 +1,189 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.IO; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Extensions which add the authentication of the admin panel. +/// +public static class AdminPanelAuthExtensions +{ + /// + /// The environment variable which defines the login name of the bootstrap user. + /// + public const string BootstrapUserVariableName = "OPENMU_ADMIN_USER"; + + /// + /// The environment variable which defines the password of the bootstrap user. + /// + public const string BootstrapPasswordVariableName = "OPENMU_ADMIN_PASSWORD"; + + /// + /// The environment variable which defines the base32 authenticator key of the bootstrap user. + /// + public const string BootstrapAuthenticatorKeyVariableName = "OPENMU_ADMIN_TOTP_SECRET"; + + /// + /// Adds the authentication of the admin panel to the service collection. + /// + /// The service collection. + /// The configuration. + /// The same instance, to allow chaining of further calls. + public static IServiceCollection AddAdminPanelAuth(this IServiceCollection services, IConfiguration configuration) + { + var authOptions = new AdminPanelAuthOptions(); + configuration.GetSection(AdminPanelAuthOptions.SectionName).Bind(authOptions); + ApplyEnvironmentVariables(authOptions); + services.Configure(options => + { + options.RequireTwoFactor = authOptions.RequireTwoFactor; + options.SessionTimeout = authOptions.SessionTimeout; + options.MaxFailedAccessAttempts = authOptions.MaxFailedAccessAttempts; + options.LockoutDuration = authOptions.LockoutDuration; + options.BootstrapUser = authOptions.BootstrapUser; + }); + + // The key ring protects the authentication cookies and the authenticator keys. It has to be + // persisted, otherwise a restart invalidates all sessions and makes all stored authenticator + // keys unreadable. In docker, the directory should be a mounted volume. + var keyPath = configuration["AdminPanel:Auth:DataProtectionKeyPath"] ?? "data-protection-keys"; + services.AddDataProtection() + .SetApplicationName("MUnique.OpenMU.AdminPanel") + .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), keyPath))); + + // The hosting application registers the real storage; this is just a fallback which lets + // the panel start in its initial setup mode instead of failing to resolve its services. + services.TryAddSingleton(); + + services.AddSingleton(); + services.AddSingleton, BCryptPasswordHasher>(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped, AdminUserStore>(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddIdentityCore(options => + { + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 12; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = authOptions.MaxFailedAccessAttempts; + options.Lockout.DefaultLockoutTimeSpan = authOptions.LockoutDuration; + }) + .AddDefaultTokenProviders(); + + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = AdminAuthenticationDefaults.CookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + + // The panel is usually run behind a reverse proxy which terminates TLS. + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = authOptions.SessionTimeout; + options.SlidingExpiration = true; + options.LoginPath = AdminAuthenticationDefaults.LoginPath; + options.LogoutPath = AdminAuthenticationDefaults.SignOutEndpointPath; + options.AccessDeniedPath = AdminAuthenticationDefaults.AccessDeniedPath; + }); + + services.AddSingleton(); + services.AddAuthorizationBuilder() + .SetDefaultPolicy(new AuthorizationPolicyBuilder().AddRequirements(new AdminAccessRequirement()).Build()) + .AddPolicy(AdminPolicies.Viewer, policy => policy.AddRequirements(new AdminAccessRequirement(AdminRoles.Viewer))) + .AddPolicy(AdminPolicies.Operator, policy => policy.AddRequirements(new AdminAccessRequirement(AdminRoles.Operator))) + .AddPolicy(AdminPolicies.Administrator, policy => policy.AddRequirements(new AdminAccessRequirement(AdminRoles.Administrator))); + services.AddCascadingAuthenticationState(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Adds the authentication middlewares to the request pipeline. + /// + /// The application builder. + /// The same instance, to allow chaining of further calls. + public static IApplicationBuilder UseAdminPanelAuth(this IApplicationBuilder app) + { + app.UseAuthentication(); + app.UseAuthorization(); + return app; + } + + /// + /// Requires the default authorization policy for all requests below the specified path. + /// + /// The application builder. + /// The path, e.g. /logs. + /// The same instance, to allow chaining of further calls. + /// + /// Static files are served by a middleware and not by an endpoint, so they are not covered by + /// the authorization of the endpoint routing. The log files must not be readable by anyone. + /// + public static IApplicationBuilder UseAuthorizedPath(this IApplicationBuilder app, string path) + { + return app.Use(async (context, next) => + { + if (!context.Request.Path.StartsWithSegments(path, StringComparison.OrdinalIgnoreCase)) + { + await next(context).ConfigureAwait(false); + return; + } + + var policyProvider = context.RequestServices.GetRequiredService(); + var authorizationService = context.RequestServices.GetRequiredService(); + var policy = await policyProvider.GetDefaultPolicyAsync().ConfigureAwait(false); + var result = await authorizationService.AuthorizeAsync(context.User, null, policy).ConfigureAwait(false); + if (!result.Succeeded) + { + await context.ChallengeAsync().ConfigureAwait(false); + return; + } + + await next(context).ConfigureAwait(false); + }); + } + + private static void ApplyEnvironmentVariables(AdminPanelAuthOptions options) + { + var loginName = Environment.GetEnvironmentVariable(BootstrapUserVariableName); + var password = Environment.GetEnvironmentVariable(BootstrapPasswordVariableName); + if (string.IsNullOrWhiteSpace(loginName) || string.IsNullOrWhiteSpace(password)) + { + return; + } + + options.BootstrapUser = new BootstrapAdminUserOptions + { + LoginName = loginName, + Password = password, + AuthenticatorKey = Environment.GetEnvironmentVariable(BootstrapAuthenticatorKeyVariableName), + }; + } +} diff --git a/src/Web/AdminPanel/Auth/AdminPanelAuthOptions.cs b/src/Web/AdminPanel/Auth/AdminPanelAuthOptions.cs new file mode 100644 index 0000000000..c60a1e4cac --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminPanelAuthOptions.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// The configuration of the admin panel authentication. +/// +public class AdminPanelAuthOptions +{ + /// + /// The name of the configuration section. + /// + public const string SectionName = "AdminPanel:Auth"; + + /// + /// Gets or sets a value indicating whether all users must set up a second factor before they can use the panel. + /// + public bool RequireTwoFactor { get; set; } + + /// + /// Gets or sets the time after which an inactive session expires. + /// + public TimeSpan SessionTimeout { get; set; } = TimeSpan.FromHours(8); + + /// + /// Gets or sets the number of failed login attempts after which a user is locked out. + /// + public int MaxFailedAccessAttempts { get; set; } = 5; + + /// + /// Gets or sets the duration of a lockout. + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the bootstrap user which is available without a database. + /// + /// + /// The admin panel is the tool which creates the game database, so on a fresh installation + /// there is no place to store a user yet. Configuring a bootstrap user closes the window in + /// which the panel would be reachable without any authentication. It's also the way to get + /// back in when the last stored user lost its second factor. + /// + public BootstrapAdminUserOptions? BootstrapUser { get; set; } +} + +/// +/// The configuration of the bootstrap user of the admin panel. +/// +public class BootstrapAdminUserOptions +{ + /// + /// Gets or sets the login name. + /// + public string LoginName { get; set; } = string.Empty; + + /// + /// Gets or sets the password, in plain text. + /// + public string Password { get; set; } = string.Empty; + + /// + /// Gets or sets the base32 encoded TOTP secret of this user, if it should require a second factor. + /// + public string? AuthenticatorKey { get; set; } +} diff --git a/src/Web/AdminPanel/Auth/AdminPolicies.cs b/src/Web/AdminPanel/Auth/AdminPolicies.cs new file mode 100644 index 0000000000..8e6a2f4833 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminPolicies.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// The authorization policies of the admin panel. +/// +public static class AdminPolicies +{ + /// + /// The policy which requires the viewer role. + /// + public const string Viewer = "OpenMU.Viewer"; + + /// + /// The policy which requires the operator role. + /// + public const string Operator = "OpenMU.Operator"; + + /// + /// The policy which requires the administrator role. + /// + public const string Administrator = "OpenMU.Administrator"; +} diff --git a/src/Web/AdminPanel/Auth/AdminUserAvailabilityService.cs b/src/Web/AdminPanel/Auth/AdminUserAvailabilityService.cs new file mode 100644 index 0000000000..873be77be8 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminUserAvailabilityService.cs @@ -0,0 +1,88 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Threading; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Keeps track of whether the admin panel has any user at all. +/// +/// +/// On a fresh installation there is neither a database nor a user, and the admin panel is the tool +/// which creates both. Until the first user exists, the panel has to stay reachable - it then runs +/// in an unprotected initial setup mode and says so. Configuring a bootstrap user avoids that state. +/// +public class AdminUserAvailabilityService +{ + private readonly IAdminUserRepository _repository; + private readonly BootstrapAdminUserProvider _bootstrapUserProvider; + private readonly SemaphoreSlim _semaphore = new(1, 1); + + private DateTime _nextCheck = DateTime.MinValue; + private bool _anyUserExists; + + /// + /// Initializes a new instance of the class. + /// + /// The repository of the stored users. + /// The provider of the bootstrap user. + public AdminUserAvailabilityService(IAdminUserRepository repository, BootstrapAdminUserProvider bootstrapUserProvider) + { + this._repository = repository; + this._bootstrapUserProvider = bootstrapUserProvider; + } + + /// + /// Determines whether at least one user exists which could log in. + /// + /// The cancellation token. + /// true, if at least one user exists; otherwise, false. + public async ValueTask AnyUserExistsAsync(CancellationToken cancellationToken = default) + { + if (this._bootstrapUserProvider.User is not null) + { + return true; + } + + if (this._anyUserExists) + { + return true; + } + + if (DateTime.UtcNow < this._nextCheck) + { + return false; + } + + await this._semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._anyUserExists || DateTime.UtcNow < this._nextCheck) + { + return this._anyUserExists; + } + + this._anyUserExists = await this._repository.GetCountAsync(cancellationToken).ConfigureAwait(false) > 0; + + // The database might not be reachable yet, so don't hammer it on every render. + this._nextCheck = DateTime.UtcNow.AddSeconds(5); + return this._anyUserExists; + } + finally + { + this._semaphore.Release(); + } + } + + /// + /// Invalidates the cached result, e.g. after a user has been created or deleted. + /// + public void Invalidate() + { + this._anyUserExists = false; + this._nextCheck = DateTime.MinValue; + } +} diff --git a/src/Web/AdminPanel/Auth/AdminUserSecretProtector.cs b/src/Web/AdminPanel/Auth/AdminUserSecretProtector.cs new file mode 100644 index 0000000000..dbc358f155 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminUserSecretProtector.cs @@ -0,0 +1,65 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +/// +/// Protects the secrets of an admin user, so they are not readable in a database dump. +/// +/// +/// The authenticator key is password equivalent - whoever knows it can generate valid codes. +/// Note that the data protection key ring must be persisted, otherwise protected values +/// become unreadable after a restart and the affected users have to set their second factor up again. +/// +public class AdminUserSecretProtector +{ + private readonly IDataProtector _protector; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The data protection provider. + /// The logger. + public AdminUserSecretProtector(IDataProtectionProvider dataProtectionProvider, ILogger logger) + { + this._protector = dataProtectionProvider.CreateProtector("MUnique.OpenMU.AdminPanel.AdminUserSecrets.v1"); + this._logger = logger; + } + + /// + /// Protects the specified plain text value. + /// + /// The plain text value. + /// The protected value. + public string Protect(string plainText) => this._protector.Protect(plainText); + + /// + /// Unprotects the specified protected value. + /// + /// The protected value. + /// The plain text value; null, if it could not be unprotected. + public string? Unprotect(string? protectedValue) + { + if (string.IsNullOrEmpty(protectedValue)) + { + return null; + } + + try + { + return this._protector.Unprotect(protectedValue); + } + catch (Exception ex) + { + this._logger.LogWarning( + ex, + "A protected admin user secret could not be read. This usually means that the data protection key ring changed - the affected user has to set up its authenticator again."); + return null; + } + } +} diff --git a/src/Web/AdminPanel/Auth/AdminUserStore.cs b/src/Web/AdminPanel/Auth/AdminUserStore.cs new file mode 100644 index 0000000000..138055c1a6 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AdminUserStore.cs @@ -0,0 +1,327 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Security.Cryptography; +using System.Threading; +using Microsoft.AspNetCore.Identity; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// An ASP.NET Core Identity user store which is backed by the +/// and by the optionally configured bootstrap user. +/// +/// +/// Only the parts of Identity which are actually needed are implemented, so the whole user +/// management fits into one table instead of the eight tables of the Identity EF store. +/// +public class AdminUserStore : + IUserStore, + IUserPasswordStore, + IUserSecurityStampStore, + IUserTwoFactorStore, + IUserAuthenticatorKeyStore, + IUserTwoFactorRecoveryCodeStore, + IUserLockoutStore, + IUserRoleStore +{ + private const char RecoveryCodeSeparator = ';'; + + private readonly IAdminUserRepository _repository; + private readonly BootstrapAdminUserProvider _bootstrapUserProvider; + private readonly AdminUserSecretProtector _secretProtector; + + /// + /// Initializes a new instance of the class. + /// + /// The repository of the stored users. + /// The provider of the bootstrap user. + /// The protector of the user secrets. + public AdminUserStore( + IAdminUserRepository repository, + BootstrapAdminUserProvider bootstrapUserProvider, + AdminUserSecretProtector secretProtector) + { + this._repository = repository; + this._bootstrapUserProvider = bootstrapUserProvider; + this._secretProtector = secretProtector; + } + + /// + public Task GetUserIdAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.Id.ToString()); + + /// + public Task GetUserNameAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.LoginName); + + /// + public Task SetUserNameAsync(AdminUser user, string? userName, CancellationToken cancellationToken) + { + user.LoginName = userName ?? string.Empty; + return Task.CompletedTask; + } + + /// + public Task GetNormalizedUserNameAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.NormalizedLoginName); + + /// + public Task SetNormalizedUserNameAsync(AdminUser user, string? normalizedName, CancellationToken cancellationToken) + { + user.NormalizedLoginName = normalizedName ?? string.Empty; + return Task.CompletedTask; + } + + /// + public async Task CreateAsync(AdminUser user, CancellationToken cancellationToken) + { + if (user.Id == Guid.Empty) + { + user.Id = Guid.NewGuid(); + } + + await this._repository.AddAsync(user, cancellationToken).ConfigureAwait(false); + return IdentityResult.Success; + } + + /// + public async Task UpdateAsync(AdminUser user, CancellationToken cancellationToken) + { + if (BootstrapAdminUserProvider.IsBootstrapUser(user)) + { + // The bootstrap user only exists in the configuration - its state is kept in memory. + return IdentityResult.Success; + } + + await this._repository.UpdateAsync(user, cancellationToken).ConfigureAwait(false); + return IdentityResult.Success; + } + + /// + public async Task DeleteAsync(AdminUser user, CancellationToken cancellationToken) + { + if (BootstrapAdminUserProvider.IsBootstrapUser(user)) + { + return IdentityResult.Failed(new IdentityError + { + Code = "BootstrapUserNotDeletable", + Description = "The bootstrap user is defined by the configuration and can't be deleted here.", + }); + } + + await this._repository.DeleteAsync(user, cancellationToken).ConfigureAwait(false); + return IdentityResult.Success; + } + + /// + public async Task FindByIdAsync(string userId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(userId, out var id)) + { + return null; + } + + if (this._bootstrapUserProvider.User is { } bootstrapUser && bootstrapUser.Id == id) + { + return bootstrapUser; + } + + return await this._repository.GetByIdAsync(id, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) + { + if (this._bootstrapUserProvider.User is { } bootstrapUser + && string.Equals(bootstrapUser.NormalizedLoginName, normalizedUserName, StringComparison.Ordinal)) + { + return bootstrapUser; + } + + return await this._repository.GetByNormalizedLoginNameAsync(normalizedUserName, cancellationToken).ConfigureAwait(false); + } + + /// + public Task SetPasswordHashAsync(AdminUser user, string? passwordHash, CancellationToken cancellationToken) + { + user.PasswordHash = passwordHash ?? string.Empty; + return Task.CompletedTask; + } + + /// + public Task GetPasswordHashAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.PasswordHash); + + /// + public Task HasPasswordAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash)); + + /// + public Task SetSecurityStampAsync(AdminUser user, string stamp, CancellationToken cancellationToken) + { + user.SecurityStamp = stamp; + return Task.CompletedTask; + } + + /// + public Task GetSecurityStampAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.SecurityStamp); + + /// + public Task SetTwoFactorEnabledAsync(AdminUser user, bool enabled, CancellationToken cancellationToken) + { + user.IsTwoFactorEnabled = enabled; + return Task.CompletedTask; + } + + /// + public Task GetTwoFactorEnabledAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.IsTwoFactorEnabled); + + /// + public Task SetAuthenticatorKeyAsync(AdminUser user, string key, CancellationToken cancellationToken) + { + user.ProtectedAuthenticatorKey = this._secretProtector.Protect(key); + return Task.CompletedTask; + } + + /// + public Task GetAuthenticatorKeyAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(this._secretProtector.Unprotect(user.ProtectedAuthenticatorKey)); + + /// + public Task ReplaceCodesAsync(AdminUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) + { + var hashes = recoveryCodes.Select(HashRecoveryCode); + user.RecoveryCodeHashes = string.Join(RecoveryCodeSeparator, hashes); + return Task.CompletedTask; + } + + /// + public Task RedeemCodeAsync(AdminUser user, string code, CancellationToken cancellationToken) + { + var hashes = SplitRecoveryCodeHashes(user).ToList(); + var codeHash = HashRecoveryCode(code); + var expected = Encoding.ASCII.GetBytes(codeHash); + var index = hashes.FindIndex(hash => + { + var actual = Encoding.ASCII.GetBytes(hash); + return actual.Length == expected.Length && CryptographicOperations.FixedTimeEquals(actual, expected); + }); + if (index < 0) + { + return Task.FromResult(false); + } + + hashes.RemoveAt(index); + user.RecoveryCodeHashes = string.Join(RecoveryCodeSeparator, hashes); + return Task.FromResult(true); + } + + /// + public Task CountCodesAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(SplitRecoveryCodeHashes(user).Count()); + + /// + public Task GetLockoutEndDateAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.LockoutEnd); + + /// + public Task SetLockoutEndDateAsync(AdminUser user, DateTimeOffset? lockoutEnd, CancellationToken cancellationToken) + { + user.LockoutEnd = lockoutEnd; + return Task.CompletedTask; + } + + /// + public Task IncrementAccessFailedCountAsync(AdminUser user, CancellationToken cancellationToken) + { + user.AccessFailedCount++; + return Task.FromResult(user.AccessFailedCount); + } + + /// + public Task ResetAccessFailedCountAsync(AdminUser user, CancellationToken cancellationToken) + { + user.AccessFailedCount = 0; + return Task.CompletedTask; + } + + /// + public Task GetAccessFailedCountAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(user.AccessFailedCount); + + /// + public Task GetLockoutEnabledAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult(true); + + /// + public Task SetLockoutEnabledAsync(AdminUser user, bool enabled, CancellationToken cancellationToken) + { + // Lockout is always enabled for admin panel users. + return Task.CompletedTask; + } + + /// + public Task AddToRoleAsync(AdminUser user, string roleName, CancellationToken cancellationToken) + { + var roles = SplitRoles(user).ToList(); + if (!roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) + { + roles.Add(roleName); + user.Roles = string.Join(',', roles); + } + + return Task.CompletedTask; + } + + /// + public Task RemoveFromRoleAsync(AdminUser user, string roleName, CancellationToken cancellationToken) + { + var roles = SplitRoles(user).Where(r => !string.Equals(r, roleName, StringComparison.OrdinalIgnoreCase)); + user.Roles = string.Join(',', roles); + return Task.CompletedTask; + } + + /// + public Task> GetRolesAsync(AdminUser user, CancellationToken cancellationToken) + => Task.FromResult>(SplitRoles(user).ToList()); + + /// + public Task IsInRoleAsync(AdminUser user, string roleName, CancellationToken cancellationToken) + => Task.FromResult(SplitRoles(user).Contains(roleName, StringComparer.OrdinalIgnoreCase)); + + /// + public async Task> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken) + { + var users = await this._repository.GetAllAsync(cancellationToken).ConfigureAwait(false); + if (this._bootstrapUserProvider.User is { } bootstrapUser) + { + users.Add(bootstrapUser); + } + + return users.Where(u => SplitRoles(u).Contains(roleName, StringComparer.OrdinalIgnoreCase)).ToList(); + } + + /// + public void Dispose() + { + // Nothing to dispose - the repository is managed by the dependency injection container. + GC.SuppressFinalize(this); + } + + private static string HashRecoveryCode(string code) + { + var normalized = code.Replace("-", string.Empty).Replace(" ", string.Empty).ToUpperInvariant(); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))); + } + + private static IEnumerable SplitRecoveryCodeHashes(AdminUser user) + => (user.RecoveryCodeHashes ?? string.Empty).Split(RecoveryCodeSeparator, StringSplitOptions.RemoveEmptyEntries); + + private static IEnumerable SplitRoles(AdminUser user) + => (user.Roles ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/Web/AdminPanel/Auth/AuthEndpoints.cs b/src/Web/AdminPanel/Auth/AuthEndpoints.cs new file mode 100644 index 0000000000..8cfd11b071 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AuthEndpoints.cs @@ -0,0 +1,78 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +/// +/// The http endpoints which set and remove the authentication cookie. +/// +/// +/// A cookie can only be set on a http response, which an interactive blazor component doesn't have. +/// These endpoints are therefore called by the browser in the background, with a single use ticket +/// the circuit issued after it validated the credentials. +/// +public static class AuthEndpoints +{ + /// + /// Maps the endpoints which set and remove the authentication cookie. + /// + /// The endpoint route builder. + /// The endpoint route builder. + public static IEndpointRouteBuilder MapAdminPanelAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost( + AdminAuthenticationDefaults.SignInEndpointPath, + async (SignInRequest request, HttpContext httpContext, SignInTicketService ticketService) => + { + if (!ticketService.TryRedeem(request.Ticket, out var ticket) || ticket is null) + { + return Results.Unauthorized(); + } + + var identity = new ClaimsIdentity( + ticket.Claims, + CookieAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + ClaimTypes.Role); + var properties = new AuthenticationProperties + { + IsPersistent = ticket.IsPersistent, + }; + + await httpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + properties) + .ConfigureAwait(false); + return Results.NoContent(); + }) + .AllowAnonymous() + .DisableAntiforgery(); + + endpoints.MapPost( + AdminAuthenticationDefaults.SignOutEndpointPath, + async (HttpContext httpContext) => + { + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false); + return Results.NoContent(); + }) + .AllowAnonymous() + .DisableAntiforgery(); + + return endpoints; + } + + /// + /// The request body of the sign in endpoint. + /// + /// The single use ticket which was issued by the circuit. + public record SignInRequest(string Ticket); +} diff --git a/src/Web/AdminPanel/Auth/AuthenticatorSetupService.cs b/src/Web/AdminPanel/Auth/AuthenticatorSetupService.cs new file mode 100644 index 0000000000..f6dfc60b73 --- /dev/null +++ b/src/Web/AdminPanel/Auth/AuthenticatorSetupService.cs @@ -0,0 +1,152 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using Microsoft.AspNetCore.Identity; +using MUnique.OpenMU.Persistence.AdminAuth; +using QRCoder; + +/// +/// The data which is needed to set an authenticator app up. +/// +/// The shared key, formatted in groups of four characters for manual entry. +/// The otpauth uri which is encoded in the QR code. +/// The QR code as inline SVG. +public record AuthenticatorSetup(string SharedKey, string AuthenticatorUri, string QrCodeSvg); + +/// +/// Sets the time based one time password (TOTP) second factor of an admin panel user up. +/// +/// +/// The parameters are deliberately kept at the defaults of SHA-1, 6 digits and a period of +/// 30 seconds. The Microsoft Authenticator app ignores deviating values in the otpauth uri and +/// calculates the default anyway, so a "stronger" configuration would just produce codes which +/// never validate. +/// +public class AuthenticatorSetupService +{ + private const string Issuer = "OpenMU AdminPanel"; + private const int RecoveryCodeCount = 10; + + private readonly UserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// The user manager. + public AuthenticatorSetupService(UserManager userManager) + { + this._userManager = userManager; + } + + /// + /// Creates a new authenticator key for the specified user and returns the data to set it up. + /// + /// The user. + /// The data which is needed to set the authenticator app up. + /// + /// The second factor is not enabled yet - that only happens after the user proved with + /// that its authenticator app produces valid codes. + /// Otherwise a mistake while scanning would lock the user out of its own panel. + /// + public async Task BeginSetupAsync(AdminUser user) + { + await this._userManager.ResetAuthenticatorKeyAsync(user).ConfigureAwait(false); + var key = await this._userManager.GetAuthenticatorKeyAsync(user).ConfigureAwait(false) + ?? throw new InvalidOperationException("The authenticator key could not be created."); + + var uri = CreateAuthenticatorUri(user.LoginName, key); + return new AuthenticatorSetup(FormatKey(key), uri, CreateQrCodeSvg(uri)); + } + + /// + /// Verifies the specified code and enables the second factor if it's correct. + /// + /// The user. + /// The code of the authenticator app. + /// The generated recovery codes, if the code was correct; otherwise, null. + public async Task?> ConfirmSetupAsync(AdminUser user, string code) + { + var normalizedCode = code.Replace(" ", string.Empty).Replace("-", string.Empty); + var isValid = await this._userManager + .VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, normalizedCode) + .ConfigureAwait(false); + if (!isValid) + { + return null; + } + + await this._userManager.SetTwoFactorEnabledAsync(user, true).ConfigureAwait(false); + var recoveryCodes = await this._userManager + .GenerateNewTwoFactorRecoveryCodesAsync(user, RecoveryCodeCount) + .ConfigureAwait(false); + await this._userManager.UpdateSecurityStampAsync(user).ConfigureAwait(false); + + return recoveryCodes?.ToList() ?? new List(); + } + + /// + /// Disables the second factor of the specified user and removes its authenticator key. + /// + /// The user. + public async Task DisableAsync(AdminUser user) + { + await this._userManager.SetTwoFactorEnabledAsync(user, false).ConfigureAwait(false); + user.ProtectedAuthenticatorKey = null; + user.RecoveryCodeHashes = null; + user.LastAcceptedTotpStep = 0; + await this._userManager.UpdateSecurityStampAsync(user).ConfigureAwait(false); + } + + /// + /// Generates a new set of recovery codes for the specified user. + /// + /// The user. + /// The new recovery codes. + public async Task> GenerateRecoveryCodesAsync(AdminUser user) + { + var codes = await this._userManager + .GenerateNewTwoFactorRecoveryCodesAsync(user, RecoveryCodeCount) + .ConfigureAwait(false); + return codes?.ToList() ?? new List(); + } + + /// + /// Gets the number of recovery codes which are still available. + /// + /// The user. + /// The number of recovery codes which are still available. + public Task GetRemainingRecoveryCodeCountAsync(AdminUser user) + => this._userManager.CountRecoveryCodesAsync(user); + + private static string CreateAuthenticatorUri(string loginName, string key) + { + var escapedIssuer = Uri.EscapeDataString(Issuer); + var escapedLogin = Uri.EscapeDataString(loginName); + + // The issuer has to appear in the label as well as in the query, because the authenticator + // apps use it to group and to name the entry. + return $"otpauth://totp/{escapedIssuer}:{escapedLogin}?secret={key}&issuer={escapedIssuer}&algorithm=SHA1&digits=6&period=30"; + } + + private static string CreateQrCodeSvg(string uri) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.Q); + var svgQrCode = new SvgQRCode(data); + return svgQrCode.GetGraphic(4, "#000000", "#ffffff", drawQuietZones: true); + } + + private static string FormatKey(string key) + { + var result = new StringBuilder(); + for (var i = 0; i < key.Length; i += 4) + { + result.Append(key.AsSpan(i, Math.Min(4, key.Length - i))).Append(' '); + } + + return result.ToString().Trim(); + } +} diff --git a/src/Web/AdminPanel/Auth/BCryptPasswordHasher.cs b/src/Web/AdminPanel/Auth/BCryptPasswordHasher.cs new file mode 100644 index 0000000000..91187b4e7f --- /dev/null +++ b/src/Web/AdminPanel/Auth/BCryptPasswordHasher.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using Microsoft.AspNetCore.Identity; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// An which uses BCrypt, like the rest of this project does. +/// +public class BCryptPasswordHasher : IPasswordHasher +{ + /// + public string HashPassword(AdminUser user, string password) + { + return BCrypt.Net.BCrypt.HashPassword(password); + } + + /// + public PasswordVerificationResult VerifyHashedPassword(AdminUser user, string hashedPassword, string providedPassword) + { + if (string.IsNullOrEmpty(hashedPassword)) + { + return PasswordVerificationResult.Failed; + } + + try + { + return BCrypt.Net.BCrypt.Verify(providedPassword, hashedPassword) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; + } + catch (BCrypt.Net.SaltParseException) + { + return PasswordVerificationResult.Failed; + } + } +} diff --git a/src/Web/AdminPanel/Auth/BootstrapAdminUserProvider.cs b/src/Web/AdminPanel/Auth/BootstrapAdminUserProvider.cs new file mode 100644 index 0000000000..1c4cad87a9 --- /dev/null +++ b/src/Web/AdminPanel/Auth/BootstrapAdminUserProvider.cs @@ -0,0 +1,85 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Provides the configured bootstrap user, which exists without a database. +/// +/// +/// Changes to this user (lockout counters, recovery codes, a newly set up authenticator) +/// are only kept in memory and are lost when the process restarts, because there is no +/// storage for them by definition. It's meant to create the first real user and to get +/// back in when that's not possible anymore. +/// +public class BootstrapAdminUserProvider +{ + /// + /// The identifier of the bootstrap user. It's fixed, so it can be recognized in the store. + /// + public static readonly Guid BootstrapUserId = new("00000000-0000-0000-0000-00000000B007"); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The authentication options. + /// The password hasher. + /// The protector for the authenticator key. + /// The logger. + public BootstrapAdminUserProvider( + IOptions options, + Microsoft.AspNetCore.Identity.IPasswordHasher passwordHasher, + AdminUserSecretProtector secretProtector, + ILogger logger) + { + this._logger = logger; + var configured = options.Value.BootstrapUser; + if (configured is null + || string.IsNullOrWhiteSpace(configured.LoginName) + || string.IsNullOrWhiteSpace(configured.Password)) + { + return; + } + + this.User = new AdminUser + { + Id = BootstrapUserId, + LoginName = configured.LoginName, + NormalizedLoginName = configured.LoginName.ToUpperInvariant(), + Roles = AdminRoles.Administrator, + SecurityStamp = Guid.NewGuid().ToString("N"), + CreatedAt = DateTime.UtcNow, + }; + + this.User.PasswordHash = passwordHasher.HashPassword(this.User, configured.Password); + if (!string.IsNullOrWhiteSpace(configured.AuthenticatorKey)) + { + this.User.ProtectedAuthenticatorKey = secretProtector.Protect(configured.AuthenticatorKey.Replace(" ", string.Empty).ToUpperInvariant()); + this.User.IsTwoFactorEnabled = true; + } + + this._logger.LogInformation( + "A bootstrap admin panel user '{LoginName}' is configured. Two factor authentication is {State}.", + this.User.LoginName, + this.User.IsTwoFactorEnabled ? "enabled" : "disabled"); + } + + /// + /// Gets the bootstrap user, if one is configured. + /// + public AdminUser? User { get; } + + /// + /// Determines whether the specified user is the bootstrap user. + /// + /// The user. + /// true, if the specified user is the bootstrap user; otherwise, false. + public static bool IsBootstrapUser(AdminUser user) => user.Id == BootstrapUserId; +} diff --git a/src/Web/AdminPanel/Auth/CurrentAdminUserService.cs b/src/Web/AdminPanel/Auth/CurrentAdminUserService.cs new file mode 100644 index 0000000000..b3d82f95d5 --- /dev/null +++ b/src/Web/AdminPanel/Auth/CurrentAdminUserService.cs @@ -0,0 +1,46 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Resolves the which belongs to the currently authenticated principal. +/// +public class CurrentAdminUserService +{ + private readonly AuthenticationStateProvider _authenticationStateProvider; + private readonly UserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// The authentication state provider. + /// The user manager. + public CurrentAdminUserService(AuthenticationStateProvider authenticationStateProvider, UserManager userManager) + { + this._authenticationStateProvider = authenticationStateProvider; + this._userManager = userManager; + } + + /// + /// Gets the currently authenticated user. + /// + /// The currently authenticated user; null, if nobody is authenticated. + public async Task GetCurrentUserAsync() + { + var state = await this._authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false); + if (state.User.Identity?.IsAuthenticated is not true) + { + return null; + } + + var userId = state.User.FindFirstValue(ClaimTypes.NameIdentifier); + return userId is null ? null : await this._userManager.FindByIdAsync(userId).ConfigureAwait(false); + } +} diff --git a/src/Web/AdminPanel/Auth/SignInTicketService.cs b/src/Web/AdminPanel/Auth/SignInTicketService.cs new file mode 100644 index 0000000000..fa29dab478 --- /dev/null +++ b/src/Web/AdminPanel/Auth/SignInTicketService.cs @@ -0,0 +1,80 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Security.Cryptography; + +/// +/// Hands out short living, single use tickets which the sign in endpoint exchanges for an authentication cookie. +/// +/// +/// An interactive Blazor component can't set a cookie, because the response of the request which +/// started the circuit has been sent long ago. The component therefore validates the credentials, +/// gets a ticket from here and posts it to , +/// which is a normal http request and can set the cookie. Since that request is done in the +/// background, the user stays on the same page - no reload, no lost state. +/// +public class SignInTicketService +{ + private static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(2); + + private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); + + /// + /// Issues a new ticket for the specified claims. + /// + /// The claims of the authenticated user. + /// If set to true, the resulting cookie survives a browser restart. + /// The ticket value, which has to be posted to the sign in endpoint. + public string Issue(IEnumerable claims, bool isPersistent) + { + this.RemoveExpiredTickets(); + var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + this._tickets[value] = new Ticket(claims.ToList(), isPersistent, DateTime.UtcNow + TicketLifetime); + return value; + } + + /// + /// Redeems the ticket with the specified value. Each ticket can only be redeemed once. + /// + /// The ticket value. + /// The redeemed ticket. + /// true, if the ticket was valid and could be redeemed; otherwise, false. + public bool TryRedeem(string? value, out Ticket? ticket) + { + ticket = null; + if (string.IsNullOrEmpty(value) || !this._tickets.TryRemove(value, out var found)) + { + return false; + } + + if (found.ExpiresAt < DateTime.UtcNow) + { + return false; + } + + ticket = found; + return true; + } + + private void RemoveExpiredTickets() + { + var now = DateTime.UtcNow; + foreach (var expired in this._tickets.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList()) + { + this._tickets.TryRemove(expired, out _); + } + } + + /// + /// A ticket which can be exchanged for an authentication cookie. + /// + /// The claims of the authenticated user. + /// A value indicating whether the resulting cookie survives a browser restart. + /// The point in time at which this ticket expires. + public record Ticket(IReadOnlyList Claims, bool IsPersistent, DateTime ExpiresAt); +} diff --git a/src/Web/AdminPanel/Auth/UnavailableAdminUserRepository.cs b/src/Web/AdminPanel/Auth/UnavailableAdminUserRepository.cs new file mode 100644 index 0000000000..437ec64ee1 --- /dev/null +++ b/src/Web/AdminPanel/Auth/UnavailableAdminUserRepository.cs @@ -0,0 +1,62 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Threading; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// A fallback which is used when the hosting application didn't +/// register a real one, for example when the admin panel is started without a persistence provider. +/// +/// +/// It behaves like an empty storage, so the panel starts in its initial setup mode instead of +/// failing to resolve its services. Only the configured bootstrap user can log in then. +/// +public class UnavailableAdminUserRepository : IAdminUserRepository +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public UnavailableAdminUserRepository(ILogger logger) + { + logger.LogWarning( + "No storage for admin panel users is registered, so no user can be created or stored. " + + "Call {MethodName} in the hosting application to enable it.", + "AddAdminUserRepository"); + } + + /// + public ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(false); + + /// + public ValueTask GetCountAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(0); + + /// + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult>(new List()); + + /// + public ValueTask GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => ValueTask.FromResult(null); + + /// + public ValueTask GetByNormalizedLoginNameAsync(string normalizedLoginName, CancellationToken cancellationToken = default) + => ValueTask.FromResult(null); + + /// + public ValueTask AddAsync(AdminUser user, CancellationToken cancellationToken = default) => throw this.CreateException(); + + /// + public ValueTask UpdateAsync(AdminUser user, CancellationToken cancellationToken = default) => throw this.CreateException(); + + /// + public ValueTask DeleteAsync(AdminUser user, CancellationToken cancellationToken = default) => throw this.CreateException(); + + private InvalidOperationException CreateException() + => new("No storage for admin panel users is registered."); +} diff --git a/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor new file mode 100644 index 0000000000..927c4d3724 --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor @@ -0,0 +1,15 @@ +@using MUnique.OpenMU.Web.AdminPanel.Properties + + + +
+ + + @context.User.Identity?.Name + + +
+
+
diff --git a/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor.cs b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor.cs new file mode 100644 index 0000000000..44be6d54fd --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout; + +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// Shows the currently signed in user and allows to sign out. +/// +public partial class LoginDisplay : IAsyncDisposable +{ + private IJSObjectReference? _authModule; + + [Inject] + private AdminAuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + /// + public async ValueTask DisposeAsync() + { + if (this._authModule is { } module) + { + this._authModule = null; + try + { + await module.DisposeAsync().ConfigureAwait(false); + } + catch (JSDisconnectedException) + { + // The circuit is already gone. + } + } + + GC.SuppressFinalize(this); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender).ConfigureAwait(true); + if (firstRender) + { + this._authModule = await this.JsRuntime + .InvokeAsync("import", AdminAuthenticationDefaults.AuthScriptPath) + .ConfigureAwait(true); + } + } + + private async Task LogoutAsync() + { + if (this._authModule is { } module) + { + await module.InvokeVoidAsync("signOut").ConfigureAwait(true); + } + + this.AuthenticationStateProvider.NotifySignedOut(); + this.NavigationManager.NavigateTo(AdminAuthenticationDefaults.LoginPath.TrimStart('/')); + } +} diff --git a/src/Web/AdminPanel/Components/Layout/LoginLayout.razor b/src/Web/AdminPanel/Components/Layout/LoginLayout.razor new file mode 100644 index 0000000000..a8bce6c867 --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginLayout.razor @@ -0,0 +1,17 @@ +@inherits LayoutComponentBase +@using MUnique.OpenMU.Web.AdminPanel.Properties + + + + + +
+ @Resources.UnhandledErrorOccurred + @Resources.Reload + 🗙 +
diff --git a/src/Web/AdminPanel/Components/Layout/LoginLayout.razor.css b/src/Web/AdminPanel/Components/Layout/LoginLayout.razor.css new file mode 100644 index 0000000000..fe473978e0 --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginLayout.razor.css @@ -0,0 +1,3 @@ +.login-panel { + max-width: 26rem; +} diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor index ff9f4f43e4..e9d224e9cd 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor @@ -1,16 +1,58 @@ -@using MUnique.OpenMU.Web.AdminPanel.Properties +@using MUnique.OpenMU.Web.AdminPanel.Properties @using MUnique.OpenMU.Web.Shared.Services +@using Microsoft.Extensions.Options @inherits LayoutComponentBase +@inject IOptions AuthOptions +@inject NavigationManager NavigationManager @code { [CascadingParameter] public HttpContext? HttpContext { get; set; } + [CascadingParameter] + private Task? AuthenticationStateTask { get; set; } + + private bool _isTwoFactorSetupPending; + private bool IsDarkTheme => string.Equals( this.HttpContext?.Request.Cookies[ThemeController.CookieName], "dark", StringComparison.OrdinalIgnoreCase); + + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + this._isTwoFactorSetupPending = await this.IsTwoFactorSetupPendingAsync(); + } + + /// + /// Determines whether the signed in user still has to set its second factor up. + /// + /// + /// A cookie is only issued after the second factor was checked, so the missing "mfa" claim + /// means that this user has no second factor at all - and the configuration demands one. + /// + private async Task IsTwoFactorSetupPendingAsync() + { + if (!this.AuthOptions.Value.RequireTwoFactor || this.AuthenticationStateTask is null) + { + return false; + } + + var relativePath = this.NavigationManager.ToBaseRelativePath(this.NavigationManager.Uri); + if (relativePath.StartsWith("account/security", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var state = await this.AuthenticationStateTask; + return state.User.Identity?.IsAuthenticated is true + && !state.User.HasClaim( + AdminAuthenticationDefaults.AuthenticationMethodClaimType, + AdminAuthenticationDefaults.MultiFactorAuthenticationMethod); + } }
@@ -27,6 +69,7 @@
@@ -42,7 +85,18 @@
- @Body + @if (this._isTwoFactorSetupPending) + { + + } + else + { + @Body + }
diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor b/src/Web/AdminPanel/Components/Layout/NavMenu.razor index 11be954f6a..8bbc0a331d 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor @@ -69,14 +69,13 @@ } - @if (UserService.IsAvailable) - { + - } + @if (AdminPanelEnvironment.IsHostingEmbedded) {