/* ========================================================================
* 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.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using Opc.Ua.Server.UserDatabase;
namespace Opc.Ua.Server.UserManagement
{
///
/// Default in-memory implementation. Wraps
/// an for credential persistence and keeps
/// the per-user and description in
/// memory.
///
///
/// Per Part 18 §6.4 "the management of these Users is server-specific" —
/// this default keeps the metadata in memory across the server lifetime.
/// Integrators that need persistence of the metadata implement
/// directly and inject the instance via
/// .
///
public sealed class UserManagement : IUserManagement, IDisposable
{
private readonly IUserDatabase m_userDatabase;
private readonly Dictionary m_metadata
= new(StringComparer.Ordinal);
private readonly ReaderWriterLockSlim m_lock = new(LockRecursionPolicy.NoRecursion);
private bool m_disposed;
///
/// Creates a new backed by
/// .
///
/// The user-credential persistence store.
///
/// Allowed password length range; defaults to {Low = 8, High = 256}.
///
///
/// Password requirements / supported features advertised to clients.
/// Defaults to
/// plus
/// plus .
///
///
/// Optional human-readable description of password restrictions.
///
public UserManagement(
IUserDatabase userDatabase,
Range? passwordLength = null,
PasswordOptionsMask? passwordOptions = null,
LocalizedText? passwordRestrictions = null)
{
m_userDatabase = userDatabase ?? throw new ArgumentNullException(nameof(userDatabase));
PasswordLength = passwordLength ?? new Range { Low = 8, High = 256 };
PasswordOptions = passwordOptions
?? (PasswordOptionsMask.SupportDisableUser |
PasswordOptionsMask.SupportInitialPasswordChange |
PasswordOptionsMask.SupportDescriptionForUser);
PasswordRestrictions = passwordRestrictions;
LoadUsersFromDatabase();
}
///
public Range PasswordLength { get; }
///
public PasswordOptionsMask PasswordOptions { get; }
///
public LocalizedText? PasswordRestrictions { get; }
///
public event EventHandler? UserDeactivated;
///
public IReadOnlyList SnapshotUsers()
{
m_lock.EnterReadLock();
try
{
var list = new List(m_metadata.Count);
foreach (KeyValuePair kv in m_metadata)
{
list.Add(new UserManagementDataType
{
UserName = kv.Key,
UserConfiguration = (uint)kv.Value.Configuration,
Description = kv.Value.Description
});
}
return list;
}
finally
{
m_lock.ExitReadLock();
}
}
///
public ServiceResult AddUser(
string userName,
string password,
UserConfigurationMask userConfiguration,
string description)
{
if (string.IsNullOrEmpty(userName))
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText("UserName must be non-empty."));
}
ServiceResult configValidation = ValidateConfigurationFlags(userConfiguration);
if (ServiceResult.IsBad(configValidation))
{
return configValidation;
}
ServiceResult passwordValidation = ValidatePassword(password);
if (ServiceResult.IsBad(passwordValidation))
{
return passwordValidation;
}
m_lock.EnterWriteLock();
try
{
if (m_metadata.ContainsKey(userName))
{
return new ServiceResult(StatusCodes.BadAlreadyExists,
new LocalizedText($"User '{userName}' already exists."));
}
if (!m_userDatabase.CreateUser(userName, GetPasswordBytes(password), []))
{
return new ServiceResult(StatusCodes.BadResourceUnavailable,
new LocalizedText("User-database rejected the create operation."));
}
m_metadata[userName] = new UserMetadata(userConfiguration, description ?? string.Empty);
}
finally
{
m_lock.ExitWriteLock();
}
return ServiceResult.Good;
}
///
public ServiceResult ModifyUser(
string userName,
bool modifyPassword,
string password,
bool modifyUserConfiguration,
UserConfigurationMask userConfiguration,
bool modifyDescription,
string description,
string? callingUserName)
{
if (string.IsNullOrEmpty(userName))
{
return new ServiceResult(StatusCodes.BadInvalidArgument);
}
bool willDisable = false;
bool disabled;
m_lock.EnterWriteLock();
try
{
if (!m_metadata.TryGetValue(userName, out UserMetadata? existing))
{
return new ServiceResult(StatusCodes.BadNotFound,
new LocalizedText($"User '{userName}' not found."));
}
UserConfigurationMask effectiveConfig = modifyUserConfiguration
? userConfiguration
: existing.Configuration;
if (modifyUserConfiguration)
{
ServiceResult configValidation = ValidateConfigurationFlags(userConfiguration);
if (ServiceResult.IsBad(configValidation))
{
return configValidation;
}
willDisable = (existing.Configuration & UserConfigurationMask.Disabled) == 0 &&
(userConfiguration & UserConfigurationMask.Disabled) != 0;
if (willDisable &&
callingUserName != null &&
string.Equals(callingUserName, userName, StringComparison.Ordinal))
{
return new ServiceResult(StatusCodes.BadInvalidSelfReference,
new LocalizedText("The user to disable is the calling session's user."));
}
}
if (modifyPassword)
{
ServiceResult passwordValidation = ValidatePassword(password);
if (ServiceResult.IsBad(passwordValidation))
{
return passwordValidation;
}
// The IUserDatabase API only exposes ChangePassword which
// requires the old password — admin password resets are
// emulated by delete + recreate to keep the abstraction
// stable. The previously assigned roles are snapshotted
// and re-applied to preserve role assignments across the
// reset (Part 18 §5.2.6 does not mandate role removal on
// password change).
//
// KNOWN LIMITATION (see Docs/RoleBasedUserManagement.md):
// this two-step is not atomic — if CreateUser fails after
// DeleteUser succeeds, the user account is lost. The
// built-in LinqUserDatabase / JsonUserDatabase delete and
// create operations succeed unconditionally so this is
// only a concern for custom IUserDatabase implementations
// that may fail mid-reset.
ICollection preservedRoles = SnapshotUserRolesSafe(userName);
if (!m_userDatabase.DeleteUser(userName))
{
return new ServiceResult(StatusCodes.BadResourceUnavailable,
new LocalizedText("User-database rejected the delete during password reset."));
}
if (!m_userDatabase.CreateUser(userName, GetPasswordBytes(password), preservedRoles))
{
// User has been deleted but recreate failed — surface
// a clear error. There is no way to recover the
// original password from IUserDatabase.
m_metadata.Remove(userName);
return new ServiceResult(StatusCodes.BadResourceUnavailable,
new LocalizedText(
"User-database rejected the create during password reset; " +
"the user account has been removed."));
}
}
disabled = (effectiveConfig & UserConfigurationMask.Disabled) != 0;
m_metadata[userName] = new UserMetadata(
effectiveConfig,
modifyDescription ? description ?? string.Empty : existing.Description);
}
finally
{
m_lock.ExitWriteLock();
}
if (willDisable && disabled)
{
RaiseUserDeactivated(userName);
}
return ServiceResult.Good;
}
///
public ServiceResult RemoveUser(string userName, string? callingUserName)
{
if (string.IsNullOrEmpty(userName))
{
return new ServiceResult(StatusCodes.BadInvalidArgument);
}
if (callingUserName != null &&
string.Equals(callingUserName, userName, StringComparison.Ordinal))
{
return new ServiceResult(StatusCodes.BadInvalidSelfReference,
new LocalizedText("The user to remove is the calling session's user."));
}
m_lock.EnterWriteLock();
try
{
if (!m_metadata.TryGetValue(userName, out UserMetadata? existing))
{
return new ServiceResult(StatusCodes.BadNotFound);
}
if ((existing.Configuration & UserConfigurationMask.NoDelete) != 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText($"User '{userName}' is marked NoDelete."));
}
if (!m_userDatabase.DeleteUser(userName))
{
return new ServiceResult(StatusCodes.BadResourceUnavailable);
}
m_metadata.Remove(userName);
}
finally
{
m_lock.ExitWriteLock();
}
RaiseUserDeactivated(userName);
return ServiceResult.Good;
}
///
public ServiceResult ChangePassword(string userName, string oldPassword, string newPassword)
{
if (string.IsNullOrEmpty(userName))
{
return new ServiceResult(StatusCodes.BadInvalidArgument);
}
ServiceResult passwordValidation = ValidatePassword(newPassword);
if (ServiceResult.IsBad(passwordValidation))
{
return passwordValidation;
}
if (string.Equals(oldPassword, newPassword, StringComparison.Ordinal))
{
return new ServiceResult(StatusCodes.BadAlreadyExists,
new LocalizedText("New password matches the old password."));
}
m_lock.EnterReadLock();
try
{
if (!m_metadata.TryGetValue(userName, out UserMetadata? metadata))
{
return new ServiceResult(StatusCodes.BadNotFound);
}
if ((metadata.Configuration & UserConfigurationMask.NoChangeByUser) != 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText($"User '{userName}' is marked NoChangeByUser."));
}
}
finally
{
m_lock.ExitReadLock();
}
if (!m_userDatabase.ChangePassword(userName,
GetPasswordBytes(oldPassword), GetPasswordBytes(newPassword)))
{
return new ServiceResult(StatusCodes.BadIdentityTokenInvalid,
new LocalizedText("Old password does not match."));
}
m_lock.EnterWriteLock();
try
{
if (m_metadata.TryGetValue(userName, out UserMetadata? current))
{
// Successful change clears the MustChangePassword bit.
m_metadata[userName] = new UserMetadata(
current.Configuration & ~UserConfigurationMask.MustChangePassword,
current.Description);
}
}
finally
{
m_lock.ExitWriteLock();
}
return ServiceResult.Good;
}
///
public bool MustChangePassword(string userName)
{
if (string.IsNullOrEmpty(userName))
{
return false;
}
m_lock.EnterReadLock();
try
{
return m_metadata.TryGetValue(userName, out UserMetadata? meta) &&
(meta.Configuration & UserConfigurationMask.MustChangePassword) != 0;
}
finally
{
m_lock.ExitReadLock();
}
}
///
public bool IsUserActive(string userName)
{
if (string.IsNullOrEmpty(userName))
{
return false;
}
m_lock.EnterReadLock();
try
{
return m_metadata.TryGetValue(userName, out UserMetadata? meta) &&
(meta.Configuration & UserConfigurationMask.Disabled) == 0;
}
finally
{
m_lock.ExitReadLock();
}
}
private void LoadUsersFromDatabase()
{
foreach (UserManagementDataType user in m_userDatabase.GetUsers())
{
if (string.IsNullOrEmpty(user.UserName))
{
continue;
}
m_metadata[user.UserName] = new UserMetadata(
(UserConfigurationMask)user.UserConfiguration,
user.Description ?? string.Empty);
}
}
private ServiceResult ValidatePassword(string password)
{
password ??= string.Empty;
int low = (int)PasswordLength.Low;
int high = (int)PasswordLength.High;
if (low > 0 && password.Length < low)
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText($"Password must be at least {low} characters long."));
}
if (high > 0 && password.Length > high)
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText($"Password must be at most {high} characters long."));
}
if ((PasswordOptions & PasswordOptionsMask.RequiresUpperCaseCharacters) != 0 &&
!password.Any(char.IsUpper))
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText("Password must contain at least one upper-case character."));
}
if ((PasswordOptions & PasswordOptionsMask.RequiresLowerCaseCharacters) != 0 &&
!password.Any(char.IsLower))
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText("Password must contain at least one lower-case character."));
}
if ((PasswordOptions & PasswordOptionsMask.RequiresDigitCharacters) != 0 &&
!password.Any(char.IsDigit))
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText("Password must contain at least one digit."));
}
if ((PasswordOptions & PasswordOptionsMask.RequiresSpecialCharacters) != 0 &&
password.All(char.IsLetterOrDigit))
{
return new ServiceResult(StatusCodes.BadOutOfRange,
new LocalizedText("Password must contain at least one special character."));
}
return ServiceResult.Good;
}
private ServiceResult ValidateConfigurationFlags(UserConfigurationMask config)
{
if ((config & UserConfigurationMask.MustChangePassword) != 0 &&
(config & UserConfigurationMask.NoChangeByUser) != 0)
{
return new ServiceResult(StatusCodes.BadConfigurationError,
new LocalizedText("MustChangePassword and NoChangeByUser are mutually exclusive."));
}
const UserConfigurationMask supported =
UserConfigurationMask.NoDelete |
UserConfigurationMask.Disabled |
UserConfigurationMask.NoChangeByUser |
UserConfigurationMask.MustChangePassword;
UserConfigurationMask unsupported = config & ~supported;
if (unsupported != 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText($"User configuration has unsupported flags: {unsupported}."));
}
// Check against this manager's PasswordOptions advertisement.
if ((config & UserConfigurationMask.MustChangePassword) != 0 &&
(PasswordOptions & PasswordOptionsMask.SupportInitialPasswordChange) == 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText("Server does not support MustChangePassword."));
}
if ((config & UserConfigurationMask.Disabled) != 0 &&
(PasswordOptions & PasswordOptionsMask.SupportDisableUser) == 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText("Server does not support disabling users."));
}
if ((config & UserConfigurationMask.NoDelete) != 0 &&
(PasswordOptions & PasswordOptionsMask.SupportDisableDeleteForUser) == 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText("Server does not support NoDelete."));
}
if ((config & UserConfigurationMask.NoChangeByUser) != 0 &&
(PasswordOptions & PasswordOptionsMask.SupportNoChangeForUser) == 0)
{
return new ServiceResult(StatusCodes.BadNotSupported,
new LocalizedText("Server does not support NoChangeByUser."));
}
return ServiceResult.Good;
}
private void RaiseUserDeactivated(string userName)
{
UserDeactivated?.Invoke(this, new UserDeactivatedEventArgs(userName));
}
private static byte[] GetPasswordBytes(string password)
{
return Encoding.UTF8.GetBytes(password ?? string.Empty);
}
///
/// Reads the current set of roles for
/// from the user database, swallowing the
/// some implementations throw when the user is unknown and returning
/// an empty collection in that case. Used to preserve roles across
/// admin password resets (see ).
///
private ICollection SnapshotUserRolesSafe(string userName)
{
try
{
return m_userDatabase.GetUserRoles(userName) ?? [];
}
catch (ArgumentException)
{
return [];
}
}
private sealed record UserMetadata(UserConfigurationMask Configuration, string Description);
///
public void Dispose()
{
if (m_disposed)
{
return;
}
m_disposed = true;
m_lock.Dispose();
}
}
}