Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deploy/all-in-one-traefik/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 4 additions & 0 deletions deploy/all-in-one/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 4 additions & 0 deletions deploy/distributed/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
100 changes: 100 additions & 0 deletions docs-website/docs/admin-panel/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <the key>
```

`Authorization: Bearer <the key>` 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": "<a long random key>" },
{ "Name": "website", "Key": "<another one>", "Roles": "Operator" }
]
}
}
}
```

For a single key, the environment variables work too, like they do for the
bootstrap user:

```bash
OPENMU_API_KEY=<a long random 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
Expand Down
1 change: 1 addition & 0 deletions docs-website/docs/admin-panel/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
70 changes: 70 additions & 0 deletions src/Persistence/AdminAuth/ApiKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// <copyright file="ApiKey.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence.AdminAuth;

/// <summary>
/// A key with which an external application authenticates itself at the public API of the server.
/// </summary>
/// <remarks>
/// It lives in the same schema as the <see cref="AdminUser"/> and for the same reasons: it grants
/// access to server functions, not to the game, and no game server database role may read it.
/// </remarks>
public class ApiKey
{
/// <summary>
/// Gets or sets the identifier of this key.
/// </summary>
public Guid Id { get; set; }

/// <summary>
/// Gets or sets the name of the application which uses this key, e.g. <c>launcher</c>.
/// </summary>
public string Name { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the hash of the key.
/// </summary>
/// <remarks>
/// 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 <see cref="AdminUser"/>: 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.
/// </remarks>
public string KeyHash { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the prefix of the key, which is shown in the admin panel so a key can be
/// recognized without knowing it.
/// </summary>
public string KeyPrefix { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the roles of this key, as a comma separated list.
/// </summary>
/// <seealso cref="AdminRoles"/>
public string Roles { get; set; } = AdminRoles.Viewer;

/// <summary>
/// Gets or sets a value indicating whether this key is disabled and therefore rejected.
/// </summary>
public bool IsDisabled { get; set; }

/// <summary>
/// Gets or sets the date and time when this key has been created.
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

/// <summary>
/// Gets or sets the date and time at which this key has been used the last time.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public DateTime? LastUsedAt { get; set; }

/// <inheritdoc />
public override string ToString() => this.Name;
}
73 changes: 73 additions & 0 deletions src/Persistence/AdminAuth/IApiKeyRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// <copyright file="IApiKeyRepository.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence.AdminAuth;

using System.Threading;

/// <summary>
/// A repository for the <see cref="ApiKey"/>s of the public API.
/// </summary>
/// <remarks>
/// Like the <see cref="IAdminUserRepository"/>, the implementation must be usable independently of
/// the game database.
/// </remarks>
public interface IApiKeyRepository
{
/// <summary>
/// Ensures that the underlying storage exists and is up to date.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// <c>true</c>, if the storage is available; otherwise, <c>false</c>, e.g. when no database server is reachable.
/// </returns>
ValueTask<bool> EnsureStorageAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Gets all stored keys, ordered by their name.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>All stored keys.</returns>
ValueTask<IList<ApiKey>> GetAllAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Gets the enabled key with the specified hash.
/// </summary>
/// <param name="keyHash">The hash of the presented key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The key, if found and enabled; otherwise, <c>null</c>.</returns>
ValueTask<ApiKey?> GetEnabledByHashAsync(string keyHash, CancellationToken cancellationToken = default);

/// <summary>
/// Adds the specified key.
/// </summary>
/// <param name="apiKey">The key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask AddAsync(ApiKey apiKey, CancellationToken cancellationToken = default);

/// <summary>
/// Updates the specified key.
/// </summary>
/// <param name="apiKey">The key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask UpdateAsync(ApiKey apiKey, CancellationToken cancellationToken = default);

/// <summary>
/// Sets the <see cref="ApiKey.LastUsedAt"/> of the key with the specified identifier.
/// </summary>
/// <param name="id">The identifier of the key.</param>
/// <param name="lastUsedAt">The point in time.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <remarks>
/// This happens while a request is served, so it must not fail the request when it doesn't work.
/// </remarks>
ValueTask TouchAsync(Guid id, DateTime lastUsedAt, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes the specified key.
/// </summary>
/// <param name="apiKey">The key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask DeleteAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.AdminAuth;
public static class AdminAuthServiceCollectionExtensions
{
/// <summary>
/// Adds the database backed <see cref="IAdminUserRepository"/> to the service collection.
/// Adds the database backed <see cref="IAdminUserRepository"/> and <see cref="IApiKeyRepository"/>
/// to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same instance, to allow chaining of further calls.</returns>
public static IServiceCollection AddAdminUserRepository(this IServiceCollection services)
{
services.TryAddSingleton<IAdminUserRepository, AdminUserRepository>();
services.TryAddSingleton<IApiKeyRepository, ApiKeyRepository>();
return services;
}
}
16 changes: 16 additions & 0 deletions src/Persistence/EntityFramework/AdminAuth/AdminPanelContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public class AdminPanelContext : DbContext
/// </summary>
public DbSet<AdminUser> AdminUsers { get; set; } = null!;

/// <summary>
/// Gets or sets the API keys of the public API.
/// </summary>
public DbSet<ApiKey> ApiKeys { get; set; } = null!;

/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
Expand Down Expand Up @@ -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<ApiKey>(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);
});
}
}
Loading
Loading