diff --git a/dDashboardServer/notify/Alerts.cpp b/dDashboardServer/notify/Alerts.cpp index 5456f5606..ef108eb90 100644 --- a/dDashboardServer/notify/Alerts.cpp +++ b/dDashboardServer/notify/Alerts.cpp @@ -266,7 +266,7 @@ namespace Alerts { "warn_account", "action_player_report", "chat_filter_allow", "chat_filter_block", "chat_filter_remove" }; static const std::set security{ - "set_gm_level", "create_api_token", "reset_password", "create_account", "enable_2fa", "disable_2fa", + "set_gm_level", "create_api_token", "create_api_key", "rotate_api_key", "revoke_api_key", "reset_password", "create_account", "enable_2fa", "disable_2fa", "reset_2fa", "regenerate_recovery_codes", "manage_webhook", "change_setting" }; std::string event; diff --git a/dDashboardServer/routes/OpenApi.h b/dDashboardServer/routes/OpenApi.h index c506e073d..bbb45d210 100644 --- a/dDashboardServer/routes/OpenApi.h +++ b/dDashboardServer/routes/OpenApi.h @@ -214,7 +214,7 @@ namespace OpenApi { "`Authorization: Bearer ` with a token from your account page. Requests signed in with the session cookie must also " "send `X-Requested-With` (this page does)."} }}, {"components", { {"securitySchemes", { - {"bearer", { {"type", "http"}, {"scheme", "bearer"}, {"description", "An API token from your account page"} }}, + {"bearer", { {"type", "http"}, {"scheme", "bearer"}, {"description", "An API key from your account page"} }}, {"session", { {"type", "apiKey"}, {"in", "cookie"}, {"name", "dashboardToken"}, {"description", "Your dashboard sign-in"} }} }} }}, {"security", { { {"session", nlohmann::json::array()} }, { {"bearer", nlohmann::json::array()} } }}, {"paths", paths} diff --git a/dDashboardServer/templates/api_docs.jinja2 b/dDashboardServer/templates/api_docs.jinja2 index 71eddde7c..c666690fc 100644 --- a/dDashboardServer/templates/api_docs.jinja2 +++ b/dDashboardServer/templates/api_docs.jinja2 @@ -20,10 +20,13 @@
Signing in from scripts and bots
-

Make an API token on your account page and send it with every request:

-
curl -H "Authorization: Bearer $TOKEN" http://localhost:2006/api/status
-

A token acts as your account with its current GM level, so each request needs the same permission as the page it matches, - and changing the account's level or banning it takes effect straight away. Using the API at all needs the api_access permission.

+

Make an API key on your account page and send it with every request:

+
curl -H "Authorization: Bearer $KEY" http://localhost:2006/api/status
+

A key can do what you picked for it (some of your permissions, or all of them), and never more than your account can do right + now: each request needs the permission for the page it matches, both on your account's current GM level and in the key. + Changing the account's level or banning it takes effect straight away. Keys can be read-only, limited to some addresses or paths, and + have a rate limit (answered with 429 and Retry-After; X-RateLimit-Remaining says what is left) + and a daily quota. Using the API at all needs the api_access permission.

Live updates: open a WebSocket to /ws (with the same Authorization header, or your sign-in cookie) and send {"event": "subscribe", "subscription": "chat_message"} (or table_changed, dashboard_update, ...).

