087a969ee6
Pulled forward from stage 7 because without it the feature is unusable and un-live-gateable: there was no way to create a key short of hand-inserting a row and computing an HMAC by hand. The minting logic is an IApiKeyAdminService in Core.Authorization, NOT logic in the Razor page — this repo has no bUnit, so anything only a page can reach is untestable, and minting credentials is not where that belongs. The page is a thin shell over it. Moved to Core.Authorization so both ends can reach them: ApiKeyOptions (was in Host) and OpcUaPrincipals (was in OpcUaServer.Security). The OPC UA server resolves principals; the AdminUI must refuse to mint a key claiming a reserved one. OpcUaServer already gained a Core reference in stage 1b, so no new edge — AdminUI gains one. ApiKeyOptions is now bound UNCONDITIONALLY rather than in the driver branch: a driver node needs the pepper to VERIFY keys and an admin node needs it to MINT them, and getting that wrong on either side is a silent authentication failure rather than a startup error. Validation rules, all covered: the reserved 'anonymous' principal is refused case-insensitively (so "ANONYMOUS" cannot slip past into a distinct principal that the evaluator would then match case-INsensitively against anonymous's grants); blank name/principal; a principal longer than the column, rejected rather than truncated, because silent truncation yields a key that authenticates and is granted nothing with no visible cause; a past expiry. Generation runs BEFORE the DbContext is opened so an unconfigured pepper surfaces as a clear error instead of a stored row whose hash nothing can verify. The page shows the plaintext ONCE and says so; it is held in component state and written nowhere else, because the server keeps only a hash. Revocation is flagged in the UI as taking effect only after the next deployment — driver-only nodes have no ConfigDb, so keys ride the artifact. FleetAdmin-only (Administrator), not ConfigEditor: this mints credentials. Core.Tests 68 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
88 lines
4.6 KiB
C#
88 lines
4.6 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
/// <summary>
|
|
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
|
|
/// application cert) and hands the cleartext username + password to this seam. Implementations
|
|
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
|
|
///
|
|
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
|
|
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
|
|
/// dev nodes don't silently accept credentials.
|
|
/// </summary>
|
|
public interface IOpcUaUserAuthenticator
|
|
{
|
|
/// <summary>
|
|
/// Resolves cleartext UserName credentials against the configured backing store. Must not
|
|
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
|
|
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
|
|
/// path where it surfaces as a generic <c>BadInternalError</c>.
|
|
/// </summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The cleartext password to authenticate.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
|
|
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
|
|
|
|
/// <summary>
|
|
/// Resolves what an <b>anonymous</b> session is allowed to do (#520). Anonymous is a normal
|
|
/// principal — <see cref="OpcUaPrincipals.Anonymous"/> — not a special case, so it can be granted
|
|
/// read or write through ordinary ACL rows.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is asked only AFTER the host has decided anonymous sessions are admissible at all
|
|
/// (<c>OpcUaApplicationHostOptions.AllowAnonymous</c>). Admission is the host's decision;
|
|
/// authority is this seam's.
|
|
/// </para>
|
|
/// <para>
|
|
/// The default implementation admits anonymous with <b>no roles</b>, which is exactly the
|
|
/// behaviour before #520: an anonymous session connects, and every role-gated operation
|
|
/// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest
|
|
/// keep the safe status quo rather than being forced to reimplement it.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The roles to attach to the anonymous session, or a denial.</returns>
|
|
Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct) =>
|
|
Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty<string>()));
|
|
}
|
|
|
|
|
|
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
|
|
public sealed record OpcUaUserAuthResult(
|
|
bool Success,
|
|
string? DisplayName,
|
|
IReadOnlyList<string> Roles,
|
|
string? Error)
|
|
{
|
|
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
|
|
/// <param name="displayName">The display name for the authenticated user.</param>
|
|
/// <param name="roles">The roles to assign to the authenticated user.</param>
|
|
/// <returns>A successful OpcUaUserAuthResult.</returns>
|
|
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
|
|
new(true, displayName, roles, null);
|
|
|
|
/// <summary>Creates a failed authentication result with the specified error message.</summary>
|
|
/// <param name="error">The error message describing why authentication failed.</param>
|
|
/// <returns>A failed OpcUaUserAuthResult.</returns>
|
|
public static OpcUaUserAuthResult Deny(string error) =>
|
|
new(false, null, Array.Empty<string>(), error);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
|
|
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
|
|
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
|
|
/// </summary>
|
|
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
public static readonly NullOpcUaUserAuthenticator Instance = new();
|
|
private NullOpcUaUserAuthenticator() { }
|
|
|
|
/// <inheritdoc />
|
|
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
|
|
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
|
|
}
|