Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions API/Models/Response/LoginSessionResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand All @@ -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; }
}
1 change: 1 addition & 0 deletions API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions Common.Tests/Services/IpEnrichmentServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using OpenShock.Common.Services.Geo;

namespace OpenShock.Common.Tests.Services;

public class IpEnrichmentServiceTests
{
[Test]
[Arguments("NordVPN")]
[Arguments("Mullvad VPN AB")]
[Arguments("Private Internet Access, Inc.")]
[Arguments("privateinternetaccess.com")]
[Arguments("Hotspot Shield")]
[Arguments("PIA")]
public async Task MatchesVpnProvider_KnownVpnOrgs_ReturnsTrue(string asnOrg)
{
await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsTrue();
}

// The datacenter and hosting ASNs that used to live in the keyword list. They carry ordinary
// traffic and must not be reported as VPNs.
[Test]
[Arguments("Amazon.com, Inc.")]
[Arguments("Google LLC")]
[Arguments("Microsoft Corporation")]
[Arguments("Akamai Technologies, Inc.")]
[Arguments("OVH SAS")]
[Arguments("Hetzner Online GmbH")]
[Arguments("DigitalOcean, LLC")]
public async Task MatchesVpnProvider_HostingOrgs_ReturnsFalse(string asnOrg)
{
await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsFalse();
}

// "pia" is a real vendor abbreviation and also a substring of ordinary words. Whole-token
// matching is what keeps the second case from being reported as a VPN.
[Test]
[Arguments("Olympia Networks")]
[Arguments("Compia Telecom")]
[Arguments("Utopia Broadband")]
public async Task MatchesVpnProvider_SubstringLookalikes_ReturnsFalse(string asnOrg)
{
await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsFalse();
}

[Test]
public async Task MatchesVpnProvider_EmptyOrg_ReturnsFalse()
{
await Assert.That(IpEnrichmentService.MatchesVpnProvider(string.Empty)).IsFalse();
}
}
1 change: 1 addition & 0 deletions Common/Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" />
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="MaxMind.GeoIP2" />
<PackageReference Include="IDisposableAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
7 changes: 7 additions & 0 deletions Common/Extensions/ConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeoOptions>() ?? new GeoOptions();
builder.Services.AddSingleton(options);
return options;
}

