Skip to content

Commit 66e4172

Browse files
committed
Implement Client Lockout
1 parent f66ceff commit 66e4172

File tree

3 files changed

+819
-2
lines changed

3 files changed

+819
-2
lines changed

Libraries/Opc.Ua.Server/Session/SessionManager.cs

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* ======================================================================*/
2929

3030
using System;
31+
using System.Collections.Concurrent;
3132
using System.Collections.Generic;
3233
using System.Globalization;
3334
using System.Security.Cryptography.X509Certificates;
@@ -295,6 +296,7 @@ public virtual async ValueTask<CreateSessionResult> CreateSessionAsync(
295296
ISession session = null;
296297
IUserIdentityTokenHandler newIdentity = null;
297298
UserTokenPolicy userTokenPolicy = null;
299+
string clientKey = null;
298300

299301
// fast path no lock
300302
if (!m_sessions.TryGetValue(authenticationToken, out _))
@@ -311,6 +313,22 @@ public virtual async ValueTask<CreateSessionResult> CreateSessionAsync(
311313
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
312314
}
313315

316+
// get client lockout key.
317+
clientKey = GetClientLockoutKey(session);
318+
319+
// check if client is locked out due to too many failed authentication attempts.
320+
if (IsClientLockedOut(clientKey, out long remainingLockoutTicks))
321+
{
322+
long remainingSeconds = remainingLockoutTicks / HiResClock.Frequency;
323+
m_logger.LogWarning(
324+
"Client {ClientKey} is locked out. Remaining lockout time: {RemainingSeconds} seconds.",
325+
clientKey,
326+
remainingSeconds);
327+
throw new ServiceResultException(
328+
StatusCodes.BadUserAccessDenied,
329+
$"Too many failed authentication attempts. Try again in {remainingSeconds} seconds.");
330+
}
331+
314332
// check if session timeout has expired.
315333
if (session.HasExpired)
316334
{
@@ -337,6 +355,11 @@ public virtual async ValueTask<CreateSessionResult> CreateSessionAsync(
337355

338356
serverNonce = serverNonceObject.Data;
339357
}
358+
catch (ServiceResultException)
359+
{
360+
RecordFailedAuthentication(clientKey);
361+
throw;
362+
}
340363
finally
341364
{
342365
m_semaphoreSlim.Release();
@@ -378,21 +401,31 @@ public virtual async ValueTask<CreateSessionResult> CreateSessionAsync(
378401
// use the identity as the effectiveIdentity if not provided.
379402
effectiveIdentity ??= identity;
380403
}
381-
catch (Exception e) when (e is not ServiceResultException)
404+
catch (Exception e)
382405
{
383-
throw ServiceResultException.Create(
406+
RecordFailedAuthentication(clientKey);
407+
408+
if (e is not ServiceResultException)
409+
{
410+
throw ServiceResultException.Create(
384411
StatusCodes.BadIdentityTokenInvalid,
385412
e,
386413
"Could not validate user identity token: {0}",
387414
newIdentity);
415+
}
416+
throw;
388417
}
389418

390419
// check for validation error.
391420
if (ServiceResult.IsBad(error))
392421
{
422+
RecordFailedAuthentication(clientKey);
393423
throw new ServiceResultException(error);
394424
}
395425

426+
// clear failed authentication attempts on successful activation.
427+
ClearFailedAuthentication(clientKey);
428+
396429
// activate session.
397430

398431
bool contextChanged = session.Activate(
@@ -694,6 +727,11 @@ await m_server.CloseSessionAsync(null, session.Id, false)
694727
private readonly int m_maxBrowseContinuationPoints;
695728
private readonly int m_maxHistoryContinuationPoints;
696729

730+
private readonly ConcurrentDictionary<string, ClientLockoutInfo> m_clientLockouts = new();
731+
private const int MaxFailedAuthenticationAttempts = 5;
732+
private static readonly long LockoutDurationTicks = (long)(HiResClock.Frequency * 5 * 60);
733+
private static readonly long FailureExpirationTicks = (long)(HiResClock.Frequency * 30 * 60);
734+
697735
private readonly Lock m_eventLock = new();
698736
private event SessionEventHandler m_SessionCreated;
699737
private event SessionEventHandler m_SessionActivated;
@@ -852,5 +890,144 @@ public ISession GetSession(NodeId authenticationToken)
852890
}
853891
return null;
854892
}
893+
894+
/// <summary>
895+
/// Gets the lockout key for a client based on certificate thumbprint or application URI.
896+
/// </summary>
897+
private static string GetClientLockoutKey(ISession session)
898+
{
899+
if (session?.ClientCertificate != null)
900+
{
901+
return session.ClientCertificate.Thumbprint;
902+
}
903+
904+
string applicationUri = session?.SessionDiagnostics?.ClientDescription?.ApplicationUri;
905+
if (!string.IsNullOrEmpty(applicationUri))
906+
{
907+
return applicationUri;
908+
}
909+
910+
return session?.SecureChannelId ?? string.Empty;
911+
}
912+
913+
/// <summary>
914+
/// Checks if a client is currently locked out due to too many failed authentication attempts.
915+
/// </summary>
916+
private bool IsClientLockedOut(string clientKey, out long remainingLockoutTicks)
917+
{
918+
remainingLockoutTicks = 0;
919+
920+
if (string.IsNullOrEmpty(clientKey))
921+
{
922+
return false;
923+
}
924+
925+
if (m_clientLockouts.TryGetValue(clientKey, out ClientLockoutInfo lockoutInfo))
926+
{
927+
long currentTicks = HiResClock.Ticks;
928+
if (lockoutInfo.IsLockedOut(currentTicks))
929+
{
930+
remainingLockoutTicks = lockoutInfo.LockoutEndTicks - currentTicks;
931+
return true;
932+
}
933+
934+
if (lockoutInfo.IsExpired(currentTicks, FailureExpirationTicks))
935+
{
936+
m_clientLockouts.TryRemove(clientKey, out _);
937+
}
938+
}
939+
940+
return false;
941+
}
942+
943+
/// <summary>
944+
/// Records a failed authentication attempt for a client.
945+
/// </summary>
946+
private void RecordFailedAuthentication(string clientKey)
947+
{
948+
if (string.IsNullOrEmpty(clientKey))
949+
{
950+
return;
951+
}
952+
953+
long currentTicks = HiResClock.Ticks;
954+
ClientLockoutInfo lockoutInfo = m_clientLockouts.AddOrUpdate(
955+
clientKey,
956+
_ => new ClientLockoutInfo(1, currentTicks, LockoutDurationTicks, MaxFailedAuthenticationAttempts),
957+
(_, existing) => existing.IncrementFailures(
958+
currentTicks, LockoutDurationTicks, FailureExpirationTicks, MaxFailedAuthenticationAttempts));
959+
960+
if (lockoutInfo.IsLockedOut(currentTicks))
961+
{
962+
long remainingSeconds = (lockoutInfo.LockoutEndTicks - currentTicks) / HiResClock.Frequency;
963+
m_logger.LogWarning(
964+
"Client {ClientKey} has been locked out after {FailedAttempts} failed authentication attempts. Lockout expires in {RemainingSeconds} seconds.",
965+
clientKey,
966+
lockoutInfo.FailedAttempts,
967+
remainingSeconds);
968+
}
969+
}
970+
971+
/// <summary>
972+
/// Clears the failed authentication attempts for a client after successful authentication.
973+
/// </summary>
974+
private void ClearFailedAuthentication(string clientKey)
975+
{
976+
if (!string.IsNullOrEmpty(clientKey))
977+
{
978+
m_clientLockouts.TryRemove(clientKey, out _);
979+
}
980+
}
981+
982+
/// <summary>
983+
/// Tracks failed authentication attempts and lockout state for a client.
984+
/// </summary>
985+
private sealed class ClientLockoutInfo
986+
{
987+
public ClientLockoutInfo(
988+
int failedAttempts,
989+
long lastFailureTicks,
990+
long lockoutDurationTicks,
991+
int maxAttempts)
992+
{
993+
FailedAttempts = failedAttempts;
994+
LastFailureTicks = lastFailureTicks;
995+
LockoutEndTicks = failedAttempts >= maxAttempts
996+
? lastFailureTicks + lockoutDurationTicks
997+
: 0;
998+
}
999+
1000+
public int FailedAttempts { get; }
1001+
public long LastFailureTicks { get; }
1002+
public long LockoutEndTicks { get; }
1003+
1004+
public bool IsLockedOut(long currentTicks) => LockoutEndTicks > currentTicks;
1005+
1006+
public bool IsExpired(long currentTicks, long expirationTicks)
1007+
=> !IsLockedOut(currentTicks) && (currentTicks - LastFailureTicks) > expirationTicks;
1008+
1009+
public ClientLockoutInfo IncrementFailures(
1010+
long currentTicks,
1011+
long lockoutDurationTicks,
1012+
long expirationTicks,
1013+
int maxAttempts)
1014+
{
1015+
if (IsLockedOut(currentTicks))
1016+
{
1017+
return this;
1018+
}
1019+
1020+
if (IsExpired(currentTicks, expirationTicks))
1021+
{
1022+
return new ClientLockoutInfo(1, currentTicks, lockoutDurationTicks, maxAttempts);
1023+
}
1024+
1025+
return new ClientLockoutInfo(
1026+
FailedAttempts + 1,
1027+
currentTicks,
1028+
lockoutDurationTicks,
1029+
maxAttempts);
1030+
}
1031+
}
8551032
}
8561033
}

0 commit comments

Comments
 (0)