Initial
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
namespace LightlessSyncAuthService.Authentication;
|
||||
|
||||
public record SecretKeyAuthReply(bool Success, string? Uid, string? PrimaryUid, string? Alias, bool TempBan, bool Permaban, bool MarkedForBan);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
55
LightlessSyncServer/LightlessSyncAuthService/Program.cs
Normal file
55
LightlessSyncServer/LightlessSyncAuthService/Program.cs
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
244
LightlessSyncServer/LightlessSyncAuthService/Startup.cs
Normal file
244
LightlessSyncServer/LightlessSyncAuthService/Startup.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user