diff --git a/deploy/all-in-one-traefik/README.md b/deploy/all-in-one-traefik/README.md index c0e981ae5b..b8096541c6 100644 --- a/deploy/all-in-one-traefik/README.md +++ b/deploy/all-in-one-traefik/README.md @@ -15,7 +15,8 @@ docker network create proxy docker compose -f docker-compose.yml up -d ``` -The admin panel is then available at http://admin.docker.localhost/ with the -user `admin` and the password `openmu` — -[change that](../../docs-website/docs/admin-panel/users.md) before the server is +The admin panel is then available at http://admin.docker.localhost/. Until the +first user exists it lets you in without a login, so +[create one](../../docs-website/docs/admin-panel/users.md) — or set +`OPENMU_ADMIN_USER` and `OPENMU_ADMIN_PASSWORD` beforehand — before the server is reachable from the internet. diff --git a/deploy/all-in-one-traefik/docker-compose-all-in-one-traefik.dcproj b/deploy/all-in-one-traefik/docker-compose-all-in-one-traefik.dcproj index 37f9cbdcfc..74355ec7f3 100644 --- a/deploy/all-in-one-traefik/docker-compose-all-in-one-traefik.dcproj +++ b/deploy/all-in-one-traefik/docker-compose-all-in-one-traefik.dcproj @@ -1,4 +1,4 @@ - + 2.1 @@ -18,7 +18,6 @@ - \ No newline at end of file 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-all-in-one.dcproj b/deploy/all-in-one/docker-compose-all-in-one.dcproj index e2f1e1bf09..8b78ed8e93 100644 --- a/deploy/all-in-one/docker-compose-all-in-one.dcproj +++ b/deploy/all-in-one/docker-compose-all-in-one.dcproj @@ -1,4 +1,4 @@ - + 2.1 @@ -19,7 +19,6 @@ - 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.dcproj b/deploy/distributed/docker-compose.dcproj index 3c16b2f123..29a6dda95d 100644 --- a/deploy/distributed/docker-compose.dcproj +++ b/deploy/distributed/docker-compose.dcproj @@ -1,4 +1,4 @@ - + 2.1 @@ -24,7 +24,6 @@ - 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/docs-website/docs/admin-panel/authentication.md b/docs-website/docs/admin-panel/authentication.md new file mode 100644 index 0000000000..a931c6a2e0 --- /dev/null +++ b/docs-website/docs/admin-panel/authentication.md @@ -0,0 +1,156 @@ +--- +title: Signing in +sidebar_label: Signing in +sidebar_position: 13 +description: How the admin panel login works, and how to set a second factor up. +--- + +# Signing in + +The admin panel authenticates its users itself. Earlier versions relied on the +basic authentication of the reverse proxy against an `.htpasswd` file; that file +is gone, and so are the proxy configurations which used it. + +What you get instead: + +* a real login page with a logout, +* an optional second factor with an authenticator app, +* roles, so not every user can change everything, +* the same protection no matter how you run the server — from source, in docker, + or in the distributed deployment. + +## The login + +Enter your login name and password. If your account has a second factor, the +form asks for the code right away, on the same page — nothing reloads, and you +don't lose what you had open. + +Check *Keep me signed in* to stay signed in after closing the browser. Without +it, the session ends when the browser does. + +A session is only created once **everything** checked out, so an active session +always means the second factor was used when your account has one. + +After five failed attempts the account is locked for five minutes. Wrong +authenticator codes count towards that too. + +## The first user + +On a fresh installation there is no database yet — and the admin panel is the +tool which creates it. Until the first user exists, the panel stays reachable +**without a login** and shows a warning banner. + +:::danger[Close that window] +Anybody who reaches the panel during that time can set your server up and read +all account data afterwards. Either finish the installation and create your +first user immediately, or configure a bootstrap user before the first start — +see below. +::: + +### Bootstrap user + +A bootstrap user is configured outside of the database, so it works from the +very first second — before any installation, and also when you locked yourself +out later. Set these environment variables on the container or process which +hosts the admin panel: + +```bash +OPENMU_ADMIN_USER=admin +OPENMU_ADMIN_PASSWORD= +# optional, if the bootstrap user should require a second factor: +OPENMU_ADMIN_TOTP_SECRET= +``` + +The docker compose files already pass these through, so you can put them in a +`.env` file next to the compose file. + +The same can be configured under `AdminPanel:Auth:BootstrapUser` in the +`appsettings.json`. + +:::note[It is a way in, not an account to work with] +Changes to the bootstrap user — a password change, a newly set up authenticator, +its lockout counter — are only kept in memory and are gone after a restart, +because there is nowhere to store them. Use it to create a real user on the +[Users page](users.md), then work with that one. +::: + +## Two-factor authentication + +Every user can protect its own account with a time based one time password +(TOTP) under *Account security*. It works with the **Microsoft Authenticator** +app and with any other authenticator app — Google Authenticator, Aegis, +Bitwarden, 1Password, and so on. + +### Setting it up + +1. Open *Account security* from the header, next to your user name. +2. Click **Set up authenticator app**. +3. Scan the QR code with your app. If you can't scan it, type the key which is + shown below the code into the app by hand. +4. Enter the six digit code your app shows and click **Verify**. + +The second factor is only switched on after that last step succeeded, so a +mis-scan can't lock you out of your own panel. + +### Recovery codes + +Right after the setup you get ten recovery codes. **They are shown exactly +once.** Store them somewhere safe, outside of the server — a password manager, +or on paper. + +Each code signs you in once when you don't have your authenticator app, through +*Use a recovery code instead* on the login page. When you run low, generate a +new set under *Account security*; the old ones stop working then. + +Only hashes of the codes are stored, so a database dump does not hand out usable +second factors. + +### Lost the authenticator and the recovery codes + +An administrator can reset the second factor of any user on the +[Users page](users.md). If nobody can sign in anymore, use a +[bootstrap user](#bootstrap-user). + +### Requiring it from everybody + +Set `AdminPanel:Auth:RequireTwoFactor` to `true` to require a second factor from +every user. Users who don't have one yet are then asked to set it up before they +can use the panel. + +## Roles + +Each user has one role. They build up on each other: + +| Role | May do | +|---|---| +| **Viewer** | See the servers, accounts and the configuration | +| **Operator** | Everything above, plus operating the servers and editing accounts | +| **Administrator** | Everything above, plus the setup, plugins, configuration updates, log files and the user management | + +Give each administrator their own user, so you can remove one without changing +everybody else's password. + +## Keeping the sessions alive across restarts + +The sessions and the stored authenticator secrets are protected with a key ring +which has to survive a restart. The docker compose files mount the +`adminpanel-keys` volume at `/app/data-protection-keys` for that. + +:::warning[Don't lose that volume] +If the key ring is lost, everybody is signed out **and** every stored +authenticator secret becomes unreadable, so every user has to set its second +factor up again. Recovery codes still work, and so does a bootstrap user. +::: + +The location can be changed with `AdminPanel:Auth:DataProtectionKeyPath`. + +## Still worth doing + +* **Set up HTTPS.** The session cookie travels over whatever the request used — + without TLS it can be read on the way + ([all-in-one](../deployment/all-in-one.md#option-b--with-https), + [Traefik](../deployment/all-in-one-traefik.md#option-b--with-https)). +* Don't expose the admin panel port to the whole internet if you can reach it + through a VPN or an SSH tunnel instead. +* Remember that admin panel access means full access to your players' account + data. diff --git a/docs-website/docs/admin-panel/common-tasks.md b/docs-website/docs/admin-panel/common-tasks.md index 414a12ba35..44b5291985 100644 --- a/docs-website/docs/admin-panel/common-tasks.md +++ b/docs-website/docs/admin-panel/common-tasks.md @@ -1,6 +1,6 @@ --- title: Common tasks -sidebar_position: 14 +sidebar_position: 15 description: Short how-tos for the things server operators do most often. --- diff --git a/docs-website/docs/admin-panel/overview.md b/docs-website/docs/admin-panel/overview.md index 41af2e067e..fa72807d72 100644 --- a/docs-website/docs/admin-panel/overview.md +++ b/docs-website/docs/admin-panel/overview.md @@ -24,16 +24,17 @@ installation. | [All-in-one with Traefik](../deployment/all-in-one-traefik.md) | [http://admin.docker.localhost/](http://admin.docker.localhost/) (locally) | | [Distributed](../deployment/distributed.md) | [http://localhost/admin](http://localhost/admin) | -In the docker deployments the panel sits behind a reverse proxy which asks for -basic authentication. The default user name is `admin` and the password is -`openmu`. +The panel asks for a login of its own, in every deployment. See +[Signing in](authentication.md) for the login, the optional second factor and +the roles. :::danger[Secure the panel before exposing it] Whoever reaches the admin panel controls your server and can read and modify all account data. Before your server is reachable from the internet: -* change the default password on the [Users page](users.md), -* set up HTTPS, so the password and the session are not sent in plain text +* create your first user on the [Users page](users.md), so the panel leaves + its initial setup mode in which it is reachable without a login, +* set up HTTPS, so the session is not sent in plain text ([all-in-one](../deployment/all-in-one.md#option-b--with-https), [Traefik](../deployment/all-in-one-traefik.md#option-b--with-https)). diff --git a/docs-website/docs/admin-panel/users.md b/docs-website/docs/admin-panel/users.md index ad75135da7..612d6a0c72 100644 --- a/docs-website/docs/admin-panel/users.md +++ b/docs-website/docs/admin-panel/users.md @@ -1,7 +1,7 @@ --- title: Admin panel users sidebar_label: Users -sidebar_position: 13 +sidebar_position: 14 description: Manage who is allowed to log into the admin panel. --- @@ -12,60 +12,50 @@ description: Manage who is allowed to log into the admin panel. These are the users which may log into the **admin panel** — they are not game accounts. Game accounts are managed on the [Accounts page](accounts.md). -:::note[Only shown when available] -The menu entry only appears when the panel actually manages an authentication -backend. In the docker deployments this is the `.htpasswd` file of the reverse -proxy, which is mounted into the container. When you run the server from source, -there is no reverse proxy and therefore no user management — and no -authentication either. +The page requires the *Administrator* role. How the login itself works, and how +each user protects its own account with a second factor, is described under +[Signing in](authentication.md). + +:::note[Admin users are stored separately from game accounts] +They live in an own `admin` schema of the database, not in the account table of +the game. A game password is typed into the game client and travels over the +game protocol, while an admin panel user can restart servers, edit the whole +configuration and read the logs — those two should not be the same secret. The +schema is also not readable by the game servers. ::: ## What you can do | Action | Effect | |---|---| -| **Create user** | Adds a user with a password | +| **Create user** | Adds a user with a password and a role | | **Change password** | Sets a new password for an existing user | +| **Role** | Changes the role. Running sessions of that user end, so the new role takes effect immediately. | +| **Reset second factor** | Removes the authenticator of a user which lost its app and its recovery codes. The user can set a new one up afterwards. | | **Delete** | Removes a user. The last remaining user cannot be deleted, so you can't lock yourself out. | -Passwords are written to the `.htpasswd` file as bcrypt hashes. - -:::tip[Do not edit `.htpasswd` by hand] -Use this page. If you edit the file manually you risk breaking the format, and -the changes may not match what the proxy expects. -::: - -## Default credentials - -The docker deployments ship with one user: +Passwords are stored as bcrypt hashes and have to be at least 12 characters +long. -* user name `admin` -* password `openmu` +A configured [bootstrap user](authentication.md#bootstrap-user) is defined by the +environment and can't be edited here. -**Change this password before your server is reachable from the internet.** The -default is public knowledge — it is written in the repository. +## Creating the first user -## Traefik: restart after changes - -In the [all-in-one with Traefik](../deployment/all-in-one-traefik.md) deployment, -Traefik reads the `.htpasswd` file at startup. After adding or changing a user you -have to **restart the Traefik container** for it to take effect. - -The nginx-based deployments pick the change up without a restart. +On a fresh installation the panel has no user yet and is therefore reachable +without a login. Create your first user right after the +[installation](setup.md) finished — or, better, configure a +[bootstrap user](authentication.md#bootstrap-user) before the first start, so +that window never exists. ## Securing the panel -Basic authentication only protects the panel if the connection is encrypted — -otherwise the password travels in plain text with every request. Set up HTTPS: - -* [All-in-one with nginx and certbot](../deployment/all-in-one.md#option-b--with-https) -* [All-in-one with Traefik](../deployment/all-in-one-traefik.md#option-b--with-https) - -Further hardening which is worth the effort on a public server: +Whoever reaches the admin panel controls your server and can read and modify all +account data. Before your server is reachable from the internet: -* Do not expose the admin panel port to the whole internet if you can reach it - through a VPN or an SSH tunnel instead. -* Give each administrator their own user, so you can remove one without changing - everybody's password. -* Remember that admin panel access means full access to your players' account - data. +* make sure a real user exists, so the panel is not in its initial setup mode, +* set up HTTPS, so the session is not sent in plain text + ([all-in-one](../deployment/all-in-one.md#option-b--with-https), + [Traefik](../deployment/all-in-one-traefik.md#option-b--with-https)), +* require a [second factor](authentication.md#two-factor-authentication) from + everybody who can reach it. diff --git a/docs-website/docs/deployment/all-in-one-traefik.md b/docs-website/docs/deployment/all-in-one-traefik.md index e0f3e400dd..1cd0904cdd 100644 --- a/docs-website/docs/deployment/all-in-one-traefik.md +++ b/docs-website/docs/deployment/all-in-one-traefik.md @@ -28,8 +28,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: # ... @@ -107,19 +105,20 @@ docker compose -f docker-compose.prod.yml up -d ## Admin panel users -:::warning[Restart Traefik after adding a user] -Avoid editing the `.htpasswd` manually. Instead, add the user in the admin panel -on the [Users page](../admin-panel/users.md). With the Traefik deployment you -need to **restart Traefik** after adding a user for it to take effect. -::: +The admin panel authenticates its users itself, so there is no basic +authentication middleware in Traefik anymore — and no restart after a user +changed. Users are managed on the [Users page](../admin-panel/users.md); the +login and the optional second factor are described under +[Signing in](../admin-panel/authentication.md). ## What's next The server is automatically started and initialized for Season 6. -Go to the admin panel — locally that is [http://admin.docker.localhost/](http://admin.docker.localhost/). The -default user name is `admin` and the password is `openmu`; change that before the -server is reachable from the internet. +Go to the admin panel — locally that is [http://admin.docker.localhost/](http://admin.docker.localhost/). Until +the first admin panel user exists, it is reachable without a login; create one +before the server is reachable from the internet, or configure a bootstrap user +beforehand (see [Signing in](../admin-panel/authentication.md)). If you want to run another game version, use the [Setup page](../admin-panel/setup.md). diff --git a/docs-website/docs/deployment/all-in-one.md b/docs-website/docs/deployment/all-in-one.md index aad4442b69..4e6bbb6c4b 100644 --- a/docs-website/docs/deployment/all-in-one.md +++ b/docs-website/docs/deployment/all-in-one.md @@ -91,10 +91,11 @@ Additionally, take a look at the [admin panel](../admin-panel/overview.md). If your containers run on docker at your local machine, you can simply go to [http://localhost/](http://localhost/). -:::danger[Change the default password] -The default user name is `admin` and the password is `openmu`. Change it on the -[Users page](../admin-panel/users.md) before your server is reachable from the -internet. +:::danger[Create a user before you expose the server] +Until the first admin panel user exists, the panel is reachable without a login. +Create one on the [Users page](../admin-panel/users.md), or configure a bootstrap +user with `OPENMU_ADMIN_USER` and `OPENMU_ADMIN_PASSWORD` before the first start +— see [Signing in](../admin-panel/authentication.md). ::: If you want to run another game version, go to the diff --git a/docs-website/docs/deployment/distributed.md b/docs-website/docs/deployment/distributed.md index 77b2ea9b76..fb633a5e57 100644 --- a/docs-website/docs/deployment/distributed.md +++ b/docs-website/docs/deployment/distributed.md @@ -71,9 +71,10 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml run --rm certbot ## What's next Discover the [admin panel](../admin-panel/overview.md). If your containers run on -docker at your local machine, go to [http://localhost/admin](http://localhost/admin). The default user -name is `admin` and the password is `openmu` — change that later on the -[Users page](../admin-panel/users.md). +docker at your local machine, go to [http://localhost/admin](http://localhost/admin). Until the first +admin panel user exists, the panel is reachable without a login — create one on +the [Users page](../admin-panel/users.md), or configure a bootstrap user before +the first start (see [Signing in](../admin-panel/authentication.md)). On the [Setup page](../admin-panel/setup.md) you select the game version, the number of game servers (just the data of it), and whether test accounts should be diff --git a/docs-website/docs/getting-started/docker.md b/docs-website/docs/getting-started/docker.md index 873ff22d32..cae260d6a6 100644 --- a/docs-website/docs/getting-started/docker.md +++ b/docs-website/docs/getting-started/docker.md @@ -36,9 +36,11 @@ That's it — the server is available on your local machine through a loopback I Go to [http://localhost/](http://localhost/). -* The default user name is `admin`, the password is `openmu`. -* **Change that** before you expose the server to the internet — see - [Admin panel users](../admin-panel/users.md). +* On a fresh installation there is no user yet, so the panel lets you in + without a login and says so. +* **Create your first user** right after the installation, or configure a + bootstrap user before the first start — see + [Signing in](../admin-panel/authentication.md). The server is automatically initialized for Season 6, so you can start playing right away. If you want another game version, another number of game servers, or diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index a6ab40094a..c3b98717a4 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -48,6 +48,7 @@ const sidebars = { 'admin-panel/map-editor', 'admin-panel/live-map', 'admin-panel/logs-and-monitoring', + 'admin-panel/authentication', 'admin-panel/users', 'admin-panel/common-tasks', ], 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/AdminRole.cs b/src/Persistence/AdminAuth/AdminRole.cs new file mode 100644 index 0000000000..3ad4edbab1 --- /dev/null +++ b/src/Persistence/AdminAuth/AdminRole.cs @@ -0,0 +1,29 @@ +// +// 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 , from the least to the most +/// privileged one. The roles build up on each other. +/// +public enum AdminRole +{ + /// + /// May see the state of the servers and the game data, but can't change anything. + /// + Viewer, + + /// + /// May additionally operate the servers, e.g. start and stop them, + /// disconnect players and edit accounts. + /// + Operator, + + /// + /// May additionally change the game configuration, install updates, + /// set the database up and manage the admin panel users. + /// + Administrator, +} diff --git a/src/Persistence/AdminAuth/AdminRoles.cs b/src/Persistence/AdminAuth/AdminRoles.cs new file mode 100644 index 0000000000..0e9461097d --- /dev/null +++ b/src/Persistence/AdminAuth/AdminRoles.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.AdminAuth; + +/// +/// The names of the s, as they are stored and used in the claims. +/// +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 = nameof(AdminRole.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 = nameof(AdminRole.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 = nameof(AdminRole.Administrator); + + /// + /// Gets all defined roles, from the least to the most privileged one. + /// + public static IReadOnlyList All { get; } = Enum.GetNames(); + + /// + /// 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) + { + if (!Enum.TryParse(role, out var parsedRole) || !Enum.IsDefined(parsedRole)) + { + yield break; + } + + foreach (var candidate in Enum.GetValues()) + { + if (candidate <= parsedRole) + { + yield return candidate.ToString(); + } + } + } +} 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..0e0c71eb3f --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor @@ -0,0 +1,26 @@ +@using MUnique.OpenMU.Web.AdminPanel.Properties + + + + @if (context.User.Identity?.IsAuthenticated is true) + { +
+ + +
+ } + else + { + @* Nobody is signed in and no user exists yet - the panel is in its initial setup mode. *@ + + } +
+
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..834a1be220 --- /dev/null +++ b/src/Web/AdminPanel/Components/Layout/LoginDisplay.razor.cs @@ -0,0 +1,87 @@ +// +// 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; +using MUnique.OpenMU.Web.AdminPanel.Pages; + +/// +/// 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 void OpenAccountSecurity() + { + this.NavigationManager.NavigateTo(AdminAuthenticationDefaults.SecurityPath.TrimStart('/')); + } + + /// + /// Sends the user to the page where it can create the first user. + /// + /// + /// As long as no user exists, the panel is reachable without a login. There is no own account + /// to manage then, so the account security page would be empty - creating a user is what + /// actually needs to happen. + /// + private void OpenUserCreation() + { + this.NavigationManager.NavigateTo(AdminUsers.CreateUserUrl); + } + + 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 + + + + + +
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..1b780b051d 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,7 +69,7 @@
@@ -42,7 +84,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..f5df6ebd29 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) { + diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs index d1bcdb06c7..463ac07468 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs @@ -31,9 +31,6 @@ public partial class NavMenu : IDisposable [Inject] private SetupService SetupService { get; set; } = null!; - [Inject] - private IUserService UserService { get; set; } = null!; - [Inject] private DataUpdateService UpdateService { get; set; } = null!; diff --git a/src/Web/AdminPanel/Components/RedirectToLogin.razor b/src/Web/AdminPanel/Components/RedirectToLogin.razor new file mode 100644 index 0000000000..3172d63f8e --- /dev/null +++ b/src/Web/AdminPanel/Components/RedirectToLogin.razor @@ -0,0 +1,13 @@ +@inject NavigationManager NavigationManager + +@code { + /// + protected override void OnInitialized() + { + var returnUrl = this.NavigationManager.ToBaseRelativePath(this.NavigationManager.Uri); + var target = string.IsNullOrEmpty(returnUrl) || returnUrl.StartsWith("login", StringComparison.OrdinalIgnoreCase) + ? "login" + : $"login?returnUrl={Uri.EscapeDataString(returnUrl)}"; + this.NavigationManager.NavigateTo(target, replace: true); + } +} diff --git a/src/Web/AdminPanel/Components/Routes.razor b/src/Web/AdminPanel/Components/Routes.razor index 08c91c3638..d9ffd95b07 100644 --- a/src/Web/AdminPanel/Components/Routes.razor +++ b/src/Web/AdminPanel/Components/Routes.razor @@ -1,7 +1,21 @@  - + + + @if (context.User.Identity?.IsAuthenticated is true) + { + + } + else + { + + } + + +
@MUnique.OpenMU.Web.AdminPanel.Properties.Resources.Loading
+
+
diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index a974a32dac..2f699fa055 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Web/AdminPanel/Pages/AccessDenied.razor b/src/Web/AdminPanel/Pages/AccessDenied.razor new file mode 100644 index 0000000000..56ffe9f08f --- /dev/null +++ b/src/Web/AdminPanel/Pages/AccessDenied.razor @@ -0,0 +1,8 @@ +@page "/access-denied" +@using MUnique.OpenMU.Web.AdminPanel.Properties +@attribute [AllowAnonymous] + +OpenMU: @Resources.AccessDenied +

@Resources.AccessDenied

+

@Resources.AccessDeniedDescription

+@Resources.Home diff --git a/src/Web/AdminPanel/Pages/AccountSecurity.razor b/src/Web/AdminPanel/Pages/AccountSecurity.razor new file mode 100644 index 0000000000..74bc6a6c3a --- /dev/null +++ b/src/Web/AdminPanel/Pages/AccountSecurity.razor @@ -0,0 +1,80 @@ +@page "/account/security" +@using MUnique.OpenMU.Web.AdminPanel.Properties + +OpenMU: @Resources.AccountSecurity + +

@Resources.AccountSecurity

+ +@if (this._isLoading) +{ +

@Resources.Loading

+} +else if (this._user is null) +{ + @* Nobody is signed in and no user exists yet - there is no own account to secure. *@ + +} +else +{ +
+
+

@Resources.TwoFactorAuthentication

+ + @if (this._setup is { } setup) + { +

@Resources.ScanQrCode

+
@((MarkupString)setup.QrCodeSvg)
+

@Resources.ManualKeyHint

+

@setup.SharedKey

+

@Resources.ConfirmSetupHint

+
+ + +
+ @if (this._errorMessage is { } setupError) + { + + } + + } + else if (this._user.IsTwoFactorEnabled) + { +

+ @Resources.TwoFactorEnabled +

+

@Resources.RemainingRecoveryCodes: @this._remainingRecoveryCodes

+ + + } + else + { +

@Resources.TwoFactorDisabled

+ @if (this._isTwoFactorRequired) + { + + } + + } +
+
+ + @if (this._recoveryCodes is { Count: > 0 } recoveryCodes) + { +
+
+

@Resources.RecoveryCodes

+

@Resources.RecoveryCodesHint

+
    + @foreach (var recoveryCode in recoveryCodes) + { +
  • @recoveryCode
  • + } +
+
+
+ } +} diff --git a/src/Web/AdminPanel/Pages/AccountSecurity.razor.cs b/src/Web/AdminPanel/Pages/AccountSecurity.razor.cs new file mode 100644 index 0000000000..e1b97564b1 --- /dev/null +++ b/src/Web/AdminPanel/Pages/AccountSecurity.razor.cs @@ -0,0 +1,239 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Web.AdminPanel.Auth; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// The page at which a user manages the second factor of its own account. +/// +public partial class AccountSecurity : IAsyncDisposable +{ + private AdminUser? _user; + private AuthenticatorSetup? _setup; + private IReadOnlyList? _recoveryCodes; + private string _confirmationCode = string.Empty; + private string? _errorMessage; + private bool _isBusy; + private bool _isLoading = true; + private bool _isTwoFactorRequired; + private int _remainingRecoveryCodes; + private IJSObjectReference? _authModule; + + [Inject] + private CurrentAdminUserService CurrentUserService { get; set; } = null!; + + [Inject] + private AuthenticatorSetupService SetupService { get; set; } = null!; + + [Inject] + private AdminLoginService LoginService { get; set; } = null!; + + [Inject] + private AdminAuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + + [Inject] + private IOptions AuthOptions { get; set; } = null!; + + [Inject] + private IToastService ToastService { get; set; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + [Inject] + private ILogger Logger { 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 OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + this._isTwoFactorRequired = this.AuthOptions.Value.RequireTwoFactor; + await this.LoadUserAsync().ConfigureAwait(true); + } + + /// + 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 LoadUserAsync() + { + try + { + this._user = await this.CurrentUserService.GetCurrentUserAsync().ConfigureAwait(true); + if (this._user is { IsTwoFactorEnabled: true }) + { + this._remainingRecoveryCodes = await this.SetupService + .GetRemainingRecoveryCodeCountAsync(this._user) + .ConfigureAwait(true); + } + } + finally + { + this._isLoading = false; + } + } + + private async Task BeginSetupAsync() + { + if (this._user is not { } user) + { + return; + } + + this._isBusy = true; + this._errorMessage = null; + this._recoveryCodes = null; + try + { + this._setup = await this.SetupService.BeginSetupAsync(user).ConfigureAwait(true); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "The authenticator setup could not be started."); + this._errorMessage = Resources.UnhandledErrorOccurred; + } + finally + { + this._isBusy = false; + } + } + + private void CancelSetup() + { + this._setup = null; + this._confirmationCode = string.Empty; + this._errorMessage = null; + } + + private async Task ConfirmSetupAsync() + { + if (this._user is not { } user || this._setup is null) + { + return; + } + + this._isBusy = true; + this._errorMessage = null; + try + { + var codes = await this.SetupService.ConfirmSetupAsync(user, this._confirmationCode).ConfigureAwait(true); + if (codes is null) + { + this._errorMessage = Resources.InvalidTwoFactorCode; + return; + } + + this._setup = null; + this._confirmationCode = string.Empty; + this._recoveryCodes = codes; + this._remainingRecoveryCodes = codes.Count; + + // Enabling the second factor rotated the security stamp, so the running session needs a fresh cookie. + await this.RefreshSessionAsync(user, usedSecondFactor: true).ConfigureAwait(true); + this.ToastService.ShowSuccess(Resources.TwoFactorEnabled); + } + finally + { + this._isBusy = false; + } + } + + private async Task DisableTwoFactorAsync() + { + if (this._user is not { } user) + { + return; + } + + this._isBusy = true; + try + { + await this.SetupService.DisableAsync(user).ConfigureAwait(true); + this._recoveryCodes = null; + this._remainingRecoveryCodes = 0; + await this.RefreshSessionAsync(user, usedSecondFactor: false).ConfigureAwait(true); + this.ToastService.ShowSuccess(Resources.TwoFactorDisabled); + } + finally + { + this._isBusy = false; + } + } + + private async Task GenerateRecoveryCodesAsync() + { + if (this._user is not { } user) + { + return; + } + + this._isBusy = true; + try + { + this._recoveryCodes = await this.SetupService.GenerateRecoveryCodesAsync(user).ConfigureAwait(true); + this._remainingRecoveryCodes = this._recoveryCodes.Count; + } + finally + { + this._isBusy = false; + } + } + + /// + /// Exchanges the authentication cookie for one which carries the updated claims. + /// + private async Task RefreshSessionAsync(AdminUser user, bool usedSecondFactor) + { + if (this._authModule is not { } module) + { + return; + } + + var (ticket, claims) = this.LoginService.IssueSessionTicket(user, usedSecondFactor); + if (await module.InvokeAsync("signIn", ticket).ConfigureAwait(true)) + { + this.AuthenticationStateProvider.NotifySignedIn(claims); + } + else + { + this.Logger.LogWarning("The session of user '{LoginName}' could not be refreshed after a security change.", user.LoginName); + } + } +} diff --git a/src/Web/AdminPanel/Pages/AccountSecurity.razor.css b/src/Web/AdminPanel/Pages/AccountSecurity.razor.css new file mode 100644 index 0000000000..e1f3fc783b --- /dev/null +++ b/src/Web/AdminPanel/Pages/AccountSecurity.razor.css @@ -0,0 +1,4 @@ +.qr-code ::deep svg { + width: 12rem; + height: 12rem; +} diff --git a/src/Web/AdminPanel/Pages/AdminUsers.razor b/src/Web/AdminPanel/Pages/AdminUsers.razor index 8cbeb9a415..edb4758a59 100644 --- a/src/Web/AdminPanel/Pages/AdminUsers.razor +++ b/src/Web/AdminPanel/Pages/AdminUsers.razor @@ -1,47 +1,72 @@ -@page "/users" -@using MUnique.OpenMU.DataModel -@using MUnique.OpenMU.DataModel.Entities +@page "/users" +@using MUnique.OpenMU.Persistence.AdminAuth @using MUnique.OpenMU.Web.AdminPanel.Properties +@attribute [Authorize(Policy = AdminPolicies.Administrator)] - -@inject IUserService UserService; @{ - var title = @MUnique.OpenMU.Web.AdminPanel.Properties.Resources.AdminUsers; + var title = Resources.AdminUsers; } OpenMU: @title - +

@title

-
- - - - - - - - @{ var users = this.UserService.Users; } - @foreach (var user in users) - { +@if (this._isLoading) +{ +

@Resources.Loading

+} +else +{ +
+
@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))@Resources.Actions
+ - - - + + + + + - } - - - - - - - -
@user - - - @if (users.Count > 1) - { - - } - @Resources.LoginName@Resources.Role@Resources.TwoFactor@Resources.LastLogin@Resources.Actions
-
+ + + @foreach (var user in this._users) + { + + @user.LoginName + + + + + @if (user.IsTwoFactorEnabled) + { + @Resources.Enabled + } + else + { + @Resources.Disabled + } + + @(user.LastLoginAt?.ToString("yyyy-MM-dd HH:mm") ?? "-") + + + @if (user.IsTwoFactorEnabled) + { + + } + @if (this._users.Count > 1) + { + + } + + + } + + + + +} diff --git a/src/Web/AdminPanel/Pages/AdminUsers.razor.cs b/src/Web/AdminPanel/Pages/AdminUsers.razor.cs new file mode 100644 index 0000000000..626535988a --- /dev/null +++ b/src/Web/AdminPanel/Pages/AdminUsers.razor.cs @@ -0,0 +1,113 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using Microsoft.AspNetCore.Components; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Web.AdminPanel.Services; + +/// +/// The page which manages the users of the admin panel. +/// +public partial class AdminUsers +{ + /// + /// The relative url of this page which directly opens the dialog to create a user. + /// + public const string CreateUserUrl = "users?create=true"; + + private IList _users = new List(); + private bool _isLoading = true; + private bool _isCreationRequestHandled; + + /// + /// Gets or sets a value indicating whether the dialog to create a user should be opened right away. + /// + [Parameter] + [SupplyParameterFromQuery(Name = "create")] + public bool OpenCreationDialog { get; set; } + + [Inject] + private AdminUserManagementService UserManagementService { get; set; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender).ConfigureAwait(true); + if (!firstRender || !this.OpenCreationDialog || this._isCreationRequestHandled) + { + return; + } + + this._isCreationRequestHandled = true; + + // Remove the parameter again, so a reload of the page doesn't open the dialog a second time. + this.NavigationManager.NavigateTo( + this.NavigationManager.GetUriWithQueryParameter("create", (bool?)null), + replace: true); + await this.OnCreateNewAsync().ConfigureAwait(true); + } + + private async Task ReloadAsync() + { + this._isLoading = true; + try + { + this._users = await this.UserManagementService.GetUsersAsync().ConfigureAwait(true); + } + finally + { + this._isLoading = false; + } + } + + private async Task OnCreateNewAsync() + { + if (await this.UserManagementService.CreateNewInModalDialogAsync().ConfigureAwait(true)) + { + await this.ReloadAsync().ConfigureAwait(true); + } + } + + private async Task OnChangePasswordAsync(AdminUser user) + { + await this.UserManagementService.ChangePasswordInModalDialogAsync(user).ConfigureAwait(true); + } + + private async Task OnResetTwoFactorAsync(AdminUser user) + { + await this.UserManagementService.ResetTwoFactorAsync(user).ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } + + private async Task OnDeleteAsync(AdminUser user) + { + if (await this.UserManagementService.DeleteAsync(user).ConfigureAwait(true)) + { + await this.ReloadAsync().ConfigureAwait(true); + } + } + + private async Task OnRoleChangedAsync(AdminUser user, string? role) + { + if (string.IsNullOrEmpty(role) || role == user.Roles) + { + return; + } + + await this.UserManagementService.SetRoleAsync(user, role).ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } +} diff --git a/src/Web/AdminPanel/Pages/Error.razor b/src/Web/AdminPanel/Pages/Error.razor index 1c452b3fb1..8d27853c2b 100644 --- a/src/Web/AdminPanel/Pages/Error.razor +++ b/src/Web/AdminPanel/Pages/Error.razor @@ -1,4 +1,5 @@ @page "/error" +@attribute [AllowAnonymous] @using MUnique.OpenMU.Web.AdminPanel.Properties @Resources.Error diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index 49ecc4a4ac..6dd5dc8015 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -1,4 +1,5 @@ @page "/logfiles" +@attribute [Authorize(Policy = AdminPolicies.Administrator)] @using System.IO @using Microsoft.Extensions.Logging diff --git a/src/Web/AdminPanel/Pages/Login.razor b/src/Web/AdminPanel/Pages/Login.razor new file mode 100644 index 0000000000..ca9d0b1722 --- /dev/null +++ b/src/Web/AdminPanel/Pages/Login.razor @@ -0,0 +1,58 @@ +@page "/login" +@layout MUnique.OpenMU.Web.AdminPanel.Components.Layout.LoginLayout +@attribute [AllowAnonymous] +@using MUnique.OpenMU.Web.AdminPanel.Properties + +OpenMU: @Resources.Login + +
+
+ @if (this._step == LoginStep.Password) + { +

@Resources.Login

+ + +
+ + +
+
+ + +
+
+ + +
+ @if (this._errorMessage is { } passwordError) + { + + } + +
+ } + else + { +

@Resources.TwoFactorTitle

+

@Resources.TwoFactorPrompt

+ + +
+ + +
+ @if (this._errorMessage is { } codeError) + { + + } + +
+ + + } +
+
diff --git a/src/Web/AdminPanel/Pages/Login.razor.cs b/src/Web/AdminPanel/Pages/Login.razor.cs new file mode 100644 index 0000000000..f495556753 --- /dev/null +++ b/src/Web/AdminPanel/Pages/Login.razor.cs @@ -0,0 +1,264 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using MUnique.OpenMU.Web.AdminPanel.Auth; +using MUnique.OpenMU.Web.AdminPanel.Properties; + +/// +/// The login page of the admin panel. +/// +/// +/// The whole login - including the second factor - runs inside the blazor circuit. Only when the +/// credentials checked out, the browser exchanges a single use ticket for the authentication cookie +/// in the background. That's why neither entering the second factor nor the successful login +/// requires a page reload. +/// +public partial class Login : IAsyncDisposable +{ + private readonly PasswordInput _passwordInput = new(); + private readonly TwoFactorInput _twoFactorInput = new(); + + private LoginStep _step = LoginStep.Password; + private string? _errorMessage; + private bool _isBusy; + private IJSObjectReference? _authModule; + + /// + /// The steps of the login. + /// + private enum LoginStep + { + /// + /// The user enters its login name and password. + /// + Password, + + /// + /// The user enters the code of its second factor. + /// + TwoFactor, + } + + /// + /// Gets or sets the relative url to which the user is sent after a successful login. + /// + [Parameter] + [SupplyParameterFromQuery(Name = "returnUrl")] + public string? ReturnUrl { get; set; } + + [Inject] + private AdminLoginService LoginService { get; set; } = null!; + + [Inject] + private AdminAuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + [Inject] + private ILogger Logger { 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 - nothing to clean up on the client anymore. + } + } + + 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 OnPasswordSubmittedAsync() + { + if (this._isBusy) + { + return; + } + + this._isBusy = true; + this._errorMessage = null; + try + { + var result = await this.LoginService + .CheckPasswordAsync(this._passwordInput.LoginName, this._passwordInput.Password, this._passwordInput.RememberMe) + .ConfigureAwait(true); + this._passwordInput.Password = string.Empty; + + switch (result.Status) + { + case AdminLoginStatus.TwoFactorRequired: + this._step = LoginStep.TwoFactor; + break; + case AdminLoginStatus.LockedOut: + this._errorMessage = Resources.AccountLockedOut; + break; + case AdminLoginStatus.Succeeded: + await this.CompleteLoginAsync(result).ConfigureAwait(true); + break; + default: + this._errorMessage = Resources.InvalidCredentials; + break; + } + } + finally + { + this._isBusy = false; + } + } + + private async Task OnTwoFactorSubmittedAsync() + { + if (this._isBusy) + { + return; + } + + this._isBusy = true; + this._errorMessage = null; + try + { + var result = await this.LoginService + .CheckTwoFactorAsync(this._twoFactorInput.Code, this._twoFactorInput.UseRecoveryCode) + .ConfigureAwait(true); + this._twoFactorInput.Code = string.Empty; + + switch (result.Status) + { + case AdminLoginStatus.Succeeded: + await this.CompleteLoginAsync(result).ConfigureAwait(true); + break; + case AdminLoginStatus.LockedOut: + this._step = LoginStep.Password; + this._errorMessage = Resources.AccountLockedOut; + break; + default: + this._errorMessage = Resources.InvalidTwoFactorCode; + break; + } + } + finally + { + this._isBusy = false; + } + } + + private async Task CompleteLoginAsync(AdminLoginResult result) + { + if (result.Ticket is not { } ticket || result.Claims is not { } claims) + { + this._errorMessage = Resources.InvalidCredentials; + return; + } + + if (this._authModule is not { } module) + { + this.Logger.LogError("The authentication script module is not loaded, so the login can't be completed."); + this._errorMessage = Resources.UnhandledErrorOccurred; + return; + } + + var isSignedIn = await module.InvokeAsync("signIn", ticket).ConfigureAwait(true); + if (!isSignedIn) + { + this.Logger.LogError("The sign in endpoint rejected the ticket."); + this._errorMessage = Resources.UnhandledErrorOccurred; + return; + } + + // The cookie is set now, so the circuit can switch to the authenticated state without a reload. + this.AuthenticationStateProvider.NotifySignedIn(claims); + this.NavigationManager.NavigateTo(this.GetSafeReturnUrl()); + } + + /// + /// Gets the return url, making sure that it stays within this application. + /// + private string GetSafeReturnUrl() + { + if (string.IsNullOrWhiteSpace(this.ReturnUrl)) + { + return string.Empty; + } + + // An absolute or protocol relative url could send the user to a foreign site after login. + if (this.ReturnUrl.StartsWith('/') + || this.ReturnUrl.StartsWith('\\') + || this.ReturnUrl.Contains("://", StringComparison.Ordinal)) + { + return string.Empty; + } + + return this.ReturnUrl; + } + + private void ToggleRecoveryCode() + { + this._twoFactorInput.UseRecoveryCode = !this._twoFactorInput.UseRecoveryCode; + this._twoFactorInput.Code = string.Empty; + this._errorMessage = null; + } + + private void BackToPasswordStep() + { + this._step = LoginStep.Password; + this._twoFactorInput.Code = string.Empty; + this._twoFactorInput.UseRecoveryCode = false; + this._errorMessage = null; + } + + /// + /// The input of the first login step. + /// + private class PasswordInput + { + [Required] + public string LoginName { get; set; } = string.Empty; + + [Required] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + } + + /// + /// The input of the second login step. + /// + private class TwoFactorInput + { + [Required] + public string Code { get; set; } = string.Empty; + + public bool UseRecoveryCode { get; set; } + } +} diff --git a/src/Web/AdminPanel/Pages/NotFound.razor b/src/Web/AdminPanel/Pages/NotFound.razor index c8f2f6192b..ff667e04f0 100644 --- a/src/Web/AdminPanel/Pages/NotFound.razor +++ b/src/Web/AdminPanel/Pages/NotFound.razor @@ -1,4 +1,5 @@ @page "/not-found" +@attribute [AllowAnonymous] @using MUnique.OpenMU.Web.AdminPanel.Components.Layout @layout MainLayout diff --git a/src/Web/AdminPanel/Pages/Plugins.razor b/src/Web/AdminPanel/Pages/Plugins.razor index f93662b74f..1809a9b928 100644 --- a/src/Web/AdminPanel/Pages/Plugins.razor +++ b/src/Web/AdminPanel/Pages/Plugins.razor @@ -1,4 +1,5 @@ -@page "/plugins" +@page "/plugins" +@attribute [Authorize(Policy = AdminPolicies.Administrator)] @using MUnique.OpenMU.Web.AdminPanel.Properties @using MUnique.OpenMU.Web.Shared.Models diff --git a/src/Web/AdminPanel/Pages/Setup.razor b/src/Web/AdminPanel/Pages/Setup.razor index 558b508529..06009a6f50 100644 --- a/src/Web/AdminPanel/Pages/Setup.razor +++ b/src/Web/AdminPanel/Pages/Setup.razor @@ -1,4 +1,5 @@ @page "/setup" +@attribute [Authorize(Policy = AdminPolicies.Administrator)] @using MUnique.OpenMU.Web.AdminPanel.Properties OpenMU: @Resources.Setup diff --git a/src/Web/AdminPanel/Pages/Updates.razor b/src/Web/AdminPanel/Pages/Updates.razor index 1f16d1fa7a..bf3c49b2c6 100644 --- a/src/Web/AdminPanel/Pages/Updates.razor +++ b/src/Web/AdminPanel/Pages/Updates.razor @@ -1,4 +1,5 @@ @page "/config-updates" +@attribute [Authorize(Policy = AdminPolicies.Administrator)] @using MUnique.OpenMU.DataModel @using MUnique.OpenMU.DataModel.Configuration @using MUnique.OpenMU.Web.AdminPanel.Properties diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 1b469d4b16..404d816b46 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1736,5 +1736,446 @@ public static string YesCreateTestAccounts { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } + /// + /// Looks up a localized string similar to Login. + /// + public static string Login { + get { + return ResourceManager.GetString("Login", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Logout. + /// + public static string Logout { + get { + return ResourceManager.GetString("Logout", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Login name. + /// + public static string LoginName { + get { + return ResourceManager.GetString("LoginName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Password. + /// + public static string Password { + get { + return ResourceManager.GetString("Password", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sign in. + /// + public static string SignIn { + get { + return ResourceManager.GetString("SignIn", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Signed in as. + /// + public static string SignedInAs { + get { + return ResourceManager.GetString("SignedInAs", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Keep me signed in. + /// + public static string RememberMe { + get { + return ResourceManager.GetString("RememberMe", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The login name or password is wrong.. + /// + public static string InvalidCredentials { + get { + return ResourceManager.GetString("InvalidCredentials", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Too many failed attempts. Please try again later.. + /// + public static string AccountLockedOut { + get { + return ResourceManager.GetString("AccountLockedOut", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication. + /// + public static string TwoFactorTitle { + get { + return ResourceManager.GetString("TwoFactorTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter the code from your authenticator app.. + /// + public static string TwoFactorPrompt { + get { + return ResourceManager.GetString("TwoFactorPrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Authenticator code. + /// + public static string AuthenticatorCode { + get { + return ResourceManager.GetString("AuthenticatorCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Recovery code. + /// + public static string RecoveryCode { + get { + return ResourceManager.GetString("RecoveryCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use a recovery code instead. + /// + public static string UseRecoveryCode { + get { + return ResourceManager.GetString("UseRecoveryCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use an authenticator code instead. + /// + public static string UseAuthenticatorCode { + get { + return ResourceManager.GetString("UseAuthenticatorCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The code is not valid.. + /// + public static string InvalidTwoFactorCode { + get { + return ResourceManager.GetString("InvalidTwoFactorCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify. + /// + public static string Verify { + get { + return ResourceManager.GetString("Verify", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Access denied. + /// + public static string AccessDenied { + get { + return ResourceManager.GetString("AccessDenied", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your account is not permitted to open this page.. + /// + public static string AccessDeniedDescription { + get { + return ResourceManager.GetString("AccessDeniedDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Account security. + /// + public static string AccountSecurity { + get { + return ResourceManager.GetString("AccountSecurity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication. + /// + public static string TwoFactorAuthentication { + get { + return ResourceManager.GetString("TwoFactorAuthentication", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication is enabled.. + /// + public static string TwoFactorEnabled { + get { + return ResourceManager.GetString("TwoFactorEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication is not set up yet.. + /// + public static string TwoFactorDisabled { + get { + return ResourceManager.GetString("TwoFactorDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set up authenticator app. + /// + public static string SetUpAuthenticator { + get { + return ResourceManager.GetString("SetUpAuthenticator", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disable two-factor authentication. + /// + public static string DisableTwoFactor { + get { + return ResourceManager.GetString("DisableTwoFactor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scan this QR code with your authenticator app, for example Microsoft Authenticator.. + /// + public static string ScanQrCode { + get { + return ResourceManager.GetString("ScanQrCode", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Can't scan it? Enter this key manually:. + /// + public static string ManualKeyHint { + get { + return ResourceManager.GetString("ManualKeyHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to To finish, enter the code which your app shows now.. + /// + public static string ConfirmSetupHint { + get { + return ResourceManager.GetString("ConfirmSetupHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Recovery codes. + /// + public static string RecoveryCodes { + get { + return ResourceManager.GetString("RecoveryCodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Store these codes somewhere safe. Each of them can be used once to sign in when you don't have your [rest of string was truncated]&quot;;. + /// + public static string RecoveryCodesHint { + get { + return ResourceManager.GetString("RecoveryCodesHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remaining recovery codes. + /// + public static string RemainingRecoveryCodes { + get { + return ResourceManager.GetString("RemainingRecoveryCodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Generate new recovery codes. + /// + public static string GenerateNewRecoveryCodes { + get { + return ResourceManager.GetString("GenerateNewRecoveryCodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Two-factor authentication is required. + /// + public static string TwoFactorRequiredTitle { + get { + return ResourceManager.GetString("TwoFactorRequiredTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server configuration requires every admin panel user to use a second factor. Please set up your [rest of string was truncated]&quot;;. + /// + public static string TwoFactorRequiredDescription { + get { + return ResourceManager.GetString("TwoFactorRequiredDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Roles. + /// + public static string Roles { + get { + return ResourceManager.GetString("Roles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Role. + /// + public static string Role { + get { + return ResourceManager.GetString("Role", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Second factor. + /// + public static string TwoFactor { + get { + return ResourceManager.GetString("TwoFactor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Last login. + /// + public static string LastLogin { + get { + return ResourceManager.GetString("LastLogin", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enabled. + /// + public static string Enabled { + get { + return ResourceManager.GetString("Enabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disabled. + /// + public static string Disabled { + get { + return ResourceManager.GetString("Disabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No admin panel user exists yet, so the panel is currently reachable without a login. Create the firs [rest of string was truncated]&quot;;. + /// + public static string NoAdminUserWarning { + get { + return ResourceManager.GetString("NoAdminUserWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create the first user. + /// + public static string CreateFirstUser { + get { + return ResourceManager.GetString("CreateFirstUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user has been created.. + /// + public static string UserCreated { + get { + return ResourceManager.GetString("UserCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user has been deleted.. + /// + public static string UserDeleted { + get { + return ResourceManager.GetString("UserDeleted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The password has been changed.. + /// + public static string PasswordChanged { + get { + return ResourceManager.GetString("PasswordChanged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The second factor of the user has been reset.. + /// + public static string TwoFactorResetForUser { + get { + return ResourceManager.GetString("TwoFactorResetForUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reset second factor. + /// + public static string ResetTwoFactor { + get { + return ResourceManager.GetString("ResetTwoFactor", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The last remaining user can't be deleted.. + /// + public static string CannotDeleteLastUser { + get { + return ResourceManager.GetString("CannotDeleteLastUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The bootstrap user is defined by the configuration and can't be changed here.. + /// + public static string CannotModifyBootstrapUser { + get { + return ResourceManager.GetString("CannotModifyBootstrapUser", resourceCulture); + } + } + } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index b1af2d2fbc..17e6d3351f 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1,4 +1,4 @@ - +