/* Technitium DNS Server Copyright (C) 2024 Shreyas Zare (shreyas@technitium.com) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DnsServerCore.Auth { sealed class AuthManager : IDisposable { #region variables readonly ConcurrentDictionary _groups = new ConcurrentDictionary(1, 4); readonly ConcurrentDictionary _users = new ConcurrentDictionary(1, 4); readonly ConcurrentDictionary _permissions = new ConcurrentDictionary(1, 11); readonly ConcurrentDictionary _sessions = new ConcurrentDictionary(1, 10); readonly ConcurrentDictionary _failedLoginAttempts = new ConcurrentDictionary(1, 10); const int MAX_LOGIN_ATTEMPTS = 5; readonly ConcurrentDictionary _blockedAddresses = new ConcurrentDictionary(1, 10); const int BLOCK_ADDRESS_INTERVAL = 5 * 60 * 1000; readonly string _configFolder; readonly LogManager _log; readonly object _saveLock = new object(); bool _pendingSave; readonly Timer _saveTimer; const int SAVE_TIMER_INITIAL_INTERVAL = 10000; #endregion #region constructor public AuthManager(string configFolder, LogManager log) { _configFolder = configFolder; _log = log; _saveTimer = new Timer(delegate (object state) { lock (_saveLock) { if (_pendingSave) { try { SaveConfigFileInternal(); _pendingSave = false; } catch (Exception ex) { _log.Write(ex); //set timer to retry again _saveTimer.Change(SAVE_TIMER_INITIAL_INTERVAL, Timeout.Infinite); } } } }); } #endregion #region IDisposable bool _disposed; public void Dispose() { if (_disposed) return; lock (_saveLock) { _saveTimer?.Dispose(); //always save config here try { SaveConfigFileInternal(); } catch (Exception ex) { _log.Write(ex); } finally { _pendingSave = false; } } _disposed = true; } #endregion #region private private void CreateDefaultConfig() { Group adminGroup = CreateGroup(Group.ADMINISTRATORS, "Super administrators"); Group dnsAdminGroup = CreateGroup(Group.DNS_ADMINISTRATORS, "DNS service administrators"); Group dhcpAdminGroup = CreateGroup(Group.DHCP_ADMINISTRATORS, "DHCP service administrators"); Group everyoneGroup = CreateGroup(Group.EVERYONE, "All users"); SetPermission(PermissionSection.Dashboard, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Zones, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Cache, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Allowed, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Blocked, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Apps, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.DnsClient, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Settings, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.DhcpServer, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Administration, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Logs, adminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Zones, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Cache, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Allowed, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Blocked, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Apps, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.DnsClient, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Settings, dnsAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.DhcpServer, dhcpAdminGroup, PermissionFlag.ViewModifyDelete); SetPermission(PermissionSection.Dashboard, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Zones, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Cache, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Allowed, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Blocked, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Apps, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.DnsClient, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.DhcpServer, everyoneGroup, PermissionFlag.View); SetPermission(PermissionSection.Logs, everyoneGroup, PermissionFlag.View); string adminPassword = Environment.GetEnvironmentVariable("DNS_SERVER_ADMIN_PASSWORD"); string adminPasswordFile = Environment.GetEnvironmentVariable("DNS_SERVER_ADMIN_PASSWORD_FILE"); User adminUser; if (!string.IsNullOrEmpty(adminPassword)) { adminUser = CreateUser("Administrator", "admin", adminPassword); } else if (!string.IsNullOrEmpty(adminPasswordFile)) { try { using (StreamReader sR = new StreamReader(adminPasswordFile, true)) { string password = sR.ReadLine(); adminUser = CreateUser("Administrator", "admin", password); } } catch (Exception ex) { _log.Write(ex); adminUser = CreateUser("Administrator", "admin", "admin"); } } else { adminUser = CreateUser("Administrator", "admin", "admin"); } adminUser.AddToGroup(adminGroup); } private void LoadConfigFileInternal(UserSession implantSession) { string configFile = Path.Combine(_configFolder, "auth.config"); try { bool passwordResetOption = false; if (!File.Exists(configFile)) { string passwordResetConfigFile = Path.Combine(_configFolder, "resetadmin.config"); if (File.Exists(passwordResetConfigFile)) { passwordResetOption = true; configFile = passwordResetConfigFile; } } using (FileStream fS = new FileStream(configFile, FileMode.Open, FileAccess.Read)) { ReadConfigFrom(new BinaryReader(fS)); } if (implantSession is not null) { using (MemoryStream mS = new MemoryStream()) { //implant current user implantSession.User.WriteTo(new BinaryWriter(mS)); mS.Position = 0; User newUser = new User(new BinaryReader(mS), this); newUser.AddToGroup(GetGroup(Group.ADMINISTRATORS)); _users[newUser.Username] = newUser; //implant current session mS.SetLength(0); implantSession.WriteTo(new BinaryWriter(mS)); mS.Position = 0; UserSession newSession = new UserSession(new BinaryReader(mS), this); _sessions.TryAdd(newSession.Token, newSession); //save config SaveConfigFileInternal(); } } _log.Write("DNS Server auth config file was loaded: " + configFile); if (passwordResetOption) { User adminUser = GetUser("admin"); if (adminUser is null) { adminUser = CreateUser("Administrator", "admin", "admin"); } else { adminUser.ChangePassword("admin"); adminUser.Disabled = false; } adminUser.AddToGroup(GetGroup(Group.ADMINISTRATORS)); _log.Write("DNS Server reset password for user: admin"); SaveConfigFileInternal(); try { File.Delete(configFile); } catch { } } } catch (FileNotFoundException) { _log.Write("DNS Server auth config file was not found: " + configFile); _log.Write("DNS Server is restoring default auth config file."); CreateDefaultConfig(); SaveConfigFileInternal(); } catch (Exception ex) { _log.Write("DNS Server encountered an error while loading auth config file: " + configFile + "\r\n" + ex.ToString()); _log.Write("Note: You may try deleting the auth config file to fix this issue. However, you will lose auth settings but, rest of the DNS settings and zone data wont be affected."); throw; } } private void SaveConfigFileInternal() { string configFile = Path.Combine(_configFolder, "auth.config"); using (MemoryStream mS = new MemoryStream()) { //serialize config WriteConfigTo(new BinaryWriter(mS)); //write config mS.Position = 0; using (FileStream fS = new FileStream(configFile, FileMode.Create, FileAccess.Write)) { mS.CopyTo(fS); } } _log.Write("DNS Server auth config file was saved: " + configFile); } private void ReadConfigFrom(BinaryReader bR) { if (Encoding.ASCII.GetString(bR.ReadBytes(2)) != "AS") //format throw new InvalidDataException("DNS Server auth config file format is invalid."); int version = bR.ReadByte(); switch (version) { case 1: { int count = bR.ReadByte(); for (int i = 0; i < count; i++) { Group group = new Group(bR); _groups.TryAdd(group.Name.ToLower(), group); } } { int count = bR.ReadByte(); for (int i = 0; i < count; i++) { User user = new User(bR, this); _users.TryAdd(user.Username, user); } } { int count = bR.ReadInt32(); for (int i = 0; i < count; i++) { Permission permission = new Permission(bR, this); _permissions.TryAdd(permission.Section, permission); } } { int count = bR.ReadInt32(); for (int i = 0; i < count; i++) { UserSession session = new UserSession(bR, this); if (!session.HasExpired()) _sessions.TryAdd(session.Token, session); } } break; default: throw new InvalidDataException("DNS Server auth config version not supported."); } } private void WriteConfigTo(BinaryWriter bW) { bW.Write(Encoding.ASCII.GetBytes("AS")); //format bW.Write((byte)1); //version bW.Write(Convert.ToByte(_groups.Count)); foreach (KeyValuePair group in _groups) group.Value.WriteTo(bW); bW.Write(Convert.ToByte(_users.Count)); foreach (KeyValuePair user in _users) user.Value.WriteTo(bW); bW.Write(_permissions.Count); foreach (KeyValuePair permission in _permissions) permission.Value.WriteTo(bW); List activeSessions = new List(_sessions.Count); foreach (KeyValuePair session in _sessions) { if (session.Value.HasExpired()) _sessions.TryRemove(session.Key, out _); else activeSessions.Add(session.Value); } bW.Write(activeSessions.Count); foreach (UserSession session in activeSessions) session.WriteTo(bW); } private void FailedLoginAttempt(IPAddress address) { _failedLoginAttempts.AddOrUpdate(address, 1, delegate (IPAddress key, int attempts) { return attempts + 1; }); } private bool LoginAttemptsExceedLimit(IPAddress address, int limit) { if (!_failedLoginAttempts.TryGetValue(address, out int attempts)) return false; return attempts >= limit; } private void ResetFailedLoginAttempt(IPAddress address) { _failedLoginAttempts.TryRemove(address, out _); } private void BlockAddress(IPAddress address, int interval) { _blockedAddresses.TryAdd(address, DateTime.UtcNow.AddMilliseconds(interval)); } private bool IsAddressBlocked(IPAddress address) { if (!_blockedAddresses.TryGetValue(address, out DateTime expiry)) return false; if (expiry > DateTime.UtcNow) { return true; } else { UnblockAddress(address); ResetFailedLoginAttempt(address); return false; } } private void UnblockAddress(IPAddress address) { _blockedAddresses.TryRemove(address, out _); } #endregion #region public public User GetUser(string username) { if (_users.TryGetValue(username.ToLower(), out User user)) return user; return null; } public User CreateUser(string displayName, string username, string password, int iterations = User.DEFAULT_ITERATIONS) { if (_users.Count >= byte.MaxValue) throw new DnsWebServiceException("Cannot create more than 255 users."); username = username.ToLower(); User user = new User(displayName, username, password, iterations); if (_users.TryAdd(username, user)) { user.AddToGroup(GetGroup(Group.EVERYONE)); return user; } throw new DnsWebServiceException("User already exists: " + username); } public void ChangeUsername(User user, string newUsername) { if (user.Username.Equals(newUsername, StringComparison.OrdinalIgnoreCase)) return; string oldUsername = user.Username; user.Username = newUsername; if (!_users.TryAdd(user.Username, user)) { user.Username = oldUsername; //revert throw new DnsWebServiceException("User already exists: " + newUsername); } _users.TryRemove(oldUsername, out _); } public bool DeleteUser(string username) { if (_users.TryRemove(username.ToLower(), out User deletedUser)) { //delete all sessions foreach (UserSession session in GetSessions(deletedUser)) DeleteSession(session.Token); //delete all permissions foreach (KeyValuePair permission in _permissions) { permission.Value.RemovePermission(deletedUser); permission.Value.RemoveAllSubItemPermissions(deletedUser); } return true; } return false; } public Group GetGroup(string name) { if (_groups.TryGetValue(name.ToLower(), out Group group)) return group; return null; } public List GetGroupMembers(Group group) { List members = new List(); foreach (KeyValuePair user in _users) { if (user.Value.IsMemberOfGroup(group)) members.Add(user.Value); } return members; } public void SyncGroupMembers(Group group, IReadOnlyDictionary users) { //remove foreach (KeyValuePair user in _users) { if (!users.ContainsKey(user.Key)) user.Value.RemoveFromGroup(group); } //set foreach (KeyValuePair user in users) user.Value.AddToGroup(group); } public Group CreateGroup(string name, string description) { if (_groups.Count >= byte.MaxValue) throw new DnsWebServiceException("Cannot create more than 255 groups."); Group group = new Group(name, description); if (_groups.TryAdd(name.ToLower(), group)) return group; throw new DnsWebServiceException("Group already exists: " + name); } public void RenameGroup(Group group, string newGroupName) { if (group.Name.Equals(newGroupName, StringComparison.OrdinalIgnoreCase)) { group.Name = newGroupName; return; } string oldGroupName = group.Name; group.Name = newGroupName; if (!_groups.TryAdd(group.Name.ToLower(), group)) { group.Name = oldGroupName; //revert throw new DnsWebServiceException("Group already exists: " + newGroupName); } _groups.TryRemove(oldGroupName.ToLower(), out _); //update users foreach (KeyValuePair user in _users) user.Value.RenameGroup(oldGroupName); } public bool DeleteGroup(string name) { name = name.ToLower(); switch (name) { case "everyone": case "administrators": case "dns administrators": case "dhcp administrators": throw new InvalidOperationException("Access was denied."); default: if (_groups.TryRemove(name, out Group deletedGroup)) { //remove all users from deleted group foreach (KeyValuePair user in _users) user.Value.RemoveFromGroup(deletedGroup); //delete all permissions foreach (KeyValuePair permission in _permissions) { permission.Value.RemovePermission(deletedGroup); permission.Value.RemoveAllSubItemPermissions(deletedGroup); } return true; } return false; } } public UserSession GetSession(string token) { if (_sessions.TryGetValue(token, out UserSession session)) return session; return null; } public List GetSessions(User user) { List userSessions = new List(); foreach (KeyValuePair session in _sessions) { if (session.Value.User.Equals(user) && !session.Value.HasExpired()) userSessions.Add(session.Value); } return userSessions; } public async Task CreateSessionAsync(UserSessionType type, string tokenName, string username, string password, IPAddress remoteAddress, string userAgent) { if (IsAddressBlocked(remoteAddress)) throw new DnsWebServiceException("Max limit of " + MAX_LOGIN_ATTEMPTS + " attempts exceeded. Access blocked for " + (BLOCK_ADDRESS_INTERVAL / 1000) + " seconds."); User user = GetUser(username); if ((user is null) || !user.PasswordHash.Equals(user.GetPasswordHashFor(password), StringComparison.Ordinal)) { if (password != "admin") { FailedLoginAttempt(remoteAddress); if (LoginAttemptsExceedLimit(remoteAddress, MAX_LOGIN_ATTEMPTS)) BlockAddress(remoteAddress, BLOCK_ADDRESS_INTERVAL); await Task.Delay(1000); } throw new DnsWebServiceException("Invalid username or password for user: " + username); } ResetFailedLoginAttempt(remoteAddress); if (user.Disabled) throw new DnsWebServiceException("User account is disabled. Please contact your administrator."); UserSession session = new UserSession(type, tokenName, user, remoteAddress, userAgent); if (!_sessions.TryAdd(session.Token, session)) throw new DnsWebServiceException("Error while creating session. Please try again."); user.LoggedInFrom(remoteAddress); return session; } public UserSession CreateApiToken(string tokenName, string username, IPAddress remoteAddress, string userAgent) { if (IsAddressBlocked(remoteAddress)) throw new DnsWebServiceException("Max limit of " + MAX_LOGIN_ATTEMPTS + " attempts exceeded. Access blocked for " + (BLOCK_ADDRESS_INTERVAL / 1000) + " seconds."); User user = GetUser(username); if (user is null) throw new DnsWebServiceException("No such user exists: " + username); if (user.Disabled) throw new DnsWebServiceException("Account is suspended."); UserSession session = new UserSession(UserSessionType.ApiToken, tokenName, user, remoteAddress, userAgent); if (!_sessions.TryAdd(session.Token, session)) throw new DnsWebServiceException("Error while creating session. Please try again."); user.LoggedInFrom(remoteAddress); return session; } public UserSession DeleteSession(string token) { if (_sessions.TryRemove(token, out UserSession session)) return session; return null; } public Permission GetPermission(PermissionSection section) { if (_permissions.TryGetValue(section, out Permission permission)) return permission; return null; } public Permission GetPermission(PermissionSection section, string subItemName) { if (_permissions.TryGetValue(section, out Permission permission)) return permission.GetSubItemPermission(subItemName); return null; } public void SetPermission(PermissionSection section, User user, PermissionFlag flags) { Permission permission = _permissions.GetOrAdd(section, delegate (PermissionSection key) { return new Permission(key); }); permission.SetPermission(user, flags); } public void SetPermission(PermissionSection section, string subItemName, User user, PermissionFlag flags) { Permission permission = _permissions.GetOrAdd(section, delegate (PermissionSection key) { return new Permission(key); }); permission.SetSubItemPermission(subItemName, user, flags); } public void SetPermission(PermissionSection section, Group group, PermissionFlag flags) { Permission permission = _permissions.GetOrAdd(section, delegate (PermissionSection key) { return new Permission(key); }); permission.SetPermission(group, flags); } public void SetPermission(PermissionSection section, string subItemName, Group group, PermissionFlag flags) { Permission permission = _permissions.GetOrAdd(section, delegate (PermissionSection key) { return new Permission(key); }); permission.SetSubItemPermission(subItemName, group, flags); } public bool RemovePermission(PermissionSection section, User user) { return _permissions.TryGetValue(section, out Permission permission) && permission.RemovePermission(user); } public bool RemovePermission(PermissionSection section, string subItemName, User user) { return _permissions.TryGetValue(section, out Permission permission) && permission.RemoveSubItemPermission(subItemName, user); } public bool RemovePermission(PermissionSection section, Group group) { return _permissions.TryGetValue(section, out Permission permission) && permission.RemovePermission(group); } public bool RemovePermission(PermissionSection section, string subItemName, Group group) { return _permissions.TryGetValue(section, out Permission permission) && permission.RemoveSubItemPermission(subItemName, group); } public bool RemoveAllPermissions(PermissionSection section, string subItemName) { return _permissions.TryGetValue(section, out Permission permission) && permission.RemoveAllSubItemPermissions(subItemName); } public bool IsPermitted(PermissionSection section, User user, PermissionFlag flag) { return _permissions.TryGetValue(section, out Permission permission) && permission.IsPermitted(user, flag); } public bool IsPermitted(PermissionSection section, string subItemName, User user, PermissionFlag flag) { return _permissions.TryGetValue(section, out Permission permission) && permission.IsSubItemPermitted(subItemName, user, flag); } public void LoadOldConfig(string password, bool isPasswordHash) { User user = GetUser("admin"); if (user is null) user = CreateUser("Administrator", "admin", "admin"); user.AddToGroup(GetGroup(Group.ADMINISTRATORS)); if (isPasswordHash) user.LoadOldSchemeCredentials(password); else user.ChangePassword(password); lock (_saveLock) { SaveConfigFileInternal(); } } public void LoadConfigFile(UserSession implantSession = null) { lock (_saveLock) { _groups.Clear(); _users.Clear(); _permissions.Clear(); _sessions.Clear(); LoadConfigFileInternal(implantSession); } } public void SaveConfigFile() { lock (_saveLock) { if (_pendingSave) return; _pendingSave = true; _saveTimer.Change(SAVE_TIMER_INITIAL_INTERVAL, Timeout.Infinite); } } #endregion #region properties public ICollection Groups { get { return _groups.Values; } } public ICollection Users { get { return _users.Values; } } public ICollection Permissions { get { return _permissions.Values; } } public ICollection Sessions { get { return _sessions.Values; } } #endregion } }