189 lines
9.1 KiB
C#
189 lines
9.1 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
// Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to
|
|
// the ZB.MOM.WW.OtOpcUa.Configuration assembly.
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="IOpcUaUserAuthenticator"/> for the machine-to-machine data plane (#520):
|
|
/// the client presents the static username <c>apikey</c> with an
|
|
/// <c>otua_<keyId>_<secret></c> credential as the password. Replaces the LDAP adapter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This runs inline on session activation.</b> The SDK raises <c>ImpersonateUser</c>
|
|
/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
|
|
/// stall one request — it serializes every session activation server-wide. That is why
|
|
/// verification is a single indexed lookup plus one HMAC, with no directory call and no retry —
|
|
/// the main practical advantage of keys over the LDAP bind this replaces.
|
|
/// </para>
|
|
/// <para>
|
|
/// ⚠️ <b>"No network I/O" is TRUE ONLY ON A DRIVER-ONLY NODE</b> (arch-review 03/S18). The claim
|
|
/// describes <see cref="ArtifactApiKeyStore"/>, which is two dictionary probes on an in-memory
|
|
/// snapshot. On an <b>admin or fused</b> node <see cref="IApiKeyStore"/> is
|
|
/// <see cref="ConfigDbApiKeyStore"/>, and a UserName activation costs <b>two</b> EF round trips
|
|
/// (the key row, then the principal's <c>NodeAcl</c> rows) — inside this same lock. What bounds
|
|
/// that is <see cref="BoundedApiKeyStore"/>, which wraps it in production; the deadline it applies
|
|
/// surfaces here as an ordinary throw and is denied by the catch-all below, like any other store
|
|
/// failure.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Fail closed, and say little.</b> Every rejection returns the same opaque message. The
|
|
/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key"
|
|
/// from "wrong secret" from "key disabled" and use the server as an oracle.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
|
|
{
|
|
/// <summary>
|
|
/// Returned for every failure mode, deliberately uninformative. Public so a test can assert that
|
|
/// the failure modes really are indistinguishable, rather than restating the literal and passing
|
|
/// even if the implementation started differentiating them.
|
|
/// </summary>
|
|
public const string DenyMessage = "Invalid API key.";
|
|
|
|
private readonly IApiKeyStore _store;
|
|
private readonly IOptions<ApiKeyOptions> _options;
|
|
private readonly ILogger<ApiKeyOpcUaUserAuthenticator> _log;
|
|
private readonly TimeProvider _time;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ApiKeyOpcUaUserAuthenticator"/> class.</summary>
|
|
/// <param name="store">The API key source.</param>
|
|
/// <param name="options">The <c>Security:ApiKeys</c> options.</param>
|
|
/// <param name="log">The logger.</param>
|
|
/// <param name="time">Time provider, injected so expiry is testable.</param>
|
|
public ApiKeyOpcUaUserAuthenticator(
|
|
IApiKeyStore store,
|
|
IOptions<ApiKeyOptions> options,
|
|
ILogger<ApiKeyOpcUaUserAuthenticator> log,
|
|
TimeProvider? time = null)
|
|
{
|
|
_store = store;
|
|
_options = options;
|
|
_log = log;
|
|
_time = time ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(
|
|
string username, string password, CancellationToken ct)
|
|
{
|
|
var opts = _options.Value;
|
|
|
|
// The username carries no information, but check it rather than ignore it: a client sending the
|
|
// wrong username gets an explicit denial it can diagnose, instead of "any username works".
|
|
if (!string.Equals(username, opts.Username, StringComparison.Ordinal))
|
|
{
|
|
_log.LogInformation(
|
|
"ApiKeyAuth: denied — username {Presented} is not the configured {Expected}",
|
|
username, opts.Username);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret))
|
|
{
|
|
_log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key");
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
var now = _time.GetUtcNow().UtcDateTime;
|
|
|
|
ApiKeyLookup? lookup;
|
|
try
|
|
{
|
|
lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The contract forbids throwing: an exception here escapes into the SDK's session-activation
|
|
// path and surfaces as BadInternalError. A store outage denies, it does not crash.
|
|
_log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
if (lookup is null)
|
|
{
|
|
_log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
bool verified;
|
|
try
|
|
{
|
|
verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
// Thrown only when the pepper is unset. This is a deployment error, not a bad credential —
|
|
// log it loudly, because otherwise it presents as "every key is rejected" with no clue why.
|
|
_log.LogError(ex,
|
|
"ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
|
|
ApiKeyOptions.SectionName);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
if (!verified)
|
|
{
|
|
_log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Defence in depth against a key authored with the reserved principal (authoring blocks it).
|
|
// Allowing it would let a credentialled client silently inherit whatever anonymous was
|
|
// granted, and vice versa — two identities that are meant to be distinct sharing one grant.
|
|
_log.LogError(
|
|
"ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'",
|
|
apiKeyId, OpcUaPrincipals.Anonymous);
|
|
return OpcUaUserAuthResult.Deny(DenyMessage);
|
|
}
|
|
|
|
var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
|
|
|
|
_log.LogInformation(
|
|
"ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
|
|
lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
|
|
|
|
// AllowKey, not Allow: the key id rides along so an open session can be re-checked against a later
|
|
// deployment that revoked this key. Without it a revoked key keeps working until the client
|
|
// happens to disconnect, which on a machine-to-machine data plane can be indefinitely.
|
|
return OpcUaUserAuthResult.AllowKey(lookup.Key.Principal, roles, apiKeyId);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
|
|
/// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case.
|
|
/// Whether anonymous may connect at all is the host's decision
|
|
/// (<c>OpcUa:AllowAnonymous</c>) and is settled before this is called.
|
|
/// </remarks>
|
|
public async Task<OpcUaUserAuthResult> AuthenticateAnonymousAsync(CancellationToken ct)
|
|
{
|
|
IReadOnlyList<NodeAcl> grants;
|
|
try
|
|
{
|
|
grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing
|
|
// it precisely when we could not read what anonymous is allowed to do is the wrong default:
|
|
// once reads are gated (stage 6) a no-roles admission stops being harmless.
|
|
_log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants");
|
|
return OpcUaUserAuthResult.Deny("Anonymous access is unavailable.");
|
|
}
|
|
|
|
var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous);
|
|
_log.LogInformation(
|
|
"ApiKeyAuth: anonymous admitted with roles [{Roles}]",
|
|
roles.Count == 0 ? "none" : string.Join(",", roles));
|
|
|
|
return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles);
|
|
}
|
|
}
|