mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
What someone may do on the dashboard is now their GM level's permissions plus the grants on their account, minus its denies (PermissionGrants.h). A deny beats a grant; denies never apply to GM 9, and settings and permissions_manage stay GM 9 only. The account's grants are read with every request (like its GM level), so a change applies at once, and they are passed through every check: RouteUtils::Can, CanViewCharacter, the rank rules (self_* and manage_equal_rank), routes guarded by a permission, the templates' `can`, the API documentation, API access, API key scopes (a key never does more than its owner may now) and WebSocket subscriptions. New permission grants_manage (GM 9 by default) and the API to manage grants: GET /api/grants/catalog, GET /api/grants, POST /api/grants, POST /api/grants/:id/remove. Nobody grants or takes away what they don't hold themselves (a permission, every permission of a group, a command they may use, every command up to their own GM level), and only on accounts the rank rules let them manage (their own with self_moderation). Commands with a fixed level or a floor above GM 1 (/execute) can't be granted. Every change goes in the audit log (grant_permission, deny_permission, remove_grant). Also: the Showcase gate and the traffic subscription now check their permission by name. Check: grant a GM 2 account accounts_ban (it can ban, and the Ban button shows); deny a GM 8 account accounts_view (the accounts list is refused); give an expiry a minute ahead and see it stop; try to grant a permission your account doesn't have (refused); dWebTests PermissionGrantsTests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
13 KiB
C++
241 lines
13 KiB
C++
#include "AuthRoutes.h"
|
|
#include "ApiKeyRoutes.h"
|
|
#include "Permissions.h"
|
|
#include "DashboardAuthService.h"
|
|
#include "AuthTokenHandler.h"
|
|
#include "JWTUtils.h"
|
|
#include "RequireAuthMiddleware.h"
|
|
#include "RouteUtils.h"
|
|
#include "WSRoutes.h"
|
|
#include "EmailService.h"
|
|
#include "AccountRoutes.h"
|
|
#include "eGameMasterLevel.h"
|
|
#include "json.hpp"
|
|
#include "Logger.h"
|
|
#include "Database.h"
|
|
#include "Web.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "HTTPContext.h"
|
|
#include "Alerts.h"
|
|
#include "PasswordRecovery.h"
|
|
|
|
namespace {
|
|
constexpr int64_t MAX_API_TOKEN_DAYS = 365;
|
|
|
|
// Per-IP limits on unauthenticated endpoints, in addition to the per-account lockout
|
|
RouteUtils::RateLimiter g_LoginLimiter(10, std::chrono::seconds(60));
|
|
RouteUtils::RateLimiter g_RegisterLimiter(5, std::chrono::seconds(60 * 60));
|
|
|
|
bool RegistrationEnabled() { return RouteUtils::ConfigFlag("allow_registration", false); }
|
|
bool RegistrationRequiresPlayKey() { return RouteUtils::ConfigFlag("registration_requires_play_key", true); }
|
|
}
|
|
|
|
void RegisterAuthRoutes() {
|
|
// POST /api/auth/login
|
|
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
|
|
// Sets an HttpOnly session cookie for the browser; the token is also returned for API clients.
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/login",
|
|
.method = eHTTPMethod::POST,
|
|
.middleware = {},
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!g_LoginLimiter.Allow(RouteUtils::ClientAddress(context))) {
|
|
return RouteUtils::JsonReply(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, { {"success", false}, {"message", "Too many login attempts, try again in a minute"} });
|
|
}
|
|
const auto json = RouteUtils::ParseBody(context);
|
|
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
|
|
const std::string username = json->value("username", "");
|
|
const std::string password = json->value("password", "");
|
|
const bool rememberMe = json->value("rememberMe", false);
|
|
|
|
if (username.empty() || password.empty()) {
|
|
return RouteUtils::JsonReply(reply, eHTTPStatusCode::BAD_REQUEST, { {"success", false}, {"message", "Username and password are required"} });
|
|
}
|
|
|
|
const auto result = DashboardAuthService::Login(username, password, rememberMe, RouteUtils::ClientAddress(context));
|
|
if (result.twoFactorRequired) {
|
|
return RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
|
|
{"success", false}, {"twoFactorRequired", true}, {"challenge", result.challenge}, {"message", result.message}
|
|
});
|
|
}
|
|
nlohmann::json response{ {"success", result.success}, {"message", result.message} };
|
|
if (result.success) {
|
|
response["token"] = result.token;
|
|
response["gmLevel"] = result.gmLevel;
|
|
reply.headers.push_back(AuthTokenHandler::BuildSessionCookie(result.token, rememberMe, RouteUtils::UseSecureCookies()));
|
|
const auto info = Database::Get()->GetAccountInfo(username);
|
|
Database::Get()->InsertAuditLog(info ? info->id : 0, username, "dashboard_login", "Login from " + RouteUtils::ClientAddress(context), info ? info->id : 0, 0);
|
|
}
|
|
RouteUtils::JsonReply(reply, result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED, response);
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/login/2fa - Second step for accounts with two-factor login
|
|
// Request body: { "challenge": "from /api/auth/login", "code": "123456 or a recovery code" }
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/login/2fa",
|
|
.method = eHTTPMethod::POST,
|
|
.middleware = {},
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!g_LoginLimiter.Allow(RouteUtils::ClientAddress(context))) {
|
|
return RouteUtils::JsonReply(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, { {"success", false}, {"message", "Too many login attempts, try again in a minute"} });
|
|
}
|
|
const auto json = RouteUtils::ParseBody(context);
|
|
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
|
|
const auto result = DashboardAuthService::CompleteTwoFactor(json->value("challenge", ""), json->value("code", ""));
|
|
nlohmann::json response{ {"success", result.success}, {"message", result.message} };
|
|
if (result.success) {
|
|
response["token"] = result.token;
|
|
response["gmLevel"] = result.gmLevel;
|
|
// The cookie lifetime follows the token's (remember me was chosen in the first step)
|
|
JWTUtils::JWTPayload payload;
|
|
const bool longLived = JWTUtils::ValidateToken(result.token, payload) && payload.rememberMe;
|
|
reply.headers.push_back(AuthTokenHandler::BuildSessionCookie(result.token, longLived, RouteUtils::UseSecureCookies()));
|
|
Database::Get()->InsertAuditLog(result.accountId, payload.username, "dashboard_login",
|
|
"Login with two-factor " + std::string(result.usedRecoveryCode ? "recovery code" : "code") + " from " + RouteUtils::ClientAddress(context), result.accountId, 0);
|
|
if (result.usedRecoveryCode) {
|
|
Alerts::Emit("security", "Recovery code used", payload.username + " signed in with a two-factor recovery code from " + RouteUtils::ClientAddress(context) + ".");
|
|
}
|
|
}
|
|
RouteUtils::JsonReply(reply, result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED, response);
|
|
}
|
|
});
|
|
|
|
// GET /api/auth/config - Public settings the login page needs
|
|
RouteUtils::Route(eHTTPMethod::GET, "/api/auth/config", RouteUtils::PUBLIC, "Whether self-registration is enabled",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
|
|
{"registration", RegistrationEnabled()},
|
|
{"playKeyRequired", RegistrationRequiresPlayKey()},
|
|
{"emailEnabled", EmailService::IsConfigured()},
|
|
{"emailRequired", EmailService::IsConfigured() && RouteUtils::ConfigFlag("registration_requires_email", false)},
|
|
{"recoveryReset", RecoveryResetEnabled()},
|
|
// Pages open without signing in (PublicRoutes.cpp, Showcase.cpp)
|
|
{"publicStatus", RouteUtils::ConfigFlag("public_status", false)},
|
|
{"publicShowcase", RouteUtils::ConfigFlag("showcase_public", false)}
|
|
});
|
|
});
|
|
|
|
// POST /api/auth/register - Self-registration (allow_registration=1)
|
|
RouteUtils::Route(eHTTPMethod::POST, "/api/auth/register", RouteUtils::PUBLIC, "Create an account. Body: {username, password, play_key, email}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!RegistrationEnabled()) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Registration is disabled");
|
|
if (!g_RegisterLimiter.Allow(RouteUtils::ClientAddress(context))) {
|
|
return RouteUtils::JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many registrations from your address, try again later");
|
|
}
|
|
|
|
const auto json = RouteUtils::ParseBody(context);
|
|
if (!json) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
const std::string username = json->value("username", "");
|
|
const std::string password = json->value("password", "");
|
|
const std::string playKey = json->value("play_key", "");
|
|
const std::string email = json->value("email", "");
|
|
const bool emailRequired = EmailService::IsConfigured() && RouteUtils::ConfigFlag("registration_requires_email", false);
|
|
if (emailRequired && email.empty()) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "An email address is required");
|
|
if (!email.empty() && !Smtp::IsValidAddress(email)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That is not a valid email address");
|
|
if (!email.empty() && Database::Get()->GetAccountIdByConfirmedEmail(email)) return RouteUtils::JsonError(reply, eHTTPStatusCode::CONFLICT, "That email address is already used by another account");
|
|
|
|
if (const auto error = RouteUtils::ValidateUsername(username)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
|
|
if (const auto error = RouteUtils::ValidatePassword(password)) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
|
|
|
|
std::optional<int32_t> keyId;
|
|
if (RegistrationRequiresPlayKey() || !playKey.empty()) {
|
|
keyId = Database::Get()->GetRedeemablePlayKeyId(playKey);
|
|
if (!keyId) return RouteUtils::JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That play key is invalid, inactive or used up");
|
|
}
|
|
if (Database::Get()->GetAccountInfo(username)) return RouteUtils::JsonError(reply, eHTTPStatusCode::CONFLICT, "That username is taken");
|
|
|
|
Database::Get()->InsertNewAccount(username, RouteUtils::HashPassword(password), eGameMasterLevel::CIVILIAN);
|
|
const auto account = Database::Get()->GetAccountInfo(username);
|
|
if (!account) return RouteUtils::JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Account creation failed");
|
|
if (keyId) Database::Get()->SetAccountPlayKey(account->id, *keyId);
|
|
|
|
Database::Get()->InsertAuditLog(account->id, username, "register_account",
|
|
"Registered from " + RouteUtils::ClientAddress(context) + (keyId ? " with play key ID " + std::to_string(*keyId) : ""), account->id, 0);
|
|
BroadcastTableChanged("accounts");
|
|
BroadcastTableChanged("play_keys");
|
|
|
|
std::string message = "Account created. You can now log in to the game and the dashboard.";
|
|
if (!email.empty() && EmailService::IsConfigured()) {
|
|
// Sent through the same path as a user changing their address
|
|
Database::Get()->SetAccountEmail(account->id, email, false);
|
|
SendVerificationEmail(account->id, username, email, 0);
|
|
message += " Check " + email + " to confirm your email address.";
|
|
}
|
|
RouteUtils::JsonSuccess(reply, { {"message", message} });
|
|
});
|
|
|
|
// POST /api/auth/logout - Clear the session cookie
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/logout",
|
|
.method = eHTTPMethod::POST,
|
|
.middleware = {},
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
reply.headers.push_back(AuthTokenHandler::BuildClearSessionCookie(RouteUtils::UseSecureCookies()));
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"success", true} });
|
|
}
|
|
});
|
|
|
|
// GET /api/auth/me - Current session info
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/me",
|
|
.method = eHTTPMethod::GET,
|
|
.middleware = {},
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!context.isAuthenticated) return RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"valid", false} });
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, {
|
|
{"valid", true},
|
|
{"username", context.authenticatedUser},
|
|
{"accountId", context.accountId},
|
|
{"gmLevel", context.gmLevel}
|
|
});
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/verify
|
|
// Request body: { "token": "string" }
|
|
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/verify",
|
|
.method = eHTTPMethod::POST,
|
|
.middleware = {},
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto json = RouteUtils::ParseBody(context);
|
|
const std::string token = json ? json->value("token", "") : "";
|
|
const auto result = AuthTokenHandler::ValidateToken(token);
|
|
|
|
nlohmann::json response{ {"valid", result.isValid} };
|
|
if (result.isValid) {
|
|
response["username"] = result.username;
|
|
response["gmLevel"] = result.gmLevel;
|
|
}
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, response);
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/token - Issue a bearer token for API clients (bots, scripts)
|
|
// Request body: { "days": number (1-365, default 30) }
|
|
// Kept for scripts written for the old API tokens: it now makes an API key named "API token" with all of the
|
|
// caller's permissions (which always follow the account's current GM level). Pick a narrower scope, limits and
|
|
// no expiry with POST /api/api_keys. Tokens made before API keys keep working until they expire.
|
|
Game::web.RegisterHTTPRoute({
|
|
.path = "/api/auth/token",
|
|
.method = eHTTPMethod::POST,
|
|
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
|
|
.handle = [](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get())) return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "API access isn't allowed for your account");
|
|
// Only a signed-in browser session may make tokens: a leaked token must not be able to renew itself for a year
|
|
const auto source = context.userData.find("auth_source");
|
|
if (source == context.userData.end() || source->second != "cookie") {
|
|
return RouteUtils::JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Make API tokens from your account page while signed in, not with another token");
|
|
}
|
|
const auto json = RouteUtils::ParseBody(context);
|
|
const int64_t days = std::clamp<int64_t>(json ? json->value("days", 30) : 30, 1, MAX_API_TOKEN_DAYS);
|
|
const auto token = ApiKeyRoutes::CreateFullKey(context, "API token", days);
|
|
RouteUtils::JsonReply(reply, eHTTPStatusCode::OK, { {"token", token}, {"expiresInDays", days} });
|
|
}
|
|
});
|
|
}
|