/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.Serialization; using System.Security.Cryptography; namespace Opc.Ua.Server.UserDatabase { /// /// Implementation of a serializable user database using a concurrent dictionary for users. /// [DataContract(Namespace = Namespaces.UserDatabase)] public class LinqUserDatabase : IUserDatabase { /// /// 128 bit /// private const int kSaltSize = 16; /// /// 100k /// private const int kIterations = 100_000; /// /// 256 bit /// private const int kKeySize = 32; /// /// The representation of a user in the Linq database. /// [DataContract(Namespace = Namespaces.UserDatabase)] public class User { /// /// A guid to distinguish users. /// [DataMember(Name = "Id", IsRequired = true, Order = 10)] public Guid ID { get; set; } /// /// The user name. /// [DataMember(Name = "UserName", IsRequired = true, Order = 20)] public string UserName { get; set; } = null!; /// /// The hashed password. /// [DataMember(Name = "Hash", IsRequired = true, Order = 30)] public string Hash { get; set; } = null!; /// /// The associated roles with the user. /// [DataMember(Name = "Roles", IsRequired = false, Order = 40)] public ICollection Roles { get; set; } = null!; } /// /// The constructor. /// public LinqUserDatabase() { Initialize(); } /// public bool CreateUser(string userName, ReadOnlySpan password, ICollection roles) { if (string.IsNullOrEmpty(userName)) { throw new ArgumentException("UserName cannot be empty.", nameof(userName)); } if (Utils.Utf8IsNullOrEmpty(password)) { throw new ArgumentException("Password cannot be empty.", nameof(password)); } string hash = Hash(password); bool added = true; User newUser = m_users.AddOrUpdate(userName, (key) => new User { ID = Guid.NewGuid(), UserName = userName, Hash = hash, Roles = roles }, (key, value) => { added = false; value.Hash = hash; value.Roles = roles; return value; }); SaveChanges(); return added; } /// public bool DeleteUser(string userName) { if (string.IsNullOrEmpty(userName)) { throw new ArgumentException("UserName cannot be empty.", nameof(userName)); } return m_users.TryRemove(userName, out _); } /// public bool CheckCredentials(string userName, ReadOnlySpan password) { if (string.IsNullOrEmpty(userName)) { throw new ArgumentException("UserName cannot be empty.", nameof(userName)); } if (Utils.Utf8IsNullOrEmpty(password)) { throw new ArgumentException("Password cannot be empty.", nameof(password)); } if (!m_users.TryGetValue(userName, out User? user)) { return false; } return Check(user!.Hash, password); } /// public ICollection GetUserRoles(string userName) { if (string.IsNullOrEmpty(userName)) { throw new ArgumentException("UserName cannot be empty.", nameof(userName)); } if (!m_users.TryGetValue(userName, out User? user)) { throw new ArgumentException("No user found with the UserName " + userName); } return user!.Roles; } /// public IReadOnlyList GetUsers() { return [ .. m_users.Values.Select(user => new UserManagementDataType { UserName = user.UserName, UserConfiguration = (uint)UserConfigurationMask.None, Description = string.Empty }) ]; } /// public bool ChangePassword(string userName, ReadOnlySpan oldPassword, ReadOnlySpan newPassword) { if (string.IsNullOrEmpty(userName)) { throw new ArgumentException("UserName cannot be empty.", nameof(userName)); } if (Utils.Utf8IsNullOrEmpty(oldPassword)) { throw new ArgumentException( "Current Password cannot be empty.", nameof(oldPassword)); } if (Utils.Utf8IsNullOrEmpty(newPassword)) { throw new ArgumentException("New Password cannot be empty.", nameof(newPassword)); } if (!m_users.TryGetValue(userName, out User? user)) { return false; } if (Check(user!.Hash, oldPassword)) { user.Hash = Hash(newPassword); return true; } return false; } /// /// Persists the changes to the users database. /// protected virtual void Save() { } /// /// Users in the database. /// [DataMember(Name = "Users", IsRequired = true, Order = 10)] public User[] Users { get => [.. m_users.Values]; set { foreach (User user in value) { m_users.TryAdd(user.UserName, user); } } } /// /// Initializes the database. /// private void Initialize() { m_users = new ConcurrentDictionary(); } private void SaveChanges() { Save(); } private static string Hash(ReadOnlySpan password) { #if NET10_0_OR_GREATER // Use span and non obsoleted APIs Span keyBytes = stackalloc byte[kKeySize]; Span saltBytes = stackalloc byte[kSaltSize + sizeof(uint)]; RandomNumberGenerator.Fill(saltBytes); Rfc2898DeriveBytes.Pbkdf2( password, saltBytes, keyBytes, kIterations, HashAlgorithmName.SHA512); string keyBase64 = Convert.ToBase64String(keyBytes); string saltBase64 = Convert.ToBase64String(saltBytes); return $"{kIterations}.{saltBase64}.{keyBase64}"; #else // !NET10_0_OR_GREATER byte[] tmpPassword = password.ToArray(); try { byte[] salt = new byte[kSaltSize + sizeof(uint)]; #if NETFRAMEWORK using (var random = RandomNumberGenerator.Create()) { random.GetNonZeroBytes(salt); } #else // !NETFRAMEWORK RandomNumberGenerator.Fill(salt.AsSpan(0, kSaltSize)); #endif using var algorithm = new Rfc2898DeriveBytes( tmpPassword, salt, kIterations, HashAlgorithmName.SHA512); string keyBase64 = Convert.ToBase64String(algorithm.GetBytes(kKeySize)); string saltBase64 = Convert.ToBase64String(algorithm.Salt); return $"{kIterations}.{saltBase64}.{keyBase64}"; } finally { Array.Clear(tmpPassword, 0, tmpPassword.Length); } #endif // !NET10_0_OR_GREATER } private static bool Check(string hash, ReadOnlySpan password) { #if NET6_0_OR_GREATER string[] parts = hash.Split('.', 3, StringSplitOptions.TrimEntries); #else string[] parts = hash.Split(['.'], 3); #endif if (parts.Length != 3) { throw new FormatException( "Unexpected hash format. Should be formatted as `{iterations}.{salt}.{hash}`"); } int iterations = Convert.ToInt32(parts[0], CultureInfo.InvariantCulture.NumberFormat); byte[] salt = Convert.FromBase64String(parts[1]); byte[] key = Convert.FromBase64String(parts[2]); #if NET10_0_OR_GREATER // Use span overloads byte[] keyToCheck = Rfc2898DeriveBytes.Pbkdf2( password, salt, kIterations, HashAlgorithmName.SHA512, key.Length); return keyToCheck.SequenceEqual(key); #else byte[] tmpPassword = password.ToArray(); try { using var algorithm = new Rfc2898DeriveBytes( tmpPassword, salt, iterations, HashAlgorithmName.SHA512); byte[] keyToCheck = algorithm.GetBytes(kKeySize); return keyToCheck.SequenceEqual(key); } finally { Array.Clear(tmpPassword, 0, tmpPassword.Length); } #endif } [OnDeserialized] internal void OnDeserializedMethod(StreamingContext context) { Initialize(); } private ConcurrentDictionary m_users = new(); } /// /// Defines constants for all namespaces. /// public static class Namespaces { /// /// The URI for the UserDatabase namespace. /// public const string UserDatabase = "http://opcfoundation.org/UA/UserDatabase/"; } }