mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-20 01:46:07 +00:00
Admin
This commit is contained in:
1 parent
5f0eea099e
commit
c3df40c96c
10 files changed
+103
-26
No files matched your search
+4
-2
@@ -8,12 +8,14 @@ The panel is protected by a password set in `config.json`:
|
||||
|
||||
```json
|
||||
"Admin": {
|
||||
"Password": "changeme",
|
||||
"Password": "your-strong-password-here",
|
||||
"MarketplaceUrl": "https://raw.githubusercontent.com/your-org/your-repo/main/marketplace/plugins.json"
|
||||
}
|
||||
```
|
||||
|
||||
On first visit the login page is shown. After a successful login a session cookie valid for 8 hours is set. Clicking **Sign out** clears it immediately.
|
||||
The password is hashed (SHA256) in memory and in cookies — the plaintext is never stored in the browser or compared directly. On first visit the login page is shown. After a successful login a session cookie (HttpOnly, Secure, SameSite=Strict) valid for 8 hours is set. Clicking **Sign out** clears it immediately.
|
||||
|
||||
After 5 failed login attempts from the same IP, that IP is locked out for 15 minutes.
|
||||
|
||||
All API endpoints under `/api/admin/` return `401 Unauthorized` if the cookie is absent or incorrect.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ The `Admin` configuration is used to set the admin password and plugin marketpla
|
||||
|
||||
| Key | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| Password | `CHANGE-ME` | The admin password for the server, used for authentication. Make sure to change this to a strong, complex password. |
|
||||
| Password | *(empty — must be set)* | The admin password for the server, used for authentication. Set a strong, complex password. The server logs a warning at startup if this is left empty or set to a known default. |
|
||||
| MarketplaceUrl | `https://raw.githubusercontent.com/HayashiUme/Empostor/main/marketplace/plugins.json` | A URL pointing to a `plugins.json` file. This file defines the list of plugins available in the plugin marketplace. |
|
||||
|
||||
### DiscordWebhook
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
".NETStandard,Version=v2.0/linux-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace Empostor.Api.Config
|
||||
{
|
||||
public const string Section = "Admin";
|
||||
|
||||
public string Password { get; set; } = "admin123";
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public string MarketplaceUrl { get; set; } =
|
||||
"https://raw.githubusercontent.com/Empostor/Empostor/main/marketplace/plugins.json";
|
||||
|
||||
@@ -34,12 +34,6 @@
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.27, )",
|
||||
"resolved": "8.0.27",
|
||||
"contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg=="
|
||||
},
|
||||
"StyleCop.Analyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.2.0-beta.556, )",
|
||||
@@ -145,7 +139,6 @@
|
||||
"NextFast.Hazel.Abstractions": {
|
||||
"type": "Project"
|
||||
}
|
||||
},
|
||||
"net8.0/linux-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Empostor.Server.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Shared admin authentication utilities: SHA256 hashing and constant-time comparison.
|
||||
/// </summary>
|
||||
internal static class AdminAuthHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the hex-encoded SHA256 hash of the input string.
|
||||
/// Used so the plaintext password never enters a cookie or lingers in memory.
|
||||
/// </summary>
|
||||
internal static string ComputeHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two strings in constant time to prevent timing side-channel attacks.
|
||||
/// Uses CryptographicOperations.FixedTimeEquals on the UTF-8 byte representations.
|
||||
/// </summary>
|
||||
internal static bool ConstantTimeEquals(string a, string b)
|
||||
{
|
||||
if (a.Length != b.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(a),
|
||||
Encoding.UTF8.GetBytes(b));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -31,6 +32,9 @@ namespace Empostor.Server.Http
|
||||
private static Dictionary<string, string>? _adminStrings;
|
||||
private static readonly object _stringsLock = new();
|
||||
|
||||
private const int LoginLockoutThreshold = 5;
|
||||
private static readonly TimeSpan LoginLockoutDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
private readonly IGameManager _gameManager;
|
||||
private readonly IClientManager _clientManager;
|
||||
@@ -42,6 +46,8 @@ namespace Empostor.Server.Http
|
||||
private readonly PlayerStatsConfig _statsConfig;
|
||||
private readonly ChatFilterStore _chatFilter;
|
||||
private readonly DiscordWebhookStore _discordWebhook;
|
||||
private readonly string _passwordHash;
|
||||
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
|
||||
|
||||
public AdminController(
|
||||
ILogger<AdminController> logger,
|
||||
@@ -67,6 +73,15 @@ namespace Empostor.Server.Http
|
||||
_statsConfig = statsConfig.Value;
|
||||
_chatFilter = chatFilter;
|
||||
_discordWebhook = discordWebhook;
|
||||
_passwordHash = AdminAuthHelper.ComputeHash(_config.Password);
|
||||
|
||||
if (string.IsNullOrEmpty(_config.Password)
|
||||
|| _config.Password == "CHANGE-ME"
|
||||
|| _config.Password == "admin123")
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[Admin] SECURITY: Default or empty admin password detected! Set a strong password in config.json -> Admin.Password to protect the admin panel.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> LoadStrings()
|
||||
@@ -83,7 +98,8 @@ namespace Empostor.Server.Http
|
||||
}
|
||||
|
||||
private bool IsAuthenticated()
|
||||
=> Request.Cookies.TryGetValue("empostor_admin", out var v) && v == _config.Password;
|
||||
=> Request.Cookies.TryGetValue("empostor_admin", out var v)
|
||||
&& AdminAuthHelper.ConstantTimeEquals(v, _passwordHash);
|
||||
|
||||
[HttpGet("/admin")]
|
||||
public IActionResult Panel()
|
||||
@@ -92,17 +108,50 @@ namespace Empostor.Server.Http
|
||||
[HttpPost("/admin/login")]
|
||||
public IActionResult Login([FromForm] string password)
|
||||
{
|
||||
if (password != _config.Password)
|
||||
var ip = (HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback).ToString();
|
||||
|
||||
// Rate-limit check: block IPs that have failed too many times recently
|
||||
if (_loginFailures.TryGetValue(ip, out var entry))
|
||||
{
|
||||
_logger.LogWarning("[Admin] Failed login from {Ip}", HttpContext.Connection.RemoteIpAddress);
|
||||
if (entry.Count >= LoginLockoutThreshold
|
||||
&& DateTime.UtcNow - entry.FirstAttempt < LoginLockoutDuration)
|
||||
{
|
||||
var remaining = (int)(LoginLockoutDuration - (DateTime.UtcNow - entry.FirstAttempt)).TotalMinutes;
|
||||
_logger.LogWarning("[Admin] Rate-limited login attempt from {Ip} (locked out for ~{Min}m more)", ip, Math.Max(1, remaining));
|
||||
return Content(LoginHtml.Replace("<!--ERR-->",
|
||||
$"<p style='color:var(--r);margin-top:8px'>Too many failed attempts. Try again in {Math.Max(1, remaining)} minute(s).</p>"),
|
||||
"text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
// Reset if lockout window has passed
|
||||
if (DateTime.UtcNow - entry.FirstAttempt >= LoginLockoutDuration)
|
||||
{
|
||||
_loginFailures.TryRemove(ip, out _);
|
||||
}
|
||||
}
|
||||
|
||||
var submittedHash = AdminAuthHelper.ComputeHash(password);
|
||||
|
||||
if (!AdminAuthHelper.ConstantTimeEquals(submittedHash, _passwordHash))
|
||||
{
|
||||
_loginFailures.AddOrUpdate(ip,
|
||||
_ => (1, DateTime.UtcNow),
|
||||
(_, e) => (e.Count + 1, e.FirstAttempt));
|
||||
|
||||
_logger.LogWarning("[Admin] Failed login from {Ip} (attempt {Count})", ip,
|
||||
_loginFailures.TryGetValue(ip, out var updated) ? updated.Count : 1);
|
||||
return Content(LoginHtml.Replace("<!--ERR-->",
|
||||
"<p style='color:var(--r);margin-top:8px'>Incorrect password.</p>"),
|
||||
"text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
Response.Cookies.Append("empostor_admin", password, new CookieOptions
|
||||
// Successful login — clear rate-limit entry for this IP
|
||||
_loginFailures.TryRemove(ip, out _);
|
||||
|
||||
Response.Cookies.Append("empostor_admin", _passwordHash, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
MaxAge = TimeSpan.FromHours(8),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace Empostor.Server.Http
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly AdminConfig _config;
|
||||
private readonly PluginLoaderService _pluginLoaderService;
|
||||
private readonly string _passwordHash;
|
||||
|
||||
public MarketplaceController(
|
||||
ILogger<MarketplaceController> logger,
|
||||
@@ -36,10 +37,12 @@ namespace Empostor.Server.Http
|
||||
_http = http;
|
||||
_config = config.Value;
|
||||
_pluginLoaderService = pluginLoaderService;
|
||||
_passwordHash = AdminAuthHelper.ComputeHash(_config.Password);
|
||||
}
|
||||
|
||||
private bool IsAuthenticated()
|
||||
=> Request.Cookies.TryGetValue("empostor_admin", out var v) && v == _config.Password;
|
||||
=> Request.Cookies.TryGetValue("empostor_admin", out var v)
|
||||
&& AdminAuthHelper.ConstantTimeEquals(v, _passwordHash);
|
||||
|
||||
[HttpGet("/api/admin/marketplace/plugins")]
|
||||
public async Task<IActionResult> ListPlugins()
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"UmeApiKey": "sk-empostor-globalapikey"
|
||||
},
|
||||
"Admin": {
|
||||
"Password": "CHANGE-ME",
|
||||
"Password": "",
|
||||
"MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json"
|
||||
},
|
||||
"DiscordWebhook": {
|
||||
|
||||
@@ -58,12 +58,6 @@
|
||||
"resolved": "8.0.1",
|
||||
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.27, )",
|
||||
"resolved": "8.0.27",
|
||||
"contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg=="
|
||||
},
|
||||
"Serilog.Extensions.Hosting": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.0, )",
|
||||
|
||||
Reference in new issue
Block a user