This commit is contained in:
Zurazan
2025-08-27 03:02:29 +02:00
commit 80235a174b
344 changed files with 43249 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
namespace LightlessSyncAuthService.Authentication;
public record SecretKeyAuthReply(bool Success, string? Uid, string? PrimaryUid, string? Alias, bool TempBan, bool Permaban, bool MarkedForBan);

View File

@@ -0,0 +1,12 @@
namespace LightlessSyncAuthService.Authentication;
internal record SecretKeyFailedAuthorization
{
private int failedAttempts = 1;
public int FailedAttempts => failedAttempts;
public Task ResetTask { get; set; }
public void IncreaseFailedAttempts()
{
Interlocked.Increment(ref failedAttempts);
}
}

View File

@@ -0,0 +1,160 @@
using LightlessSyncAuthService.Authentication;
using LightlessSyncAuthService.Services;
using LightlessSyncShared.Data;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace LightlessSyncAuthService.Controllers;
public abstract class AuthControllerBase : Controller
{
protected readonly ILogger Logger;
protected readonly IHttpContextAccessor HttpAccessor;
protected readonly IConfigurationService<AuthServiceConfiguration> Configuration;
protected readonly IDbContextFactory<LightlessDbContext> LightlessDbContextFactory;
protected readonly SecretKeyAuthenticatorService SecretKeyAuthenticatorService;
private readonly IDatabase _redis;
private readonly GeoIPService _geoIPProvider;
protected AuthControllerBase(ILogger logger,
IHttpContextAccessor accessor, IDbContextFactory<LightlessDbContext> lightlessDbContextFactory,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IDatabase redisDb, GeoIPService geoIPProvider)
{
Logger = logger;
HttpAccessor = accessor;
_redis = redisDb;
_geoIPProvider = geoIPProvider;
LightlessDbContextFactory = lightlessDbContextFactory;
SecretKeyAuthenticatorService = secretKeyAuthenticatorService;
Configuration = configuration;
}
protected async Task<IActionResult> GenericAuthResponse(LightlessDbContext dbContext, string charaIdent, SecretKeyAuthReply authResult)
{
if (await IsIdentBanned(dbContext, charaIdent))
{
Logger.LogWarning("Authenticate:IDENTBAN:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Your character is banned from using the service.");
}
if (!authResult.Success && !authResult.TempBan)
{
Logger.LogWarning("Authenticate:INVALID:{id}:{ident}", authResult?.Uid ?? "NOUID", charaIdent);
return Unauthorized("The provided secret key is invalid. Verify your Lightless accounts existence and/or recover the secret key.");
}
if (!authResult.Success && authResult.TempBan)
{
Logger.LogWarning("Authenticate:TEMPBAN:{id}:{ident}", authResult.Uid ?? "NOUID", charaIdent);
return Unauthorized("Due to an excessive amount of failed authentication attempts you are temporarily locked out. Check your Secret Key configuration and try connecting again in 5 minutes.");
}
if (authResult.Permaban || authResult.MarkedForBan)
{
if (authResult.MarkedForBan)
{
Logger.LogWarning("Authenticate:MARKBAN:{id}:{primaryid}:{ident}", authResult.Uid, authResult.PrimaryUid, charaIdent);
await EnsureBan(authResult.Uid!, authResult.PrimaryUid, charaIdent);
}
Logger.LogWarning("Authenticate:UIDBAN:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Your Lightless account is banned from using the service.");
}
var existingIdent = await _redis.StringGetAsync("UID:" + authResult.Uid);
if (!string.IsNullOrEmpty(existingIdent))
{
Logger.LogWarning("Authenticate:DUPLICATE:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Already logged in to this Lightless account. Reconnect in 60 seconds. If you keep seeing this issue, restart your game.");
}
Logger.LogInformation("Authenticate:SUCCESS:{id}:{ident}", authResult.Uid, charaIdent);
return await CreateJwtFromId(authResult.Uid!, charaIdent, authResult.Alias ?? string.Empty);
}
protected JwtSecurityToken CreateJwt(IEnumerable<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration.GetValue<string>(nameof(LightlessConfigurationBase.Jwt))));
var token = new SecurityTokenDescriptor()
{
Subject = new ClaimsIdentity(authClaims),
SigningCredentials = new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256Signature),
Expires = new(long.Parse(authClaims.First(f => string.Equals(f.Type, LightlessClaimTypes.Expires, StringComparison.Ordinal)).Value!, CultureInfo.InvariantCulture), DateTimeKind.Utc),
};
var handler = new JwtSecurityTokenHandler();
return handler.CreateJwtSecurityToken(token);
}
protected async Task<IActionResult> CreateJwtFromId(string uid, string charaIdent, string alias)
{
var token = CreateJwt(new List<Claim>()
{
new Claim(LightlessClaimTypes.Uid, uid),
new Claim(LightlessClaimTypes.CharaIdent, charaIdent),
new Claim(LightlessClaimTypes.Alias, alias),
new Claim(LightlessClaimTypes.Expires, DateTime.UtcNow.AddHours(6).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(LightlessClaimTypes.Continent, await _geoIPProvider.GetCountryFromIP(HttpAccessor))
});
return Content(token.RawData);
}
protected async Task EnsureBan(string uid, string? primaryUid, string charaIdent)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
if (!dbContext.BannedUsers.Any(c => c.CharacterIdentification == charaIdent))
{
dbContext.BannedUsers.Add(new Banned()
{
CharacterIdentification = charaIdent,
Reason = "Autobanned CharacterIdent (" + uid + ")",
});
}
var uidToLookFor = primaryUid ?? uid;
var primaryUserAuth = await dbContext.Auth.FirstAsync(f => f.UserUID == uidToLookFor);
primaryUserAuth.MarkForBan = false;
primaryUserAuth.IsBanned = true;
var lodestone = await dbContext.LodeStoneAuth.Include(a => a.User).FirstOrDefaultAsync(c => c.User.UID == uidToLookFor);
if (lodestone != null)
{
if (!dbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.HashedLodestoneId))
{
dbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.HashedLodestoneId,
});
}
if (!dbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.DiscordId.ToString()))
{
dbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.DiscordId.ToString(),
});
}
}
await dbContext.SaveChangesAsync();
}
protected async Task<bool> IsIdentBanned(LightlessDbContext dbContext, string charaIdent)
{
return await dbContext.BannedUsers.AsNoTracking().AnyAsync(u => u.CharacterIdentification == charaIdent).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,89 @@
using LightlessSync.API.Routes;
using LightlessSyncAuthService.Services;
using LightlessSyncShared;
using LightlessSyncShared.Data;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
namespace LightlessSyncAuthService.Controllers;
[Route(LightlessAuth.Auth)]
public class JwtController : AuthControllerBase
{
public JwtController(ILogger<JwtController> logger,
IHttpContextAccessor accessor, IDbContextFactory<LightlessDbContext> lightlessDbContextFactory,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IDatabase redisDb, GeoIPService geoIPProvider)
: base(logger, accessor, lightlessDbContextFactory, secretKeyAuthenticatorService,
configuration, redisDb, geoIPProvider)
{
}
[AllowAnonymous]
[HttpPost(LightlessAuth.Auth_CreateIdent)]
public async Task<IActionResult> CreateToken(string auth, string charaIdent)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
return await AuthenticateInternal(dbContext, auth, charaIdent).ConfigureAwait(false);
}
[Authorize(Policy = "Authenticated")]
[HttpGet(LightlessAuth.Auth_RenewToken)]
public async Task<IActionResult> RenewToken()
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
try
{
var uid = HttpContext.User.Claims.Single(p => string.Equals(p.Type, LightlessClaimTypes.Uid, StringComparison.Ordinal))!.Value;
var ident = HttpContext.User.Claims.Single(p => string.Equals(p.Type, LightlessClaimTypes.CharaIdent, StringComparison.Ordinal))!.Value;
var alias = HttpContext.User.Claims.SingleOrDefault(p => string.Equals(p.Type, LightlessClaimTypes.Alias))?.Value ?? string.Empty;
if (await dbContext.Auth.Where(u => u.UserUID == uid || u.PrimaryUserUID == uid).AnyAsync(a => a.MarkForBan))
{
var userAuth = await dbContext.Auth.SingleAsync(u => u.UserUID == uid);
await EnsureBan(uid, userAuth.PrimaryUserUID, ident);
return Unauthorized("Your Lightless account is banned.");
}
if (await IsIdentBanned(dbContext, ident))
{
return Unauthorized("Your XIV service account is banned from using the service.");
}
Logger.LogInformation("RenewToken:SUCCESS:{id}:{ident}", uid, ident);
return await CreateJwtFromId(uid, ident, alias);
}
catch (Exception ex)
{
Logger.LogError(ex, "RenewToken:FAILURE");
return Unauthorized("Unknown error while renewing authentication token");
}
}
protected async Task<IActionResult> AuthenticateInternal(LightlessDbContext dbContext, string auth, string charaIdent)
{
try
{
if (string.IsNullOrEmpty(auth)) return BadRequest("No Authkey");
if (string.IsNullOrEmpty(charaIdent)) return BadRequest("No CharaIdent");
var ip = HttpAccessor.GetIpAddress();
var authResult = await SecretKeyAuthenticatorService.AuthorizeAsync(ip, auth);
return await GenericAuthResponse(dbContext, charaIdent, authResult);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Authenticate:UNKNOWN");
return Unauthorized("Unknown internal server error during authentication");
}
}
}

