From 3647333a2feaf4885078a9ad1cc7d8c70375b3b9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 12 Jun 2026 15:33:46 +0200 Subject: [PATCH] Add GeoIP support --- API/Models/Response/LoginSessionResponse.cs | 10 +- API/Program.cs | 1 + Common/Common.csproj | 1 + Common/Extensions/ConfigurationExtensions.cs | 7 ++ Common/OpenShockControllerBase.cs | 9 +- Common/OpenShockServiceHelper.cs | 2 + Common/Options/GeoOptions.cs | 16 +++ Common/Redis/LoginSessions.cs | 4 + Common/Services/Geo/IIpEnrichmentService.cs | 11 ++ Common/Services/Geo/IpEnrichmentData.cs | 8 ++ Common/Services/Geo/IpEnrichmentService.cs | 114 +++++++++++++++++++ Common/Services/Session/ISessionService.cs | 3 +- Common/Services/Session/SessionService.cs | 7 +- Directory.Packages.props | 1 + 14 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 Common/Options/GeoOptions.cs create mode 100644 Common/Services/Geo/IIpEnrichmentService.cs create mode 100644 Common/Services/Geo/IpEnrichmentData.cs create mode 100644 Common/Services/Geo/IpEnrichmentService.cs diff --git a/API/Models/Response/LoginSessionResponse.cs b/API/Models/Response/LoginSessionResponse.cs index 8033d14a..98bb57a3 100644 --- a/API/Models/Response/LoginSessionResponse.cs +++ b/API/Models/Response/LoginSessionResponse.cs @@ -13,7 +13,11 @@ public static LoginSessionResponse MapFrom(LoginSession session) UserAgent = session.UserAgent, Created = session.Created!.Value, Expires = session.Expires!.Value, - LastUsed = session.LastUsed + LastUsed = session.LastUsed, + AsnOrg = session.AsnOrg, + IsVpn = session.IsVpn, + CountryCode = session.CountryCode, + City = session.City, }; } @@ -23,4 +27,8 @@ public static LoginSessionResponse MapFrom(LoginSession session) public required DateTimeOffset Created { get; init; } public required DateTimeOffset Expires { get; init; } public required DateTimeOffset? LastUsed { get; init; } + public string? AsnOrg { get; init; } + public bool? IsVpn { get; init; } + public string? CountryCode { get; init; } + public string? City { get; init; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 5dcf07fa..08907479 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -26,6 +26,7 @@ var databaseOptions = builder.RegisterDatabaseOptions(); builder.RegisterMetricsOptions(); builder.RegisterFrontendOptions(); +builder.RegisterGeoOptions(); builder.Services .AddOpenShockMemDB(redisOptions) diff --git a/Common/Common.csproj b/Common/Common.csproj index a1eb241f..e2253b45 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Common/Extensions/ConfigurationExtensions.cs b/Common/Extensions/ConfigurationExtensions.cs index cf5b910a..765b685f 100644 --- a/Common/Extensions/ConfigurationExtensions.cs +++ b/Common/Extensions/ConfigurationExtensions.cs @@ -80,6 +80,13 @@ public static MetricsOptions RegisterMetricsOptions(this WebApplicationBuilder b return options; } + public static GeoOptions RegisterGeoOptions(this WebApplicationBuilder builder) + { + var options = builder.Configuration.GetSection(GeoOptions.SectionName).Get() ?? new GeoOptions(); + builder.Services.AddSingleton(options); + return options; + } + public static FrontendOptions RegisterFrontendOptions(this WebApplicationBuilder builder) { var section = builder.Configuration.GetRequiredSection("OpenShock:Frontend"); diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 4e11c2cb..601d5c58 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -3,6 +3,7 @@ using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -42,8 +43,12 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); - - var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); + var enrichmentService = HttpContext.RequestServices.GetRequiredService(); + + var remoteIp = HttpContext.GetRemoteIP(); + var enrichment = enrichmentService.Enrich(remoteIp); + + var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), remoteIp.ToString(), enrichment); HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..d8e4db22 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -19,6 +19,7 @@ using OpenShock.Common.Services.BatchUpdate; using OpenShock.Common.Services.Configuration; using OpenShock.Common.Services.RedisPubSub; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Services.Session; using OpenShock.Common.Services.Webhook; using OpenTelemetry.Metrics; @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); diff --git a/Common/Options/GeoOptions.cs b/Common/Options/GeoOptions.cs new file mode 100644 index 00000000..fde74a11 --- /dev/null +++ b/Common/Options/GeoOptions.cs @@ -0,0 +1,16 @@ +namespace OpenShock.Common.Options; + +public sealed class GeoOptions +{ + public const string SectionName = "Geo"; + + /// + /// Path to the MaxMind GeoLite2-ASN.mmdb file. + /// + public string? AsnDbPath { get; init; } + + /// + /// Path to the MaxMind GeoLite2-City.mmdb file. + /// + public string? CityDbPath { get; init; } +} diff --git a/Common/Redis/LoginSessions.cs b/Common/Redis/LoginSessions.cs index 34b4f8a3..b0aad4d2 100644 --- a/Common/Redis/LoginSessions.cs +++ b/Common/Redis/LoginSessions.cs @@ -18,4 +18,8 @@ public sealed class LoginSession public DateTimeOffset? Expires { get; set; } [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] public DateTimeOffset? LastUsed { get; set; } + public string? AsnOrg { get; set; } + public bool? IsVpn { get; set; } + public string? CountryCode { get; set; } + public string? City { get; set; } } \ No newline at end of file diff --git a/Common/Services/Geo/IIpEnrichmentService.cs b/Common/Services/Geo/IIpEnrichmentService.cs new file mode 100644 index 00000000..31bea8e1 --- /dev/null +++ b/Common/Services/Geo/IIpEnrichmentService.cs @@ -0,0 +1,11 @@ +using System.Net; + +namespace OpenShock.Common.Services.Geo; + +public interface IIpEnrichmentService +{ + /// + /// Returns null when neither GeoLite2 database is configured or available. + /// + IpEnrichmentData? Enrich(IPAddress ip); +} diff --git a/Common/Services/Geo/IpEnrichmentData.cs b/Common/Services/Geo/IpEnrichmentData.cs new file mode 100644 index 00000000..223b389f --- /dev/null +++ b/Common/Services/Geo/IpEnrichmentData.cs @@ -0,0 +1,8 @@ +namespace OpenShock.Common.Services.Geo; + +public sealed record IpEnrichmentData( + string? AsnOrg, + bool IsVpn, + string? CountryCode, + string? City +); diff --git a/Common/Services/Geo/IpEnrichmentService.cs b/Common/Services/Geo/IpEnrichmentService.cs new file mode 100644 index 00000000..990719c2 --- /dev/null +++ b/Common/Services/Geo/IpEnrichmentService.cs @@ -0,0 +1,114 @@ +using System.Net; +using MaxMind.GeoIP2; +using Microsoft.Extensions.Logging; +using OpenShock.Common.Options; + +namespace OpenShock.Common.Services.Geo; + +public sealed class IpEnrichmentService : IIpEnrichmentService, IDisposable +{ + private static readonly string[] VpnKeywords = + [ + "mullvad", "nordvpn", "expressvpn", "protonvpn", "ipvanish", "surfshark", + "privateinternetaccess", "pia", "hidemyass", "purevpn", "cyberghost", + "windscribe", "tunnelbear", "hotspot shield", "vyprvpn", "airvpn", + "perfect privacy", "ivpn", "ovpn", + // Datacenter / hosting ASNs that VPN exit nodes overwhelmingly use + "digitalocean", "linode", "akamai", "hetzner", "vultr", "ovh", + "amazon", "google", "microsoft", "choopa", "m247", "datacamp", + "frantech", "quadranet", "leaseweb", "serverius", "hostwinds", + "psychz", "tzulo", "nexeon", "misaka", + ]; + + private readonly DatabaseReader? _asnReader; + private readonly DatabaseReader? _cityReader; + private readonly ILogger _logger; + + public IpEnrichmentService(GeoOptions options, ILogger logger) + { + _logger = logger; + + _asnReader = TryOpen(options.AsnDbPath, "ASN"); + _cityReader = TryOpen(options.CityDbPath, "City"); + } + + private DatabaseReader? TryOpen(string? path, string dbName) + { + if (string.IsNullOrWhiteSpace(path)) + { + _logger.LogInformation("GeoLite2 {DbName} database path not configured, skipping", dbName); + return null; + } + + if (!File.Exists(path)) + { + _logger.LogWarning("GeoLite2 {DbName} database not found at {Path}", dbName, path); + return null; + } + + try + { + return new DatabaseReader(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open GeoLite2 {DbName} database at {Path}", dbName, path); + return null; + } + } + + public IpEnrichmentData? Enrich(IPAddress ip) + { + if (_asnReader is null && _cityReader is null) return null; + + string? asnOrg = null; + bool isVpn = false; + + if (_asnReader is not null) + { + try + { + if (_asnReader.TryAsn(ip, out var asn) && asn is not null) + { + asnOrg = asn.AutonomousSystemOrganization; + if (asnOrg is not null) + { + var lower = asnOrg.ToLowerInvariant(); + isVpn = Array.Exists(VpnKeywords, k => lower.Contains(k)); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ASN lookup failed for {Ip}", ip); + } + } + + string? countryCode = null; + string? city = null; + + if (_cityReader is not null) + { + try + { + if (_cityReader.TryCity(ip, out var cityResponse) && cityResponse is not null) + { + countryCode = cityResponse.Country.IsoCode; + city = cityResponse.City.Name; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "City lookup failed for {Ip}", ip); + } + } + + return new IpEnrichmentData(asnOrg, isVpn, countryCode, city); + } + + public void Dispose() + { + _asnReader?.Dispose(); + _cityReader?.Dispose(); + } +} diff --git a/Common/Services/Session/ISessionService.cs b/Common/Services/Session/ISessionService.cs index 6538356e..dd7dfe29 100644 --- a/Common/Services/Session/ISessionService.cs +++ b/Common/Services/Session/ISessionService.cs @@ -1,10 +1,11 @@ using OpenShock.Common.Redis; +using OpenShock.Common.Services.Geo; namespace OpenShock.Common.Services.Session; public interface ISessionService { - public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress); + public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, IpEnrichmentData? enrichment = null); public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId); diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index f506640f..a4edb221 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Redis; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Utils; using Redis.OM; using Redis.OM.Contracts; @@ -24,7 +25,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider) _loginSessions = redisConnectionProvider.RedisCollection(false); } - public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress) + public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, IpEnrichmentData? enrichment = null) { Guid id = Guid.CreateVersion7(); string token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); @@ -38,6 +39,10 @@ await _loginSessions.InsertAsync(new LoginSession PublicId = id, Created = DateTime.UtcNow, Expires = DateTime.UtcNow.Add(Duration.LoginSessionLifetime), + AsnOrg = enrichment?.AsnOrg, + IsVpn = enrichment?.IsVpn, + CountryCode = enrichment?.CountryCode, + City = enrichment?.City, }, Duration.LoginSessionLifetime); return new CreateSessionResult(id, token); diff --git a/Directory.Packages.props b/Directory.Packages.props index b0317f27..a9a0c35a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ +