From 259caf1ff337fbd58ed7d68fe6e70444eabc179b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:35:04 +0000 Subject: [PATCH 1/2] Authenticate the public API with an API key The public API under /api - the server status, the online state of an account and the global message - is meant for external applications like a game launcher or a status page. Since the admin panel authenticates its users, that API was only reachable with the authentication cookie of the panel, which such an application can't get: it would have to go through a login form and a second factor. It now accepts an API key as well, in an "X-Api-Key" header or as a bearer token. The keys are configured under "AdminPanel:Api:Keys", or with the OPENMU_API_KEY environment variable for a single key, like the bootstrap user is. They are not stored in the database on purpose: the API has to work before the game database exists. A key carries the same roles as a user and defaults to the least privileged one, so a status page can be given a key which can only read. The endpoints which report something require the viewer role, while the global message - the only endpoint which does something - requires the operator role. Keys shorter than 32 characters are refused and logged, because the key travels with every request. All configured keys are compared in constant time, and the key itself is never written to the log. The cookie handler now answers with 401 and 403 below /api instead of redirecting to the login page, which an API client can't use anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VZLARthjVEssPfKLacpaWw --- deploy/all-in-one-traefik/docker-compose.yml | 4 + deploy/all-in-one/docker-compose.yml | 4 + deploy/distributed/docker-compose.yml | 4 + .../docs/admin-panel/authentication.md | 76 ++++++++ src/Web/AdminPanel/API/ServerController.cs | 14 ++ .../Auth/AdminPanelAuthExtensions.cs | 54 +++++- .../Auth/ApiKeyAuthenticationDefaults.cs | 56 ++++++ .../Auth/ApiKeyAuthenticationHandler.cs | 108 +++++++++++ src/Web/AdminPanel/Auth/ApiKeyOptions.cs | 51 +++++ src/Web/AdminPanel/Auth/ApiKeyRegistry.cs | 143 ++++++++++++++ .../AdminAuth/ApiKeyAuthenticationTests.cs | 175 ++++++++++++++++++ 11 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 src/Web/AdminPanel/Auth/ApiKeyAuthenticationDefaults.cs create mode 100644 src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs create mode 100644 src/Web/AdminPanel/Auth/ApiKeyOptions.cs create mode 100644 src/Web/AdminPanel/Auth/ApiKeyRegistry.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs diff --git a/deploy/all-in-one-traefik/docker-compose.yml b/deploy/all-in-one-traefik/docker-compose.yml index 8fa9e905a..75d248f1f 100644 --- a/deploy/all-in-one-traefik/docker-compose.yml +++ b/deploy/all-in-one-traefik/docker-compose.yml @@ -21,6 +21,10 @@ services: 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:-} + # Optional API key, with which an external application like a launcher or a + # website authenticates itself at the public API under /api. + OPENMU_API_KEY: ${OPENMU_API_KEY:-} + OPENMU_API_KEY_ROLES: ${OPENMU_API_KEY_ROLES:-} volumes: - adminpanel-keys:/app/data-protection-keys working_dir: /app/ diff --git a/deploy/all-in-one/docker-compose.yml b/deploy/all-in-one/docker-compose.yml index ef48d553b..24fdb3706 100644 --- a/deploy/all-in-one/docker-compose.yml +++ b/deploy/all-in-one/docker-compose.yml @@ -32,6 +32,10 @@ services: 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:-} + # Optional API key, with which an external application like a launcher or a + # website authenticates itself at the public API under /api. + OPENMU_API_KEY: ${OPENMU_API_KEY:-} + OPENMU_API_KEY_ROLES: ${OPENMU_API_KEY_ROLES:-} volumes: - adminpanel-keys:/app/data-protection-keys working_dir: /app/ diff --git a/deploy/distributed/docker-compose.yml b/deploy/distributed/docker-compose.yml index f68d1ad78..8fa23082d 100644 --- a/deploy/distributed/docker-compose.yml +++ b/deploy/distributed/docker-compose.yml @@ -262,6 +262,10 @@ services: 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:-} + # Optional API key, with which an external application like a launcher or a + # website authenticates itself at the public API under /api. + OPENMU_API_KEY: ${OPENMU_API_KEY:-} + OPENMU_API_KEY_ROLES: ${OPENMU_API_KEY_ROLES:-} volumes: - adminpanel-keys:/app/data-protection-keys diff --git a/docs-website/docs/admin-panel/authentication.md b/docs-website/docs/admin-panel/authentication.md index a931c6a2e..c4f77f62e 100644 --- a/docs-website/docs/admin-panel/authentication.md +++ b/docs-website/docs/admin-panel/authentication.md @@ -130,6 +130,82 @@ Each user has one role. They build up on each other: Give each administrator their own user, so you can remove one without changing everybody else's password. +## API keys for external applications + +The server has a small public API under `/api` — the server status, the number +of online players, whether an account is online, and a global message. A game +launcher, a status page or a website needs it, and none of them can go through +a login form with a second factor. + +They authenticate with an API key instead. Send it in the `X-Api-Key` header: + +```http +GET /api/status HTTP/1.1 +X-Api-Key: +``` + +`Authorization: Bearer ` works as well, for clients which only speak +that. + +### Configuring the keys + +Give every application its own key, so you can revoke one of them without +touching the others: + +```json +{ + "AdminPanel": { + "Api": { + "Keys": [ + { "Name": "launcher", "Key": "" }, + { "Name": "website", "Key": "", "Roles": "Operator" } + ] + } + } +} +``` + +For a single key, the environment variables work too, like they do for the +bootstrap user: + +```bash +OPENMU_API_KEY= +# optional, defaults to Viewer: +OPENMU_API_KEY_ROLES=Operator +``` + +Generate a key with something which is actually random, e.g. +`openssl rand -base64 24`. Keys shorter than 32 characters are refused and +logged, because a key travels with every request and is only as good as its +entropy. + +### What a key may do + +A key has the same [roles](#roles) as a user, and defaults to **Viewer**: + +| Endpoint | Needs | +|---|---| +| `GET /api/status` | Viewer | +| `GET /api/is-online/{account}` | Viewer | +| `GET /api/send/{server}?msg=` | Operator | + +So a status page gets a Viewer key and can only read, while an application which +announces something in the game needs an Operator key. + +A signed in admin panel user can use the API as well, with the same roles — this +is handy while trying things out in the browser. + +Like the panel itself, the API is open as long as [no user exists at all](#the-first-user), +so configure a key together with the bootstrap user rather than after the first +start. + +:::warning[The key is a password] +It is sent in plain text with every request, so use HTTPS, and keep it out of +client side code — a key in a launcher which ships to players is a key your +players have. Requests without a valid key get `401`, and requests whose key +lacks the role get `403`. +::: + ## Keeping the sessions alive across restarts The sessions and the stored authenticator secrets are protected with a key ring diff --git a/src/Web/AdminPanel/API/ServerController.cs b/src/Web/AdminPanel/API/ServerController.cs index 7b97144b9..f68874cc6 100644 --- a/src/Web/AdminPanel/API/ServerController.cs +++ b/src/Web/AdminPanel/API/ServerController.cs @@ -5,17 +5,26 @@ namespace MUnique.OpenMU.Web.API { using System.Text.Json; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; + using MUnique.OpenMU.Web.AdminPanel.Auth; /// /// Server API controller. /// + /// + /// This is the public API of the server, meant to be used by external applications like a + /// game launcher or a website. Unlike the rest of the admin panel, it accepts an API key in + /// addition to the authentication cookie, because such an application can't go through an + /// interactive login with a second factor. + /// [Route("api/")] + [Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.ApiSchemes, Policy = AdminPolicies.Viewer)] public class ServerController : Controller { private IDictionary _gameServers; @@ -31,7 +40,12 @@ public class ServerController : Controller /// /// The server id. /// The message. + /// + /// This is the only endpoint of the API which does something instead of reporting + /// something, so it requires the operator role and not just the viewer role. + /// [Route("send/{id=0}")] + [Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.ApiSchemes, Policy = AdminPolicies.Operator)] public async Task SendGlobalMessageAsync(int id, [FromQuery(Name = "msg")] string msg) { var server = (GameServer)this._gameServers.Values.ElementAt(id); diff --git a/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs index c2cf9058e..e83d3e084 100644 --- a/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs +++ b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs @@ -38,6 +38,16 @@ public static class AdminPanelAuthExtensions /// public const string BootstrapAuthenticatorKeyVariableName = "OPENMU_ADMIN_TOTP_SECRET"; + /// + /// The environment variable which defines an API key for the public API. + /// + public const string ApiKeyVariableName = "OPENMU_API_KEY"; + + /// + /// The environment variable which defines the roles of the . + /// + public const string ApiKeyRolesVariableName = "OPENMU_API_KEY_ROLES"; + /// /// Adds the authentication of the admin panel to the service collection. /// @@ -58,6 +68,12 @@ public static IServiceCollection AddAdminPanelAuth(this IServiceCollection servi options.BootstrapUser = authOptions.BootstrapUser; }); + var apiKeyOptions = new ApiKeyOptions(); + configuration.GetSection(ApiKeyOptions.SectionName).Bind(apiKeyOptions); + ApplyApiKeyEnvironmentVariable(apiKeyOptions); + services.Configure(options => options.Keys = apiKeyOptions.Keys); + services.AddSingleton(); + // 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. @@ -108,7 +124,15 @@ public static IServiceCollection AddAdminPanelAuth(this IServiceCollection servi options.LoginPath = AdminAuthenticationDefaults.LoginPath; options.LogoutPath = AdminAuthenticationDefaults.SignOutEndpointPath; options.AccessDeniedPath = AdminAuthenticationDefaults.AccessDeniedPath; - }); + + // An API client can't do anything with the login page, so it gets a status code + // instead of a redirect to it. + options.Events.OnRedirectToLogin = context => RespondWithStatusCodeOnApiPath(context, StatusCodes.Status401Unauthorized); + options.Events.OnRedirectToAccessDenied = context => RespondWithStatusCodeOnApiPath(context, StatusCodes.Status403Forbidden); + }) + .AddScheme( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + configureOptions: null); services.AddSingleton(); services.AddAuthorizationBuilder() @@ -170,6 +194,34 @@ public static IApplicationBuilder UseAuthorizedPath(this IApplicationBuilder app }); } + private static Task RespondWithStatusCodeOnApiPath(RedirectContext context, int statusCode) + { + if (context.Request.Path.StartsWithSegments(ApiKeyAuthenticationDefaults.ApiPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = statusCode; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + } + + private static void ApplyApiKeyEnvironmentVariable(ApiKeyOptions options) + { + var key = Environment.GetEnvironmentVariable(ApiKeyVariableName); + if (string.IsNullOrWhiteSpace(key)) + { + return; + } + + options.Keys.Add(new ApiKeyEntry + { + Name = ApiKeyVariableName, + Key = key, + Roles = Environment.GetEnvironmentVariable(ApiKeyRolesVariableName), + }); + } + private static void ApplyEnvironmentVariables(AdminPanelAuthOptions options) { var loginName = Environment.GetEnvironmentVariable(BootstrapUserVariableName); diff --git a/src/Web/AdminPanel/Auth/ApiKeyAuthenticationDefaults.cs b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationDefaults.cs new file mode 100644 index 000000000..e9881d335 --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationDefaults.cs @@ -0,0 +1,56 @@ +// +// 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.Authentication.Cookies; + +/// +/// Constants of the API key authentication. +/// +public static class ApiKeyAuthenticationDefaults +{ + /// + /// The name of the authentication scheme. + /// + public const string AuthenticationScheme = "OpenMU.ApiKey"; + + /// + /// The request header which carries the API key. + /// + public const string HeaderName = "X-Api-Key"; + + /// + /// The scheme of the Authorization header which carries the API key as an alternative + /// to the header. + /// + public const string AuthorizationHeaderScheme = "Bearer"; + + /// + /// The claim type which holds the configured name of the API client. + /// + public const string ClientNameClaimType = "openmu:api-client"; + + /// + /// The path prefix of the public API. Requests below it get a status code instead of a + /// redirect to the login page when they are not authenticated. + /// + public const string ApiPathPrefix = "/api"; + + /// + /// The authentication schemes which are accepted by the public API: an API key for external + /// applications, and the cookie of the admin panel, so a logged in user can use it as well. + /// + public const string ApiSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + AuthenticationScheme; + + /// + /// The minimum length of a configured API key. + /// + /// + /// The key is a bearer credential which is sent with every request, so it has to have enough + /// entropy to make guessing it pointless. 32 characters are what a base64 encoded 24 byte + /// random value takes. + /// + public const int MinimumKeyLength = 32; +} diff --git a/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs new file mode 100644 index 000000000..6a56e0c4b --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,108 @@ +// +// 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.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +/// +/// Authenticates a request by the API key in its +/// header, or in its Authorization header with the Bearer scheme. +/// +/// +/// A request without a key is not a failure, but simply not authenticated by this scheme, so the +/// cookie of the admin panel still gets its chance to authenticate the same request. +/// +public class ApiKeyAuthenticationHandler : AuthenticationHandler +{ + private readonly ApiKeyRegistry _registry; + + /// + /// Initializes a new instance of the class. + /// + /// The scheme options. + /// The logger factory. + /// The url encoder. + /// The registry of the configured API keys. + public ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ApiKeyRegistry registry) + : base(options, logger, encoder) + { + this._registry = registry; + } + + /// + protected override Task HandleAuthenticateAsync() + { + if (!this.TryGetPresentedKey(out var presentedKey)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var client = this._registry.Find(presentedKey); + if (client is null) + { + // The key itself is never logged: it's a credential, and the log is readable in the panel. + this.Logger.LogWarning( + "Rejected an API request from {RemoteIpAddress} because its API key is unknown.", + this.Context.Connection.RemoteIpAddress); + return Task.FromResult(AuthenticateResult.Fail("The presented API key is unknown.")); + } + + var identity = new ClaimsIdentity( + client.CreateClaims(), + ApiKeyAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + ClaimTypes.Role); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), this.Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + this.Response.StatusCode = StatusCodes.Status401Unauthorized; + this.Response.Headers.Append(HeaderNames.WWWAuthenticate, ApiKeyAuthenticationDefaults.AuthorizationHeaderScheme); + return Task.CompletedTask; + } + + /// + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + this.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + + private bool TryGetPresentedKey(out string presentedKey) + { + presentedKey = string.Empty; + if (this.Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var apiKeyHeader) + && apiKeyHeader.Count > 0 + && !string.IsNullOrWhiteSpace(apiKeyHeader[0])) + { + presentedKey = apiKeyHeader[0]!.Trim(); + return true; + } + + if (this.Request.Headers.TryGetValue(HeaderNames.Authorization, out var authorizationHeader) + && authorizationHeader.Count > 0 + && authorizationHeader[0] is { } authorization + && authorization.StartsWith(ApiKeyAuthenticationDefaults.AuthorizationHeaderScheme + " ", StringComparison.OrdinalIgnoreCase)) + { + presentedKey = authorization[(ApiKeyAuthenticationDefaults.AuthorizationHeaderScheme.Length + 1)..].Trim(); + return presentedKey.Length > 0; + } + + return false; + } +} diff --git a/src/Web/AdminPanel/Auth/ApiKeyOptions.cs b/src/Web/AdminPanel/Auth/ApiKeyOptions.cs new file mode 100644 index 000000000..37d528a84 --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyOptions.cs @@ -0,0 +1,51 @@ +// +// 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 API keys with which external applications authenticate themselves +/// at the public API of the server. +/// +/// +/// The keys are configured, not stored in the database: the API has to work before the game +/// database exists, and an operator who can edit the configuration of the server can grant an +/// API key anyway. A key which is managed in the admin panel and stored as a hash would be the +/// next step, but it needs a schema of its own and a user interface. +/// +public class ApiKeyOptions +{ + /// + /// The name of the configuration section. + /// + public const string SectionName = "AdminPanel:Api"; + + /// + /// Gets or sets the configured API clients. + /// + public IList Keys { get; set; } = new List(); +} + +/// +/// One configured API client. +/// +public class ApiKeyEntry +{ + /// + /// Gets or sets the name of the client, e.g. launcher. It's only used to tell the + /// clients apart in the log, and to be able to revoke one of them. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the key itself, in plain text. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Gets or sets the roles of this client as a comma separated list, e.g. Viewer. + /// When it's not set, the client gets the least privileged role. + /// + public string? Roles { get; set; } +} diff --git a/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs b/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs new file mode 100644 index 000000000..9972a550d --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs @@ -0,0 +1,143 @@ +// +// 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.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// Holds the configured API keys and resolves a presented key to its client. +/// +public class ApiKeyRegistry +{ + private readonly ILogger _logger; + private readonly IReadOnlyList _clients; + + /// + /// Initializes a new instance of the class. + /// + /// The configured keys. + /// The logger. + public ApiKeyRegistry(IOptions options, ILogger logger) + { + this._logger = logger; + this._clients = this.CreateClients(options.Value); + } + + /// + /// Gets a value indicating whether any usable key is configured. + /// + public bool IsConfigured => this._clients.Count > 0; + + /// + /// Finds the client which presented the specified key. + /// + /// The key of the request. + /// The client; null, if no configured key matches. + /// + /// All configured keys are compared, and each of them in constant time, so neither the + /// duration of the comparison nor the number of comparisons tells an attacker how much of a + /// guessed key was right. + /// + public ApiKeyClient? Find(string presentedKey) + { + if (string.IsNullOrEmpty(presentedKey)) + { + return null; + } + + var presentedBytes = Encoding.UTF8.GetBytes(presentedKey); + ApiKeyClient? match = null; + foreach (var client in this._clients) + { + if (CryptographicOperations.FixedTimeEquals(presentedBytes, client.KeyBytes)) + { + match = client; + } + } + + return match; + } + + private IReadOnlyList CreateClients(ApiKeyOptions options) + { + var clients = new List(); + var knownKeys = new HashSet(StringComparer.Ordinal); + foreach (var entry in options.Keys) + { + var name = string.IsNullOrWhiteSpace(entry.Name) ? $"api-client-{clients.Count + 1}" : entry.Name.Trim(); + if (string.IsNullOrWhiteSpace(entry.Key)) + { + this._logger.LogWarning("The API key of client {ClientName} is empty and is ignored.", name); + continue; + } + + if (entry.Key.Length < ApiKeyAuthenticationDefaults.MinimumKeyLength) + { + this._logger.LogWarning( + "The API key of client {ClientName} is shorter than the required {MinimumLength} characters and is ignored.", + name, + ApiKeyAuthenticationDefaults.MinimumKeyLength); + continue; + } + + if (!knownKeys.Add(entry.Key)) + { + this._logger.LogWarning("The API key of client {ClientName} is used by another client as well and is ignored.", name); + continue; + } + + clients.Add(new ApiKeyClient(name, Encoding.UTF8.GetBytes(entry.Key), GetEffectiveRoles(entry.Roles))); + } + + if (clients.Count == 0) + { + this._logger.LogInformation("No API key is configured; the public API is only reachable with a logged in admin panel user."); + } + + return clients; + } + + private static IReadOnlyList GetEffectiveRoles(string? configuredRoles) + { + var assignedRoles = (configuredRoles ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (assignedRoles.Length == 0) + { + assignedRoles = [AdminRoles.Viewer]; + } + + return assignedRoles + .SelectMany(AdminRoles.GetEffectiveRoles) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} + +/// +/// An external application which is allowed to use the public API. +/// +/// The configured name of the client. +/// The utf-8 bytes of its key. +/// The effective roles of the client. +public record ApiKeyClient(string Name, byte[] KeyBytes, IReadOnlyList Roles) +{ + /// + /// Creates the claims of this client. + /// + /// The claims. + public IEnumerable CreateClaims() + { + yield return new Claim(ClaimTypes.Name, this.Name); + yield return new Claim(ApiKeyAuthenticationDefaults.ClientNameClaimType, this.Name); + foreach (var role in this.Roles) + { + yield return new Claim(ClaimTypes.Role, role); + } + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs new file mode 100644 index 000000000..a0ffbc10f --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs @@ -0,0 +1,175 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.AdminAuth; + +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// Tests for the API key authentication of the public API. +/// +[TestFixture] +public class ApiKeyAuthenticationTests +{ + private const string ValidKey = "0123456789abcdef0123456789abcdef"; + private const string OtherValidKey = "fedcba9876543210fedcba9876543210"; + + /// + /// Tests that a configured key resolves to its client. + /// + [Test] + public void ConfiguredKeyIsFound() + { + var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + + var client = registry.Find(ValidKey); + + Assert.That(client, Is.Not.Null); + Assert.That(client!.Name, Is.EqualTo("launcher")); + Assert.That(registry.IsConfigured, Is.True); + } + + /// + /// Tests that an unknown key is not accepted. + /// + [Test] + public void UnknownKeyIsNotFound() + { + var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + + Assert.That(registry.Find(OtherValidKey), Is.Null); + Assert.That(registry.Find(ValidKey + "x"), Is.Null); + Assert.That(registry.Find(string.Empty), Is.Null); + } + + /// + /// Tests that a key which is too short to be safe is not usable at all. + /// + [Test] + public void TooShortKeyIsIgnored() + { + var shortKey = new string('a', ApiKeyAuthenticationDefaults.MinimumKeyLength - 1); + + var registry = CreateRegistry(new ApiKeyEntry { Name = "sloppy", Key = shortKey }); + + Assert.That(registry.IsConfigured, Is.False); + Assert.That(registry.Find(shortKey), Is.Null); + } + + /// + /// Tests that a client without configured roles gets the least privileged role, and that the + /// roles of a client build up on each other like they do for a user. + /// + [Test] + public void RolesBuildUpOnEachOther() + { + var registry = CreateRegistry( + new ApiKeyEntry { Name = "reader", Key = ValidKey }, + new ApiKeyEntry { Name = "writer", Key = OtherValidKey, Roles = AdminRoles.Operator }); + + Assert.That(registry.Find(ValidKey)!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer })); + Assert.That(registry.Find(OtherValidKey)!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer, AdminRoles.Operator })); + } + + /// + /// Tests that the handler authenticates a request which carries the key in its own header. + /// + [Test] + public async Task ApiKeyHeaderAuthenticatesAsync() + { + var context = CreateContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = ValidKey; + + var result = await CreateHandlerAsync(context).ConfigureAwait(false); + + Assert.That(result.Succeeded, Is.True); + Assert.That(result.Principal!.Identity!.IsAuthenticated, Is.True); + Assert.That(result.Principal.FindFirstValue(ClaimTypes.Name), Is.EqualTo("launcher")); + Assert.That(result.Principal.IsInRole(AdminRoles.Viewer), Is.True); + } + + /// + /// Tests that the handler also accepts the key as a bearer token. + /// + [Test] + public async Task BearerTokenAuthenticatesAsync() + { + var context = CreateContext(); + context.Request.Headers[HeaderNames.Authorization] = $"Bearer {ValidKey}"; + + var result = await CreateHandlerAsync(context).ConfigureAwait(false); + + Assert.That(result.Succeeded, Is.True); + } + + /// + /// Tests that a request without a key is not a failure, so that the authentication cookie of + /// the admin panel still gets its chance on the same request. + /// + [Test] + public async Task RequestWithoutKeyIsNoResultAsync() + { + var result = await CreateHandlerAsync(CreateContext()).ConfigureAwait(false); + + Assert.That(result.None, Is.True); + Assert.That(result.Succeeded, Is.False); + } + + /// + /// Tests that a request with a wrong key fails instead of staying anonymous. + /// + [Test] + public async Task RequestWithWrongKeyFailsAsync() + { + var context = CreateContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = OtherValidKey; + + var result = await CreateHandlerAsync(context).ConfigureAwait(false); + + Assert.That(result.Succeeded, Is.False); + Assert.That(result.None, Is.False); + Assert.That(result.Failure, Is.Not.Null); + } + + private static ApiKeyRegistry CreateRegistry(params ApiKeyEntry[] entries) + { + var options = Options.Create(new ApiKeyOptions { Keys = entries.ToList() }); + return new ApiKeyRegistry(options, NullLogger.Instance); + } + + private static DefaultHttpContext CreateContext() => new(); + + private static async Task CreateHandlerAsync(HttpContext context) + { + var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + var handler = new ApiKeyAuthenticationHandler( + new StaticOptionsMonitor(), + NullLoggerFactory.Instance, + UrlEncoder.Default, + registry); + var scheme = new AuthenticationScheme( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + null, + typeof(ApiKeyAuthenticationHandler)); + await handler.InitializeAsync(scheme, context).ConfigureAwait(false); + return await handler.AuthenticateAsync().ConfigureAwait(false); + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + { + public AuthenticationSchemeOptions CurrentValue { get; } = new(); + + public AuthenticationSchemeOptions Get(string? name) => this.CurrentValue; + + public IDisposable? OnChange(Action listener) => null; + } +} From 13c5ebbc2d57f221280066bda7efb785f2eaa0df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:14:04 +0000 Subject: [PATCH 2/2] Manage the API keys in the admin panel The keys of the public API were configured only, which meant a restart to add or revoke one, and the key in plain text in a configuration file. They are now created and revoked on an own page next to the users, which needs the administrator role. A created key is generated from a cryptographic random number and shown exactly once - only its SHA-256 hash is stored, like the recovery codes of a user. A plain hash without a salt is enough and necessary here: the key is not guessable, and it has to be looked up on every request of the API. The list shows each key by its name and its leading characters, so keys can be told apart without knowing them, together with the last time each was used. A key which is not used anymore is therefore visible as such. That timestamp is written at most once per minute per key and never fails a request. A key can be disabled instead of deleted, which stops it from working without touching the configuration of the application which uses it, and its role can be changed without handing out a new key. The keys live in the "admin" schema next to the users, and for the same reason: they grant access to server functions, and no game server database role may read them. The configured keys keep working. The panel needs a database to store keys in, and the API has to work before that database exists - the same reason the panel has a bootstrap user. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VZLARthjVEssPfKLacpaWw --- .../docs/admin-panel/authentication.md | 78 +++++--- docs-website/docs/admin-panel/overview.md | 1 + src/Persistence/AdminAuth/ApiKey.cs | 70 +++++++ .../AdminAuth/IApiKeyRepository.cs | 73 ++++++++ .../AdminAuthServiceCollectionExtensions.cs | 4 +- .../AdminAuth/AdminPanelContext.cs | 16 ++ .../AdminAuth/ApiKeyRepository.cs | 131 +++++++++++++ .../20260826050827_AddApiKeys.Designer.cs | 138 ++++++++++++++ .../AdminPanel/20260826050827_AddApiKeys.cs | 49 +++++ .../AdminPanelContextModelSnapshot.cs | 43 +++++ .../Auth/AdminPanelAuthExtensions.cs | 3 + .../Auth/ApiKeyAuthenticationHandler.cs | 10 +- src/Web/AdminPanel/Auth/ApiKeyGenerator.cs | 62 +++++++ src/Web/AdminPanel/Auth/ApiKeyRegistry.cs | 119 ++++++++---- .../Auth/UnavailableApiKeyRepository.cs | 44 +++++ .../Components/Layout/NavMenu.razor | 5 + src/Web/AdminPanel/Pages/ApiKeys.razor | 81 ++++++++ src/Web/AdminPanel/Pages/ApiKeys.razor.cs | 84 +++++++++ .../Properties/Resources.Designer.cs | 155 +++++++++++++++- src/Web/AdminPanel/Properties/Resources.resx | 51 ++++++ src/Web/AdminPanel/Readme.md | 1 + .../Services/ApiKeyManagementService.cs | 173 ++++++++++++++++++ .../AdminAuth/ApiKeyAuthenticationTests.cs | 164 +++++++++++++---- .../AdminAuth/InMemoryApiKeyRepository.cs | 61 ++++++ 24 files changed, 1511 insertions(+), 105 deletions(-) create mode 100644 src/Persistence/AdminAuth/ApiKey.cs create mode 100644 src/Persistence/AdminAuth/IApiKeyRepository.cs create mode 100644 src/Persistence/EntityFramework/AdminAuth/ApiKeyRepository.cs create mode 100644 src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.cs create mode 100644 src/Web/AdminPanel/Auth/ApiKeyGenerator.cs create mode 100644 src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs create mode 100644 src/Web/AdminPanel/Pages/ApiKeys.razor create mode 100644 src/Web/AdminPanel/Pages/ApiKeys.razor.cs create mode 100644 src/Web/AdminPanel/Services/ApiKeyManagementService.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/AdminAuth/InMemoryApiKeyRepository.cs diff --git a/docs-website/docs/admin-panel/authentication.md b/docs-website/docs/admin-panel/authentication.md index c4f77f62e..ce67af057 100644 --- a/docs-website/docs/admin-panel/authentication.md +++ b/docs-website/docs/admin-panel/authentication.md @@ -147,10 +147,48 @@ X-Api-Key: `Authorization: Bearer ` works as well, for clients which only speak that. -### Configuring the keys +### Creating a key -Give every application its own key, so you can revoke one of them without -touching the others: +Open **API keys** in the navigation — it's next to *Users* and needs the +administrator role. Click **Create API key**, give the application a name and +pick its role. + +:::warning[The key is shown exactly once] +Only a hash of the key is stored, the same way recovery codes are. Copy it +straight into the application which needs it. If it gets lost, delete the key +and create a new one. +::: + +The list shows each key by its name and its first characters, so you can tell +them apart without knowing them, together with the last time it was used — a +key whose *Last used* stays empty is one you can delete. + +**Disable** stops a key from working without deleting it, which is the quickest +reaction when you suspect a key has leaked and you don't want to touch the +application's configuration yet. **Delete** removes it for good. + +### What a key may do + +A key has the same [roles](#roles) as a user, and defaults to **Viewer**: + +| Endpoint | Needs | +|---|---| +| `GET /api/status` | Viewer | +| `GET /api/is-online/{account}` | Viewer | +| `GET /api/send/{server}?msg=` | Operator | + +So a status page gets a Viewer key and can only read, while an application which +announces something in the game needs an Operator key. The role of a key can be +changed in the list at any time; the key itself stays the same. + +A signed in admin panel user can use the API as well, with the same roles — this +is handy while trying things out in the browser. + +### Keys from the configuration + +The panel needs a database to store keys in, and the API has to work before that +database exists. For that case — and to deploy a key together with the server +instead of clicking it in afterwards — keys can also be configured: ```json { @@ -158,7 +196,7 @@ touching the others: "Api": { "Keys": [ { "Name": "launcher", "Key": "" }, - { "Name": "website", "Key": "", "Roles": "Operator" } + { "Name": "website", "Key": "", "Roles": "Operator" } ] } } @@ -174,30 +212,14 @@ OPENMU_API_KEY= OPENMU_API_KEY_ROLES=Operator ``` -Generate a key with something which is actually random, e.g. -`openssl rand -base64 24`. Keys shorter than 32 characters are refused and -logged, because a key travels with every request and is only as good as its -entropy. - -### What a key may do - -A key has the same [roles](#roles) as a user, and defaults to **Viewer**: - -| Endpoint | Needs | -|---|---| -| `GET /api/status` | Viewer | -| `GET /api/is-online/{account}` | Viewer | -| `GET /api/send/{server}?msg=` | Operator | - -So a status page gets a Viewer key and can only read, while an application which -announces something in the game needs an Operator key. +Generate one with something which is actually random, e.g. +`openssl rand -base64 24`. Configured keys shorter than 32 characters are +refused and logged, because a key travels with every request and is only as good +as its entropy. -A signed in admin panel user can use the API as well, with the same roles — this -is handy while trying things out in the browser. - -Like the panel itself, the API is open as long as [no user exists at all](#the-first-user), -so configure a key together with the bootstrap user rather than after the first -start. +Configured keys can't be managed or disabled in the panel — they live outside of +the database, exactly like the bootstrap user. Prefer created keys, and use +configured ones only where you need them. :::warning[The key is a password] It is sent in plain text with every request, so use HTTPS, and keep it out of @@ -206,6 +228,8 @@ players have. Requests without a valid key get `401`, and requests whose key lacks the role get `403`. ::: +Like the panel itself, the API is open as long as [no user exists at all](#the-first-user). + ## Keeping the sessions alive across restarts The sessions and the stored authenticator secrets are protected with a key ring diff --git a/docs-website/docs/admin-panel/overview.md b/docs-website/docs/admin-panel/overview.md index fa72807d7..13662e1c9 100644 --- a/docs-website/docs/admin-panel/overview.md +++ b/docs-website/docs/admin-panel/overview.md @@ -59,6 +59,7 @@ with `-adminpanel:disabled`. | [Live map](live-map.md) | Watch what happens on a map in real time | | [Logs and monitoring](logs-and-monitoring.md) | Log files, Grafana, Prometheus, Zipkin | | [Users](users.md) | The users which may log into the admin panel | +| [API keys](authentication.md#api-keys-for-external-applications) | The keys with which external applications use the public API | Server features which are configured through the panel have their own pages, for example the [server-side AI bots](../server-features/bots.md). diff --git a/src/Persistence/AdminAuth/ApiKey.cs b/src/Persistence/AdminAuth/ApiKey.cs new file mode 100644 index 000000000..aa33458bd --- /dev/null +++ b/src/Persistence/AdminAuth/ApiKey.cs @@ -0,0 +1,70 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// A key with which an external application authenticates itself at the public API of the server. +/// +/// +/// It lives in the same schema as the and for the same reasons: it grants +/// access to server functions, not to the game, and no game server database role may read it. +/// +public class ApiKey +{ + /// + /// Gets or sets the identifier of this key. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the name of the application which uses this key, e.g. launcher. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the hash of the key. + /// + /// + /// Only the hash is stored, so a database dump doesn't hand out usable keys. It's a plain + /// SHA-256 without a salt, like the recovery codes of an : the key is + /// generated by the server from a cryptographic random number and is therefore not guessable, + /// which makes a slow hash unnecessary - and it has to be looked up on every API request. + /// + public string KeyHash { get; set; } = string.Empty; + + /// + /// Gets or sets the prefix of the key, which is shown in the admin panel so a key can be + /// recognized without knowing it. + /// + public string KeyPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the roles of this key, as a comma separated list. + /// + /// + public string Roles { get; set; } = AdminRoles.Viewer; + + /// + /// Gets or sets a value indicating whether this key is disabled and therefore rejected. + /// + public bool IsDisabled { get; set; } + + /// + /// Gets or sets the date and time when this key has been created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets the date and time at which this key has been used the last time. + /// + /// + /// It's only updated at most once per minute, so a busy client doesn't cause a database write + /// on every request. It's meant to tell an unused key from a used one, not to be an audit log. + /// + public DateTime? LastUsedAt { get; set; } + + /// + public override string ToString() => this.Name; +} diff --git a/src/Persistence/AdminAuth/IApiKeyRepository.cs b/src/Persistence/AdminAuth/IApiKeyRepository.cs new file mode 100644 index 000000000..ed76ba7e2 --- /dev/null +++ b/src/Persistence/AdminAuth/IApiKeyRepository.cs @@ -0,0 +1,73 @@ +// +// 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 public API. +/// +/// +/// Like the , the implementation must be usable independently of +/// the game database. +/// +public interface IApiKeyRepository +{ + /// + /// 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 all stored keys, ordered by their name. + /// + /// The cancellation token. + /// All stored keys. + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the enabled key with the specified hash. + /// + /// The hash of the presented key. + /// The cancellation token. + /// The key, if found and enabled; otherwise, null. + ValueTask GetEnabledByHashAsync(string keyHash, CancellationToken cancellationToken = default); + + /// + /// Adds the specified key. + /// + /// The key. + /// The cancellation token. + ValueTask AddAsync(ApiKey apiKey, CancellationToken cancellationToken = default); + + /// + /// Updates the specified key. + /// + /// The key. + /// The cancellation token. + ValueTask UpdateAsync(ApiKey apiKey, CancellationToken cancellationToken = default); + + /// + /// Sets the of the key with the specified identifier. + /// + /// The identifier of the key. + /// The point in time. + /// The cancellation token. + /// + /// This happens while a request is served, so it must not fail the request when it doesn't work. + /// + ValueTask TouchAsync(Guid id, DateTime lastUsedAt, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified key. + /// + /// The key. + /// The cancellation token. + ValueTask DeleteAsync(ApiKey apiKey, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs b/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs index 23aa5d380..77cc7a200 100644 --- a/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs +++ b/src/Persistence/EntityFramework/AdminAuth/AdminAuthServiceCollectionExtensions.cs @@ -14,13 +14,15 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; public static class AdminAuthServiceCollectionExtensions { /// - /// Adds the database backed to the service collection. + /// Adds the database backed and + /// 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(); + services.TryAddSingleton(); return services; } } diff --git a/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs index 74e33b69a..553ce5130 100644 --- a/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs +++ b/src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs @@ -25,6 +25,11 @@ public class AdminPanelContext : DbContext /// public DbSet AdminUsers { get; set; } = null!; + /// + /// Gets or sets the API keys of the public API. + /// + public DbSet ApiKeys { get; set; } = null!; + /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -54,5 +59,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(u => u.SecurityStamp).IsRequired(); entity.Property(u => u.Roles).IsRequired().HasMaxLength(200); }); + + modelBuilder.Entity(entity => + { + entity.ToTable(nameof(ApiKey), SchemaNames.AdminPanel); + entity.HasKey(k => k.Id); + entity.Property(k => k.Name).IsRequired().HasMaxLength(100); + entity.Property(k => k.KeyHash).IsRequired().HasMaxLength(100); + entity.HasIndex(k => k.KeyHash).IsUnique(); + entity.Property(k => k.KeyPrefix).IsRequired().HasMaxLength(16); + entity.Property(k => k.Roles).IsRequired().HasMaxLength(200); + }); } } diff --git a/src/Persistence/EntityFramework/AdminAuth/ApiKeyRepository.cs b/src/Persistence/EntityFramework/AdminAuth/ApiKeyRepository.cs new file mode 100644 index 000000000..ae785876f --- /dev/null +++ b/src/Persistence/EntityFramework/AdminAuth/ApiKeyRepository.cs @@ -0,0 +1,131 @@ +// +// 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; + +/// +/// Implementation of the which stores the keys +/// in the admin schema of the configured PostgreSQL database. +/// +public class ApiKeyRepository : IApiKeyRepository +{ + private readonly IAdminUserRepository _adminUserRepository; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The repository of the admin users, which owns the migration of the shared context. + /// The logger. + public ApiKeyRepository(IAdminUserRepository adminUserRepository, ILogger logger) + { + this._adminUserRepository = adminUserRepository; + this._logger = logger; + } + + /// + /// + /// Both tables live in the same context, so the migration is run by the admin user repository + /// and doesn't have to be triggered a second time here. + /// + public ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) + => this._adminUserRepository.EnsureStorageAsync(cancellationToken); + + /// + 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.ApiKeys + .AsNoTracking() + .OrderBy(k => k.Name) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask GetEnabledByHashAsync(string keyHash, CancellationToken cancellationToken = default) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return null; + } + + await using var context = new AdminPanelContext(); + return await context.ApiKeys + .AsNoTracking() + .FirstOrDefaultAsync(k => k.KeyHash == keyHash && !k.IsDisabled, cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask AddAsync(ApiKey apiKey, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.ApiKeys.Add(apiKey); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask UpdateAsync(ApiKey apiKey, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.ApiKeys.Update(apiKey); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask TouchAsync(Guid id, DateTime lastUsedAt, CancellationToken cancellationToken = default) + { + try + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + return; + } + + await using var context = new AdminPanelContext(); + await context.ApiKeys + .Where(k => k.Id == id) + .ExecuteUpdateAsync(setters => setters.SetProperty(k => k.LastUsedAt, lastUsedAt), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // This is only a convenience for the admin panel, so it must never fail an API request. + this._logger.LogDebug(ex, "Could not update the last usage of the API key {ApiKeyId}.", id); + } + } + + /// + public async ValueTask DeleteAsync(ApiKey apiKey, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new AdminPanelContext(); + context.ApiKeys.Remove(apiKey); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + private async ValueTask EnsureAvailableStorageAsync(CancellationToken cancellationToken) + { + if (!await this.EnsureStorageAsync(cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("The API key storage is not available. Please check the database connection."); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.Designer.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.Designer.cs new file mode 100644 index 000000000..ab8b64468 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.Designer.cs @@ -0,0 +1,138 @@ +// +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("20260826050827_AddApiKeys")] + partial class AddApiKeys + { + /// + 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"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.AdminAuth.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("ApiKey", "admin"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.cs new file mode 100644 index 000000000..a289b386b --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/20260826050827_AddApiKeys.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.AdminPanel +{ + /// + public partial class AddApiKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKey", + schema: "admin", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyHash = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + KeyPrefix = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Roles = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsDisabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKey", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKey_KeyHash", + schema: "admin", + table: "ApiKey", + column: "KeyHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKey", + schema: "admin"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs index c2f56eafb..911b55628 100644 --- a/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/AdminPanel/AdminPanelContextModelSnapshot.cs @@ -86,6 +86,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AdminUser", "admin"); }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.AdminAuth.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDisabled") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Roles") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("ApiKey", "admin"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs index e83d3e084..7aadf08d5 100644 --- a/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs +++ b/src/Web/AdminPanel/Auth/AdminPanelAuthExtensions.cs @@ -17,6 +17,7 @@ namespace MUnique.OpenMU.Web.AdminPanel.Auth; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Web.AdminPanel.Services; /// /// Extensions which add the authentication of the admin panel. @@ -73,6 +74,7 @@ public static IServiceCollection AddAdminPanelAuth(this IServiceCollection servi ApplyApiKeyEnvironmentVariable(apiKeyOptions); services.Configure(options => options.Keys = apiKeyOptions.Keys); services.AddSingleton(); + services.AddScoped(); // 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 @@ -85,6 +87,7 @@ public static IServiceCollection AddAdminPanelAuth(this IServiceCollection servi // 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.TryAddSingleton(); services.AddSingleton(); services.AddSingleton, BCryptPasswordHasher>(); diff --git a/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs index 6a56e0c4b..53c4f6f23 100644 --- a/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs +++ b/src/Web/AdminPanel/Auth/ApiKeyAuthenticationHandler.cs @@ -42,21 +42,21 @@ public ApiKeyAuthenticationHandler( } /// - protected override Task HandleAuthenticateAsync() + protected override async Task HandleAuthenticateAsync() { if (!this.TryGetPresentedKey(out var presentedKey)) { - return Task.FromResult(AuthenticateResult.NoResult()); + return AuthenticateResult.NoResult(); } - var client = this._registry.Find(presentedKey); + var client = await this._registry.FindAsync(presentedKey, this.Context.RequestAborted).ConfigureAwait(false); if (client is null) { // The key itself is never logged: it's a credential, and the log is readable in the panel. this.Logger.LogWarning( "Rejected an API request from {RemoteIpAddress} because its API key is unknown.", this.Context.Connection.RemoteIpAddress); - return Task.FromResult(AuthenticateResult.Fail("The presented API key is unknown.")); + return AuthenticateResult.Fail("The presented API key is unknown."); } var identity = new ClaimsIdentity( @@ -65,7 +65,7 @@ protected override Task HandleAuthenticateAsync() ClaimTypes.Name, ClaimTypes.Role); var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), this.Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(ticket)); + return AuthenticateResult.Success(ticket); } /// diff --git a/src/Web/AdminPanel/Auth/ApiKeyGenerator.cs b/src/Web/AdminPanel/Auth/ApiKeyGenerator.cs new file mode 100644 index 000000000..519326660 --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyGenerator.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.Security.Cryptography; + +/// +/// Creates API keys and hashes them for the storage. +/// +public static class ApiKeyGenerator +{ + /// + /// The prefix of every generated key, so it can be recognized as one, e.g. in a log or a + /// secret scanner. + /// + public const string KeyPrefix = "omu_"; + + /// + /// The number of leading characters of a key which are stored in plain text, so a key can be + /// told apart from another one in the admin panel without knowing it. + /// + public const int VisiblePrefixLength = 12; + + private const int SecretByteCount = 32; + + /// + /// Creates a new random key. + /// + /// The key, which is only ever available here and is not recoverable afterwards. + public static string GenerateKey() + { + var secret = RandomNumberGenerator.GetBytes(SecretByteCount); + return KeyPrefix + Convert.ToBase64String(secret).Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + /// + /// Hashes the specified key for the storage. + /// + /// The key. + /// The base64 encoded SHA-256 hash of the key. + /// + /// A plain SHA-256 without a salt is enough here, because a generated key is a random value of + /// bytes and therefore not guessable. It also has to be computed + /// on every request of the public API, which rules out a slow hash. + /// + public static string Hash(string key) + { + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(key))); + } + + /// + /// Gets the visible prefix of the specified key. + /// + /// The key. + /// The first characters of the key. + public static string GetVisiblePrefix(string key) + { + return key.Length <= VisiblePrefixLength ? key : key[..VisiblePrefixLength]; + } +} diff --git a/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs b/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs index 9972a550d..5a06313e1 100644 --- a/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs +++ b/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs @@ -4,83 +4,140 @@ namespace MUnique.OpenMU.Web.AdminPanel.Auth; +using System.Collections.Concurrent; using System.Security.Claims; using System.Security.Cryptography; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MUnique.OpenMU.Persistence.AdminAuth; /// -/// Holds the configured API keys and resolves a presented key to its client. +/// Resolves a presented API key to the client which uses it. /// +/// +/// There are two sources: the keys which are managed in the admin panel and stored as hashes, and +/// the keys from the configuration. The configured ones stay supported because the API has to work +/// before the database exists - the same reason the admin panel has a bootstrap user. +/// public class ApiKeyRegistry { + /// + /// The interval in which the of a key is updated at most, so a + /// busy client doesn't cause a database write on every single request. + /// + private static readonly TimeSpan TouchInterval = TimeSpan.FromMinutes(1); + + private readonly IApiKeyRepository _repository; private readonly ILogger _logger; - private readonly IReadOnlyList _clients; + private readonly IReadOnlyList _configuredKeys; + private readonly ConcurrentDictionary _lastTouched = new(); /// /// Initializes a new instance of the class. /// /// The configured keys. + /// The repository of the keys which are managed in the admin panel. /// The logger. - public ApiKeyRegistry(IOptions options, ILogger logger) + public ApiKeyRegistry(IOptions options, IApiKeyRepository repository, ILogger logger) { + this._repository = repository; this._logger = logger; - this._clients = this.CreateClients(options.Value); + this._configuredKeys = this.CreateConfiguredKeys(options.Value); } /// - /// Gets a value indicating whether any usable key is configured. + /// Finds the client which presented the specified key. /// - public bool IsConfigured => this._clients.Count > 0; + /// The key of the request. + /// The cancellation token. + /// The client; null, if neither a configured nor a stored key matches. + public async ValueTask FindAsync(string presentedKey, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(presentedKey)) + { + return null; + } + + if (this.FindConfigured(presentedKey) is { } configuredClient) + { + return configuredClient; + } + + var storedKey = await this._repository + .GetEnabledByHashAsync(ApiKeyGenerator.Hash(presentedKey), cancellationToken) + .ConfigureAwait(false); + if (storedKey is null) + { + return null; + } + + this.TouchInBackground(storedKey); + return new ApiKeyClient(storedKey.Name, GetEffectiveRoles(storedKey.Roles)); + } /// - /// Finds the client which presented the specified key. + /// Finds the client of a configured key. /// /// The key of the request. /// The client; null, if no configured key matches. /// - /// All configured keys are compared, and each of them in constant time, so neither the - /// duration of the comparison nor the number of comparisons tells an attacker how much of a - /// guessed key was right. + /// All configured keys are compared, and each of them in constant time, so neither the duration + /// of the comparison nor the number of comparisons tells an attacker how much of a guessed key + /// was right. The stored keys don't need this: they are looked up by their hash. /// - public ApiKeyClient? Find(string presentedKey) + private ApiKeyClient? FindConfigured(string presentedKey) { - if (string.IsNullOrEmpty(presentedKey)) + if (this._configuredKeys.Count == 0) { return null; } var presentedBytes = Encoding.UTF8.GetBytes(presentedKey); - ApiKeyClient? match = null; - foreach (var client in this._clients) + ConfiguredApiKey? match = null; + foreach (var candidate in this._configuredKeys) { - if (CryptographicOperations.FixedTimeEquals(presentedBytes, client.KeyBytes)) + if (CryptographicOperations.FixedTimeEquals(presentedBytes, candidate.KeyBytes)) { - match = client; + match = candidate; } } - return match; + return match is null ? null : new ApiKeyClient(match.Name, match.Roles); } - private IReadOnlyList CreateClients(ApiKeyOptions options) + private void TouchInBackground(ApiKey storedKey) { - var clients = new List(); + var now = DateTime.UtcNow; + var lastTouched = this._lastTouched.GetOrAdd(storedKey.Id, DateTime.MinValue); + if (now - lastTouched < TouchInterval + || !this._lastTouched.TryUpdate(storedKey.Id, now, lastTouched)) + { + return; + } + + // Deliberately not awaited: the last usage is a convenience for the admin panel and must + // neither slow a request down nor fail it. The repository swallows its own errors. + _ = this._repository.TouchAsync(storedKey.Id, now, CancellationToken.None).AsTask(); + } + + private IReadOnlyList CreateConfiguredKeys(ApiKeyOptions options) + { + var keys = new List(); var knownKeys = new HashSet(StringComparer.Ordinal); foreach (var entry in options.Keys) { - var name = string.IsNullOrWhiteSpace(entry.Name) ? $"api-client-{clients.Count + 1}" : entry.Name.Trim(); + var name = string.IsNullOrWhiteSpace(entry.Name) ? $"api-client-{keys.Count + 1}" : entry.Name.Trim(); if (string.IsNullOrWhiteSpace(entry.Key)) { - this._logger.LogWarning("The API key of client {ClientName} is empty and is ignored.", name); + this._logger.LogWarning("The configured API key of client {ClientName} is empty and is ignored.", name); continue; } if (entry.Key.Length < ApiKeyAuthenticationDefaults.MinimumKeyLength) { this._logger.LogWarning( - "The API key of client {ClientName} is shorter than the required {MinimumLength} characters and is ignored.", + "The configured API key of client {ClientName} is shorter than the required {MinimumLength} characters and is ignored.", name, ApiKeyAuthenticationDefaults.MinimumKeyLength); continue; @@ -88,19 +145,14 @@ private IReadOnlyList CreateClients(ApiKeyOptions options) if (!knownKeys.Add(entry.Key)) { - this._logger.LogWarning("The API key of client {ClientName} is used by another client as well and is ignored.", name); + this._logger.LogWarning("The configured API key of client {ClientName} is used by another client as well and is ignored.", name); continue; } - clients.Add(new ApiKeyClient(name, Encoding.UTF8.GetBytes(entry.Key), GetEffectiveRoles(entry.Roles))); + keys.Add(new ConfiguredApiKey(name, Encoding.UTF8.GetBytes(entry.Key), GetEffectiveRoles(entry.Roles))); } - if (clients.Count == 0) - { - this._logger.LogInformation("No API key is configured; the public API is only reachable with a logged in admin panel user."); - } - - return clients; + return keys; } private static IReadOnlyList GetEffectiveRoles(string? configuredRoles) @@ -117,15 +169,16 @@ private static IReadOnlyList GetEffectiveRoles(string? configuredRoles) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } + + private sealed record ConfiguredApiKey(string Name, byte[] KeyBytes, IReadOnlyList Roles); } /// /// An external application which is allowed to use the public API. /// -/// The configured name of the client. -/// The utf-8 bytes of its key. +/// The name of the client. /// The effective roles of the client. -public record ApiKeyClient(string Name, byte[] KeyBytes, IReadOnlyList Roles) +public record ApiKeyClient(string Name, IReadOnlyList Roles) { /// /// Creates the claims of this client. diff --git a/src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs b/src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs new file mode 100644 index 000000000..2026e7968 --- /dev/null +++ b/src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs @@ -0,0 +1,44 @@ +// +// 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; + +/// +/// A fallback which is used when the hosting application didn't +/// register a real one. +/// +/// +/// It behaves like an empty storage, so only the configured keys work. Unlike the users, this +/// doesn't warn on its own: the admin user repository already does, and both are registered together. +/// +public class UnavailableApiKeyRepository : IApiKeyRepository +{ + private const string NotAvailableMessage = "The API key storage is not available."; + + /// + public ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(false); + + /// + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult>(new List()); + + /// + public ValueTask GetEnabledByHashAsync(string keyHash, CancellationToken cancellationToken = default) + => ValueTask.FromResult(null); + + /// + public ValueTask AddAsync(ApiKey apiKey, CancellationToken cancellationToken = default) => throw new InvalidOperationException(NotAvailableMessage); + + /// + public ValueTask UpdateAsync(ApiKey apiKey, CancellationToken cancellationToken = default) => throw new InvalidOperationException(NotAvailableMessage); + + /// + public ValueTask TouchAsync(Guid id, DateTime lastUsedAt, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + /// + public ValueTask DeleteAsync(ApiKey apiKey, CancellationToken cancellationToken = default) => throw new InvalidOperationException(NotAvailableMessage); +} diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor b/src/Web/AdminPanel/Components/Layout/NavMenu.razor index f5df6ebd2..7eedc0add 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor @@ -75,6 +75,11 @@ @Resources.Users + @if (AdminPanelEnvironment.IsHostingEmbedded) { diff --git a/src/Web/AdminPanel/Pages/ApiKeys.razor b/src/Web/AdminPanel/Pages/ApiKeys.razor new file mode 100644 index 000000000..9f8558c8a --- /dev/null +++ b/src/Web/AdminPanel/Pages/ApiKeys.razor @@ -0,0 +1,81 @@ +@page "/api-keys" +@using MUnique.OpenMU.Persistence.AdminAuth +@using MUnique.OpenMU.Web.AdminPanel.Properties +@attribute [Authorize(Policy = AdminPolicies.Administrator)] + +@{ + var title = Resources.ApiKeys; +} +OpenMU: @title + +