View File

@@ -0,0 +1,307 @@
using LightlessSync.API.Routes;
using LightlessSyncAuthService.Services;
using LightlessSyncShared;
using LightlessSyncShared.Data;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using System.Web;
namespace LightlessSyncAuthService.Controllers;
[Route(LightlessAuth.OAuth)]
public class OAuthController : AuthControllerBase
{
private const string _discordOAuthCall = "discordCall";
private const string _discordOAuthCallback = "discordCallback";
private static readonly ConcurrentDictionary<string, string> _cookieOAuthResponse = [];
public OAuthController(ILogger<OAuthController> logger,
IHttpContextAccessor accessor, IDbContextFactory<LightlessDbContext> lightlessDbContext,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IDatabase redisDb, GeoIPService geoIPProvider)
: base(logger, accessor, lightlessDbContext, secretKeyAuthenticatorService,
configuration, redisDb, geoIPProvider)
{
}
[AllowAnonymous]
[HttpGet(_discordOAuthCall)]
public IActionResult DiscordOAuthSetCookieAndRedirect([FromQuery] string sessionId)
{
var discordOAuthUri = Configuration.GetValueOrDefault<Uri?>(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null);
var discordClientSecret = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null);
var discordClientId = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientId), null);
if (discordClientSecret == null || discordClientId == null || discordOAuthUri == null)
return BadRequest("Server does not support OAuth2");
Logger.LogDebug("Starting OAuth Process for {session}", sessionId);
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true,
Expires = DateTime.UtcNow.AddMinutes(30)
};
Response.Cookies.Append("DiscordOAuthSessionCookie", sessionId, cookieOptions);
var parameters = new Dictionary<string, string>
{
{ "client_id", discordClientId },
{ "response_type", "code" },
{ "redirect_uri", new Uri(discordOAuthUri, _discordOAuthCallback).ToString() },
{ "scope", "identify"},
};
using var content = new FormUrlEncodedContent(parameters);
UriBuilder builder = new UriBuilder("https://discord.com/oauth2/authorize");
var query = HttpUtility.ParseQueryString(builder.Query);
foreach (var param in parameters)
{
query[param.Key] = param.Value;
}
builder.Query = query.ToString();
return Redirect(builder.ToString());
}
[AllowAnonymous]
[HttpGet(_discordOAuthCallback)]
public async Task<IActionResult> DiscordOAuthCallback([FromQuery] string code)
{
var reqId = Request.Cookies["DiscordOAuthSessionCookie"];
var discordOAuthUri = Configuration.GetValueOrDefault<Uri?>(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null);
var discordClientSecret = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null);
var discordClientId = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientId), null);
if (discordClientSecret == null || discordClientId == null || discordOAuthUri == null)
return BadRequest("Server does not support OAuth2");
if (string.IsNullOrEmpty(reqId)) return BadRequest("No session cookie found");
if (string.IsNullOrEmpty(code)) return BadRequest("No Discord OAuth2 code found");
Logger.LogDebug("Discord OAuth Callback for {session}", reqId);
var query = HttpUtility.ParseQueryString(discordOAuthUri.Query);
using var client = new HttpClient();
var parameters = new Dictionary<string, string>
{
{ "client_id", discordClientId },
{ "client_secret", discordClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", new Uri(discordOAuthUri, _discordOAuthCallback).ToString() }
};
using var content = new FormUrlEncodedContent(parameters);
using var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
using var responseBody = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
Logger.LogDebug("Failed to get Discord token for {session}", reqId);
return BadRequest("Failed to get Discord token");
}
using var tokenJson = await JsonDocument.ParseAsync(responseBody).ConfigureAwait(false);
var token = tokenJson.RootElement.GetProperty("access_token").GetString();
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
using var meResponse = await httpClient.GetAsync("https://discord.com/api/users/@me");
using var meBody = await meResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
if (!meResponse.IsSuccessStatusCode)
{
Logger.LogDebug("Failed to get Discord user info for {session}", reqId);
return BadRequest("Failed to get Discord user info");
}
ulong discordUserId = 0;
string discordUserName = string.Empty;
try
{
using var jsonResponse = await JsonDocument.ParseAsync(meBody).ConfigureAwait(false);
discordUserId = ulong.Parse(jsonResponse.RootElement.GetProperty("id").GetString()!);
discordUserName = jsonResponse.RootElement.GetProperty("username").GetString()!;
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to parse Discord user info for {session}", reqId);
return BadRequest("Failed to parse user id from @me response for token");
}
if (discordUserId == 0)
return BadRequest("Failed to get Discord ID from login token");
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
var lightlessUser = await dbContext.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == discordUserId);
if (lightlessUser == default)
{
Logger.LogDebug("Failed to get Lightless user for {session}, DiscordId: {id}", reqId, discordUserId);
return BadRequest("Could not find a Lightless user associated to this Discord account.");
}
JwtSecurityToken? jwt = null;
try
{
jwt = CreateJwt([
new Claim(LightlessClaimTypes.Uid, lightlessUser.User!.UID),
new Claim(LightlessClaimTypes.Expires, DateTime.UtcNow.AddDays(14).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(LightlessClaimTypes.DiscordId, discordUserId.ToString()),
new Claim(LightlessClaimTypes.DiscordUser, discordUserName),
new Claim(LightlessClaimTypes.OAuthLoginToken, true.ToString())
]);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to create the OAuth2 token for session {session} and Discord user {user}", reqId, discordUserId);
return BadRequest("Failed to create the OAuth2 token. Please contact the developer for more information.");
}
_cookieOAuthResponse[reqId] = jwt.RawData;
_ = Task.Run(async () =>
{
bool isRemoved = false;
for (int i = 0; i < 30; i++)
{
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
if (!_cookieOAuthResponse.ContainsKey(reqId))
{
isRemoved = true;
break;
}
}
if (!isRemoved)
_cookieOAuthResponse.TryRemove(reqId, out _);
});
Logger.LogDebug("Setting JWT response for {session}, process complete", reqId);
return Ok("The OAuth2 token was generated. The plugin will grab it automatically. You can close this browser tab.");
}
[Authorize(Policy = "OAuthToken")]
[HttpPost(LightlessAuth.OAuth_GetUIDsBasedOnSecretKeys)]
public async Task<Dictionary<string, string>> GetUIDsBasedOnSecretKeys([FromBody] List<string> secretKeys)
{
if (!secretKeys.Any())
return [];
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
Dictionary<string, string> secretKeysToUIDDict = secretKeys.Distinct().ToDictionary(k => k, _ => string.Empty, StringComparer.Ordinal);
foreach (var key in secretKeys)
{
var shaKey = StringUtils.Sha256String(key);
var associatedAuth = await dbContext.Auth.AsNoTracking().SingleOrDefaultAsync(a => a.HashedKey == shaKey);
if (associatedAuth != null)
{
secretKeysToUIDDict[key] = associatedAuth.UserUID;
}
}
return secretKeysToUIDDict;
}
[Authorize(Policy = "OAuthToken")]
[HttpPost(LightlessAuth.OAuth_RenewOAuthToken)]
public IActionResult RenewOAuthToken()
{
var claims = HttpContext.User.Claims.Where(c => c.Type != LightlessClaimTypes.Expires).ToList();
claims.Add(new Claim(LightlessClaimTypes.Expires, DateTime.UtcNow.AddDays(14).Ticks.ToString(CultureInfo.InvariantCulture)));
return Content(CreateJwt(claims).RawData);
}
[AllowAnonymous]
[HttpGet(LightlessAuth.OAuth_GetDiscordOAuthToken)]
public async Task<IActionResult> GetDiscordOAuthToken([FromQuery] string sessionId)
{
Logger.LogDebug("Starting to wait for GetDiscordOAuthToken for {session}", sessionId);
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromSeconds(55));
while (!_cookieOAuthResponse.ContainsKey(sessionId) && !cts.Token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
}
if (cts.IsCancellationRequested)
{
Logger.LogDebug("Timeout elapsed for {session}", sessionId);
return BadRequest("Did not find Discord OAuth2 response");
}
_cookieOAuthResponse.TryRemove(sessionId, out var token);
if (token == null)
{
Logger.LogDebug("No token found for {session}", sessionId);
return BadRequest("OAuth session was never established");
}
Logger.LogDebug("Returning JWT for {session}, process complete", sessionId);
return Content(token);
}
[AllowAnonymous]
[HttpGet(LightlessAuth.OAuth_GetDiscordOAuthEndpoint)]
public Uri? GetDiscordOAuthEndpoint()
{
var discordOAuthUri = Configuration.GetValueOrDefault<Uri?>(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null);
var discordClientSecret = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null);
var discordClientId = Configuration.GetValueOrDefault<string?>(nameof(AuthServiceConfiguration.DiscordOAuthClientId), null);
if (discordClientSecret == null || discordClientId == null || discordOAuthUri == null)
return null;
return new Uri(discordOAuthUri, _discordOAuthCall);
}
[Authorize(Policy = "OAuthToken")]
[HttpGet(LightlessAuth.OAuth_GetUIDs)]
public async Task<Dictionary<string, string>> GetAvailableUIDs()
{
string primaryUid = HttpContext.User.Claims.Single(c => string.Equals(c.Type, LightlessClaimTypes.Uid, StringComparison.Ordinal))!.Value;
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
var lightlessUser = await dbContext.Auth.AsNoTracking().Include(u => u.User).FirstOrDefaultAsync(f => f.UserUID == primaryUid).ConfigureAwait(false);
if (lightlessUser == null || lightlessUser.User == null) return [];
var uid = lightlessUser.User.UID;
var allUids = await dbContext.Auth.AsNoTracking().Include(u => u.User).Where(a => a.UserUID == uid || a.PrimaryUserUID == uid).ToListAsync().ConfigureAwait(false);
var result = allUids.OrderBy(u => u.UserUID == uid ? 0 : 1).ThenBy(u => u.UserUID).Select(u => (u.UserUID, u.User.Alias)).ToDictionary();
return result;
}
[Authorize(Policy = "OAuthToken")]
[HttpPost(LightlessAuth.OAuth_CreateOAuth)]
public async Task<IActionResult> CreateTokenWithOAuth(string uid, string charaIdent)
{
using var dbContext = await LightlessDbContextFactory.CreateDbContextAsync();
return await AuthenticateOAuthInternal(dbContext, uid, charaIdent);
}
private async Task<IActionResult> AuthenticateOAuthInternal(LightlessDbContext dbContext, string requestedUid, string charaIdent)
{
try
{
string primaryUid = HttpContext.User.Claims.Single(c => string.Equals(c.Type, LightlessClaimTypes.Uid, StringComparison.Ordinal))!.Value;
if (string.IsNullOrEmpty(requestedUid)) return BadRequest("No UID");
if (string.IsNullOrEmpty(charaIdent)) return BadRequest("No CharaIdent");
var ip = HttpAccessor.GetIpAddress();
var authResult = await SecretKeyAuthenticatorService.AuthorizeOauthAsync(ip, primaryUid, requestedUid);
return await GenericAuthResponse(dbContext, charaIdent, authResult);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Authenticate:UNKNOWN");
return Unauthorized("Unknown internal server error during authentication");
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LightlessSyncShared\LightlessSyncShared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,55 @@
namespace LightlessSyncAuthService;
public class Program
{
public static void Main(string[] args)
{
var hostBuilder = CreateHostBuilder(args);
using var host = hostBuilder.Build();
try
{
host.Run();
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
public static IHostBuilder CreateHostBuilder(string[] args)
{
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.ClearProviders();
builder.AddConsole();
});
var logger = loggerFactory.CreateLogger<Startup>();
return Host.CreateDefaultBuilder(args)
.UseSystemd()
.UseConsoleLifetime()
.ConfigureAppConfiguration((ctx, config) =>
{
var appSettingsPath = Environment.GetEnvironmentVariable("APPSETTINGS_PATH");
if (!string.IsNullOrEmpty(appSettingsPath))
{
config.AddJsonFile(appSettingsPath, optional: true, reloadOnChange: true);
}
else
{
config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
}
config.AddEnvironmentVariables();
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseContentRoot(AppContext.BaseDirectory);
webBuilder.ConfigureLogging((ctx, builder) =>
{
builder.AddConfiguration(ctx.Configuration.GetSection("Logging"));
builder.AddFile(o => o.RootPath = AppContext.BaseDirectory);
});
webBuilder.UseStartup(ctx => new Startup(ctx.Configuration, logger));
});
}
}

View File

@@ -0,0 +1,29 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:37726",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5056",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,143 @@
using LightlessSyncShared;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using MaxMind.GeoIP2;
namespace LightlessSyncAuthService.Services;
public class GeoIPService : IHostedService
{
private readonly ILogger<GeoIPService> _logger;
private readonly IConfigurationService<AuthServiceConfiguration> _lightlessConfiguration;
private bool _useGeoIP = false;
private string _cityFile = string.Empty;
private DatabaseReader? _dbReader;
private DateTime _dbLastWriteTime = DateTime.MinValue;
private CancellationTokenSource _fileWriteTimeCheckCts = new();
private bool _processingReload = false;
public GeoIPService(ILogger<GeoIPService> logger,
IConfigurationService<AuthServiceConfiguration> lightlessConfiguration)
{
_logger = logger;
_lightlessConfiguration = lightlessConfiguration;
}
public async Task<string> GetCountryFromIP(IHttpContextAccessor httpContextAccessor)
{
if (!_useGeoIP)
{
return "*";
}
try
{
var ip = httpContextAccessor.GetIpAddress();
using CancellationTokenSource waitCts = new();
waitCts.CancelAfter(TimeSpan.FromSeconds(5));
while (_processingReload) await Task.Delay(100, waitCts.Token).ConfigureAwait(false);
if (_dbReader!.TryCity(ip, out var response))
{
string? continent = response?.Continent.Code;
if (!string.IsNullOrEmpty(continent) &&
string.Equals(continent, "NA", StringComparison.Ordinal)
&& response?.Location.Longitude != null)
{
if (response.Location.Longitude < -102)
{
continent = "NA-W";
}
else
{
continent = "NA-E";
}
}
return continent ?? "*";
}
return "*";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error handling Geo IP country in request");
return "*";
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("GeoIP module starting update task");
var token = _fileWriteTimeCheckCts.Token;
_ = PeriodicReloadTask(token);
return Task.CompletedTask;
}
private async Task PeriodicReloadTask(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
_processingReload = true;
var useGeoIP = _lightlessConfiguration.GetValueOrDefault(nameof(AuthServiceConfiguration.UseGeoIP), false);
var cityFile = _lightlessConfiguration.GetValueOrDefault(nameof(AuthServiceConfiguration.GeoIPDbCityFile), string.Empty);
DateTime lastWriteTime = DateTime.MinValue;
if (File.Exists(cityFile))
{
lastWriteTime = new FileInfo(cityFile).LastWriteTimeUtc;
}
if (useGeoIP && (!string.Equals(cityFile, _cityFile, StringComparison.OrdinalIgnoreCase) || lastWriteTime > _dbLastWriteTime))
{
_cityFile = cityFile;
if (!File.Exists(_cityFile)) throw new FileNotFoundException($"Could not open GeoIP City Database, path does not exist: {_cityFile}");
_dbReader?.Dispose();
_dbReader = null;
_dbReader = new DatabaseReader(_cityFile);
_dbLastWriteTime = lastWriteTime;
_ = _dbReader.City("8.8.8.8").Continent;
_logger.LogInformation($"Loaded GeoIP city file from {_cityFile}");
if (_useGeoIP != useGeoIP)
{
_logger.LogInformation("GeoIP module is now enabled");
_useGeoIP = useGeoIP;
}
}
if (_useGeoIP != useGeoIP && !useGeoIP)
{
_logger.LogInformation("GeoIP module is now disabled");
_useGeoIP = useGeoIP;
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Error during periodic GeoIP module reload task, disabling GeoIP");
_useGeoIP = false;
}
finally
{
_processingReload = false;
}
await Task.Delay(TimeSpan.FromMinutes(1)).ConfigureAwait(false);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_fileWriteTimeCheckCts.Cancel();
_fileWriteTimeCheckCts.Dispose();
_dbReader?.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Concurrent;
using LightlessSyncAuthService.Authentication;
using LightlessSyncShared.Data;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Models;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
namespace LightlessSyncAuthService.Services;
public class SecretKeyAuthenticatorService
{
private readonly LightlessMetrics _metrics;
private readonly IDbContextFactory<LightlessDbContext> _dbContextFactory;
private readonly IConfigurationService<AuthServiceConfiguration> _configurationService;
private readonly ILogger<SecretKeyAuthenticatorService> _logger;
private readonly ConcurrentDictionary<string, SecretKeyFailedAuthorization> _failedAuthorizations = new(StringComparer.Ordinal);
public SecretKeyAuthenticatorService(LightlessMetrics metrics, IDbContextFactory<LightlessDbContext> dbContextFactory,
IConfigurationService<AuthServiceConfiguration> configuration, ILogger<SecretKeyAuthenticatorService> logger)
{
_logger = logger;
_configurationService = configuration;
_metrics = metrics;
_dbContextFactory = dbContextFactory;
}
public async Task<SecretKeyAuthReply> AuthorizeOauthAsync(string ip, string primaryUid, string requestedUid)
{
_metrics.IncCounter(MetricsAPI.CounterAuthenticationRequests);
var checkOnIp = FailOnIp(ip);
if (checkOnIp != null) return checkOnIp;
using var context = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var authUser = await context.Auth.SingleOrDefaultAsync(u => u.UserUID == primaryUid).ConfigureAwait(false);
if (authUser == null) return AuthenticationFailure(ip);
var authReply = await context.Auth.Include(a => a.User).AsNoTracking()
.SingleOrDefaultAsync(u => u.UserUID == requestedUid).ConfigureAwait(false);
return await GetAuthReply(ip, context, authReply);
}
public async Task<SecretKeyAuthReply> AuthorizeAsync(string ip, string hashedSecretKey)
{
_metrics.IncCounter(MetricsAPI.CounterAuthenticationRequests);
var checkOnIp = FailOnIp(ip);
if (checkOnIp != null) return checkOnIp;
using var context = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
var authReply = await context.Auth.Include(a => a.User).AsNoTracking()
.SingleOrDefaultAsync(u => u.HashedKey == hashedSecretKey).ConfigureAwait(false);
return await GetAuthReply(ip, context, authReply).ConfigureAwait(false);
}
private async Task<SecretKeyAuthReply> GetAuthReply(string ip, LightlessDbContext context, Auth? authReply)
{
var isBanned = authReply?.IsBanned ?? false;
var markedForBan = authReply?.MarkForBan ?? false;
var primaryUid = authReply?.PrimaryUserUID ?? authReply?.UserUID;
if (authReply?.PrimaryUserUID != null)
{
var primaryUser = await context.Auth.AsNoTracking().SingleAsync(u => u.UserUID == authReply.PrimaryUserUID).ConfigureAwait(false);
isBanned = isBanned || primaryUser.IsBanned;
markedForBan = markedForBan || primaryUser.MarkForBan;
}
SecretKeyAuthReply reply = new(authReply != null, authReply?.UserUID,
authReply?.PrimaryUserUID ?? authReply?.UserUID, authReply?.User?.Alias ?? string.Empty,
TempBan: false, isBanned, markedForBan);
if (reply.Success)
{
_metrics.IncCounter(MetricsAPI.CounterAuthenticationSuccesses);
_metrics.IncGauge(MetricsAPI.GaugeAuthenticationCacheEntries);
return reply;
}
else
{
return AuthenticationFailure(ip);
}
}
private SecretKeyAuthReply? FailOnIp(string ip)
{
if (_failedAuthorizations.TryGetValue(ip, out var existingFailedAuthorization)
&& existingFailedAuthorization.FailedAttempts > _configurationService.GetValueOrDefault(nameof(AuthServiceConfiguration.FailedAuthForTempBan), 5))
{
if (existingFailedAuthorization.ResetTask == null)
{
_logger.LogWarning("TempBan {ip} for authorization spam", ip);
existingFailedAuthorization.ResetTask = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMinutes(_configurationService.GetValueOrDefault(nameof(AuthServiceConfiguration.TempBanDurationInMinutes), 5))).ConfigureAwait(false);
}).ContinueWith((t) =>
{
_failedAuthorizations.Remove(ip, out _);
});
}
return new(Success: false, Uid: null, PrimaryUid: null, Alias: null, TempBan: true, Permaban: false, MarkedForBan: false);
}
return null;
}
private SecretKeyAuthReply AuthenticationFailure(string ip)
{
_metrics.IncCounter(MetricsAPI.CounterAuthenticationFailures);
_logger.LogWarning("Failed authorization from {ip}", ip);
var whitelisted = _configurationService.GetValueOrDefault(nameof(AuthServiceConfiguration.WhitelistedIps), new List<string>());
if (!whitelisted.Exists(w => ip.Contains(w, StringComparison.OrdinalIgnoreCase)))
{
if (_failedAuthorizations.TryGetValue(ip, out var auth))
{
auth.IncreaseFailedAttempts();
}
else
{
_failedAuthorizations[ip] = new SecretKeyFailedAuthorization();
}
}
return new(Success: false, Uid: null, PrimaryUid: null, Alias: null, TempBan: false, Permaban: false, MarkedForBan: false);
}
}

View File

@@ -0,0 +1,244 @@
using LightlessSyncAuthService.Controllers;
using LightlessSyncShared.Metrics;
using LightlessSyncShared.Services;
using LightlessSyncShared.Utils;
using Microsoft.AspNetCore.Mvc.Controllers;
using StackExchange.Redis.Extensions.Core.Configuration;
using StackExchange.Redis.Extensions.System.Text.Json;
using StackExchange.Redis;
using System.Net;
using LightlessSyncAuthService.Services;
using LightlessSyncShared.RequirementHandlers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using LightlessSyncShared.Data;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using LightlessSyncShared.Utils.Configuration;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace LightlessSyncAuthService;
public class Startup
{
private readonly IConfiguration _configuration;
private ILogger<Startup> _logger;
public Startup(IConfiguration configuration, ILogger<Startup> logger)
{
_configuration = configuration;
_logger = logger;
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger<Startup> logger)
{
var config = app.ApplicationServices.GetRequiredService<IConfigurationService<LightlessConfigurationBase>>();
app.UseRouting();
app.UseHttpMetrics();
app.UseAuthentication();
app.UseAuthorization();
KestrelMetricServer metricServer = new KestrelMetricServer(config.GetValueOrDefault<int>(nameof(LightlessConfigurationBase.MetricsPort), 4985));
metricServer.Start();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
foreach (var source in endpoints.DataSources.SelectMany(e => e.Endpoints).Cast<RouteEndpoint>())
{
if (source == null) continue;
_logger.LogInformation("Endpoint: {url} ", source.RoutePattern.RawText);
}
});
}
public void ConfigureServices(IServiceCollection services)
{
var lightlessConfig = _configuration.GetRequiredSection("LightlessSync");
services.AddHttpContextAccessor();
ConfigureRedis(services, lightlessConfig);
services.AddSingleton<SecretKeyAuthenticatorService>();
services.AddSingleton<GeoIPService>();
services.AddHostedService(provider => provider.GetRequiredService<GeoIPService>());
services.Configure<AuthServiceConfiguration>(_configuration.GetRequiredSection("LightlessSync"));
services.Configure<LightlessConfigurationBase>(_configuration.GetRequiredSection("LightlessSync"));
services.AddSingleton<ServerTokenGenerator>();
ConfigureAuthorization(services);
ConfigureDatabase(services, lightlessConfig);
ConfigureConfigServices(services);
ConfigureMetrics(services);
services.AddHealthChecks();
services.AddControllers().ConfigureApplicationPartManager(a =>
{
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController), typeof(OAuthController)));
});
}
private static void ConfigureAuthorization(IServiceCollection services)
{
services.AddTransient<IAuthorizationHandler, RedisDbUserRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ValidTokenRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ExistingUserRequirementHandler>();
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IConfigurationService<LightlessConfigurationBase>>((options, config) =>
{
options.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateLifetime = true,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.GetValue<string>(nameof(LightlessConfigurationBase.Jwt)))),
};
});
services.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer();
services.AddAuthorization(options =>
{
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser().Build();
options.AddPolicy("OAuthToken", policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
policy.AddRequirements(new ValidTokenRequirement());
policy.AddRequirements(new ExistingUserRequirement());
policy.RequireClaim(LightlessClaimTypes.OAuthLoginToken, "True");
});
options.AddPolicy("Authenticated", policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Identified", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Admin", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified | UserRequirements.Administrator));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Moderator", policy =>
{
policy.AddRequirements(new UserRequirement(UserRequirements.Identified | UserRequirements.Moderator | UserRequirements.Administrator));
policy.AddRequirements(new ValidTokenRequirement());
});
options.AddPolicy("Internal", new AuthorizationPolicyBuilder().RequireClaim(LightlessClaimTypes.Internal, "true").Build());
});
}
private static void ConfigureMetrics(IServiceCollection services)
{
services.AddSingleton<LightlessMetrics>(m => new LightlessMetrics(m.GetService<ILogger<LightlessMetrics>>(), new List<string>
{
MetricsAPI.CounterAuthenticationCacheHits,
MetricsAPI.CounterAuthenticationFailures,
MetricsAPI.CounterAuthenticationRequests,
MetricsAPI.CounterAuthenticationSuccesses,
}, new List<string>
{
MetricsAPI.GaugeAuthenticationCacheEntries,
}));
}
private void ConfigureRedis(IServiceCollection services, IConfigurationSection lightlessConfig)
{
// configure redis for SignalR
var redisConnection = lightlessConfig.GetValue(nameof(ServerConfiguration.RedisConnectionString), string.Empty);
var options = ConfigurationOptions.Parse(redisConnection);
var endpoint = options.EndPoints[0];
string address = "";
int port = 0;
if (endpoint is DnsEndPoint dnsEndPoint) { address = dnsEndPoint.Host; port = dnsEndPoint.Port; }
if (endpoint is IPEndPoint ipEndPoint) { address = ipEndPoint.Address.ToString(); port = ipEndPoint.Port; }
/*
var redisConfiguration = new RedisConfiguration()
{
AbortOnConnectFail = true,
KeyPrefix = "",
Hosts = new RedisHost[]
{
new RedisHost(){ Host = address, Port = port },
},
AllowAdmin = true,
ConnectTimeout = options.ConnectTimeout,
Database = 0,
Ssl = false,
Password = options.Password,
ServerEnumerationStrategy = new ServerEnumerationStrategy()
{
Mode = ServerEnumerationStrategy.ModeOptions.All,
TargetRole = ServerEnumerationStrategy.TargetRoleOptions.Any,
UnreachableServerAction = ServerEnumerationStrategy.UnreachableServerActionOptions.Throw,
},
MaxValueLength = 1024,
PoolSize = lightlessConfig.GetValue(nameof(ServerConfiguration.RedisPool), 50),
SyncTimeout = options.SyncTimeout,
};*/
var muxer = ConnectionMultiplexer.Connect(options);
var db = muxer.GetDatabase();
services.AddSingleton<IDatabase>(db);
_logger.LogInformation("Setting up Redis to connect to {host}:{port}", address, port);
}
private void ConfigureConfigServices(IServiceCollection services)
{
services.AddSingleton<IConfigurationService<AuthServiceConfiguration>, LightlessConfigurationServiceServer<AuthServiceConfiguration>>();
services.AddSingleton<IConfigurationService<LightlessConfigurationBase>, LightlessConfigurationServiceServer<LightlessConfigurationBase>>();
}
private void ConfigureDatabase(IServiceCollection services, IConfigurationSection lightlessConfig)
{
services.AddDbContextPool<LightlessDbContext>(options =>
{
options.UseNpgsql(_configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
builder.MigrationsAssembly("LightlessSyncShared");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
}, lightlessConfig.GetValue(nameof(LightlessConfigurationBase.DbContextPoolSize), 1024));
services.AddDbContextFactory<LightlessDbContext>(options =>
{
options.UseNpgsql(_configuration.GetConnectionString("DefaultConnection"), builder =>
{
builder.MigrationsHistoryTable("_efmigrationshistory", "public");
builder.MigrationsAssembly("LightlessSyncShared");
}).UseSnakeCaseNamingConvention();
options.EnableThreadSafetyChecks(false);
});
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}