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",