mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
a81d981755
# Description
Pure code-style sweep across the repo — no behavioral changes. Drives
`dotnet format` info-level diagnostics down on a large slice of the
codebase (Stack, Libraries, Tests, Tools).
Six commits, applied bottom-up in safety order so each batch leaves the
build green:
1. **`style: fix whitespace and final-newline formatting (WHITESPACE,
FINALNEWLINE)`**
197 hits across ~30 files; pure mechanical cleanup.
2. **`style: apply dotnet format style fixes (IDE rules)`**
IDE-rule batch via `dotnet format style --severity info` —
`var`/explicit type, simplify names, collection expressions, expression
bodies, unused usings, etc.
3. **`style: apply dotnet format analyzers fixes (RCS blank-line
formatting)`**
Roslynator formatting (RCS0xxx) blank-line and punctuation rules.
4. **`style: convert comments to doc comments and inline local vars
(RCS1181, RCS1124)`**
24 files. RCS1181 promotes `// summary`-style comments above
public/internal members to `/// <summary>` doc comments; RCS1124 inlines
locals used exactly once. Generated `Tools/` files were reverted to keep
the source-generator outputs deterministic.
5. **`docs(agent): correct --diagnostics flag form and verify Roslynator
4.x code fixers`**
Updates `.github/agents/opc-ua-codestyle-enforcer.agent.md` with two
corrections discovered while running this sweep:
- `dotnet format --diagnostics "RULE1 RULE2"` (single quoted,
space-separated) silently fixes nothing on `dotnet format` 10.x — it
parses as one unknown ID and exits 0. The correct form is `--diagnostics
RULE1 --diagnostics RULE2 …`, repeated per rule.
- Roslynator 4.15.0 **does** ship code fixers for
RCS1078/1118/1124/1140/1142/1181/1221/1260 (the previous "no auto-fixer"
list was outdated). RCS1140/1142 default to `Hidden` so need `--severity
info` to surface and fire; RCS1078 requires
`roslynator_empty_string_style = field` (already set in
`.editorconfig`). Only NUnit4002/2046/2010 genuinely lack auto-fixers in
this stack.
6. **`style: format new code from master merge (dotnet format sweep)`**
After merging master into the branch (commit `e3ebc8742`), re-ran the
same three-phase format sweep on the 151 .cs files brought in by the
merge, scoped per project via repeated `--include`. 81 files changed;
same rule families as commits 1–4.
- Hand-fixed `Tests/Opc.Ua.MigrationAnalyzer.Tests/AnalyzerHarness.cs`
for `RCS1174` (redundant `async`/`await` removal) because the auto-fixer
didn't apply for that specific case.
- Reverted files that the formatter would break:
- `Libraries/Opc.Ua.Server/Server/ServerInternalData.cs` — multi-TFM
analysis disagreement injected `<<<<<<<` / `=======` / `>>>>>>>`
markers.
-
`Tools/Opc.Ua.MigrationAnalyzer.Core/{Core/Stack/Client/ChannelBase.cs,
Core/Stack/Types/IUserIdentityTokenHandler.cs,
Client/Session/Session.cs}` — IDE0005 removed `using
Opc.Ua.Security.Certificates` that IS used (the files are 1.5.378
reference fixtures for the analyzer).
- `Tests/Opc.Ua.Gds.Tests/PushTest.cs` — IDE0002 over-simplified
`OpcUa.ObjectTypeIds` to `ObjectTypeIds`, which then resolves to a
conflicting type.
### What this PR does **not** touch
- No behavioral or semantic logic changes.
- No `.editorconfig` rule promotions (rules stay at their current
severities; this is a backlog-burndown pass, not a rule tightening).
- Generated source under `Tools/` is intentionally excluded.
- Higher-risk rule families (`RCS1090`/`CA2007` `ConfigureAwait`,
`RCS1249` null-forgiving cleanup, `IDE0270`/`IDE0059`) are left for
follow-up sweeps because they need per-TFM build verification or
semantic review.
## Related Issues
_No tracking issue — this is a non-functional style sweep and the
agent-doc correction is a follow-up to the same work. Happy to open one
if the maintainers prefer._
## Checklist
- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. _(N/A — style-only, no
behavioral change)_
- [x] I have added all necessary documentation. _(agent doc updated with
the two corrections)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. _(verified clean on `net10.0` and `net48`; only
pre-existing `NETSDK1005`/`NETSDK1207` failures on Tools/AOT projects)_
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(build
verified on net10 and net48 per batch; full test suite not yet run
locally — relying on CI)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
---------
Co-authored-by: agent <agent@local>
1496 lines
57 KiB
C#
1496 lines
57 KiB
C#
/* ========================================================================
|
|
* 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.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
// TODO: RCS1256 — needs polyfill for net48
|
|
#pragma warning disable RCS1256 // Invalid argument null check
|
|
|
|
namespace Opc.Ua.Server
|
|
{
|
|
/// <summary>
|
|
/// A generic session manager object for a server.
|
|
/// </summary>
|
|
public class SessionManager : ISessionManager
|
|
{
|
|
/// <summary>
|
|
/// Initializes the manager with its configuration.
|
|
/// </summary>
|
|
public SessionManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
: this(server, configuration, timeProvider: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the manager with its configuration and an explicit
|
|
/// <see cref="TimeProvider"/>.
|
|
/// </summary>
|
|
/// <param name="server">The server instance.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="timeProvider">
|
|
/// Optional <see cref="TimeProvider"/> used for monotonic timeout
|
|
/// calculations and client-lockout windows. When <c>null</c>, the
|
|
/// time provider exposed by the server (via
|
|
/// <see cref="ITimeProviderProvider"/>) is used, falling back to
|
|
/// <see cref="TimeProvider.System"/>.
|
|
/// </param>
|
|
public SessionManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
TimeProvider? timeProvider)
|
|
{
|
|
if (configuration == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(configuration));
|
|
}
|
|
|
|
m_server = server ?? throw new ArgumentNullException(nameof(server));
|
|
m_timeProvider = timeProvider
|
|
?? (server as ITimeProviderProvider)?.TimeProvider
|
|
?? TimeProvider.System;
|
|
m_logger = server.Telemetry.CreateLogger<SessionManager>();
|
|
|
|
m_minSessionTimeout = configuration.ServerConfiguration!.MinSessionTimeout;
|
|
m_maxSessionTimeout = configuration.ServerConfiguration.MaxSessionTimeout;
|
|
m_maxSessionCount = configuration.ServerConfiguration.MaxSessionCount;
|
|
m_maxRequestAge = configuration.ServerConfiguration.MaxRequestAge;
|
|
m_maxBrowseContinuationPoints = configuration.ServerConfiguration
|
|
.MaxBrowseContinuationPoints;
|
|
m_maxHistoryContinuationPoints = configuration.ServerConfiguration
|
|
.MaxHistoryContinuationPoints;
|
|
|
|
// Lockout duration / failure expiration are stored in TimestampFrequency
|
|
// ticks so they can be compared directly against TimeProvider.GetTimestamp().
|
|
long ticksPerSecond = m_timeProvider.TimestampFrequency;
|
|
m_lockoutDurationTicks = ticksPerSecond * 5 * 60;
|
|
m_failureExpirationTicks = ticksPerSecond * 1 * 60;
|
|
|
|
m_sessions = new NodeIdDictionary<ISession>(m_maxSessionCount);
|
|
m_lastSessionId = BitConverter.ToUInt32(Nonce.CreateRandomNonceData(sizeof(uint)), 0);
|
|
|
|
// create a event to signal shutdown.
|
|
m_shutdownEvent = new ManualResetEvent(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Frees any unmanaged resources.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An overrideable version of the Dispose.
|
|
/// </summary>
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
// Unsubscribe from RoleManager configuration events before
|
|
// tearing down sessions to avoid late-stage callbacks racing
|
|
// with disposal.
|
|
IRoleManager? subscribed = Interlocked.Exchange(ref m_subscribedRoleManager, null);
|
|
subscribed?.RoleConfigurationChanged -= OnRoleConfigurationChanged;
|
|
|
|
// create snapshot of all sessions
|
|
KeyValuePair<NodeId, ISession>[] sessions = [.. m_sessions];
|
|
m_sessions.Clear();
|
|
|
|
foreach (KeyValuePair<NodeId, ISession> sessionKeyValue in sessions)
|
|
{
|
|
sessionKeyValue.Value?.Dispose();
|
|
}
|
|
|
|
m_shutdownEvent.Set();
|
|
m_shutdownEvent.Dispose();
|
|
m_semaphoreSlim.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the session manager.
|
|
/// </summary>
|
|
public virtual async ValueTask StartupAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await m_semaphoreSlim.WaitAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
try
|
|
{
|
|
// start thread to monitor sessions.
|
|
m_shutdownEvent.Reset();
|
|
|
|
// TODO: Await the task completion in shutdown and pass cancellation token
|
|
_ = Task.Factory.StartNew(
|
|
() => MonitorSessionsAsync(m_minSessionTimeout),
|
|
default,
|
|
TaskCreationOptions.LongRunning | TaskCreationOptions.DenyChildAttach,
|
|
TaskScheduler.Default);
|
|
}
|
|
finally
|
|
{
|
|
m_semaphoreSlim.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the session manager and closes all sessions.
|
|
/// </summary>
|
|
public virtual void Shutdown()
|
|
{
|
|
// stop the monitoring thread.
|
|
m_shutdownEvent.Set();
|
|
|
|
// dispose of session objects using a snapshot.
|
|
KeyValuePair<NodeId, ISession>[] sessions = [.. m_sessions];
|
|
m_sessions.Clear();
|
|
|
|
foreach (KeyValuePair<NodeId, ISession> sessionKeyValue in sessions)
|
|
{
|
|
sessionKeyValue.Value?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new session.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
public virtual async ValueTask<CreateSessionResult> CreateSessionAsync(
|
|
OperationContext context,
|
|
Certificate serverCertificate,
|
|
string? sessionName,
|
|
ByteString clientNonce,
|
|
ApplicationDescription? clientDescription,
|
|
string? endpointUrl,
|
|
Certificate? clientCertificate,
|
|
CertificateCollection? clientCertificateChain,
|
|
double requestedSessionTimeout,
|
|
uint maxResponseMessageSize,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
NodeId sessionId = default;
|
|
NodeId authenticationToken;
|
|
ByteString serverNonce;
|
|
double revisedSessionTimeout = requestedSessionTimeout;
|
|
|
|
ISession session;
|
|
Nonce? tempNonce = null;
|
|
|
|
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// check session count.
|
|
if (m_maxSessionCount > 0 && m_sessions.Count >= m_maxSessionCount)
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadTooManySessions);
|
|
}
|
|
|
|
// check for same Nonce in another session
|
|
if (!clientNonce.IsEmpty)
|
|
{
|
|
// iterate over key/value pairs in the dictionary with a thread safe iterator
|
|
foreach (KeyValuePair<NodeId, ISession> sessionKeyValueIterator in m_sessions)
|
|
{
|
|
ByteString sessionClientNonce =
|
|
sessionKeyValueIterator.Value?.ClientNonce ?? default;
|
|
if (sessionClientNonce == clientNonce)
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadNonceInvalid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// can assign a simple identifier if secured.
|
|
authenticationToken = default;
|
|
// CreateSession is reached only after a secure channel is bound.
|
|
SecureChannelContext channelContext = context.ChannelContext!;
|
|
if (!string.IsNullOrEmpty(channelContext.SecureChannelId) &&
|
|
channelContext.EndpointDescription!
|
|
.SecurityMode != MessageSecurityMode.None)
|
|
{
|
|
authenticationToken = new NodeId(
|
|
Utils.IncrementIdentifier(ref m_lastSessionId));
|
|
}
|
|
|
|
// must assign a hard-to-guess id if not secured.
|
|
if (authenticationToken.IsNull)
|
|
{
|
|
byte[] token = Nonce.CreateRandomNonceData(32);
|
|
authenticationToken = new NodeId(token.ToByteString());
|
|
}
|
|
|
|
// determine session timeout.
|
|
if (requestedSessionTimeout > m_maxSessionTimeout)
|
|
{
|
|
revisedSessionTimeout = m_maxSessionTimeout;
|
|
}
|
|
|
|
if (requestedSessionTimeout < m_minSessionTimeout)
|
|
{
|
|
revisedSessionTimeout = m_minSessionTimeout;
|
|
}
|
|
|
|
// create server nonce.
|
|
tempNonce = Nonce.CreateNonce(
|
|
channelContext.EndpointDescription!.SecurityPolicyUri!);
|
|
Nonce serverNonceObject = tempNonce;
|
|
|
|
// assign client name.
|
|
if (string.IsNullOrEmpty(sessionName))
|
|
{
|
|
sessionName = Utils.Format("Session {0}", sessionId);
|
|
}
|
|
|
|
// create instance of session.
|
|
session = CreateSession(
|
|
context,
|
|
m_server,
|
|
serverCertificate,
|
|
authenticationToken,
|
|
clientNonce,
|
|
serverNonceObject,
|
|
sessionName!,
|
|
clientDescription!,
|
|
endpointUrl!,
|
|
clientCertificate!,
|
|
clientCertificateChain!,
|
|
revisedSessionTimeout,
|
|
maxResponseMessageSize,
|
|
m_maxRequestAge,
|
|
m_maxBrowseContinuationPoints);
|
|
tempNonce = null; // ownership transferred to session
|
|
|
|
// get the session id.
|
|
sessionId = session.Id;
|
|
serverNonce = serverNonceObject.Data.ToByteString();
|
|
|
|
// save session.
|
|
if (!m_sessions.TryAdd(authenticationToken, session))
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadTooManySessions);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
tempNonce?.Dispose();
|
|
m_semaphoreSlim.Release();
|
|
}
|
|
|
|
// raise session related event.
|
|
RaiseSessionEvent(session, SessionEventReason.Created);
|
|
|
|
// return session.
|
|
return new CreateSessionResult
|
|
{
|
|
Session = session,
|
|
SessionId = sessionId,
|
|
AuthenticationToken = authenticationToken,
|
|
RevisedSessionTimeout = revisedSessionTimeout,
|
|
ServerNonce = serverNonce
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Activates an existing session
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
public virtual async ValueTask<(bool IdentityContextChanged, ByteString ServerNonce, ServiceResult ActivationStatus)> ActivateSessionAsync(
|
|
OperationContext context,
|
|
NodeId authenticationToken,
|
|
SignatureData? clientSignature,
|
|
ExtensionObject userIdentityToken,
|
|
SignatureData? userTokenSignature,
|
|
ArrayOf<string> localeIds,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ByteString serverNonce = default;
|
|
|
|
ISession? session = null;
|
|
IUserIdentityTokenHandler? newIdentity = null;
|
|
UserTokenPolicy? userTokenPolicy = null;
|
|
string? clientKey = null;
|
|
|
|
// fast path no lock
|
|
if (!m_sessions.TryGetValue(authenticationToken, out _))
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
|
|
}
|
|
Nonce? serverNonceObject = null;
|
|
try
|
|
{
|
|
await m_semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// find session.
|
|
if (!m_sessions.TryGetValue(authenticationToken, out session))
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
|
|
}
|
|
|
|
// get client lockout key.
|
|
clientKey = GetClientLockoutKey(session);
|
|
|
|
// check if client is locked out due to too many failed authentication attempts.
|
|
if (IsClientLockedOut(clientKey, out long remainingLockoutTicks))
|
|
{
|
|
long remainingSeconds = remainingLockoutTicks / m_timeProvider.TimestampFrequency;
|
|
m_logger.LogWarning(
|
|
"Client {ClientKey} is locked out. Remaining lockout time: {RemainingSeconds} seconds.",
|
|
clientKey,
|
|
remainingSeconds);
|
|
throw new ServiceResultException(
|
|
StatusCodes.BadUserAccessDenied,
|
|
$"Too many failed authentication attempts. Try again in {remainingSeconds} seconds.");
|
|
}
|
|
|
|
// check if session timeout has expired.
|
|
if (session.HasExpired)
|
|
{
|
|
// raise audit event for session closed because of timeout
|
|
m_server.ReportAuditCloseSessionEvent(null!, session, m_logger, "Session/Timeout");
|
|
|
|
await m_server.CloseSessionAsync(null!, session.Id, false, default).ConfigureAwait(false);
|
|
|
|
throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
|
}
|
|
|
|
// create new server nonce.
|
|
serverNonceObject = Nonce.CreateNonce(
|
|
context.ChannelContext!.EndpointDescription!.SecurityPolicyUri!);
|
|
|
|
// validate before activation.
|
|
session.ValidateBeforeActivate(
|
|
context,
|
|
clientSignature!,
|
|
userIdentityToken,
|
|
userTokenSignature!,
|
|
out newIdentity,
|
|
out userTokenPolicy);
|
|
|
|
serverNonce = serverNonceObject.Data.ToByteString();
|
|
}
|
|
catch (ServiceResultException)
|
|
{
|
|
RecordFailedAuthentication(clientKey!);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
m_semaphoreSlim.Release();
|
|
}
|
|
IUserIdentity? identity = null;
|
|
IUserIdentity? effectiveIdentity = null;
|
|
ServiceResult? error = null;
|
|
UserIdentity? tempIdentity = null;
|
|
|
|
try
|
|
{
|
|
(
|
|
identity,
|
|
effectiveIdentity,
|
|
error) = await AuthenticateUserIdentityAsync(
|
|
session,
|
|
newIdentity!,
|
|
userTokenPolicy,
|
|
context.ChannelContext!.EndpointDescription!,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// parse the token manually if the identity is not provided.
|
|
if (identity == null)
|
|
{
|
|
tempIdentity = newIdentity != null
|
|
? new UserIdentity(newIdentity)
|
|
: new UserIdentity();
|
|
identity = tempIdentity;
|
|
}
|
|
|
|
// use the identity as the effectiveIdentity if not provided.
|
|
effectiveIdentity ??= identity;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
RecordFailedAuthentication(clientKey);
|
|
|
|
if (e is not ServiceResultException)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadIdentityTokenInvalid,
|
|
e,
|
|
"Could not validate user identity token: {0}",
|
|
newIdentity!);
|
|
}
|
|
throw;
|
|
}
|
|
|
|
// check for validation error.
|
|
if (ServiceResult.IsBad(error))
|
|
{
|
|
RecordFailedAuthentication(clientKey);
|
|
throw new ServiceResultException(error!);
|
|
}
|
|
|
|
// Clear failed authentication attempts on successful activation,
|
|
// but only for non-anonymous identities. An anonymous login must not
|
|
// reset the lockout counter that was accumulated from failed
|
|
// username/certificate attempts — otherwise an attacker can reset the
|
|
// counter by interleaving anonymous logins between password guesses.
|
|
if (newIdentity is not (null or AnonymousIdentityTokenHandler))
|
|
{
|
|
ClearFailedAuthentication(clientKey);
|
|
}
|
|
|
|
// Add mandatory roles based on session/channel security context (e.g., TrustedApplication).
|
|
effectiveIdentity = AddMandatoryRoles(session, context, effectiveIdentity);
|
|
|
|
// Per Part 18 §5.2.8: when the authenticated user has the
|
|
// MustChangePassword bit set on UserManagement, the activation
|
|
// response shall carry Good_PasswordChangeRequired so the
|
|
// client knows to prompt for a new password. The role
|
|
// restriction is enforced separately by AddMandatoryRoles.
|
|
ServiceResult activationStatus = ComputeActivationStatus(effectiveIdentity);
|
|
|
|
// activate session.
|
|
|
|
bool contextChanged = session.Activate(
|
|
context,
|
|
newIdentity!,
|
|
identity,
|
|
effectiveIdentity,
|
|
localeIds,
|
|
serverNonceObject);
|
|
serverNonceObject = null; // ownership transferred to session
|
|
tempIdentity = null; // ownership transferred to session
|
|
|
|
// raise session related event.
|
|
if (contextChanged)
|
|
{
|
|
RaiseSessionEvent(session, SessionEventReason.Activated);
|
|
}
|
|
|
|
// indicates that the identity context for the session has changed.
|
|
return (contextChanged, serverNonce, activationStatus);
|
|
}
|
|
finally
|
|
{
|
|
serverNonceObject?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the specified session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This method should not throw an exception if the session no longer exists.
|
|
/// </remarks>
|
|
public virtual async ValueTask CloseSessionAsync(NodeId sessionId, CancellationToken cancellationToken = default)
|
|
{
|
|
ISession? session = null;
|
|
|
|
// thread safe search for the session.
|
|
foreach (KeyValuePair<NodeId, ISession> current in m_sessions)
|
|
{
|
|
if (current.Value.Id == sessionId)
|
|
{
|
|
#pragma warning disable CA2000 // Disposed correctly later
|
|
if (!m_sessions.TryRemove(current.Key, out session))
|
|
#pragma warning restore CA2000
|
|
{
|
|
// found but was already removed by another thread
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// close the session if removed.
|
|
if (session != null)
|
|
{
|
|
try
|
|
{
|
|
// raise session related event.
|
|
RaiseSessionEvent(session, SessionEventReason.Closing);
|
|
|
|
// close the session.
|
|
await session.CloseAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
session.Dispose();
|
|
|
|
// update diagnostics.
|
|
lock (m_server.DiagnosticsWriteLock)
|
|
{
|
|
m_server.ServerDiagnostics.CurrentSessionCount--;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates request header and returns a request context.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This method verifies that the session id is valid and that it uses secure channel id
|
|
/// associated with current thread. It also verifies that the timestamp is not too
|
|
/// and that the sequence number is not out of order (update requests only).
|
|
/// </remarks>
|
|
/// <exception cref="ArgumentNullException"><paramref name="requestHeader"/> is <c>null</c>.</exception>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
public virtual async ValueTask<OperationContext> ValidateRequestAsync(
|
|
RequestHeader? requestHeader,
|
|
SecureChannelContext secureChannelContext,
|
|
RequestType requestType,
|
|
RequestLifetime requestLifetime)
|
|
{
|
|
if (requestHeader == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(requestHeader));
|
|
}
|
|
|
|
ISession? session = null;
|
|
|
|
try
|
|
{
|
|
// check for create session request.
|
|
if (requestType is RequestType.CreateSession or RequestType.ActivateSession)
|
|
{
|
|
return new OperationContext(requestHeader, secureChannelContext, requestType, requestLifetime);
|
|
}
|
|
|
|
// find session.
|
|
if (!m_sessions.TryGetValue(requestHeader.AuthenticationToken, out session))
|
|
{
|
|
EventHandler<ValidateSessionLessRequestEventArgs>? handler = m_ValidateSessionLessRequest;
|
|
|
|
if (handler != null)
|
|
{
|
|
var args = new ValidateSessionLessRequestEventArgs(
|
|
requestHeader.AuthenticationToken,
|
|
requestType);
|
|
handler(this, args);
|
|
|
|
if (ServiceResult.IsBad(args.Error))
|
|
{
|
|
throw new ServiceResultException(args.Error);
|
|
}
|
|
|
|
return new OperationContext(requestHeader, secureChannelContext, requestType, requestLifetime, args.Identity);
|
|
}
|
|
|
|
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
|
|
}
|
|
|
|
// validate request header.
|
|
session!.ValidateRequest(requestHeader, secureChannelContext, requestType);
|
|
|
|
// validate user has permissions for additional info
|
|
session.ValidateDiagnosticInfo(requestHeader);
|
|
|
|
// Lazily reconcile the RoleManager subscription. The
|
|
// RoleManager may be injected after SessionManager
|
|
// construction (see IServerInternal.SetRoleManager), so we
|
|
// can't subscribe at startup; the first request that flows
|
|
// through a fully-initialized server wires it up.
|
|
EnsureRoleManagerSubscription();
|
|
|
|
// Part 18 §4.4.1 — if a Role configuration change marked the
|
|
// session's identity stale, re-evaluate the mandatory roles
|
|
// (Anonymous/AuthenticatedUser/TrustedApplication + live
|
|
// RoleManager identity-mapping rules) before the request runs
|
|
// so that downstream access checks see the current grants.
|
|
ReevaluateIdentityIfStale(session, secureChannelContext);
|
|
|
|
// return context.
|
|
return new OperationContext(requestHeader, secureChannelContext, requestType, requestLifetime, session);
|
|
}
|
|
catch (ServiceResultException sre)
|
|
{
|
|
if (sre.StatusCode == StatusCodes.BadSessionNotActivated && session != null)
|
|
{
|
|
await CloseSessionAsync(session.Id, requestLifetime.CancellationToken).ConfigureAwait(false);
|
|
}
|
|
throw;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
throw ServiceResultException.Unexpected(e, e.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an inbound user token through the identity registry first, then the legacy event.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// Thrown when <paramref name="session"/>, <paramref name="newIdentity"/>, or
|
|
/// <paramref name="endpointDescription"/> is <c>null</c>.
|
|
/// </exception>
|
|
protected virtual async ValueTask<(
|
|
IUserIdentity? Identity,
|
|
IUserIdentity? EffectiveIdentity,
|
|
ServiceResult? Error)> AuthenticateUserIdentityAsync(
|
|
ISession session,
|
|
IUserIdentityTokenHandler newIdentity,
|
|
UserTokenPolicy? userTokenPolicy,
|
|
EndpointDescription endpointDescription,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (session == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(session));
|
|
}
|
|
if (newIdentity == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(newIdentity));
|
|
}
|
|
if (endpointDescription == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(endpointDescription));
|
|
}
|
|
|
|
UserTokenPolicy policy = userTokenPolicy ??
|
|
new UserTokenPolicy
|
|
{
|
|
TokenType = newIdentity.TokenType
|
|
};
|
|
|
|
Certificate? channelCert = null;
|
|
try
|
|
{
|
|
string? channelAppUri = null;
|
|
try
|
|
{
|
|
Certificate? rawCert = session.ClientCertificate;
|
|
if (rawCert != null)
|
|
{
|
|
channelCert = Certificate.FromRawData(rawCert.RawData);
|
|
}
|
|
channelAppUri = session.SessionDiagnostics?.ClientDescription?.ApplicationUri;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogDebug(ex, "Failed to populate channel context for authentication.");
|
|
}
|
|
|
|
var authCtx = new AuthenticationContext(
|
|
newIdentity,
|
|
policy,
|
|
endpointDescription,
|
|
m_server.MessageContext,
|
|
channelCert,
|
|
channelAppUri);
|
|
|
|
AuthenticationResult authResult = await m_server.IdentityRegistry
|
|
.AuthenticateAsync(authCtx, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (authResult.Outcome == AuthenticationOutcome.Accepted)
|
|
{
|
|
return (authResult.Identity, authResult.Identity, null);
|
|
}
|
|
|
|
if (authResult.Outcome == AuthenticationOutcome.Rejected)
|
|
{
|
|
return (
|
|
null,
|
|
null,
|
|
authResult.Error ?? new ServiceResult(StatusCodes.BadIdentityTokenRejected));
|
|
}
|
|
|
|
// check if the application has a callback which validates the identity tokens.
|
|
lock (m_eventLock)
|
|
{
|
|
if (m_ImpersonateUser != null)
|
|
{
|
|
var args = new ImpersonateEventArgs(
|
|
newIdentity,
|
|
userTokenPolicy,
|
|
endpointDescription);
|
|
m_ImpersonateUser(session, args);
|
|
|
|
if (ServiceResult.IsBad(args.IdentityValidationError))
|
|
{
|
|
return (null, null, args.IdentityValidationError);
|
|
}
|
|
|
|
return (args.Identity, args.EffectiveIdentity, null);
|
|
}
|
|
}
|
|
|
|
return (null, null, null);
|
|
}
|
|
finally
|
|
{
|
|
channelCert?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns mandatory roles to the effective identity based on the session's security context.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Per OPC UA Part 3 §4.9, the <see cref="Role.TrustedApplication"/> role is always
|
|
/// assigned when a Session has been authenticated with a trusted ApplicationInstance
|
|
/// Certificate and uses at least a signed communication channel.
|
|
/// </para>
|
|
/// <para>
|
|
/// Per OPC UA Part 18 §4.4 the live <see cref="IRoleManager"/> identity-mapping rules
|
|
/// are evaluated and any matching roles are layered on top of the identity supplied
|
|
/// by the ImpersonateUser callback.
|
|
/// </para>
|
|
/// <para>
|
|
/// Per OPC UA Part 18 §5.2.8, when the session authenticates via a USERNAME token and
|
|
/// the user has the <see cref="UserConfigurationMask.MustChangePassword"/> bit set,
|
|
/// the session is restricted to the <see cref="Role.Anonymous"/> role only — the
|
|
/// session can only call <c>ChangePassword</c> until the password is changed.
|
|
/// </para>
|
|
/// </remarks>
|
|
protected virtual IUserIdentity AddMandatoryRoles(
|
|
ISession session,
|
|
OperationContext context,
|
|
IUserIdentity effectiveIdentity)
|
|
{
|
|
// Part 18 §5.2.8 — restrict the session to the Anonymous role if
|
|
// the user has MustChangePassword set. The ChangePassword method
|
|
// is callable by USERNAME sessions regardless of role; once the
|
|
// password is changed the next ActivateSession will see
|
|
// MustChangePassword == false and grant the full role set.
|
|
if (effectiveIdentity.TokenType == UserTokenType.UserName &&
|
|
!string.IsNullOrEmpty(effectiveIdentity.DisplayName) &&
|
|
m_server.UserManagement?.MustChangePassword(effectiveIdentity.DisplayName) == true)
|
|
{
|
|
if (effectiveIdentity is RoleBasedIdentity rbiMustChange)
|
|
{
|
|
return rbiMustChange.WithAdditionalRoles([Role.Anonymous], m_server.NamespaceUris);
|
|
}
|
|
return new RoleBasedIdentity(
|
|
effectiveIdentity,
|
|
[Role.Anonymous],
|
|
m_server.NamespaceUris);
|
|
}
|
|
|
|
// Assign TrustedApplication role per OPC UA Part 3 §4.9.
|
|
if (session.ClientCertificate != null &&
|
|
context.ChannelContext?.EndpointDescription?.SecurityMode >= MessageSecurityMode.Sign)
|
|
{
|
|
if (effectiveIdentity is RoleBasedIdentity rbi)
|
|
{
|
|
effectiveIdentity = rbi.WithAdditionalRoles([Role.TrustedApplication], m_server.NamespaceUris);
|
|
}
|
|
else
|
|
{
|
|
effectiveIdentity = new RoleBasedIdentity(
|
|
effectiveIdentity,
|
|
[Role.TrustedApplication],
|
|
m_server.NamespaceUris);
|
|
}
|
|
}
|
|
|
|
// Layer in roles from the live IRoleManager identity-mapping rules
|
|
// (Part 18 §4.4.4). Roles already granted by the ImpersonateUser
|
|
// callback are preserved.
|
|
IRoleManager roleManager = m_server.RoleManager;
|
|
if (roleManager != null)
|
|
{
|
|
IList<NodeId> dynamicRoleIds = roleManager.ResolveGrantedRoles(
|
|
effectiveIdentity,
|
|
session.ClientCertificate,
|
|
context.ChannelContext?.EndpointDescription);
|
|
|
|
if (dynamicRoleIds.Count > 0)
|
|
{
|
|
var dynamicRoles = new List<Role>(dynamicRoleIds.Count);
|
|
foreach (NodeId roleId in dynamicRoleIds)
|
|
{
|
|
dynamicRoles.Add(new Role(roleId, roleId.ToString()));
|
|
}
|
|
|
|
if (effectiveIdentity is RoleBasedIdentity rbi2)
|
|
{
|
|
effectiveIdentity = rbi2.WithAdditionalRoles(dynamicRoles, m_server.NamespaceUris);
|
|
}
|
|
else
|
|
{
|
|
effectiveIdentity = new RoleBasedIdentity(
|
|
effectiveIdentity,
|
|
dynamicRoles,
|
|
m_server.NamespaceUris);
|
|
}
|
|
}
|
|
}
|
|
|
|
return effectiveIdentity;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the <see cref="ServiceResult"/> that the ActivateSession
|
|
/// response shall carry.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Per OPC UA Part 18 §5.2.8, when the session authenticates via a
|
|
/// USERNAME token and the user has the
|
|
/// <see cref="UserConfigurationMask.MustChangePassword"/> bit set, the
|
|
/// activation must return <c>Good_PasswordChangeRequired</c>. All other
|
|
/// activations return <c>Good</c>.
|
|
/// </remarks>
|
|
/// <param name="effectiveIdentity">
|
|
/// The identity after impersonation and role resolution. Must not be
|
|
/// <see langword="null"/>.
|
|
/// </param>
|
|
protected virtual ServiceResult ComputeActivationStatus(IUserIdentity effectiveIdentity)
|
|
{
|
|
if (effectiveIdentity != null &&
|
|
effectiveIdentity.TokenType == UserTokenType.UserName &&
|
|
!string.IsNullOrEmpty(effectiveIdentity.DisplayName) &&
|
|
m_server.UserManagement?.MustChangePassword(effectiveIdentity.DisplayName) == true)
|
|
{
|
|
return new ServiceResult(StatusCodes.GoodPasswordChangeRequired);
|
|
}
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-evaluates the session's effective identity if it has been
|
|
/// marked stale by a Role configuration change.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Implements OPC UA Part 18 §4.4.1 "live re-evaluation": when an
|
|
/// <see cref="IRoleManager"/> identity-mapping rule changes, sessions
|
|
/// receive the new role grants on the next request without needing
|
|
/// the client to re-activate. The re-evaluation re-runs
|
|
/// <see cref="AddMandatoryRoles"/> using the original impersonated
|
|
/// <see cref="ISession.Identity"/> as the starting point, so the
|
|
/// outcome is deterministic and idempotent.
|
|
/// </para>
|
|
/// <para>
|
|
/// Multiple concurrent requests racing through this method may each
|
|
/// compute the same refresh; the last writer wins. This is acceptable
|
|
/// because the computation is pure with respect to the current
|
|
/// RoleManager state.
|
|
/// </para>
|
|
/// </remarks>
|
|
protected virtual void ReevaluateIdentityIfStale(
|
|
ISession session,
|
|
SecureChannelContext secureChannelContext)
|
|
{
|
|
if (session == null || !session.IsIdentityStale)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Build a minimal OperationContext to satisfy the
|
|
// AddMandatoryRoles signature; only ChannelContext is
|
|
// consulted (for the endpoint).
|
|
var refreshContext = new OperationContext(
|
|
new RequestHeader(),
|
|
secureChannelContext,
|
|
RequestType.Unknown,
|
|
RequestLifetime.None,
|
|
session.EffectiveIdentity);
|
|
|
|
IUserIdentity refreshed = AddMandatoryRoles(
|
|
session,
|
|
refreshContext,
|
|
session.Identity);
|
|
|
|
session.RefreshEffectiveIdentity(refreshed);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogWarning(ex,
|
|
"Failed to re-evaluate session {SessionId} identity after Role configuration change.",
|
|
session.Id);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks every active session's effective identity stale in response
|
|
/// to a Role configuration change (Part 18 §4.4.1).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The actual re-evaluation happens lazily on the next request via
|
|
/// <see cref="ReevaluateIdentityIfStale"/> so that the event handler
|
|
/// stays cheap and contention-free.
|
|
/// </remarks>
|
|
protected virtual void OnRoleConfigurationChanged(object? sender, RoleConfigurationChangedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
// GetSessions() is virtual to allow tests to inject sentinel
|
|
// sessions; production walks the SessionManager's own table.
|
|
foreach (ISession session in GetSessions())
|
|
{
|
|
session?.MarkIdentityStale();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogWarning(ex,
|
|
"Failed to mark sessions stale after Role configuration change.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the SessionManager is subscribed to the current
|
|
/// <see cref="IRoleManager"/>'s <see cref="IRoleManager.RoleConfigurationChanged"/>
|
|
/// event.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="IServerInternal.SetRoleManager"/> may be called after
|
|
/// SessionManager construction, and the RoleManager instance can be
|
|
/// swapped at runtime (e.g. by integrators wiring a persistence
|
|
/// backend). The subscription is reconciled lazily on each request
|
|
/// so the wiring works regardless of injection order.
|
|
/// </remarks>
|
|
private void EnsureRoleManagerSubscription()
|
|
{
|
|
IRoleManager? current = m_server.RoleManager;
|
|
IRoleManager? previous = m_subscribedRoleManager;
|
|
if (current == previous)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// CAS to claim the swap; on failure another thread already
|
|
// reconciled — defer to it.
|
|
if (Interlocked.CompareExchange(ref m_subscribedRoleManager, current, previous) != previous)
|
|
{
|
|
return;
|
|
}
|
|
|
|
previous?.RoleConfigurationChanged -= OnRoleConfigurationChanged;
|
|
current?.RoleConfigurationChanged += OnRoleConfigurationChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of a session.
|
|
/// </summary>
|
|
protected virtual ISession CreateSession(
|
|
OperationContext context,
|
|
IServerInternal server,
|
|
Certificate serverCertificate,
|
|
NodeId sessionCookie,
|
|
ByteString clientNonce,
|
|
Nonce serverNonce,
|
|
string sessionName,
|
|
ApplicationDescription clientDescription,
|
|
string endpointUrl,
|
|
Certificate clientCertificate,
|
|
CertificateCollection clientCertificateChain,
|
|
double sessionTimeout,
|
|
uint maxResponseMessageSize,
|
|
int maxRequestAge, // TBD - Remove unused parameter.
|
|
int maxContinuationPoints) // TBD - Remove unused parameter.
|
|
{
|
|
return new Session(
|
|
context,
|
|
m_server,
|
|
serverCertificate,
|
|
sessionCookie,
|
|
clientNonce,
|
|
serverNonce,
|
|
sessionName,
|
|
clientDescription,
|
|
endpointUrl,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
sessionTimeout,
|
|
m_maxBrowseContinuationPoints,
|
|
m_maxHistoryContinuationPoints,
|
|
m_timeProvider);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public virtual void RaiseSessionDiagnosticsChangedEvent(ISession session)
|
|
{
|
|
if (session == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(session));
|
|
}
|
|
|
|
RaiseSessionEvent(session, SessionEventReason.DiagnosticsChanged);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises an event related to a session.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
protected virtual void RaiseSessionEvent(ISession session, SessionEventReason reason)
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
SessionEventHandler? handler = null;
|
|
|
|
switch (reason)
|
|
{
|
|
case SessionEventReason.Created:
|
|
handler = m_SessionCreated;
|
|
break;
|
|
case SessionEventReason.Activated:
|
|
handler = m_SessionActivated;
|
|
break;
|
|
case SessionEventReason.Closing:
|
|
handler = m_SessionClosing;
|
|
break;
|
|
case SessionEventReason.DiagnosticsChanged:
|
|
handler = m_SessionDiagnosticsChanged;
|
|
break;
|
|
case SessionEventReason.ChannelKeepAlive:
|
|
handler = m_SessionChannelKeepAlive;
|
|
break;
|
|
case SessionEventReason.Impersonating:
|
|
break;
|
|
default:
|
|
throw ServiceResultException.Unexpected(
|
|
$"Unexpected SessionEventReason {reason}");
|
|
}
|
|
|
|
if (handler != null)
|
|
{
|
|
try
|
|
{
|
|
handler(session, reason);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogTrace(e, "Session event handler raised an exception.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Periodically checks if the sessions have timed out.
|
|
/// </summary>
|
|
private async ValueTask MonitorSessionsAsync(object data)
|
|
{
|
|
try
|
|
{
|
|
m_logger.LogInformation("Server - Session Monitor Thread Started.");
|
|
|
|
int sleepCycle = Convert.ToInt32(data, CultureInfo.InvariantCulture);
|
|
|
|
while (true)
|
|
{
|
|
// enumerator is thread safe
|
|
foreach (KeyValuePair<NodeId, ISession> sessionKeyValue in m_sessions)
|
|
{
|
|
ISession session = sessionKeyValue.Value;
|
|
if (session.HasExpired)
|
|
{
|
|
// update diagnostics.
|
|
lock (m_server.DiagnosticsWriteLock)
|
|
{
|
|
m_server.ServerDiagnostics.SessionTimeoutCount++;
|
|
}
|
|
|
|
// raise audit event for session closed because of timeout
|
|
m_server.ReportAuditCloseSessionEvent(null!, session, m_logger, "Session/Timeout");
|
|
|
|
await m_server.CloseSessionAsync(null!, session.Id, false)
|
|
.ConfigureAwait(false);
|
|
}
|
|
// if a session had no activity for the last m_minSessionTimeout milliseconds, send a keep alive event.
|
|
else if (m_timeProvider.GetTimestampMilliseconds() - session.LastContactTickCount > m_minSessionTimeout)
|
|
{
|
|
// signal the channel that the session is still active.
|
|
RaiseSessionEvent(session, SessionEventReason.ChannelKeepAlive);
|
|
}
|
|
}
|
|
|
|
if (m_shutdownEvent.WaitOne(sleepCycle))
|
|
{
|
|
m_logger.LogTrace("Server - Session Monitor Thread Exited Normally.");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Server - Session Monitor Thread Exited Unexpectedly");
|
|
}
|
|
}
|
|
|
|
private readonly SemaphoreSlim m_semaphoreSlim = new(1, 1);
|
|
private readonly IServerInternal m_server;
|
|
private readonly TimeProvider m_timeProvider;
|
|
private readonly ILogger m_logger;
|
|
private readonly NodeIdDictionary<ISession> m_sessions;
|
|
private uint m_lastSessionId;
|
|
private readonly ManualResetEvent m_shutdownEvent;
|
|
|
|
private readonly int m_minSessionTimeout;
|
|
private readonly int m_maxSessionTimeout;
|
|
private readonly int m_maxSessionCount;
|
|
private readonly int m_maxRequestAge;
|
|
|
|
private readonly int m_maxBrowseContinuationPoints;
|
|
private readonly int m_maxHistoryContinuationPoints;
|
|
|
|
private readonly ConcurrentDictionary<string, ClientLockoutInfo> m_clientLockouts = new();
|
|
private const int kMaxFailedAuthenticationAttempts = 5;
|
|
private readonly long m_lockoutDurationTicks;
|
|
private readonly long m_failureExpirationTicks;
|
|
|
|
private readonly Lock m_eventLock = new();
|
|
private event SessionEventHandler? m_SessionCreated;
|
|
private event SessionEventHandler? m_SessionActivated;
|
|
private event SessionEventHandler? m_SessionClosing;
|
|
private event SessionEventHandler? m_SessionDiagnosticsChanged;
|
|
private event SessionEventHandler? m_SessionChannelKeepAlive;
|
|
private event ImpersonateEventHandler? m_ImpersonateUser;
|
|
private event EventHandler<ValidateSessionLessRequestEventArgs>? m_ValidateSessionLessRequest;
|
|
|
|
/// <summary>
|
|
/// Last <see cref="IRoleManager"/> we wired
|
|
/// <see cref="OnRoleConfigurationChanged"/> onto. Reconciled in
|
|
/// <see cref="EnsureRoleManagerSubscription"/>.
|
|
/// </summary>
|
|
private IRoleManager? m_subscribedRoleManager;
|
|
|
|
/// <inheritdoc/>
|
|
public event SessionEventHandler SessionCreated
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionCreated += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionCreated -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public event SessionEventHandler SessionActivated
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionActivated += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionActivated -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public event SessionEventHandler SessionDiagnosticsChanged
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionDiagnosticsChanged += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionDiagnosticsChanged -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public event SessionEventHandler SessionClosing
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionClosing += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionClosing -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public event SessionEventHandler SessionChannelKeepAlive
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionChannelKeepAlive += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_SessionChannelKeepAlive -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
[Obsolete(
|
|
"Replaced by IUserTokenAuthenticator + IServerIdentityRegistry. " +
|
|
"Register authenticators via services.AddIdentityAuthenticator<T>() or " +
|
|
"server.CurrentInstance.IdentityRegistry.Register(...). See Docs/IdentityProviders.md.")]
|
|
public event ImpersonateEventHandler ImpersonateUser
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_ImpersonateUser += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_ImpersonateUser -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public event EventHandler<ValidateSessionLessRequestEventArgs> ValidateSessionLessRequest
|
|
{
|
|
add
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_ValidateSessionLessRequest += value;
|
|
}
|
|
}
|
|
remove
|
|
{
|
|
lock (m_eventLock)
|
|
{
|
|
m_ValidateSessionLessRequest -= value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual IList<ISession> GetSessions()
|
|
{
|
|
return [.. m_sessions.Values];
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ISession? GetSession(NodeId authenticationToken)
|
|
{
|
|
// find session.
|
|
if (m_sessions.TryGetValue(authenticationToken, out ISession? session))
|
|
{
|
|
return session;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the lockout key for a client based on certificate thumbprint or application URI.
|
|
/// </summary>
|
|
private static string GetClientLockoutKey(ISession session)
|
|
{
|
|
if (session?.ClientCertificate != null)
|
|
{
|
|
return session.ClientCertificate.Thumbprint;
|
|
}
|
|
|
|
string? applicationUri = session?.SessionDiagnostics?.ClientDescription?.ApplicationUri;
|
|
if (!string.IsNullOrEmpty(applicationUri))
|
|
{
|
|
return applicationUri!;
|
|
}
|
|
|
|
return session?.SecureChannelId ?? string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a client is currently locked out due to too many failed authentication attempts.
|
|
/// </summary>
|
|
private bool IsClientLockedOut(string clientKey, out long remainingLockoutTicks)
|
|
{
|
|
remainingLockoutTicks = 0;
|
|
|
|
if (string.IsNullOrEmpty(clientKey))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (m_clientLockouts.TryGetValue(clientKey, out ClientLockoutInfo? lockoutInfo))
|
|
{
|
|
long currentTicks = m_timeProvider.GetTimestamp();
|
|
if (lockoutInfo.IsLockedOut(currentTicks))
|
|
{
|
|
remainingLockoutTicks = lockoutInfo.LockoutEndTicks - currentTicks;
|
|
return true;
|
|
}
|
|
|
|
if (lockoutInfo.IsExpired(currentTicks, m_failureExpirationTicks))
|
|
{
|
|
m_clientLockouts.TryRemove(clientKey, out _);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a failed authentication attempt for a client.
|
|
/// </summary>
|
|
private void RecordFailedAuthentication(string clientKey)
|
|
{
|
|
if (string.IsNullOrEmpty(clientKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
long currentTicks = m_timeProvider.GetTimestamp();
|
|
ClientLockoutInfo lockoutInfo = m_clientLockouts.AddOrUpdate(
|
|
clientKey,
|
|
_ => new ClientLockoutInfo(1, currentTicks, m_lockoutDurationTicks, kMaxFailedAuthenticationAttempts),
|
|
(_, existing) => existing.IncrementFailures(
|
|
currentTicks, m_lockoutDurationTicks, m_failureExpirationTicks, kMaxFailedAuthenticationAttempts));
|
|
|
|
if (lockoutInfo.IsLockedOut(currentTicks))
|
|
{
|
|
long remainingSeconds = (lockoutInfo.LockoutEndTicks - currentTicks) / m_timeProvider.TimestampFrequency;
|
|
m_logger.LogWarning(
|
|
"Client {ClientKey} has been locked out after {FailedAttempts} failed authentication attempts. Lockout expires in {RemainingSeconds} seconds.",
|
|
clientKey,
|
|
lockoutInfo.FailedAttempts,
|
|
remainingSeconds);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the failed authentication attempts for a client after successful authentication.
|
|
/// </summary>
|
|
private void ClearFailedAuthentication(string clientKey)
|
|
{
|
|
if (!string.IsNullOrEmpty(clientKey))
|
|
{
|
|
m_clientLockouts.TryRemove(clientKey, out _);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void ClearAuthenticationLockouts()
|
|
{
|
|
m_clientLockouts.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tracks failed authentication attempts and lockout state for a client.
|
|
/// </summary>
|
|
private sealed class ClientLockoutInfo
|
|
{
|
|
public ClientLockoutInfo(
|
|
int failedAttempts,
|
|
long lastFailureTicks,
|
|
long lockoutDurationTicks,
|
|
int maxAttempts)
|
|
{
|
|
FailedAttempts = failedAttempts;
|
|
LastFailureTicks = lastFailureTicks;
|
|
LockoutEndTicks = failedAttempts >= maxAttempts
|
|
? lastFailureTicks + lockoutDurationTicks
|
|
: 0;
|
|
}
|
|
|
|
public int FailedAttempts { get; }
|
|
public long LastFailureTicks { get; }
|
|
public long LockoutEndTicks { get; }
|
|
|
|
public bool IsLockedOut(long currentTicks)
|
|
{
|
|
return LockoutEndTicks > currentTicks;
|
|
}
|
|
|
|
public bool IsExpired(long currentTicks, long expirationTicks)
|
|
{
|
|
return !IsLockedOut(currentTicks) && (currentTicks - LastFailureTicks) > expirationTicks;
|
|
}
|
|
|
|
public ClientLockoutInfo IncrementFailures(
|
|
long currentTicks,
|
|
long lockoutDurationTicks,
|
|
long expirationTicks,
|
|
int maxAttempts)
|
|
{
|
|
if (IsLockedOut(currentTicks))
|
|
{
|
|
return this;
|
|
}
|
|
|
|
if (IsExpired(currentTicks, expirationTicks))
|
|
{
|
|
return new ClientLockoutInfo(1, currentTicks, lockoutDurationTicks, maxAttempts);
|
|
}
|
|
|
|
return new ClientLockoutInfo(
|
|
FailedAttempts + 1,
|
|
currentTicks,
|
|
lockoutDurationTicks,
|
|
maxAttempts);
|
|
}
|
|
}
|
|
}
|
|
}
|