55using CleanArchitecture . Blazor . Domain . Enums ;
66using CleanArchitecture . Blazor . Domain . Identity ;
77using System . Collections . Concurrent ;
8+ using System . Net ;
9+ using System . Text . RegularExpressions ;
810using ZiggyCreatures . Caching . Fusion ;
911using Microsoft . Extensions . Localization ;
1012
@@ -19,8 +21,9 @@ public class SecurityAnalysisService : ISecurityAnalysisService
1921 private readonly IApplicationDbContextFactory _dbContextFactory ;
2022 private readonly IStringLocalizer < SecurityAnalysisService > _localizer ;
2123
22- // Cache for IP-based analysis to reduce database queries
23- private static readonly ConcurrentDictionary < string , DateTime > _lastIpAnalysis = new ( ) ;
24+ // Removed static dictionary to avoid unbounded growth & per-process divergence.
25+ // Use FusionCache short TTL entries (per-IP throttle) instead.
26+ private const string IpThrottlePrefix = "ip-analysis-throttle:" ; // key = prefix + normalizedIp
2427
2528 public SecurityAnalysisService (
2629 ILogger < SecurityAnalysisService > logger ,
@@ -46,11 +49,19 @@ public async Task AnalyzeUserSecurityAsync(LoginAudit loginAudit, CancellationTo
4649 await CreateOrUpdateRiskSummaryAsync ( db , loginAudit . UserId , loginAudit . UserName ,
4750 analysisResult , cancellationToken ) ;
4851
49- // Invalidate cache for the user's risk summary
50- foreach ( var tag in LoginAuditCacheKey . Tags )
52+ // Invalidate tags: always invalidate user-specific collections (loginaudits, userloginrisksummary)
53+ // Avoid clearing aggregated 'statistics' unless the risk summary changed significantly
54+ var tagsToRemove = new List < string > ( ) ;
55+ tagsToRemove . Add ( "loginaudits" ) ;
56+ tagsToRemove . Add ( "userloginrisksummary" ) ;
57+ // Heuristic: critical or high risk or change in risk level triggers statistics invalidation.
58+ if ( analysisResult . RiskLevel != SecurityRiskLevel . Low )
5159 {
52- await _fusionCache . RemoveByTagAsync ( tag , token : cancellationToken ) ;
60+ tagsToRemove . Add ( "statistics" ) ;
5361 }
62+ var tagRemovalTasks = tagsToRemove . Distinct ( )
63+ . Select ( tag => _fusionCache . RemoveByTagAsync ( tag , token : cancellationToken ) . AsTask ( ) ) ;
64+ await Task . WhenAll ( tagRemovalTasks ) ;
5465
5566 _logger . LogInformation ( "Security analysis completed for user {UserId}. Risk Level: {RiskLevel}, Score: {RiskScore}, Factors: {FactorCount}" ,
5667 loginAudit . UserId , analysisResult . RiskLevel , analysisResult . RiskScore , analysisResult . RiskFactors . Count ) ;
@@ -129,24 +140,23 @@ private async Task<RiskAnalysisRuleResult> AnalyzeConcentratedFailuresAsync(
129140 result . Factors . Add ( _localizer [ "AccountBruteForceFactor" , userFailuresInWindow , _options . BruteForceWindowMinutes ] ) ;
130141 }
131142
132- // Analyze IP-level brute force ( with caching to reduce database load )
133- if ( ! string . IsNullOrEmpty ( currentLogin . IpAddress ) )
143+ // Analyze IP-level brute force with FusionCache throttle (multi-instance friendly if distributed layer configured )
144+ if ( ! string . IsNullOrWhiteSpace ( currentLogin . IpAddress ) )
134145 {
135- var cacheKey = $ "ip_analysis_ { currentLogin . IpAddress } " ;
136- var shouldAnalyzeIp = ! _lastIpAnalysis . TryGetValue ( cacheKey , out var lastAnalysis ) ||
137- lastAnalysis < DateTime . UtcNow . AddMinutes ( - 5 ) ; // Analyze IP at most every 5 minutes
138-
139- if ( shouldAnalyzeIp )
146+ var normalizedIp = SecurityAnalysisHeuristics . NormalizeIpForThrottle ( currentLogin . IpAddress ) ;
147+ var throttleKey = IpThrottlePrefix + normalizedIp ;
148+ // Try get a throttle marker; if absent, perform analysis and set marker with short TTL.
149+ var throttle = await _fusionCache . TryGetAsync < bool > ( throttleKey , token : cancellationToken ) ;
150+ if ( ! throttle . HasValue )
140151 {
141152 var ipAnalysisResult = await AnalyzeIpBruteForceAsync ( dbContext , currentLogin , bruteForceWindow , cancellationToken ) ;
142-
143153 if ( ipAnalysisResult . IsTriggered )
144154 {
145155 result . Score += ipAnalysisResult . Score ;
146156 result . Factors . AddRange ( ipAnalysisResult . Factors ) ;
147157 }
148-
149- _lastIpAnalysis . TryAdd ( cacheKey , DateTime . UtcNow ) ;
158+ // Set marker with TTL (configurable? use 5 min default, could expose via options later)
159+ await _fusionCache . SetAsync ( throttleKey , true , TimeSpan . FromMinutes ( 5 ) , token : cancellationToken ) ;
150160 }
151161 }
152162
@@ -193,33 +203,27 @@ private RiskAnalysisRuleResult AnalyzeNewDeviceOrLocation(List<LoginAudit> userL
193203
194204 var newFactors = new List < string > ( ) ;
195205
196- // Check for new IP address (more efficient with LINQ)
197- var hasSeenIpBefore = ! string . IsNullOrEmpty ( currentLogin . IpAddress ) &&
198- userLoginAudits . Any ( x => x . Success &&
199- x . IpAddress == currentLogin . IpAddress &&
200- x . Id != currentLogin . Id ) ;
201-
202- // Check for new region
203- var hasSeenRegionBefore = ! string . IsNullOrEmpty ( currentLogin . Region ) &&
204- userLoginAudits . Any ( x => x . Success &&
205- x . Region == currentLogin . Region &&
206- x . Id != currentLogin . Id ) ;
207-
208- // Check for new browser info
209- var hasSeenBrowserBefore = ! string . IsNullOrEmpty ( currentLogin . BrowserInfo ) &&
210- userLoginAudits . Any ( x => x . Success &&
211- x . BrowserInfo == currentLogin . BrowserInfo &&
212- x . Id != currentLogin . Id ) ;
213-
214- // Evaluate novelty factors
215- if ( ! hasSeenIpBefore && ! string . IsNullOrEmpty ( currentLogin . IpAddress ) )
216- newFactors . Add ( _localizer [ "NewIpFactor" , currentLogin . IpAddress ] ) ;
217-
218- if ( ! hasSeenRegionBefore && ! string . IsNullOrEmpty ( currentLogin . Region ) )
219- newFactors . Add ( _localizer [ "NewRegionFactor" , currentLogin . Region ] ) ;
220-
221- if ( ! hasSeenBrowserBefore && ! string . IsNullOrEmpty ( currentLogin . BrowserInfo ) )
222- newFactors . Add ( _localizer [ "NewBrowserFactor" , currentLogin . BrowserInfo ] ) ;
206+ // Normalize comparison tokens
207+ var currentIpCidr24 = SecurityAnalysisHeuristics . NormalizeIpForHeuristic ( currentLogin . IpAddress ) ;
208+ var currentRegionLevel = SecurityAnalysisHeuristics . ExtractRegionHierarchy ( currentLogin . Region , out var regionDisplay ) ;
209+ var currentUaCore = SecurityAnalysisHeuristics . ExtractUserAgentCore ( currentLogin . BrowserInfo ) ;
210+
211+ bool seenIpSubnet = ! string . IsNullOrEmpty ( currentIpCidr24 ) && userLoginAudits . Any ( x => x . Success && SecurityAnalysisHeuristics . NormalizeIpForHeuristic ( x . IpAddress ) == currentIpCidr24 && x . Id != currentLogin . Id ) ;
212+ bool seenRegionLevel = ! string . IsNullOrEmpty ( currentRegionLevel ) && userLoginAudits . Any ( x => x . Success && SecurityAnalysisHeuristics . ExtractRegionHierarchy ( x . Region , out _ ) == currentRegionLevel && x . Id != currentLogin . Id ) ;
213+ bool seenUaCore = ! string . IsNullOrEmpty ( currentUaCore ) && userLoginAudits . Any ( x => x . Success && SecurityAnalysisHeuristics . ExtractUserAgentCore ( x . BrowserInfo ) == currentUaCore && x . Id != currentLogin . Id ) ;
214+
215+ if ( ! seenIpSubnet && ! string . IsNullOrEmpty ( currentIpCidr24 ) )
216+ {
217+ newFactors . Add ( _localizer [ "NewIpFactor" , currentIpCidr24 ] ) ;
218+ }
219+ if ( ! seenRegionLevel && ! string . IsNullOrEmpty ( regionDisplay ) )
220+ {
221+ newFactors . Add ( _localizer [ "NewRegionFactor" , regionDisplay ] ) ;
222+ }
223+ if ( ! seenUaCore && ! string . IsNullOrEmpty ( currentUaCore ) )
224+ {
225+ newFactors . Add ( _localizer [ "NewBrowserFactor" , currentUaCore ] ) ;
226+ }
223227
224228 if ( newFactors . Any ( ) )
225229 {
@@ -230,6 +234,11 @@ private RiskAnalysisRuleResult AnalyzeNewDeviceOrLocation(List<LoginAudit> userL
230234 return result ;
231235 }
232236
237+ // ---------------------------------------
238+ // Helper methods for relaxed heuristics
239+ // ---------------------------------------
240+ // helper methods moved to SecurityAnalysisHeuristics for testability
241+
233242 private RiskAnalysisRuleResult AnalyzeUnusualTimeLogin ( LoginAudit currentLogin )
234243 {
235244 var result = new RiskAnalysisRuleResult { RuleName = "UnusualTimeLogin" } ;
0 commit comments