Skip to content

Commit 3eb1bd5

Browse files
committed
Refactor security analysis and improve caching logic
Replaced static dictionaries with FusionCache for IP analysis, enhancing scalability and distributed cache compatibility. Refactored novelty factor analysis with relaxed heuristics for IP, region, and browser data, improving accuracy and testability. Introduced `SecurityAnalysisHeuristics` for modular helper methods and added corresponding unit tests. Unified exception handling with a global handler and adjusted middleware order for proper execution. Updated `UserPreference` color lists and simplified UI interaction state adjustments. Upgraded `Microsoft.Extensions.Logging.Abstractions` to 9.0.9.
1 parent b76adb8 commit 3eb1bd5

8 files changed

Lines changed: 199 additions & 81 deletions

File tree

src/Application/Pipeline/PreProcessors/ValidationPreProcessor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ public sealed class ValidationPreProcessor<TRequest> : IRequestPreProcessor<TReq
66

77
public ValidationPreProcessor(IEnumerable<IValidator<TRequest>> validators)
88
{
9-
_validators = validators.ToList() ?? throw new ArgumentNullException(nameof(validators));
9+
if (validators is null) throw new ArgumentNullException(nameof(validators));
10+
_validators = validators.ToList();
1011
}
1112

1213
public async Task Process(TRequest request, CancellationToken cancellationToken)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net;
5+
using System.Text.RegularExpressions;
6+
7+
namespace CleanArchitecture.Blazor.Infrastructure.Services;
8+
9+
public static class SecurityAnalysisHeuristics
10+
{
11+
public static string NormalizeIpForThrottle(string? ip)
12+
{
13+
if (string.IsNullOrWhiteSpace(ip)) return string.Empty;
14+
if (IPAddress.TryParse(ip, out var addr))
15+
{
16+
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
17+
{
18+
return addr.ToString();
19+
}
20+
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
21+
{
22+
var bytes = addr.GetAddressBytes();
23+
return string.Join(':', Enumerable.Range(0, 8/2).Select(i => bytes[2*i].ToString("x2") + bytes[2*i+1].ToString("x2")));
24+
}
25+
}
26+
return ip.Trim();
27+
}
28+
29+
public static string NormalizeIpForHeuristic(string? ip)
30+
{
31+
if (string.IsNullOrWhiteSpace(ip)) return string.Empty;
32+
if (IPAddress.TryParse(ip, out var addr))
33+
{
34+
if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
35+
{
36+
var parts = ip.Split('.');
37+
if (parts.Length == 4) return $"{parts[0]}.{parts[1]}.{parts[2]}.*";
38+
}
39+
else if (addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
40+
{
41+
var segments = ip.Split(':');
42+
if (segments.Length >= 4)
43+
{
44+
return string.Join(':', segments.Take(4)) + ":*";
45+
}
46+
}
47+
}
48+
return ip.Trim();
49+
}
50+
51+
public static string ExtractUserAgentCore(string? userAgent)
52+
{
53+
if (string.IsNullOrWhiteSpace(userAgent)) return string.Empty;
54+
var match = Regex.Match(userAgent, "(Chrome|Firefox|Safari|Edg|Edge|OPR|Opera|Brave)[/ ](?<ver>\\d+)", RegexOptions.IgnoreCase);
55+
if (match.Success)
56+
{
57+
var name = match.Groups[1].Value;
58+
var ver = match.Groups["ver"].Value;
59+
return $"{name}/{ver}";
60+
}
61+
var token = userAgent.Split(' ').FirstOrDefault(t => t.Contains('/'));
62+
if (token != null)
63+
{
64+
var major = token.Split('/');
65+
if (major.Length == 2)
66+
{
67+
var digits = new string(major[1].TakeWhile(char.IsDigit).ToArray());
68+
if (!string.IsNullOrEmpty(digits)) return $"{major[0]}/{digits}";
69+
}
70+
}
71+
return userAgent.Length > 40 ? userAgent.Substring(0, 40) : userAgent;
72+
}
73+
74+
public static string ExtractRegionHierarchy(string? regionRaw, out string? display)
75+
{
76+
display = regionRaw;
77+
if (string.IsNullOrWhiteSpace(regionRaw)) return string.Empty;
78+
var separators = new[] { '|', '-', ',', '/' };
79+
var parts = regionRaw.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
80+
if (parts.Length == 0) return regionRaw;
81+
if (parts.Length == 1)
82+
{
83+
display = parts[0];
84+
return parts[0];
85+
}
86+
display = string.Join("/", parts.Take(Math.Min(3, parts.Length)));
87+
return string.Join("/", parts.Take(2));
88+
}
89+
}

src/Infrastructure/Services/SecurityAnalysisService.cs

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using CleanArchitecture.Blazor.Domain.Enums;
66
using CleanArchitecture.Blazor.Domain.Identity;
77
using System.Collections.Concurrent;
8+
using System.Net;
9+
using System.Text.RegularExpressions;
810
using ZiggyCreatures.Caching.Fusion;
911
using 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" };

src/Server.UI/DependencyInjection.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,14 @@ public static WebApplication ConfigureServer(this WebApplication app, IConfigura
149149
}
150150
else
151151
{
152-
app.UseExceptionHandler("/Error", true);
152+
// Unified exception handling: rely on AddExceptionHandler<GlobalExceptionHandler>() + ProblemDetails.
153+
// Removed the conventional "/Error" endpoint handler to avoid duplication/conflict.
153154
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
154155
app.UseHsts();
155156
}
156157

158+
// Single global exception handler registration (no path) to activate IExceptionHandler + ProblemDetails pipeline.
159+
app.UseExceptionHandler();
157160
app.UseStatusCodePagesWithRedirects("/404");
158161
app.MapHealthChecks("/health");
159162
app.UseAuthentication();
@@ -188,8 +191,7 @@ public static WebApplication ConfigureServer(this WebApplication app, IConfigura
188191
localizationOptions.RequestCultureProviders.Remove(acceptLanguageProvider);
189192
}
190193
app.UseRequestLocalization(localizationOptions);
191-
app.UseMiddleware<LocalizationCookiesMiddleware>();
192-
app.UseExceptionHandler();
194+
app.UseMiddleware<LocalizationCookiesMiddleware>();
193195
app.UseHangfireDashboard("/jobs", new DashboardOptions
194196
{
195197
Authorization = new[] { new HangfireDashboardAuthorizationFilter() },

src/Server.UI/Services/UserPreferences/UserPreference.cs

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,25 @@ public class UserPreference
1414
// List of available primary colors
1515
public static readonly List<string> PrimaryColors = new()
1616
{
17-
"#1447e6",
17+
"#171717",
18+
"#1d4ed8",
1819
"#4c1d95",
1920
"#5ea500",
2021
"#f97316",
2122
"#e7000b",
22-
"#171717"
23+
2324
};
2425

2526
// List of available dark primary colors
2627
public static readonly List<string> DarkPrimaryColors = new()
2728
{
28-
"#1447e6",
29+
"#e5e5e5",
30+
"#1d4ed8",
2931
"#8e51ff",
3032
"#5ea500",
3133
"#ff6900",
3234
"#ff2056",
33-
"#e5e5e5"
35+
3436
};
3537
/// <summary>
3638
/// Indicates whether the dark mode is enabled.
@@ -88,35 +90,7 @@ public class UserPreference
8890
/// </summary>
8991
public DarkLightMode DarkLightTheme { get; set; }
9092

91-
/// <summary>
92-
/// Adjusts the brightness of a hex color.
93-
/// </summary>
94-
/// <param name="hexColor">The hex color code.</param>
95-
/// <param name="factor">
96-
/// The factor by which to adjust brightness (values less than 1 darken the color,
97-
/// values greater than 1 lighten the color).
98-
/// </param>
99-
/// <returns>The adjusted hex color code.</returns>
100-
private string AdjustBrightness(string hexColor, double factor)
101-
{
102-
if (string.IsNullOrWhiteSpace(hexColor))
103-
throw new ArgumentException("Color code cannot be null or empty.", nameof(hexColor));
104-
105-
// Parse the hex color using ColorTranslator
106-
Color color = ColorTranslator.FromHtml(hexColor);
107-
108-
// Convert RGB to HSL
109-
ColorToHsl(color, out double h, out double s, out double l);
110-
111-
// Adjust lightness
112-
l = Math.Clamp(l * factor, 0.0, 1.0);
113-
114-
// Convert HSL back to Color
115-
Color adjustedColor = HslToColor(h, s, l);
116-
117-
// Return the hex representation
118-
return ColorTranslator.ToHtml(adjustedColor);
119-
}
93+
12094

12195
/// <summary>
12296
/// Adjusts the color for UI interaction states (hover, focus) with better visual contrast.

src/Server.UI/appsettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
},
2828
"AppConfigurationSettings": {
2929
"ApplicationUrl": "https://architecture.blazorserver.com",
30-
"Version": "1.3.18",
30+
"Version": "1.3.20",
3131
"App": "Blazor",
3232
"AppName": "Blazor Studio",
3333
"Company": "Company",

tests/Infrastructure.UnitTests/Infrastructure.UnitTests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<PrivateAssets>all</PrivateAssets>
2222
</PackageReference>
2323
<PackageReference Include="Moq" Version="4.20.72" />
24-
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
24+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
2525
</ItemGroup>
2626

2727
<ItemGroup>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using CleanArchitecture.Blazor.Infrastructure.Services;
5+
using Xunit;
6+
7+
namespace CleanArchitecture.Blazor.Infrastructure.UnitTests.Services;
8+
9+
public class SecurityAnalysisHeuristicsTests
10+
{
11+
[Theory]
12+
[InlineData("192.168.1.15", "192.168.1.*")]
13+
[InlineData("10.0.0.200", "10.0.0.*")]
14+
public void NormalizeIpForHeuristic_Ipv4_ReturnsSlash24(string ip, string expected)
15+
{
16+
var normalized = SecurityAnalysisHeuristics.NormalizeIpForHeuristic(ip);
17+
Assert.Equal(expected, normalized);
18+
}
19+
20+
[Fact]
21+
public void ExtractUserAgentCore_ChromeFullVersion_ReturnsMajor()
22+
{
23+
var ua = "Mozilla/5.0 Chrome/118.0.5993.70 Safari/537.36";
24+
var core = SecurityAnalysisHeuristics.ExtractUserAgentCore(ua);
25+
Assert.Equal("Chrome/118", core);
26+
}
27+
28+
[Fact]
29+
public void ExtractUserAgentCore_Firefox_ReturnsMajor()
30+
{
31+
var ua = "Mozilla/5.0 Firefox/121.1";
32+
var core = SecurityAnalysisHeuristics.ExtractUserAgentCore(ua);
33+
Assert.Equal("Firefox/121", core);
34+
}
35+
36+
[Fact]
37+
public void ExtractRegionHierarchy_CountryStateCity()
38+
{
39+
var key = SecurityAnalysisHeuristics.ExtractRegionHierarchy("US|CA|SanFrancisco", out var display);
40+
Assert.Equal("US/CA", key);
41+
Assert.Equal("US/CA/SanFrancisco", display);
42+
}
43+
}

0 commit comments

Comments
 (0)