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 c5bfa067..a04fde55 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -25,6 +25,7 @@ var databaseOptions = builder.RegisterDatabaseOptions(); builder.RegisterMetricsOptions(); builder.RegisterFrontendOptions(); +builder.RegisterGeoOptions(); builder.RegisterAccountOptions(); // The API never sends mail, but it must know whether anything ever will: with mail disabled there is // no activation link, so accounts are activated on creation instead of waiting for one. diff --git a/Common/Common.csproj b/Common/Common.csproj index c920230c..16efce5e 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -9,6 +9,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Common/Extensions/ConfigurationExtensions.cs b/Common/Extensions/ConfigurationExtensions.cs index 3522baf8..020b6169 100644 --- a/Common/Extensions/ConfigurationExtensions.cs +++ b/Common/Extensions/ConfigurationExtensions.cs @@ -82,6 +82,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 AccountOptions RegisterAccountOptions(this WebApplicationBuilder builder) { var options = builder.Configuration.GetSection("OpenShock:Account").Get() diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 166f46d0..19a06e2c 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -3,6 +3,7 @@ using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -43,11 +44,14 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); + var enrichmentService = HttpContext.RequestServices.GetRequiredService(); var remoteIp = HttpContext.GetRemoteIP(); var userAgent = HttpContext.GetUserAgent(); + var enrichment = enrichmentService.Enrich(remoteIp); + + var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString(), actorId: accountId, enrichment: enrichment); - var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString(), actorId: accountId); HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 43ad1dcd..5cce68de 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -20,6 +20,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; @@ -237,6 +238,12 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // Ensure GeoOptions is always resolvable so IpEnrichmentService can activate even in hosts + // (Cron, LiveControlGateway, SeedE2E) that don't call RegisterGeoOptions(). TryAdd leaves the + // API's config-bound instance untouched; other hosts get a disabled default (no DB paths). + services.TryAddSingleton(new GeoOptions()); + 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..95b7c642 --- /dev/null +++ b/Common/Options/GeoOptions.cs @@ -0,0 +1,16 @@ +namespace OpenShock.Common.Options; + +public sealed class GeoOptions +{ + public const string SectionName = "OpenShock: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 034e6e90..8ac1b6e1 100644 --- a/Common/Redis/LoginSessions.cs +++ b/Common/Redis/LoginSessions.cs @@ -20,4 +20,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..2af10145 --- /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..79e5a258 --- /dev/null +++ b/Common/Services/Geo/IpEnrichmentService.cs @@ -0,0 +1,115 @@ +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; + // Null means "unknown" (no ASN DB, lookup miss, or failure); only a resolved ASN org yields a verdict. + bool? isVpn = null; + + 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 474a42a4..2022fa2d 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, Guid? actorId); + public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null); public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId); diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index f5d7d3fb..500435ed 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -4,6 +4,7 @@ using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; using OpenShock.Common.Services.Audit; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Utils; using Redis.OM; using Redis.OM.Contracts; @@ -32,7 +33,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider, IAuditSe _auditService = auditService; } - public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId) + public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null) { Guid id = Guid.CreateVersion7(); string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); @@ -46,6 +47,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); await _auditService.LogAsync( diff --git a/Directory.Packages.props b/Directory.Packages.props index 161abf87..2f54eb8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ +