mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-27 21:55:54 +08:00
# Description Introduces a pluggable user-identity provider model covering all OPC UA Part 6 §6.5 mechanisms (Anonymous, UserName, X509, IssuedToken/JWT) on both client and server, with full `Microsoft.Extensions.DependencyInjection` integration, plus the Part 12 §9 `AuthorizationService` two-phase token-request flow **including refresh-token rotation**, the Part 12 §8 KeyCredential Push model, and a clean provider model for OPC 10000-12 §7.2 `ApplicationSelfAdmin`. The work is split across phase-by-phase commits so it can be reviewed independently; nothing here changes wire-level behaviour or forces existing applications to migrate. ### Documentation All under `Docs/`: - `IdentityProviders.md` — full developer guide: three-layer model, recipes, configuration reference, "what gets auto-wired" matrix, JWS algorithm table, identity-augmenters section, **"Implementing your own provider"** how-to subsections for Entra / OIDC / Windows / AspNetCore (sibling files merged in per review feedback). - `DependencyInjection.md` — `Identity` sections with `appsettings.json` examples, identity-extension matrix, `WithAuthorizationService` + `WithKeyCredentialPush` + `AddIdentityAugmenter` extensions documented. - `RoleBasedUserManagement.md` — claim-criteria callout describing `IIdentityClaims` probe surface. - `AuthorizationService.md` — Architecture diagram + `IAccessTokenProvider` conceptual narrative restored from master; modern `StartRequestToken` / `FinishRequestToken` flow leads; refresh-token recipe added. - `KeyCredentialService.md` — Push model + Pull vs Push vs Bridge decision matrix + Hybrid (Push + Pull) section; explicit experimental warning for the bridge. - `GDS.md` — KeyCredential Push, `StartRequestToken`, `GdsApplicationSelfAdminProvider`, **shipped Refresh-token support with rotation policy and audit semantics** all documented. - `MigrationGuide.md` — User Identity Providers section listing `[Obsolete]` markers + mechanical recipes; SelfAdmin migration via `IIdentityAugmenter`; `EnableRefreshTokens` default note. - `README.md` index entries. ## Checklist - [x] 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. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [x] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
467 lines
19 KiB
C#
467 lines
19 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.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua.Gds.Server.Database;
|
|
using Opc.Ua.Gds.Server.Identity;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
using Opc.Ua.Server;
|
|
using Opc.Ua.Server.UserDatabase;
|
|
|
|
namespace Opc.Ua.Gds.Server
|
|
{
|
|
/// <summary>
|
|
/// Implements a sample Global Discovery Server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Each server instance must have one instance of a StandardServer object which is
|
|
/// responsible for reading the configuration file, creating the endpoints and dispatching
|
|
/// incoming requests to the appropriate handler.
|
|
/// </para>
|
|
/// <para>
|
|
/// This sub-class specifies non-configurable metadata such as Product Name and initializes
|
|
/// the ApplicationNodeManager which provides access to the data exposed by the
|
|
/// Global Discovery Server.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class GlobalDiscoverySampleServer : StandardServer
|
|
{
|
|
public GlobalDiscoverySampleServer(
|
|
IApplicationsDatabase database,
|
|
ICertificateRequest request,
|
|
ICertificateGroup certificateGroup,
|
|
IUserDatabase userDatabase,
|
|
ITelemetryContext telemetry,
|
|
bool autoApprove = true)
|
|
: this(
|
|
database,
|
|
request,
|
|
certificateGroup,
|
|
userDatabase,
|
|
telemetry,
|
|
autoApprove,
|
|
enableApplicationSelfAdminProvider: true)
|
|
{
|
|
}
|
|
|
|
public GlobalDiscoverySampleServer(
|
|
IApplicationsDatabase database,
|
|
ICertificateRequest request,
|
|
ICertificateGroup certificateGroup,
|
|
IUserDatabase userDatabase,
|
|
ITelemetryContext telemetry,
|
|
bool autoApprove,
|
|
bool enableApplicationSelfAdminProvider)
|
|
: base(telemetry)
|
|
{
|
|
m_database = database;
|
|
m_request = request;
|
|
m_certificateGroup = certificateGroup;
|
|
m_userDatabase = userDatabase;
|
|
m_autoApprove = autoApprove;
|
|
m_enableApplicationSelfAdminProvider = enableApplicationSelfAdminProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called before the server starts. Registers GDS-specific
|
|
/// encodeable types in the server's message context factory,
|
|
/// which is required for NativeAOT where reflection-based
|
|
/// assembly scanning does not discover types automatically.
|
|
/// </summary>
|
|
protected override void OnServerStarting(
|
|
ApplicationConfiguration configuration)
|
|
{
|
|
base.OnServerStarting(configuration);
|
|
|
|
if (!MessageContext.Factory.ContainsEncodeableType(
|
|
DataTypeIds.ApplicationRecordDataType))
|
|
{
|
|
MessageContext.Factory.Builder
|
|
.AddOpcUaGds()
|
|
.AddOpcUaGdsServerDataTypes()
|
|
.Commit();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after the server has been started.
|
|
/// </summary>
|
|
protected override void OnServerStarted(IServerInternal server)
|
|
{
|
|
base.OnServerStarted(server);
|
|
|
|
server.IdentityRegistry.Register(new AnonymousAuthenticator());
|
|
server.IdentityRegistry.Register(new GlobalDiscoverySampleUserNameAuthenticator(this));
|
|
server.IdentityRegistry.Register(new GlobalDiscoverySampleX509Authenticator(this));
|
|
if (m_enableApplicationSelfAdminProvider)
|
|
{
|
|
server.IdentityRegistry.RegisterAugmenter(
|
|
new GdsApplicationSelfAdminProvider(
|
|
m_database,
|
|
server.Telemetry.CreateLogger<GdsApplicationSelfAdminProvider>(),
|
|
server.NamespaceUris,
|
|
IsApplicationCertificateRegistered));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the node managers for the server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This method allows the sub-class create any additional node managers which it uses.
|
|
/// The SDK always creates a CoreNodeManager which handles the built-in nodes defined
|
|
/// by the specification.
|
|
/// Any additional NodeManagers are expected to handle application specific nodes.
|
|
/// </remarks>
|
|
protected override ValueTask<IMasterNodeManager> CreateMasterNodeManagerAsync(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
m_logger.LogInformation("Creating the Node Managers.");
|
|
|
|
// create the custom node managers.
|
|
var nodeManagers = new IAsyncNodeManager[]
|
|
{
|
|
new ApplicationsNodeManager(
|
|
server,
|
|
configuration,
|
|
m_database,
|
|
m_request,
|
|
m_certificateGroup,
|
|
m_autoApprove)
|
|
};
|
|
|
|
// create master node manager.
|
|
#pragma warning disable CA2000 // ownership of MasterNodeManager transfers to the caller via the returned ValueTask<IMasterNodeManager>
|
|
return new ValueTask<IMasterNodeManager>(
|
|
new MasterNodeManager(server, configuration, null, nodeManagers));
|
|
#pragma warning restore CA2000
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the non-configurable properties for the application.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// These properties are exposed by the server but cannot be changed by administrators.
|
|
/// </remarks>
|
|
protected override ServerProperties LoadServerProperties()
|
|
{
|
|
return new ServerProperties
|
|
{
|
|
ManufacturerName = "Some Company Inc",
|
|
ProductName = "Global Discovery Server",
|
|
ProductUri = "http://somecompany.com/GlobalDiscoveryServer",
|
|
SoftwareVersion = Utils.GetAssemblySoftwareVersion(),
|
|
BuildNumber = Utils.GetAssemblyBuildNumber(),
|
|
BuildDate = Utils.GetAssemblyTimestamp()
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// This method is called at the being of the thread that processes a request.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
protected override async ValueTask<OperationContext> ValidateRequestAsync(
|
|
SecureChannelContext secureChannelContext,
|
|
[NotNull] RequestHeader? requestHeader,
|
|
RequestType requestType,
|
|
RequestLifetime requestLifetime)
|
|
{
|
|
OperationContext context = await base.ValidateRequestAsync(
|
|
secureChannelContext,
|
|
requestHeader,
|
|
requestType,
|
|
requestLifetime).ConfigureAwait(false);
|
|
|
|
if (requestType == RequestType.Write)
|
|
{
|
|
// reject all writes if no user provided.
|
|
if (context.UserIdentity.TokenType == UserTokenType.Anonymous)
|
|
{
|
|
// construct translation object with default text.
|
|
var info = new TranslationInfo(
|
|
"NoWriteAllowed",
|
|
"en-US",
|
|
"Must provide a valid user before calling write.");
|
|
|
|
// create an exception with a vendor defined sub-code.
|
|
throw new ServiceResultException(
|
|
new ServiceResult(
|
|
Namespaces.OpcUaGds,
|
|
new StatusCode(StatusCodes.BadUserAccessDenied.Code, "NoWriteAllowed"),
|
|
new LocalizedText(info)));
|
|
}
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
private bool IsApplicationCertificateRegistered(Certificate applicationInstanceCertificate)
|
|
{
|
|
if (applicationInstanceCertificate == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(applicationInstanceCertificate));
|
|
}
|
|
|
|
GlobalDiscoveryServerConfiguration configuration =
|
|
Configuration!.ParseExtension<GlobalDiscoveryServerConfiguration>()
|
|
?? new GlobalDiscoveryServerConfiguration();
|
|
var certificateStoreIdentifier = new CertificateStoreIdentifier(
|
|
configuration.ApplicationCertificatesStorePath!);
|
|
using (ICertificateStore applicationsStore =
|
|
certificateStoreIdentifier.OpenStore(MessageContext.Telemetry))
|
|
{
|
|
using CertificateCollection matchingCerts = applicationsStore
|
|
.FindByThumbprintAsync(applicationInstanceCertificate.Thumbprint)
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
|
|
if (matchingCerts.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
certificateStoreIdentifier = new CertificateStoreIdentifier(
|
|
configuration.AuthoritiesStorePath!);
|
|
using ICertificateStore authoritiesStore =
|
|
certificateStoreIdentifier.OpenStore(MessageContext.Telemetry);
|
|
IEnumerable<X509CRL> certificateRevocationLists = authoritiesStore
|
|
.EnumerateCRLsAsync()
|
|
.GetAwaiter()
|
|
.GetResult();
|
|
foreach (X509CRL crl in certificateRevocationLists)
|
|
{
|
|
if (crl.IsRevoked(applicationInstanceCertificate))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a certificate user token is trusted.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
private void VerifyX509IdentityToken(X509IdentityToken token)
|
|
{
|
|
using Certificate? userCertificate = token.CertificateData.IsEmpty
|
|
? null
|
|
: Certificate.FromRawData(token.CertificateData);
|
|
try
|
|
{
|
|
// Validate against the Users trust list using the new
|
|
// CertificateManager pipeline. Throws on validation failure.
|
|
// CA2025: task awaited via GetAwaiter().GetResult(); the disposable's
|
|
// using scope extends past the await.
|
|
#pragma warning disable CA2025
|
|
CertificateValidationResult result = CertificateManager!
|
|
.ValidateAsync(
|
|
userCertificate!,
|
|
TrustListIdentifier.Users)
|
|
.GetAwaiter().GetResult();
|
|
#pragma warning restore CA2025
|
|
if (!result.IsValid)
|
|
{
|
|
throw new ServiceResultException(result.StatusCode);
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
TranslationInfo info;
|
|
StatusCode result = StatusCodes.BadIdentityTokenRejected;
|
|
if (e is ServiceResultException se &&
|
|
se.StatusCode == StatusCodes.BadCertificateUseNotAllowed)
|
|
{
|
|
info = new TranslationInfo(
|
|
"InvalidCertificate",
|
|
"en-US",
|
|
"'{0}' is an invalid user certificate.",
|
|
userCertificate?.Subject ?? string.Empty);
|
|
|
|
result = StatusCodes.BadIdentityTokenInvalid;
|
|
}
|
|
else
|
|
{
|
|
// construct translation object with default text.
|
|
info = new TranslationInfo(
|
|
"UntrustedCertificate",
|
|
"en-US",
|
|
"'{0}' is not a trusted user certificate.",
|
|
userCertificate?.Subject ?? string.Empty);
|
|
}
|
|
|
|
// create an exception with a vendor defined sub-code.
|
|
throw new ServiceResultException(
|
|
new ServiceResult(
|
|
LoadServerProperties().ProductUri,
|
|
new StatusCode(result.Code, info.Key),
|
|
new LocalizedText(info)));
|
|
}
|
|
}
|
|
|
|
private bool VerifyPassword(UserNameIdentityTokenHandler userTokenHandler)
|
|
{
|
|
return m_userDatabase.CheckCredentials(
|
|
userTokenHandler.UserName,
|
|
userTokenHandler.DecryptedPassword);
|
|
}
|
|
|
|
private sealed class GlobalDiscoverySampleUserNameAuthenticator : IUserTokenAuthenticator
|
|
{
|
|
private readonly GlobalDiscoverySampleServer m_server;
|
|
|
|
public GlobalDiscoverySampleUserNameAuthenticator(GlobalDiscoverySampleServer server)
|
|
{
|
|
m_server = server ?? throw new ArgumentNullException(nameof(server));
|
|
}
|
|
|
|
public UserTokenType TokenType => UserTokenType.UserName;
|
|
|
|
public string? IssuedTokenProfileUri => null;
|
|
|
|
public ValueTask<AuthenticationResult> AuthenticateAsync(
|
|
AuthenticationContext context,
|
|
CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (context.TokenHandler is UserNameIdentityTokenHandler userNameToken)
|
|
{
|
|
return new ValueTask<AuthenticationResult>(AuthenticateUserName(userNameToken));
|
|
}
|
|
|
|
return new ValueTask<AuthenticationResult>(AuthenticationResult.NotHandled);
|
|
}
|
|
|
|
private AuthenticationResult AuthenticateUserName(
|
|
UserNameIdentityTokenHandler userNameToken)
|
|
{
|
|
try
|
|
{
|
|
if (!m_server.VerifyPassword(userNameToken))
|
|
{
|
|
return AuthenticationResult.Reject(
|
|
new ServiceResult(
|
|
StatusCodes.BadUserAccessDenied,
|
|
new LocalizedText("Invalid username or password.")));
|
|
}
|
|
|
|
IEnumerable<Role> roles = m_server.m_userDatabase.GetUserRoles(userNameToken.UserName);
|
|
IReadOnlyList<NodeId>? administeredAppIds =
|
|
(m_server.m_userDatabase as IGdsUserDatabase)?
|
|
.GetAdministeredApplicationIds(userNameToken.UserName);
|
|
var identity = new GdsRoleBasedIdentity(
|
|
new UserIdentity(userNameToken),
|
|
roles,
|
|
default,
|
|
administeredAppIds,
|
|
m_server.ServerInternal.MessageContext.NamespaceUris);
|
|
return AuthenticationResult.Accept(identity);
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
return AuthenticationResult.Reject(ex.Result);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return AuthenticationResult.Reject(
|
|
new ServiceResult(
|
|
StatusCodes.BadIdentityTokenRejected,
|
|
new LocalizedText(ex.Message)));
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class GlobalDiscoverySampleX509Authenticator : IUserTokenAuthenticator
|
|
{
|
|
private readonly GlobalDiscoverySampleServer m_server;
|
|
|
|
public GlobalDiscoverySampleX509Authenticator(GlobalDiscoverySampleServer server)
|
|
{
|
|
m_server = server ?? throw new ArgumentNullException(nameof(server));
|
|
}
|
|
|
|
public UserTokenType TokenType => UserTokenType.Certificate;
|
|
|
|
public string? IssuedTokenProfileUri => null;
|
|
|
|
public ValueTask<AuthenticationResult> AuthenticateAsync(
|
|
AuthenticationContext context,
|
|
CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (context.TokenHandler is not X509IdentityTokenHandler x509Token)
|
|
{
|
|
return new ValueTask<AuthenticationResult>(AuthenticationResult.NotHandled);
|
|
}
|
|
|
|
return new ValueTask<AuthenticationResult>(AuthenticateX509(x509Token));
|
|
}
|
|
|
|
private AuthenticationResult AuthenticateX509(X509IdentityTokenHandler x509Token)
|
|
{
|
|
try
|
|
{
|
|
m_server.VerifyX509IdentityToken((X509IdentityToken)x509Token.Token);
|
|
var identity = new GdsRoleBasedIdentity(
|
|
new UserIdentity(x509Token),
|
|
[Role.AuthenticatedUser],
|
|
m_server.ServerInternal.MessageContext.NamespaceUris);
|
|
m_server.m_logger.LogInformation(
|
|
"X509 Token Accepted: {Identity} as {Role}",
|
|
identity.DisplayName,
|
|
Role.AuthenticatedUser);
|
|
return AuthenticationResult.Accept(identity);
|
|
}
|
|
catch (ServiceResultException ex)
|
|
{
|
|
return AuthenticationResult.Reject(ex.Result);
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly IApplicationsDatabase m_database;
|
|
private readonly ICertificateRequest m_request;
|
|
private readonly ICertificateGroup m_certificateGroup;
|
|
private readonly IUserDatabase m_userDatabase;
|
|
private readonly bool m_autoApprove;
|
|
private readonly bool m_enableApplicationSelfAdminProvider;
|
|
}
|
|
}
|