/* Technitium DNS Server Copyright (C) 2023 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.Security.Cryptography; using System.Text; using TechnitiumLibrary.IO; using TechnitiumLibrary.Net; namespace DnsServerCore.Auth { enum UserPasswordHashType : byte { Unknown = 0, OldScheme = 1, PBKDF2_SHA256 = 2 } class User : IComparable { #region variables static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); public const int DEFAULT_ITERATIONS = 100000; string _displayName; string _username; UserPasswordHashType _passwordHashType; int _iterations; byte[] _salt; string _passwordHash; bool _disabled; int _sessionTimeoutSeconds = 30 * 60; //default 30 mins DateTime _previousSessionLoggedOn; IPAddress _previousSessionRemoteAddress; DateTime _recentSessionLoggedOn; IPAddress _recentSessionRemoteAddress; readonly ConcurrentDictionary _memberOfGroups; #endregion #region constructor public User(string displayName, string username, string password, int iterations = DEFAULT_ITERATIONS) { Username = username; DisplayName = displayName; ChangePassword(password, iterations); _previousSessionRemoteAddress = IPAddress.Any; _recentSessionRemoteAddress = IPAddress.Any; _memberOfGroups = new ConcurrentDictionary(1, 2); } public User(BinaryReader bR, AuthManager authManager) { switch (bR.ReadByte()) { case 1: _displayName = bR.ReadShortString(); _username = bR.ReadShortString(); _passwordHashType = (UserPasswordHashType)bR.ReadByte(); _iterations = bR.ReadInt32(); _salt = bR.ReadBuffer(); _passwordHash = bR.ReadShortString(); _disabled = bR.ReadBoolean(); _sessionTimeoutSeconds = bR.ReadInt32(); _previousSessionLoggedOn = bR.ReadDateTime(); _previousSessionRemoteAddress = IPAddressExtensions.ReadFrom(bR); _recentSessionLoggedOn = bR.ReadDateTime(); _recentSessionRemoteAddress = IPAddressExtensions.ReadFrom(bR); { int count = bR.ReadByte(); _memberOfGroups = new ConcurrentDictionary(1, count); for (int i = 0; i < count; i++) { Group group = authManager.GetGroup(bR.ReadShortString()); if (group is not null) _memberOfGroups.TryAdd(group.Name.ToLower(), group); } } break; default: throw new InvalidDataException("Invalid data or version not supported."); } } #endregion #region internal internal void RenameGroup(string oldName) { if (_memberOfGroups.TryRemove(oldName.ToLower(), out Group renamedGroup)) _memberOfGroups.TryAdd(renamedGroup.Name.ToLower(), renamedGroup); } #endregion #region public public string GetPasswordHashFor(string password) { switch (_passwordHashType) { case UserPasswordHashType.OldScheme: using (HMAC hmac = new HMACSHA256(Encoding.UTF8.GetBytes(password))) { return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(_username))).ToLower(); } case UserPasswordHashType.PBKDF2_SHA256: return Convert.ToHexString(Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), _salt, _iterations, HashAlgorithmName.SHA256, 32)).ToLower(); default: throw new NotSupportedException(); } } public void ChangePassword(string newPassword, int iterations = DEFAULT_ITERATIONS) { _passwordHashType = UserPasswordHashType.PBKDF2_SHA256; _iterations = iterations; _salt = new byte[32]; _rng.GetBytes(_salt); _passwordHash = GetPasswordHashFor(newPassword); } public void LoadOldSchemeCredentials(string passwordHash) { _passwordHashType = UserPasswordHashType.OldScheme; _passwordHash = passwordHash; } public void LoggedInFrom(IPAddress remoteAddress) { if (remoteAddress.IsIPv4MappedToIPv6) remoteAddress = remoteAddress.MapToIPv4(); _previousSessionLoggedOn = _recentSessionLoggedOn; _previousSessionRemoteAddress = _recentSessionRemoteAddress; _recentSessionLoggedOn = DateTime.UtcNow; _recentSessionRemoteAddress = remoteAddress; } public void AddToGroup(Group group) { if (_memberOfGroups.Count == 255) throw new InvalidOperationException("Cannot add user to group: user can be member of max 255 groups."); _memberOfGroups.TryAdd(group.Name.ToLower(), group); } public bool RemoveFromGroup(Group group) { if (group.Name.Equals("everyone", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Access was denied."); return _memberOfGroups.TryRemove(group.Name.ToLower(), out _); } public void SyncGroups(IReadOnlyDictionary groups) { //remove non-existent groups foreach (KeyValuePair group in _memberOfGroups) { if (!groups.ContainsKey(group.Key)) _memberOfGroups.TryRemove(group.Key, out _); } //set new groups foreach (KeyValuePair group in groups) _memberOfGroups[group.Key] = group.Value; } public bool IsMemberOfGroup(Group group) { return _memberOfGroups.ContainsKey(group.Name.ToLower()); } public void WriteTo(BinaryWriter bW) { bW.Write((byte)1); bW.WriteShortString(_displayName); bW.WriteShortString(_username); bW.Write((byte)_passwordHashType); bW.Write(_iterations); bW.WriteBuffer(_salt); bW.WriteShortString(_passwordHash); bW.Write(_disabled); bW.Write(_sessionTimeoutSeconds); bW.Write(_previousSessionLoggedOn); IPAddressExtensions.WriteTo(_previousSessionRemoteAddress, bW); bW.Write(_recentSessionLoggedOn); IPAddressExtensions.WriteTo(_recentSessionRemoteAddress, bW); bW.Write(Convert.ToByte(_memberOfGroups.Count)); foreach (KeyValuePair group in _memberOfGroups) bW.WriteShortString(group.Value.Name.ToLower()); } public override bool Equals(object obj) { if (obj is not User other) return false; return _username.Equals(other._username, StringComparison.OrdinalIgnoreCase); } public override int GetHashCode() { return base.GetHashCode(); } public override string ToString() { return _username; } public int CompareTo(User other) { return _username.CompareTo(other._username); } #endregion #region properties public string DisplayName { get { return _displayName; } set { if (value.Length > 255) throw new ArgumentException("Display name length cannot exceed 255 characters.", nameof(DisplayName)); _displayName = value; if (string.IsNullOrWhiteSpace(_displayName)) _displayName = _username; } } public string Username { get { return _username; } set { if (_passwordHashType == UserPasswordHashType.OldScheme) throw new InvalidOperationException("Cannot change username when using old password hash scheme. Change password once and try again."); if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Username cannot be null or empty.", nameof(Username)); if (value.Length > 255) throw new ArgumentException("Username length cannot exceed 255 characters.", nameof(Username)); foreach (char c in value) { if ((c >= 97) && (c <= 122)) //[a-z] continue; if ((c >= 65) && (c <= 90)) //[A-Z] continue; if ((c >= 48) && (c <= 57)) //[0-9] continue; if (c == '-') continue; if (c == '_') continue; if (c == '.') continue; throw new ArgumentException("Username can contain only alpha numeric, '-', '_', or '.' characters.", nameof(Username)); } _username = value.ToLower(); } } public UserPasswordHashType PasswordHashType { get { return _passwordHashType; } } public string PasswordHash { get { return _passwordHash; } } public bool Disabled { get { return _disabled; } set { _disabled = value; } } public int SessionTimeoutSeconds { get { return _sessionTimeoutSeconds; } set { if ((value < 0) || (value > 604800)) throw new ArgumentOutOfRangeException(nameof(SessionTimeoutSeconds), "Session timeout value must be between 0-604800 seconds."); if ((value > 0) && (value < 60)) value = 60; //to prevent issues with too low timeout set by mistake _sessionTimeoutSeconds = value; } } public DateTime PreviousSessionLoggedOn { get { return _previousSessionLoggedOn; } } public IPAddress PreviousSessionRemoteAddress { get { return _previousSessionRemoteAddress; } } public DateTime RecentSessionLoggedOn { get { return _recentSessionLoggedOn; } } public IPAddress RecentSessionRemoteAddress { get { return _recentSessionRemoteAddress; } } public ICollection MemberOfGroups { get { return _memberOfGroups.Values; } } #endregion } }