public static AccountOptions RegisterAccountOptions(this WebApplicationBuilder builder)
{
var options = builder.Configuration.GetSection("OpenShock:Account").Get<AccountOptions>()
Expand Down
6 changes: 5 additions & 1 deletion Common/OpenShockControllerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -43,11 +44,14 @@ protected OkObjectResult LegacyEmptyOk(string message = "")
protected async Task CreateSession(Guid accountId, string domain)
{
var sessionService = HttpContext.RequestServices.GetRequiredService<ISessionService>();
var enrichmentService = HttpContext.RequestServices.GetRequiredService<IIpEnrichmentService>();

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
{
Expand Down
7 changes: 7 additions & 0 deletions Common/OpenShockServiceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -237,6 +238,12 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se
services.AddScoped<IConfigurationService, ConfigurationService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<IAuditService, AuditService>();

// 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<IIpEnrichmentService, IpEnrichmentService>();
services.AddHttpClient<IWebhookService, WebhookService>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
Expand Down
16 changes: 16 additions & 0 deletions Common/Options/GeoOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace OpenShock.Common.Options;

public sealed class GeoOptions
{
public const string SectionName = "OpenShock:Geo";

Comment thread
hhvrc marked this conversation as resolved.
/// <summary>
/// Path to the MaxMind GeoLite2-ASN.mmdb file.
/// </summary>
public string? AsnDbPath { get; init; }

/// <summary>
/// Path to the MaxMind GeoLite2-City.mmdb file.
/// </summary>
public string? CityDbPath { get; init; }
}
4 changes: 4 additions & 0 deletions Common/Redis/LoginSessions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
11 changes: 11 additions & 0 deletions Common/Services/Geo/IIpEnrichmentService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Net;

namespace OpenShock.Common.Services.Geo;

public interface IIpEnrichmentService
{
/// <summary>
/// Returns null when neither GeoLite2 database is configured or available.
/// </summary>
IpEnrichmentData? Enrich(IPAddress ip);
}
8 changes: 8 additions & 0 deletions Common/Services/Geo/IpEnrichmentData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace OpenShock.Common.Services.Geo;

public sealed record IpEnrichmentData(
string? AsnOrg,
bool? IsVpn,
string? CountryCode,
string? City
);
139 changes: 139 additions & 0 deletions Common/Services/Geo/IpEnrichmentService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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
{
// Consumer VPN providers only. Datacenter and hosting ASNs are deliberately NOT listed here:
// AWS, Google, Microsoft, Akamai, OVH and friends carry an enormous amount of ordinary traffic
// (corporate egress, mobile carrier NAT, CDN-fronted clients), so treating them as VPN evidence
// flags a large share of legitimate users. That signal is worth surfacing one day, but as its own
// "hosting provider" field rather than folded into a flag the UI presents as "VPN".
//
// Matched against whole tokens, not raw substrings, so "pia" cannot match "Olympia". Multi-word
// vendors are listed in both spaced and concatenated form because ASN org names use both.
private static readonly string[] VpnProviderKeywords =
[
"mullvad", "nordvpn", "expressvpn", "protonvpn", "ipvanish", "surfshark",
"privateinternetaccess", "private internet access", "pia",
"hidemyass", "hide my ass", "purevpn", "cyberghost",
"windscribe", "tunnelbear", "hotspot shield", "hotspotshield",
"vyprvpn", "airvpn", "perfect privacy", "perfectprivacy", "ivpn", "ovpn",
];

private readonly DatabaseReader? _asnReader;
private readonly DatabaseReader? _cityReader;
private readonly ILogger<IpEnrichmentService> _logger;

public IpEnrichmentService(GeoOptions options, ILogger<IpEnrichmentService> logger)
{
_logger = logger;

_asnReader = TryOpen(options.AsnDbPath, "ASN");
_cityReader = TryOpen(options.CityDbPath, "City");
}

/// <summary>
/// Whole-token match of an ASN organisation name against <see cref="VpnProviderKeywords"/>.
/// Punctuation becomes whitespace so "Amazon.com, Inc." tokenises the way a reader expects, and
/// the padded haystack lets a single Contains express a word-boundary match for phrases too.
/// </summary>
public static bool MatchesVpnProvider(string asnOrg)
{
var normalized = string.Create(asnOrg.Length, asnOrg, static (span, source) =>
{
for (var i = 0; i < source.Length; i++)
{
var c = char.ToLowerInvariant(source[i]);
span[i] = char.IsLetterOrDigit(c) ? c : ' ';
}
});

var haystack = $" {string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries))} ";

return Array.Exists(VpnProviderKeywords, k => haystack.Contains($" {k} ", StringComparison.Ordinal));
}

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;

Comment thread
hhvrc marked this conversation as resolved.
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)
{
isVpn = MatchesVpnProvider(asnOrg);
}
}
}
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();
}
}
3 changes: 2 additions & 1 deletion Common/Services/Session/ISessionService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using OpenShock.Common.Redis;
using OpenShock.Common.Services.Geo;

namespace OpenShock.Common.Services.Session;

public interface ISessionService
{
public Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId);
public Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null);

public IAsyncEnumerable<LoginSession> ListSessionsByUserIdAsync(Guid userId);

Expand Down
7 changes: 6 additions & 1 deletion Common/Services/Session/SessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,7 +33,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider, IAuditSe
_auditService = auditService;
}

public async Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId)
public async Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null)
{
Guid id = Guid.CreateVersion7();
string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength);
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageVersion Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.1" />
<PackageVersion Include="Bogus" Version="35.6.5" />
<PackageVersion Include="MaxMind.GeoIP2" Version="5.2.0" />
<PackageVersion Include="Fluid.Core" Version="2.31.0" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.24" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
Expand Down
Loading