Skip to content
Draft
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
10 changes: 9 additions & 1 deletion 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 @@ -26,6 +26,7 @@
var databaseOptions = builder.RegisterDatabaseOptions();
builder.RegisterMetricsOptions();
builder.RegisterFrontendOptions();
builder.RegisterGeoOptions();

builder.Services
.AddOpenShockMemDB(redisOptions)
Expand Down
1 change: 1 addition & 0 deletions Common/Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<!-- NuGet packages -->
<ItemGroup>
<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 @@ -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<GeoOptions>() ?? new GeoOptions();
builder.Services.AddSingleton(options);
return options;
}

public static FrontendOptions RegisterFrontendOptions(this WebApplicationBuilder builder)
{
var section = builder.Configuration.GetRequiredSection("OpenShock:Frontend");
Expand Down
9 changes: 7 additions & 2 deletions 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.Options;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.Geo;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Utils;

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

var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString());
var enrichmentService = HttpContext.RequestServices.GetRequiredService<IIpEnrichmentService>();

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
{
Expand Down
2 changes: 2 additions & 0 deletions Common/OpenShockServiceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se

services.AddScoped<IConfigurationService, ConfigurationService>();
services.AddScoped<ISessionService, SessionService>();
services.AddSingleton<IIpEnrichmentService, IpEnrichmentService>();
services.AddHttpClient<IWebhookService, WebhookService>(client =>
Comment on lines 201 to 204
{
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 = "Geo";

Comment on lines +3 to +6
/// <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 @@ -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; }
}
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
);
114 changes: 114 additions & 0 deletions Common/Services/Geo/IpEnrichmentService.cs
Original file line number Diff line number Diff line change
@@ -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<IpEnrichmentService> _logger;

public IpEnrichmentService(GeoOptions options, ILogger<IpEnrichmentService> 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;

Comment on lines +64 to +66
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();
}
}
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);
public Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, 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
@@ -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;
Expand All @@ -24,7 +25,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider)
_loginSessions = redisConnectionProvider.RedisCollection<LoginSession>(false);
}

public async Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress)
public async Task<CreateSessionResult> CreateSessionAsync(Guid userId, string userAgent, string ipAddress, IpEnrichmentData? enrichment = null)
{
Guid id = Guid.CreateVersion7();
string token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="AspNet.Security.OAuth.Discord" Version="10.0.0" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
<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.23" />
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
Expand Down
Loading