using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace MSCO.Server;
/// Rollen im Web-Panel: Moderator (Spieler/Chat), Admin (alles am Server), Owner (zusätzlich Benutzer).
public enum PanelRole { Moderator = 1, Admin = 2, Owner = 3 }
///
/// Benutzer des Web-Panels in data/panel_users.json. Passwörter nur als PBKDF2-SHA256-Hash mit eigenem Salz.
///
public sealed class PanelUsers
{
public sealed class User
{
public string Name { get; set; }
public PanelRole Role { get; set; }
public string Salt { get; set; }
public string Hash { get; set; }
public int Iterations { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime? LastLoginUtc { get; set; }
}
sealed class FileData { public List Users { get; set; } = new(); }
const int Iterations = 210_000;
static readonly Regex ValidName = new("^[A-Za-z0-9_.-]{3,32}$", RegexOptions.Compiled);
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
readonly string path;
readonly object gate = new();
List users;
public PanelUsers(string path)
{
this.path = path;
users = File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Options)?.Users ?? new() : new();
}
public bool Empty { get { lock (gate) return users.Count == 0; } }
public List All() { lock (gate) return users.ToList(); }
public User Find(string name)
{
lock (gate) return users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase));
}
public static string CheckName(string name) =>
name != null && ValidName.IsMatch(name) ? null : "Username: 3-32 characters (letters, numbers, _ . -)";
public static string CheckPassword(string password) =>
password != null && password.Length >= 8 && password.Length <= 200 ? null : "Password: at least 8 characters";
/// Neuen Benutzer anlegen. Gibt einen Fehler zurück oder null.
public string Add(string name, string password, PanelRole role)
{
string error = CheckName(name) ?? CheckPassword(password);
if (error != null) return error;
lock (gate)
{
if (Find(name) != null) return "A user with this name already exists";
var user = new User { Name = name, Role = role, CreatedUtc = DateTime.UtcNow };
SetPasswordHash(user, password);
users.Add(user);
Save();
}
return null;
}
/// Benutzer prüfen. Gibt ihn bei richtigem Passwort zurück, sonst null.
public User Verify(string name, string password)
{
User user = Find(name ?? "");
if (user == null || password == null)
{
// Gleich lange rechnen wie bei einem echten Benutzer (kein Rückschluss, ob es den Namen gibt)
Rfc2898DeriveBytes.Pbkdf2(password ?? "", new byte[16], Iterations, HashAlgorithmName.SHA256, 32);
return null;
}
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), Convert.FromBase64String(user.Salt),
user.Iterations, HashAlgorithmName.SHA256, 32);
if (!CryptographicOperations.FixedTimeEquals(hash, Convert.FromBase64String(user.Hash))) return null;
lock (gate)
{
user.LastLoginUtc = DateTime.UtcNow;
Save();
}
return user;
}
public string SetPassword(string name, string password)
{
string error = CheckPassword(password);
if (error != null) return error;
lock (gate)
{
User user = Find(name);
if (user == null) return "User not found";
SetPasswordHash(user, password);
Save();
}
return null;
}
public string SetRole(string name, PanelRole role)
{
lock (gate)
{
User user = Find(name);
if (user == null) return "User not found";
if (user.Role == PanelRole.Owner && role != PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1)
return "There must be at least one owner";
user.Role = role;
Save();
}
return null;
}
public string Remove(string name)
{
lock (gate)
{
User user = Find(name);
if (user == null) return "User not found";
if (user.Role == PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1)
return "The last owner cannot be deleted";
users.Remove(user);
Save();
}
return null;
}
static void SetPasswordHash(User user, string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
user.Salt = Convert.ToBase64String(salt);
user.Iterations = Iterations;
user.Hash = Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32));
}
void Save()
{
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(new FileData { Users = users }, Options));
PanelTls.Restrict(tmp); // Passwort-Hashes nur für den Server-Benutzer lesbar (Linux)
if (!OperatingSystem.IsWindows())
try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
File.Move(tmp, path, true);
}
}