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); } }