This commit is contained in:
linmeideli committed 2026-06-27 20:07:10 +08:00
1 parent 5f0eea099e
commit c3df40c96c
10 files changed
+103 -26

No files matched your search

+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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": {}
}
}
}
+1 -1
View File
@@ -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";
+1 -8
View File
@@ -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));
}
}
+53 -4
View File
@@ -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()
+1 -1
View File
@@ -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": {
-6
View File
@@ -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, )",