@title

+ +

@Resources.ApiKeysDescription

+ +@if (!string.IsNullOrEmpty(this._createdKey)) +{ + +} + +@if (this._isLoading) +{ +

@Resources.Loading

+} +else +{ +
+ + + + + + + + + + + + @foreach (var apiKey in this._apiKeys) + { + + + + + + + + } + @if (this._apiKeys.Count == 0) + { + + + + } + +
@Resources.ApiKeyName@Resources.ApiKeyPrefix@Resources.Role@Resources.LastUsed@Resources.Actions
@apiKey.Name@apiKey.KeyPrefix… + + @(apiKey.LastUsedAt?.ToString("yyyy-MM-dd HH:mm") ?? "-") + @if (apiKey.IsDisabled) + { + + } + else + { + + } + +
@Resources.NoApiKeys
+ +
+} diff --git a/src/Web/AdminPanel/Pages/ApiKeys.razor.cs b/src/Web/AdminPanel/Pages/ApiKeys.razor.cs new file mode 100644 index 000000000..58c128bf1 --- /dev/null +++ b/src/Web/AdminPanel/Pages/ApiKeys.razor.cs @@ -0,0 +1,84 @@ +// +// 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 API keys of the public API. +/// +public partial class ApiKeys +{ + private IList _apiKeys = new List(); + private bool _isLoading = true; + + /// + /// The key which has just been created. It's the only moment at which it's available, because + /// only its hash is stored. + /// + private string? _createdKey; + + [Inject] + private ApiKeyManagementService ApiKeyManagementService { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } + + private async Task ReloadAsync() + { + this._isLoading = true; + try + { + this._apiKeys = await this.ApiKeyManagementService.GetKeysAsync().ConfigureAwait(true); + } + finally + { + this._isLoading = false; + } + } + + private async Task OnCreateNewAsync() + { + var createdKey = await this.ApiKeyManagementService.CreateNewInModalDialogAsync().ConfigureAwait(true); + if (createdKey is null) + { + return; + } + + this._createdKey = createdKey; + await this.ReloadAsync().ConfigureAwait(true); + } + + private async Task OnSetDisabledAsync(ApiKey apiKey, bool isDisabled) + { + await this.ApiKeyManagementService.SetDisabledAsync(apiKey, isDisabled).ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } + + private async Task OnDeleteAsync(ApiKey apiKey) + { + if (await this.ApiKeyManagementService.DeleteAsync(apiKey).ConfigureAwait(true)) + { + await this.ReloadAsync().ConfigureAwait(true); + } + } + + private async Task OnRoleChangedAsync(ApiKey apiKey, string? role) + { + if (string.IsNullOrEmpty(role) || role == apiKey.Roles) + { + return; + } + + await this.ApiKeyManagementService.SetRoleAsync(apiKey, role).ConfigureAwait(true); + await this.ReloadAsync().ConfigureAwait(true); + } +} diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 404d816b4..6b45ad5b7 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -2177,5 +2177,158 @@ public static string CannotModifyBootstrapUser { } } - } + + /// + /// Looks up a localized string similar to API keys. + /// + public static string ApiKeys { + get { + return ResourceManager.GetString("ApiKeys", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to External applications like a game launcher or a website authenticate themselves at the .... + /// + public static string ApiKeysDescription { + get { + return ResourceManager.GetString("ApiKeysDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Application. + /// + public static string ApiKeyName { + get { + return ResourceManager.GetString("ApiKeyName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key. + /// + public static string ApiKeyPrefix { + get { + return ResourceManager.GetString("ApiKeyPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Create API key. + /// + public static string CreateApiKey { + get { + return ResourceManager.GetString("CreateApiKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete API key. + /// + public static string DeleteApiKey { + get { + return ResourceManager.GetString("DeleteApiKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Do you really want to delete the API key of '{0}'? The application which uses it stops .... + /// + public static string DeleteApiKeyQuestion { + get { + return ResourceManager.GetString("DeleteApiKeyQuestion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The API key has been created.. + /// + public static string ApiKeyCreated { + get { + return ResourceManager.GetString("ApiKeyCreated", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The API key has been deleted.. + /// + public static string ApiKeyDeleted { + get { + return ResourceManager.GetString("ApiKeyDeleted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The API key has been enabled.. + /// + public static string ApiKeyEnabled { + get { + return ResourceManager.GetString("ApiKeyEnabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The API key has been disabled.. + /// + public static string ApiKeyDisabled { + get { + return ResourceManager.GetString("ApiKeyDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy this key now. + /// + public static string ApiKeyShownOnce { + get { + return ResourceManager.GetString("ApiKeyShownOnce", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only the hash of the key is stored, so this is the only time it's shown. If it gets los.... + /// + public static string ApiKeyShownOnceDescription { + get { + return ResourceManager.GetString("ApiKeyShownOnceDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No API key has been created yet.. + /// + public static string NoApiKeys { + get { + return ResourceManager.GetString("NoApiKeys", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Last used. + /// + public static string LastUsed { + get { + return ResourceManager.GetString("LastUsed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable. + /// + public static string Enable { + get { + return ResourceManager.GetString("Enable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disable. + /// + public static string Disable { + get { + return ResourceManager.GetString("Disable", resourceCulture); + } + } +} } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 17e6d3351..bd7e69e39 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -825,4 +825,55 @@ The bootstrap user is defined by the configuration and can't be changed here. + + API keys + + + External applications like a game launcher or a website authenticate themselves at the public API under /api with one of these keys. Give each application its own key, so a single one can be revoked. + + + Application + + + Key + + + Create API key + + + Delete API key + + + Do you really want to delete the API key of '{0}'? The application which uses it stops working immediately. + + + The API key has been created. + + + The API key has been deleted. + + + The API key has been enabled. + + + The API key has been disabled. + + + Copy this key now + + + Only the hash of the key is stored, so this is the only time it's shown. If it gets lost, delete the key and create a new one. + + + No API key has been created yet. + + + Last used + + + Enable + + + Disable + \ No newline at end of file diff --git a/src/Web/AdminPanel/Readme.md b/src/Web/AdminPanel/Readme.md index 93462365b..942901721 100644 --- a/src/Web/AdminPanel/Readme.md +++ b/src/Web/AdminPanel/Readme.md @@ -20,5 +20,6 @@ screen: [Live map](../../../docs-website/docs/admin-panel/live-map.md) * [Logs and monitoring](../../../docs-website/docs/admin-panel/logs-and-monitoring.md) and [Users](../../../docs-website/docs/admin-panel/users.md) +* [Signing in and API keys](../../../docs-website/docs/admin-panel/authentication.md) * [Common tasks](../../../docs-website/docs/admin-panel/common-tasks.md) — short how-tos which combine several of the pages diff --git a/src/Web/AdminPanel/Services/ApiKeyManagementService.cs b/src/Web/AdminPanel/Services/ApiKeyManagementService.cs new file mode 100644 index 000000000..99314c5d9 --- /dev/null +++ b/src/Web/AdminPanel/Services/ApiKeyManagementService.cs @@ -0,0 +1,173 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Services; + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using MUnique.OpenMU.Persistence.AdminAuth; +using MUnique.OpenMU.Web.AdminPanel.Auth; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared; +using MUnique.OpenMU.Web.Shared.Components.Form.Modal; +using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Manages the API keys with which external applications use the public API. +/// +public class ApiKeyManagementService +{ + private readonly IApiKeyRepository _repository; + private readonly IModalService _modalService; + private readonly IToastService _toastService; + + /// + /// Initializes a new instance of the class. + /// + /// The repository of the stored keys. + /// The modal service. + /// The toast service. + public ApiKeyManagementService(IApiKeyRepository repository, IModalService modalService, IToastService toastService) + { + this._repository = repository; + this._modalService = modalService; + this._toastService = toastService; + } + + /// + /// Gets all stored keys. + /// + /// All stored keys. + public async Task> GetKeysAsync() + { + return await this._repository.GetAllAsync().ConfigureAwait(false); + } + + /// + /// Creates a new key, asking for its name and role in a modal dialog. + /// + /// + /// The generated key in plain text, so it can be shown to the user exactly once; + /// null, if no key has been created. + /// + public async Task CreateNewInModalDialogAsync() + { + var input = new ApiKeyCreationParameters(); + var parameters = new ModalParameters(); + parameters.Add(nameof(ModalCreateNew.Item), input); + var modal = this._modalService.Show>(Resources.CreateApiKey, parameters, new ModalOptions { DisableBackgroundCancel = true }); + var result = await modal.Result.ConfigureAwait(false); + if (result.Cancelled) + { + return null; + } + + var generatedKey = ApiKeyGenerator.GenerateKey(); + var apiKey = new ApiKey + { + Id = Guid.NewGuid(), + Name = input.Name, + KeyHash = ApiKeyGenerator.Hash(generatedKey), + KeyPrefix = ApiKeyGenerator.GetVisiblePrefix(generatedKey), + Roles = input.Role.ToString(), + }; + + try + { + await this._repository.AddAsync(apiKey).ConfigureAwait(false); + } + catch (Exception ex) + { + this._toastService.ShowError(ex.Message); + return null; + } + + this._toastService.ShowSuccess(Resources.ApiKeyCreated); + return generatedKey; + } + + /// + /// Enables or disables the specified key. + /// + /// The key. + /// If set to true, the key is rejected from now on. + public async Task SetDisabledAsync(ApiKey apiKey, bool isDisabled) + { + apiKey.IsDisabled = isDisabled; + try + { + await this._repository.UpdateAsync(apiKey).ConfigureAwait(false); + this._toastService.ShowSuccess(isDisabled ? Resources.ApiKeyDisabled : Resources.ApiKeyEnabled); + } + catch (Exception ex) + { + this._toastService.ShowError(ex.Message); + } + } + + /// + /// Assigns the specified role to the specified key. + /// + /// The key. + /// The role. + public async Task SetRoleAsync(ApiKey apiKey, string role) + { + apiKey.Roles = role; + try + { + await this._repository.UpdateAsync(apiKey).ConfigureAwait(false); + } + catch (Exception ex) + { + this._toastService.ShowError(ex.Message); + } + } + + /// + /// Deletes the specified key, after asking for a confirmation. + /// + /// The key. + /// true, if the key has been deleted; otherwise, false. + public async Task DeleteAsync(ApiKey apiKey) + { + var confirmed = await this._modalService + .ShowQuestionAsync(Resources.DeleteApiKey, string.Format(Resources.DeleteApiKeyQuestion, apiKey.Name)) + .ConfigureAwait(false); + if (!confirmed) + { + return false; + } + + try + { + await this._repository.DeleteAsync(apiKey).ConfigureAwait(false); + } + catch (Exception ex) + { + this._toastService.ShowError(ex.Message); + return false; + } + + this._toastService.ShowSuccess(Resources.ApiKeyDeleted); + return true; + } + + /// + /// The parameters to create a new API key. + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used by data binding.")] + private class ApiKeyCreationParameters + { + [Display(ResourceType = typeof(Resources), Name = nameof(Resources.ApiKeyName))] + [MaxLength(100)] + [MinLength(3)] + [Required] + public string Name { get; set; } = string.Empty; + + [Display(ResourceType = typeof(Resources), Name = nameof(Resources.Role))] + [Required] + public AdminRole Role { get; set; } = AdminRole.Viewer; + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs index a0ffbc10f..5e1bf8b66 100644 --- a/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs +++ b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs @@ -20,49 +20,58 @@ namespace MUnique.OpenMU.Web.Tests.AdminAuth; [TestFixture] public class ApiKeyAuthenticationTests { - private const string ValidKey = "0123456789abcdef0123456789abcdef"; - private const string OtherValidKey = "fedcba9876543210fedcba9876543210"; + private const string ConfiguredKey = "0123456789abcdef0123456789abcdef"; + private const string OtherConfiguredKey = "fedcba9876543210fedcba9876543210"; + + private InMemoryApiKeyRepository _repository = null!; + + /// + /// Sets a fresh repository up for each test. + /// + [SetUp] + public void SetUp() + { + this._repository = new InMemoryApiKeyRepository(); + } /// /// Tests that a configured key resolves to its client. /// [Test] - public void ConfiguredKeyIsFound() + public async Task ConfiguredKeyIsFoundAsync() { - var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); - var client = registry.Find(ValidKey); + var client = await registry.FindAsync(ConfiguredKey).ConfigureAwait(false); Assert.That(client, Is.Not.Null); Assert.That(client!.Name, Is.EqualTo("launcher")); - Assert.That(registry.IsConfigured, Is.True); } /// /// Tests that an unknown key is not accepted. /// [Test] - public void UnknownKeyIsNotFound() + public async Task UnknownKeyIsNotFoundAsync() { - var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); - Assert.That(registry.Find(OtherValidKey), Is.Null); - Assert.That(registry.Find(ValidKey + "x"), Is.Null); - Assert.That(registry.Find(string.Empty), Is.Null); + Assert.That(await registry.FindAsync(OtherConfiguredKey).ConfigureAwait(false), Is.Null); + Assert.That(await registry.FindAsync(ConfiguredKey + "x").ConfigureAwait(false), Is.Null); + Assert.That(await registry.FindAsync(string.Empty).ConfigureAwait(false), Is.Null); } /// - /// Tests that a key which is too short to be safe is not usable at all. + /// Tests that a configured key which is too short to be safe is not usable at all. /// [Test] - public void TooShortKeyIsIgnored() + public async Task TooShortConfiguredKeyIsIgnoredAsync() { var shortKey = new string('a', ApiKeyAuthenticationDefaults.MinimumKeyLength - 1); - var registry = CreateRegistry(new ApiKeyEntry { Name = "sloppy", Key = shortKey }); + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "sloppy", Key = shortKey }); - Assert.That(registry.IsConfigured, Is.False); - Assert.That(registry.Find(shortKey), Is.Null); + Assert.That(await registry.FindAsync(shortKey).ConfigureAwait(false), Is.Null); } /// @@ -70,14 +79,81 @@ public void TooShortKeyIsIgnored() /// roles of a client build up on each other like they do for a user. /// [Test] - public void RolesBuildUpOnEachOther() + public async Task RolesBuildUpOnEachOtherAsync() + { + var registry = this.CreateRegistry( + new ApiKeyEntry { Name = "reader", Key = ConfiguredKey }, + new ApiKeyEntry { Name = "writer", Key = OtherConfiguredKey, Roles = AdminRoles.Operator }); + + var reader = await registry.FindAsync(ConfiguredKey).ConfigureAwait(false); + var writer = await registry.FindAsync(OtherConfiguredKey).ConfigureAwait(false); + + Assert.That(reader!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer })); + Assert.That(writer!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer, AdminRoles.Operator })); + } + + /// + /// Tests that a key which has been created in the admin panel is accepted, and that only its + /// hash is stored. + /// + [Test] + public async Task StoredKeyIsFoundAsync() { - var registry = CreateRegistry( - new ApiKeyEntry { Name = "reader", Key = ValidKey }, - new ApiKeyEntry { Name = "writer", Key = OtherValidKey, Roles = AdminRoles.Operator }); + var generatedKey = await this.AddStoredKeyAsync("website", AdminRoles.Operator).ConfigureAwait(false); + var registry = this.CreateRegistry(); + + var client = await registry.FindAsync(generatedKey).ConfigureAwait(false); + + Assert.That(client, Is.Not.Null); + Assert.That(client!.Name, Is.EqualTo("website")); + Assert.That(client.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer, AdminRoles.Operator })); - Assert.That(registry.Find(ValidKey)!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer })); - Assert.That(registry.Find(OtherValidKey)!.Roles, Is.EquivalentTo(new[] { AdminRoles.Viewer, AdminRoles.Operator })); + var stored = (await this._repository.GetAllAsync().ConfigureAwait(false)).Single(); + Assert.That(stored.KeyHash, Is.Not.EqualTo(generatedKey)); + Assert.That(stored.KeyPrefix, Is.EqualTo(generatedKey[..ApiKeyGenerator.VisiblePrefixLength])); + } + + /// + /// Tests that a disabled key is rejected without having to delete it. + /// + [Test] + public async Task DisabledStoredKeyIsRejectedAsync() + { + var generatedKey = await this.AddStoredKeyAsync("website", AdminRoles.Viewer).ConfigureAwait(false); + (await this._repository.GetAllAsync().ConfigureAwait(false)).Single().IsDisabled = true; + var registry = this.CreateRegistry(); + + Assert.That(await registry.FindAsync(generatedKey).ConfigureAwait(false), Is.Null); + } + + /// + /// Tests that the last usage of a stored key is not written on every single request. + /// + [Test] + public async Task LastUsageIsUpdatedAtMostOncePerIntervalAsync() + { + var generatedKey = await this.AddStoredKeyAsync("website", AdminRoles.Viewer).ConfigureAwait(false); + var registry = this.CreateRegistry(); + + for (var i = 0; i < 5; i++) + { + await registry.FindAsync(generatedKey).ConfigureAwait(false); + } + + Assert.That(this._repository.TouchCount, Is.EqualTo(1)); + } + + /// + /// Tests that two generated keys are not the same. + /// + [Test] + public void GeneratedKeysAreUnique() + { + var keys = Enumerable.Range(0, 50).Select(_ => ApiKeyGenerator.GenerateKey()).ToList(); + + Assert.That(keys.Distinct(StringComparer.Ordinal).Count(), Is.EqualTo(keys.Count)); + Assert.That(keys, Is.All.StartWith(ApiKeyGenerator.KeyPrefix)); + Assert.That(keys, Is.All.Length.AtLeast(ApiKeyAuthenticationDefaults.MinimumKeyLength)); } /// @@ -86,10 +162,10 @@ public void RolesBuildUpOnEachOther() [Test] public async Task ApiKeyHeaderAuthenticatesAsync() { - var context = CreateContext(); - context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = ValidKey; + var context = new DefaultHttpContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = ConfiguredKey; - var result = await CreateHandlerAsync(context).ConfigureAwait(false); + var result = await this.AuthenticateAsync(context).ConfigureAwait(false); Assert.That(result.Succeeded, Is.True); Assert.That(result.Principal!.Identity!.IsAuthenticated, Is.True); @@ -103,10 +179,10 @@ public async Task ApiKeyHeaderAuthenticatesAsync() [Test] public async Task BearerTokenAuthenticatesAsync() { - var context = CreateContext(); - context.Request.Headers[HeaderNames.Authorization] = $"Bearer {ValidKey}"; + var context = new DefaultHttpContext(); + context.Request.Headers[HeaderNames.Authorization] = $"Bearer {ConfiguredKey}"; - var result = await CreateHandlerAsync(context).ConfigureAwait(false); + var result = await this.AuthenticateAsync(context).ConfigureAwait(false); Assert.That(result.Succeeded, Is.True); } @@ -118,7 +194,7 @@ public async Task BearerTokenAuthenticatesAsync() [Test] public async Task RequestWithoutKeyIsNoResultAsync() { - var result = await CreateHandlerAsync(CreateContext()).ConfigureAwait(false); + var result = await this.AuthenticateAsync(new DefaultHttpContext()).ConfigureAwait(false); Assert.That(result.None, Is.True); Assert.That(result.Succeeded, Is.False); @@ -130,27 +206,39 @@ public async Task RequestWithoutKeyIsNoResultAsync() [Test] public async Task RequestWithWrongKeyFailsAsync() { - var context = CreateContext(); - context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = OtherValidKey; + var context = new DefaultHttpContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = OtherConfiguredKey; - var result = await CreateHandlerAsync(context).ConfigureAwait(false); + var result = await this.AuthenticateAsync(context).ConfigureAwait(false); Assert.That(result.Succeeded, Is.False); Assert.That(result.None, Is.False); Assert.That(result.Failure, Is.Not.Null); } - private static ApiKeyRegistry CreateRegistry(params ApiKeyEntry[] entries) + private async Task AddStoredKeyAsync(string name, string roles) { - var options = Options.Create(new ApiKeyOptions { Keys = entries.ToList() }); - return new ApiKeyRegistry(options, NullLogger.Instance); + var generatedKey = ApiKeyGenerator.GenerateKey(); + await this._repository.AddAsync(new ApiKey + { + Id = Guid.NewGuid(), + Name = name, + KeyHash = ApiKeyGenerator.Hash(generatedKey), + KeyPrefix = ApiKeyGenerator.GetVisiblePrefix(generatedKey), + Roles = roles, + }).ConfigureAwait(false); + return generatedKey; } - private static DefaultHttpContext CreateContext() => new(); + private ApiKeyRegistry CreateRegistry(params ApiKeyEntry[] entries) + { + var options = Options.Create(new ApiKeyOptions { Keys = entries.ToList() }); + return new ApiKeyRegistry(options, this._repository, NullLogger.Instance); + } - private static async Task CreateHandlerAsync(HttpContext context) + private async Task AuthenticateAsync(HttpContext context) { - var registry = CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ValidKey }); + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); var handler = new ApiKeyAuthenticationHandler( new StaticOptionsMonitor(), NullLoggerFactory.Instance, diff --git a/tests/MUnique.OpenMU.Web.Tests/AdminAuth/InMemoryApiKeyRepository.cs b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/InMemoryApiKeyRepository.cs new file mode 100644 index 000000000..eff8ff057 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/InMemoryApiKeyRepository.cs @@ -0,0 +1,61 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.AdminAuth; + +using System.Threading; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// An in-memory for the tests. +/// +public class InMemoryApiKeyRepository : IApiKeyRepository +{ + private readonly List _apiKeys = new(); + + /// + /// Gets the number of calls to . + /// + public int TouchCount { get; private set; } + + /// + public ValueTask EnsureStorageAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(true); + + /// + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult>(this._apiKeys.OrderBy(k => k.Name).ToList()); + + /// + public ValueTask GetEnabledByHashAsync(string keyHash, CancellationToken cancellationToken = default) + => ValueTask.FromResult(this._apiKeys.FirstOrDefault(k => k.KeyHash == keyHash && !k.IsDisabled)); + + /// + public ValueTask AddAsync(ApiKey apiKey, CancellationToken cancellationToken = default) + { + this._apiKeys.Add(apiKey); + return ValueTask.CompletedTask; + } + + /// + public ValueTask UpdateAsync(ApiKey apiKey, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + /// + public ValueTask TouchAsync(Guid id, DateTime lastUsedAt, CancellationToken cancellationToken = default) + { + this.TouchCount++; + if (this._apiKeys.FirstOrDefault(k => k.Id == id) is { } apiKey) + { + apiKey.LastUsedAt = lastUsedAt; + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DeleteAsync(ApiKey apiKey, CancellationToken cancellationToken = default) + { + this._apiKeys.Remove(apiKey); + return ValueTask.CompletedTask; + } +}