2828 * ======================================================================*/
2929
3030using System ;
31+ using System . Collections . Concurrent ;
3132using System . Collections . Generic ;
3233using System . Globalization ;
3334using 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