diff --git a/deploy/all-in-one-traefik/docker-compose.yml b/deploy/all-in-one-traefik/docker-compose.yml index 8fa9e905ad..75d248f1fe 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 ef48d553b4..24fdb37063 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 f68d1ad78c..8fa23082d6 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 a931c6a2e0..ce67af0573 100644 --- a/docs-website/docs/admin-panel/authentication.md +++ b/docs-website/docs/admin-panel/authentication.md @@ -130,6 +130,106 @@ 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. + +### Creating a key + +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 +{ + "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 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. + +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 +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`. +::: + +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 fa72807d72..13662e1c91 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 0000000000..aa33458bd4 --- /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 0000000000..ed76ba7e23 --- /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 23aa5d380b..77cc7a200b 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 74e33b69a6..553ce51301 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 0000000000..ae785876f1 --- /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 0000000000..ab8b644682 --- /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 0000000000..a289b386b2 --- /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 c2f56eafb2..911b55628e 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/API/ServerController.cs b/src/Web/AdminPanel/API/ServerController.cs index 7b97144b98..f68874cc62 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 c2cf9058ed..7aadf08d5a 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. @@ -38,6 +39,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 +69,13 @@ 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(); + 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 // keys unreadable. In docker, the directory should be a mounted volume. @@ -69,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>(); @@ -108,7 +127,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 +197,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 0000000000..e9881d3350 --- /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 0000000000..53c4f6f238 --- /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 async Task HandleAuthenticateAsync() + { + if (!this.TryGetPresentedKey(out var presentedKey)) + { + return AuthenticateResult.NoResult(); + } + + 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 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 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/ApiKeyGenerator.cs b/src/Web/AdminPanel/Auth/ApiKeyGenerator.cs new file mode 100644 index 0000000000..5193266604 --- /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/ApiKeyOptions.cs b/src/Web/AdminPanel/Auth/ApiKeyOptions.cs new file mode 100644 index 0000000000..37d528a843 --- /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 0000000000..5a06313e1c --- /dev/null +++ b/src/Web/AdminPanel/Auth/ApiKeyRegistry.cs @@ -0,0 +1,196 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Auth; + +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MUnique.OpenMU.Persistence.AdminAuth; + +/// +/// 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 _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, IApiKeyRepository repository, ILogger logger) + { + this._repository = repository; + this._logger = logger; + this._configuredKeys = this.CreateConfiguredKeys(options.Value); + } + + /// + /// Finds the client which presented the specified key. + /// + /// 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 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. The stored keys don't need this: they are looked up by their hash. + /// + private ApiKeyClient? FindConfigured(string presentedKey) + { + if (this._configuredKeys.Count == 0) + { + return null; + } + + var presentedBytes = Encoding.UTF8.GetBytes(presentedKey); + ConfiguredApiKey? match = null; + foreach (var candidate in this._configuredKeys) + { + if (CryptographicOperations.FixedTimeEquals(presentedBytes, candidate.KeyBytes)) + { + match = candidate; + } + } + + return match is null ? null : new ApiKeyClient(match.Name, match.Roles); + } + + private void TouchInBackground(ApiKey storedKey) + { + 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-{keys.Count + 1}" : entry.Name.Trim(); + if (string.IsNullOrWhiteSpace(entry.Key)) + { + 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 configured 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 configured API key of client {ClientName} is used by another client as well and is ignored.", name); + continue; + } + + keys.Add(new ConfiguredApiKey(name, Encoding.UTF8.GetBytes(entry.Key), GetEffectiveRoles(entry.Roles))); + } + + return keys; + } + + 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(); + } + + private sealed record ConfiguredApiKey(string Name, byte[] KeyBytes, IReadOnlyList Roles); +} + +/// +/// An external application which is allowed to use the public API. +/// +/// The name of the client. +/// The effective roles of the client. +public record ApiKeyClient(string Name, 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/src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs b/src/Web/AdminPanel/Auth/UnavailableApiKeyRepository.cs new file mode 100644 index 0000000000..2026e79689 --- /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 f5df6ebd29..7eedc0add5 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 0000000000..9f8558c8a5 --- /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 0000000000..58c128bf1a --- /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 404d816b46..6b45ad5b71 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 17e6d3351f..bd7e69e391 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 93462365b4..942901721d 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 0000000000..99314c5d9b --- /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 new file mode 100644 index 0000000000..5e1bf8b66f --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/AdminAuth/ApiKeyAuthenticationTests.cs @@ -0,0 +1,263 @@ +// +// 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 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 async Task ConfiguredKeyIsFoundAsync() + { + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); + + var client = await registry.FindAsync(ConfiguredKey).ConfigureAwait(false); + + Assert.That(client, Is.Not.Null); + Assert.That(client!.Name, Is.EqualTo("launcher")); + } + + /// + /// Tests that an unknown key is not accepted. + /// + [Test] + public async Task UnknownKeyIsNotFoundAsync() + { + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); + + 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 configured key which is too short to be safe is not usable at all. + /// + [Test] + public async Task TooShortConfiguredKeyIsIgnoredAsync() + { + var shortKey = new string('a', ApiKeyAuthenticationDefaults.MinimumKeyLength - 1); + + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "sloppy", Key = shortKey }); + + Assert.That(await registry.FindAsync(shortKey).ConfigureAwait(false), 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 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 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 })); + + 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)); + } + + /// + /// Tests that the handler authenticates a request which carries the key in its own header. + /// + [Test] + public async Task ApiKeyHeaderAuthenticatesAsync() + { + var context = new DefaultHttpContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = ConfiguredKey; + + var result = await this.AuthenticateAsync(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 = new DefaultHttpContext(); + context.Request.Headers[HeaderNames.Authorization] = $"Bearer {ConfiguredKey}"; + + var result = await this.AuthenticateAsync(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 this.AuthenticateAsync(new DefaultHttpContext()).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 = new DefaultHttpContext(); + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = OtherConfiguredKey; + + 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 async Task AddStoredKeyAsync(string name, string roles) + { + 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 ApiKeyRegistry CreateRegistry(params ApiKeyEntry[] entries) + { + var options = Options.Create(new ApiKeyOptions { Keys = entries.ToList() }); + return new ApiKeyRegistry(options, this._repository, NullLogger.Instance); + } + + private async Task AuthenticateAsync(HttpContext context) + { + var registry = this.CreateRegistry(new ApiKeyEntry { Name = "launcher", Key = ConfiguredKey }); + 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; + } +} 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 0000000000..eff8ff0574 --- /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; + } +}