diff --git a/docker-compose.yml b/docker-compose.yml
index 14e8abf28..2698bd0be 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -29,6 +29,8 @@ services:
- Minio__AccessKey=${MINIO_ACCESS_KEY}
- Minio__SecretKey=${MINIO_SECRET_KEY}
- Minio__BucketName=${MINIO_BUCKET}
+ - MaxMind__AccountId=${MAXMIND_ACCOUNT_ID}
+ - MaxMind__LicenseKey=${MAXMIND_LICENSE_KEY}
ports:
- "5005:80"
diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj
index 8390e79fa..b06b3a482 100644
--- a/src/Application/Application.csproj
+++ b/src/Application/Application.csproj
@@ -8,6 +8,7 @@
default
+
@@ -18,8 +19,8 @@
-
-
+
+
diff --git a/src/Application/Common/Interfaces/IGeolocationService.cs b/src/Application/Common/Interfaces/IGeolocationService.cs
deleted file mode 100644
index 2b4669919..000000000
--- a/src/Application/Common/Interfaces/IGeolocationService.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-namespace CleanArchitecture.Blazor.Application.Common.Interfaces;
-
-///
-/// Service for retrieving geolocation information based on IP addresses.
-///
-public interface IGeolocationService
-{
- ///
- /// Gets the country code for the specified IP address.
- ///
- /// The IP address to look up.
- /// Cancellation token.
- /// The ISO 3166-1 alpha-2 country code, or null if lookup fails.
- Task GetCountryAsync(string ipAddress, CancellationToken cancellationToken = default);
-
- ///
- /// Gets detailed geolocation information for the specified IP address.
- ///
- /// The IP address to look up.
- /// Cancellation token.
- /// Geolocation information, or null if lookup fails.
- Task GetGeolocationAsync(string ipAddress, CancellationToken cancellationToken = default);
-}
-
-///
-/// Represents geolocation information for an IP address.
-///
-public class GeolocationInfo
-{
- ///
- /// Gets or sets the IP address.
- ///
- public string IpAddress { get; set; } = string.Empty;
-
- ///
- /// Gets or sets the ISO 3166-1 alpha-2 country code.
- ///
- public string? Country { get; set; }
-
- ///
- /// Gets or sets the country name.
- ///
- public string? CountryName { get; set; }
-
- ///
- /// Gets or sets the region or state.
- ///
- public string? Region { get; set; }
-
- ///
- /// Gets or sets the city.
- ///
- public string? City { get; set; }
-
- ///
- /// Gets or sets the postal code.
- ///
- public string? PostalCode { get; set; }
-
- ///
- /// Gets or sets the latitude.
- ///
- public double? Latitude { get; set; }
-
- ///
- /// Gets or sets the longitude.
- ///
- public double? Longitude { get; set; }
-
- ///
- /// Gets or sets the timezone.
- ///
- public string? Timezone { get; set; }
-
- ///
- /// Gets or sets the Internet Service Provider (ISP).
- ///
- public string? Isp { get; set; }
-
- ///
- /// Gets or sets the organization.
- ///
- public string? Organization { get; set; }
-}
diff --git a/src/Application/Features/LoginAudits/EventHandlers/LoginAuditCreatedEventHandler.cs b/src/Application/Features/LoginAudits/EventHandlers/LoginAuditCreatedEventHandler.cs
index 44fc70396..1c2b204a0 100644
--- a/src/Application/Features/LoginAudits/EventHandlers/LoginAuditCreatedEventHandler.cs
+++ b/src/Application/Features/LoginAudits/EventHandlers/LoginAuditCreatedEventHandler.cs
@@ -1,4 +1,4 @@
-//------------------------------------------------------------------------------
+//------------------------------------------------------------------------------
//
// CleanArchitecture.Blazor - MIT Licensed.
// Author: neozhu
@@ -12,6 +12,7 @@
using CleanArchitecture.Blazor.Domain.Enums;
using CleanArchitecture.Blazor.Domain.Identity;
using DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
+using MaxMind.GeoIP2;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
@@ -19,19 +20,17 @@ namespace CleanArchitecture.Blazor.Application.Features.LoginAudits.EventHandler
public class LoginAuditCreatedEventHandler : INotificationHandler
{
- private readonly IGeolocationService _geolocationService;
+
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
private readonly ISecurityAnalysisService _securityAnalysisService;
public LoginAuditCreatedEventHandler(
- IGeolocationService geolocationService,
IServiceScopeFactory scopeFactory,
ILogger logger,
ISecurityAnalysisService securityAnalysisService)
{
- _geolocationService = geolocationService;
_scopeFactory = scopeFactory;
_logger = logger;
_securityAnalysisService = securityAnalysisService;
@@ -39,32 +38,45 @@ public LoginAuditCreatedEventHandler(
public async Task Handle(LoginAuditCreatedEvent notification, CancellationToken cancellationToken)
{
+ notification.Item.IpAddress = "186.150.138.239";
if (!string.IsNullOrEmpty(notification.Item.IpAddress) && !notification.Item.IpAddress.StartsWith("127") && string.IsNullOrEmpty(notification.Item.Region))
{
- var geolocation = await _geolocationService.GetGeolocationAsync(notification.Item.IpAddress, cancellationToken);
- if (geolocation != null)
+ try
{
- var regionParts = new List();
+ using (var scope = _scopeFactory.CreateScope())
+ {
+ using (var client = scope.ServiceProvider.GetRequiredService())
+ {
+ var geolocation = await client.CityAsync(notification.Item.IpAddress);
+ if (geolocation != null)
+ {
+ var regionParts = new List();
- if (!string.IsNullOrEmpty(geolocation.City))
- regionParts.Add(geolocation.City);
+ if (!string.IsNullOrEmpty(geolocation.City.Name))
+ regionParts.Add(geolocation.City.Name);
- if (!string.IsNullOrEmpty(geolocation.Region))
- regionParts.Add(geolocation.Region);
+ if (geolocation.Subdivisions.Any())
+ regionParts.Add(geolocation.Subdivisions.FirstOrDefault().Name);
- if (!string.IsNullOrEmpty(geolocation.CountryName))
- regionParts.Add(geolocation.CountryName);
- else if (!string.IsNullOrEmpty(geolocation.Country))
- regionParts.Add(geolocation.Country);
+ if (!string.IsNullOrEmpty(geolocation.Country.Name))
+ regionParts.Add(geolocation.Country.Name);
- var region = regionParts.Count > 0 ? string.Join(", ", regionParts) : null;
- using (var scope = _scopeFactory.CreateScope())
- {
- var dbContextFactory = scope.ServiceProvider.GetRequiredService();
- await using var db = await dbContextFactory.CreateAsync(cancellationToken);
- await db.LoginAudits.Where(x => x.Id == notification.Item.Id).ExecuteUpdateAsync(x => x.SetProperty(y => y.Region, region));
+
+ var region = regionParts.Count > 0 ? string.Join(", ", regionParts) : null;
+
+
+ var dbContextFactory = scope.ServiceProvider.GetRequiredService();
+ await using var db = await dbContextFactory.CreateAsync(cancellationToken);
+ await db.LoginAudits.Where(x => x.Id == notification.Item.Id).ExecuteUpdateAsync(x => x.SetProperty(y => y.Region, region));
+
+ }
+ }
}
}
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get geolocation for IP: {IpAddress}", notification.Item.IpAddress);
+ }
}
// Analyze account security using the dedicated service in a new scope
diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs
index 48aa319b3..f8d6b1ae1 100644
--- a/src/Infrastructure/DependencyInjection.cs
+++ b/src/Infrastructure/DependencyInjection.cs
@@ -2,28 +2,29 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Reflection;
+using CleanArchitecture.Blazor.Application.Common.Constants;
+using CleanArchitecture.Blazor.Application.Common.Interfaces; // IDataSourceService
using CleanArchitecture.Blazor.Application.Common.Models;
+using CleanArchitecture.Blazor.Application.Common.Security;
+using CleanArchitecture.Blazor.Application.Features.Identity.DTOs;
+using CleanArchitecture.Blazor.Application.Features.PicklistSets.DTOs;
+using CleanArchitecture.Blazor.Application.Features.Tenants.DTOs;
using CleanArchitecture.Blazor.Domain.Identity;
using CleanArchitecture.Blazor.Infrastructure.Configurations;
-using CleanArchitecture.Blazor.Application.Common.Security;
using CleanArchitecture.Blazor.Infrastructure.Persistence.Interceptors;
+using CleanArchitecture.Blazor.Infrastructure.Services;
using CleanArchitecture.Blazor.Infrastructure.Services.Circuits;
using CleanArchitecture.Blazor.Infrastructure.Services.Gemini;
+using CleanArchitecture.Blazor.Infrastructure.Services.Identity;
using CleanArchitecture.Blazor.Infrastructure.Services.MultiTenant;
+using MaxMind.GeoIP2;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using ZiggyCreatures.Caching.Fusion;
-using Microsoft.AspNetCore.SignalR;
-using Microsoft.AspNetCore.Http;
-using CleanArchitecture.Blazor.Application.Common.Constants;
-using CleanArchitecture.Blazor.Infrastructure.Services.Identity;
-using CleanArchitecture.Blazor.Infrastructure.Services;
-using CleanArchitecture.Blazor.Application.Common.Interfaces; // IDataSourceService
-using CleanArchitecture.Blazor.Application.Features.Tenants.DTOs;
-using CleanArchitecture.Blazor.Application.Features.Identity.DTOs;
-using CleanArchitecture.Blazor.Application.Features.PicklistSets.DTOs;
namespace CleanArchitecture.Blazor.Infrastructure;
@@ -80,6 +81,8 @@ private static IServiceCollection AddApplicationSettings(this IServiceCollection
services.Configure(configuration.GetSection(AISettings.Key))
.AddSingleton(s => s.GetRequiredService>().Value)
.AddSingleton(s => s.GetRequiredService>().Value);
+
+
return services;
}
#endregion
@@ -171,17 +174,15 @@ private static IServiceCollection AddBusinessServices(this IServiceCollection se
services.AddScoped();
- // Configure HttpClient for GeolocationService
- services.AddHttpClient(client =>
- {
- client.Timeout = TimeSpan.FromSeconds(10);
- client.DefaultRequestHeaders.Add("User-Agent", "CleanArchitectureBlazorServer/1.0");
- });
+
// Configure SecurityAnalysisService with options
+ services.Configure(configuration.GetSection("MaxMind"));
+ services.AddHttpClient();
+
services.Configure(configuration.GetSection(SecurityAnalysisOptions.SectionName));
services.AddScoped();
-
+
return services
.AddScoped()
.AddScoped()
@@ -329,7 +330,7 @@ private static IServiceCollection AddIdentityAndSecurity(this IServiceCollection
});
services.AddDataProtection().PersistKeysToDbContext();
-
+
return services;
}
diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj
index ba0e4f52f..c0bb635e0 100644
--- a/src/Infrastructure/Infrastructure.csproj
+++ b/src/Infrastructure/Infrastructure.csproj
@@ -8,15 +8,16 @@
default
+
-
+
-
+
-
+
diff --git a/src/Infrastructure/Services/GeolocationService.cs b/src/Infrastructure/Services/GeolocationService.cs
deleted file mode 100644
index 2111f16aa..000000000
--- a/src/Infrastructure/Services/GeolocationService.cs
+++ /dev/null
@@ -1,310 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace CleanArchitecture.Blazor.Infrastructure.Services;
-
-///
-/// Service for retrieving geolocation information using the ipapi.co API.
-///
-public class GeolocationService : IGeolocationService
-{
- private readonly HttpClient _httpClient;
- private readonly ILogger _logger;
- private readonly JsonSerializerOptions _jsonOptions;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The HTTP client for making API requests.
- /// The logger instance.
- public GeolocationService(HttpClient httpClient, ILogger logger)
- {
- _httpClient = httpClient;
- _logger = logger;
- _jsonOptions = new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true,
- PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
- };
- }
-
- ///
- public async Task GetCountryAsync(string ipAddress, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(ipAddress))
- {
- _logger.LogWarning("IP address is null or empty");
- return null;
- }
-
- try
- {
- var url = $"http://ipapi.co/{ipAddress}/country/";
-
- var response = await _httpClient.GetAsync(url, cancellationToken);
-
- if (!response.IsSuccessStatusCode)
- {
- _logger.LogWarning("Failed to get country information. Status: {StatusCode}", response.StatusCode);
- return null;
- }
-
- var country = await response.Content.ReadAsStringAsync(cancellationToken);
-
- // API returns the country code as plain text
- country = country?.Trim();
-
- if (string.IsNullOrEmpty(country) || country.Equals("undefined", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogWarning("Received invalid country response");
- return null;
- }
-
- return country;
- }
- catch (HttpRequestException ex)
- {
- _logger.LogError(ex, "HTTP request failed while getting country information");
- return null;
- }
- catch (TaskCanceledException ex)
- {
- _logger.LogWarning(ex, "Request timeout while getting country information");
- return null;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Unexpected error while getting country information");
- return null;
- }
- }
-
- ///
- public async Task GetGeolocationAsync(string ipAddress, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(ipAddress))
- {
- _logger.LogWarning("IP address is null or empty");
- return null;
- }
-
- try
- {
- //ipAddress = "180.107.106.63";
- // Using HTTPS for secure communication
- var url = $"http://ip-api.com/json/{ipAddress}";
-
- var response = await _httpClient.GetAsync(url, cancellationToken);
-
- if (!response.IsSuccessStatusCode)
- {
- _logger.LogWarning("Failed to get geolocation information. Status: {StatusCode}", response.StatusCode);
- return null;
- }
-
- var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken);
-
- if (string.IsNullOrWhiteSpace(jsonContent))
- {
- _logger.LogWarning("Received empty response");
- return null;
- }
-
- var apiResponse = JsonSerializer.Deserialize(jsonContent, _jsonOptions);
-
- if (apiResponse == null)
- {
- _logger.LogWarning("Failed to deserialize response");
- return null;
- }
-
- // Check if the API returned an error (status != "success")
- if (apiResponse.Status != "success")
- {
- _logger.LogWarning("API returned error status: {Status}", apiResponse.Status);
- return null;
- }
-
- var geolocation = new GeolocationInfo
- {
- IpAddress = ipAddress,
- Country = apiResponse.CountryCode,
- CountryName = apiResponse.Country,
- Region = apiResponse.RegionName,
- City = apiResponse.City,
- PostalCode = apiResponse.Zip,
- Latitude = apiResponse.Lat,
- Longitude = apiResponse.Lon,
- Timezone = apiResponse.Timezone,
- Isp = apiResponse.Isp,
- Organization = apiResponse.Org
- };
-
- return geolocation;
- }
- catch (JsonException ex)
- {
- _logger.LogError(ex, "JSON deserialization failed");
- return null;
- }
- catch (HttpRequestException ex)
- {
- _logger.LogError(ex, "HTTP request failed while getting geolocation information");
- return null;
- }
- catch (TaskCanceledException ex)
- {
- _logger.LogWarning(ex, "Request timeout while getting geolocation information");
- return null;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Unexpected error while getting geolocation information");
- return null;
- }
- }
-
- ///
- /// Represents the response from the ip-api.com JSON API.
- ///
- private class IpApiComResponse
- {
- [JsonPropertyName("status")]
- public string? Status { get; set; }
-
- [JsonPropertyName("country")]
- public string? Country { get; set; }
-
- [JsonPropertyName("countryCode")]
- public string? CountryCode { get; set; }
-
- [JsonPropertyName("region")]
- public string? Region { get; set; }
-
- [JsonPropertyName("regionName")]
- public string? RegionName { get; set; }
-
- [JsonPropertyName("city")]
- public string? City { get; set; }
-
- [JsonPropertyName("zip")]
- public string? Zip { get; set; }
-
- [JsonPropertyName("lat")]
- public double? Lat { get; set; }
-
- [JsonPropertyName("lon")]
- public double? Lon { get; set; }
-
- [JsonPropertyName("timezone")]
- public string? Timezone { get; set; }
-
- [JsonPropertyName("isp")]
- public string? Isp { get; set; }
-
- [JsonPropertyName("org")]
- public string? Org { get; set; }
-
- [JsonPropertyName("as")]
- public string? As { get; set; }
-
- [JsonPropertyName("query")]
- public string? Query { get; set; }
-
- [JsonPropertyName("message")]
- public string? Message { get; set; }
- }
-
- ///
- /// Represents the response from the ipapi.co JSON API.
- ///
- private class IpApiResponse
- {
- [JsonPropertyName("ip")]
- public string? Ip { get; set; }
-
- [JsonPropertyName("version")]
- public string? Version { get; set; }
-
- [JsonPropertyName("city")]
- public string? City { get; set; }
-
- [JsonPropertyName("region")]
- public string? Region { get; set; }
-
- [JsonPropertyName("region_code")]
- public string? RegionCode { get; set; }
-
- [JsonPropertyName("country")]
- public string? Country { get; set; }
-
- [JsonPropertyName("country_name")]
- public string? CountryName { get; set; }
-
- [JsonPropertyName("country_code")]
- public string? CountryCode { get; set; }
-
- [JsonPropertyName("country_code_iso3")]
- public string? CountryCodeIso3 { get; set; }
-
- [JsonPropertyName("country_capital")]
- public string? CountryCapital { get; set; }
-
- [JsonPropertyName("country_tld")]
- public string? CountryTld { get; set; }
-
- [JsonPropertyName("continent_code")]
- public string? ContinentCode { get; set; }
-
- [JsonPropertyName("in_eu")]
- public bool? InEu { get; set; }
-
- [JsonPropertyName("postal")]
- public string? Postal { get; set; }
-
- [JsonPropertyName("latitude")]
- public double? Latitude { get; set; }
-
- [JsonPropertyName("longitude")]
- public double? Longitude { get; set; }
-
- [JsonPropertyName("timezone")]
- public string? Timezone { get; set; }
-
- [JsonPropertyName("utc_offset")]
- public string? UtcOffset { get; set; }
-
- [JsonPropertyName("country_calling_code")]
- public string? CountryCallingCode { get; set; }
-
- [JsonPropertyName("currency")]
- public string? Currency { get; set; }
-
- [JsonPropertyName("currency_name")]
- public string? CurrencyName { get; set; }
-
- [JsonPropertyName("languages")]
- public string? Languages { get; set; }
-
- [JsonPropertyName("country_area")]
- public double? CountryArea { get; set; }
-
- [JsonPropertyName("country_population")]
- public long? CountryPopulation { get; set; }
-
- [JsonPropertyName("asn")]
- public string? Asn { get; set; }
-
- [JsonPropertyName("org")]
- public string? Org { get; set; }
-
- [JsonPropertyName("error")]
- public string? Error { get; set; }
-
- [JsonPropertyName("reason")]
- public string? Reason { get; set; }
- }
-}
diff --git a/src/Server.UI/Pages/Identity/Forgot/Forgot.razor b/src/Server.UI/Pages/Identity/Forgot/Forgot.razor
index 8f8cd6929..503781f38 100644
--- a/src/Server.UI/Pages/Identity/Forgot/Forgot.razor
+++ b/src/Server.UI/Pages/Identity/Forgot/Forgot.razor
@@ -1,4 +1,4 @@
-@page "/account/forgot-password"
+@page "/account/forgot-password"
@using CleanArchitecture.Blazor.Domain.Identity
@using Microsoft.AspNetCore.WebUtilities
@using System.Text
diff --git a/src/Server.UI/Server.UI.csproj b/src/Server.UI/Server.UI.csproj
index d08422ce0..e56d653d0 100644
--- a/src/Server.UI/Server.UI.csproj
+++ b/src/Server.UI/Server.UI.csproj
@@ -25,7 +25,7 @@
-
+
all
diff --git a/src/Server.UI/appsettings.json b/src/Server.UI/appsettings.json
index 75a7e6f2e..792f74c6d 100644
--- a/src/Server.UI/appsettings.json
+++ b/src/Server.UI/appsettings.json
@@ -42,6 +42,20 @@
"RequireCredentials": true,
"DefaultFromEmail": "noreply@blazorserver.com"
},
+ "MaxMind": {
+ "AccountId": 0,
+ "LicenseKey": "your license key",
+
+ // Optionally set a timeout. The default is 3000 ms.
+ // "Timeout": 3000,
+
+ // Optionally set host. "geolite.info" will use the GeoLite2
+ // web service instead of GeoIP2. "sandbox.maxmind.com" will use the
+ // Sandbox GeoIP2 web service instead of the production GeoIP2 web
+ // service.
+ //
+ "Host": "geolite.info"
+ },
"Minio": {
"Endpoint": "minio.blazors.app:8443",
"AccessKey": "your-access-key",