Tables take DataTables-style bodies: {"start": 0, "length": 25, "search": "text"}. Calls that act on diff --git a/docs/Dashboard.md b/docs/Dashboard.md index 5bc877110..a7839067b 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -253,7 +253,7 @@ With [email](#email) set up, **Forgot your password?** (`/forgot_password`) send Players with two-factor login can also choose a new password there without email: their username, a current code from their authenticator app, and one of their recovery codes. Both codes are needed, so someone who found the printed recovery codes, or has the phone, can't take the account alone. The recovery code is used up, every dashboard session -and API token of the account is signed out, and an emailed reset link that's still open stops working. The game +and API key of the account is signed out, and an emailed reset link that's still open stops working. The game password changes too. It's limited like signing in: 10 tries per address every 15 minutes, and wrong codes count as @@ -559,7 +559,7 @@ Restoring an older backup is fine: migrations bring it up to date when the serve On the **Webhooks** page (GM 9) add Discord, Slack or generic JSON webhooks and choose which events they get: `bug_report`, `pending_name` (names waiting for approval), `moderation` (bans, locks, mutes, kicks and restrictions, -from the dashboard or in game), `security` (GM level and two-factor changes, API tokens, recovery code use), +from the dashboard or in game), `security` (GM level and two-factor changes, API keys, recovery code use), `economy_flag` and `server` (auth or chat going down or coming back, restarts, events). JSON deliveries can be signed: with a secret set, each request has `X-DLU-Signature: sha256=`. @@ -575,10 +575,10 @@ master, auth and chat are up, memory and process count per kind of server, dashb messages by channel (and how many the filter blocked), today's coins, items and map events, moderation queues (names, pet names, properties, bug reports, economy flags, player reports), active strikes, each scheduled task's last run, and how long the database reads behind them took. Labels never contain account or character names or addresses. A scraper -sends `Authorization: Bearer` with either an API token of an account with the `metrics_view` permission (GM 8 by +sends `Authorization: Bearer` with either an API key of an account with the `metrics_view` permission (GM 8 by default), or the shared `metrics_token` (at least 16 characters). `metrics_allowed_ips` can limit where requests come from (addresses or IPv4 ranges like `10.0.0.0/8`), and the numbers are worked out at most every `metrics_cache_seconds` -(10), however often they are fetched. `/api/metrics` returns the same text for API tokens. An example scrape config and +(10), however often they are fetched. `/api/metrics` returns the same text for API keys (the key needs `metrics_view` in its scope). An example scrape config and Grafana dashboard are in `docs/grafana/`. **Instance Load** (`health_view`) records the players in each world instance at the same time and keeps them as long: @@ -897,9 +897,10 @@ Settings: `log_chat` (on), `log_private_chat` (on), `log_chat_days` (90), and `c into the game go through the chat filter like players' do). **Bridging chat to Discord or another service.** Make an account for the bot with a GM level that has `chat_view`, -`chat_send` and `api_access`, create an API token on its account page, and: +`chat_send` and `api_access`, make an API key on its account page with just `chat_view` and `chat_send` (and, +for the WebSocket, no address limit), and: -- Read: `GET /api/chat?after=` with `Authorization: Bearer ` returns the messages newer than that id, +- Read: `GET /api/chat?after=` with `Authorization: Bearer ` returns the messages newer than that id, oldest first, and `last_id` for the next call (`zone` and `instance` narrow it to one zone or one world). Or connect to the `/ws` WebSocket with the same header and subscribe to the `chat_message` topic to get each message as it's said (`{"event":"subscribe","subscription":"chat_message"}`). @@ -1251,13 +1252,48 @@ with just that event. ## API -Everything the dashboard does is available as a JSON API. Create a token on your account page and send it as -`Authorization: Bearer `. The **API** page lists every endpoint your GM level can use, and each can be tried -out there. A token acts as its account with the account's current GM level, so every request needs the same -permission as the page it matches (for example `/api/zones` needs `characters_rescue`). Using the API at all needs the -`api_access` permission (every account by default); raise it on the Permissions page to turn the API off for players or -lower staff levels. Tokens can only be made from a signed-in browser session, not with another token, so a leaked token -can't make itself a new one. +Everything the dashboard does is available as a JSON API. Make an **API key** on your account page and send it as +`Authorization: Bearer `. The **API** page lists every endpoint your GM level can use, and each can be tried +out there. Using the API at all needs the `api_access` permission (every account by default); raise it on the +Permissions page to turn the API off for players or lower staff levels. + +### API keys + +Each key has a name and note, an optional expiry, and a scope: either **all of your permissions** or only the ones you +pick (grouped by category like the Permissions page; you can only pick permissions you have). A key never does more +than its owner can do *right now*: every request is checked against the owner's current account and GM level and +against the key's scope, so demoting, banning or locking the owner narrows or stops their keys at once. + +- Routes guarded by a permission need it in the key's scope. The self and rank rules apply twice: the key also needs + `self_tools` / `self_items` / `self_moderation` in its scope to act on its owner's own account or characters, and + `manage_equal_rank` to act on accounts of the owner's level (even when the owner is GM 9, who needs neither). + The few routes guarded only by a GM level above 0 need a key with all of its owner's permissions. +- **Read-only** keys only make GET requests (and the DataTables queries under `/api/tables/`, which are POSTs that + only read). +- **Only from addresses**: exact addresses or prefixes ending in `.` or `:` (`10.0.0.`). With `behind_proxy=1` the + proxy's `X-Forwarded-For` is used. Keys limited to some addresses can't open the WebSocket (its address isn't + checked there). +- **Only these paths**: path prefixes such as `/api/chat`; include `/ws` to allow the WebSocket. +- **Limits**: requests a minute (default `api_key_rate_limit`, 120; up to 6000) and optionally requests a UTC day. + Every answer carries `X-RateLimit-Limit` and `X-RateLimit-Remaining` (and `X-Quota-Limit` / `X-Quota-Remaining` + with a quota); going over answers `429` with `Retry-After`. Requests, last use and last address are shown in the + key list; they are written to the database once a minute, not on every request. +- WebSocket subscriptions follow the scope too (`chat_message` needs `chat_view`, and so on). +- Keys can never be used to sign in, change the account's password, email or two-factor login, sign out sessions, + or make, rotate or revoke keys. Those need a signed-in browser session, so a leaked key can't make itself new keys + or lock its owner out. +- Only a hash of each key is stored; the key is shown once when it is made. **Rotate** gives a key a new secret and + keeps its settings; **Revoke** stops it for good. **Sign out everywhere**, a password change or a password reset + stops every key made before it (rotate a key to use it again). +- Staff with `api_keys_manage` (GM 8 by default) see other accounts' keys on their account pages and can revoke + them, following the rank rules. Nobody can make keys for someone else. +- Making, rotating and revoking keys is audited (and sent to `security` webhooks). What is done with a key is audited + as `user (key name)`, and requests a key was refused (scope, read-only, address, path, limits) are audited as + `api_key_denied`, at most once a minute per key and reason. + +`POST /api/auth/token` (for scripts written for the old API tokens) now makes a key named "API token" with all of the +caller's permissions, valid for the given `days`. API tokens made before API keys existed keep working, with the +account's full permissions, until they expire (at most a year) or the account signs out everywhere. ## Developer tools