add oauth or something

This commit is contained in:
Stanley Dimant
2024-10-29 12:27:55 +01:00
parent fe0ee4ed1e
commit 2554fa6d0e
16 changed files with 560 additions and 193 deletions

Submodule MareAPI updated: 4e939e8cd8...040add0608

View File

@@ -0,0 +1,159 @@
using MareSynchronosAuthService.Authentication;
using MareSynchronosAuthService.Services;
using MareSynchronosShared.Data;
using MareSynchronosShared.Models;
using MareSynchronosShared.Services;
using MareSynchronosShared.Utils;
using MareSynchronosShared.Utils.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace MareSynchronosAuthService.Controllers;
public abstract class AuthControllerBase : Controller
{
protected readonly ILogger Logger;
protected readonly IHttpContextAccessor HttpAccessor;
protected readonly IConfigurationService<AuthServiceConfiguration> Configuration;
protected readonly MareDbContext MareDbContext;
protected readonly SecretKeyAuthenticatorService SecretKeyAuthenticatorService;
private readonly IRedisDatabase _redis;
private readonly GeoIPService _geoIPProvider;
protected AuthControllerBase(ILogger logger,
IHttpContextAccessor accessor, MareDbContext mareDbContext,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IRedisDatabase redisDb, GeoIPService geoIPProvider)
{
Logger = logger;
HttpAccessor = accessor;
_redis = redisDb;
_geoIPProvider = geoIPProvider;
MareDbContext = mareDbContext;
SecretKeyAuthenticatorService = secretKeyAuthenticatorService;
Configuration = configuration;
}
protected async Task<IActionResult> GenericAuthResponse(string charaIdent, SecretKeyAuthReply authResult)
{
if (await IsIdentBanned(charaIdent))
{
Logger.LogWarning("Authenticate:IDENTBAN:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Your XIV service account 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 Mare 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 banned. 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 Mare account is banned from using the service.");
}
var existingIdent = await _redis.GetAsync<string>("UID:" + authResult.Uid);
if (!string.IsNullOrEmpty(existingIdent))
{
Logger.LogWarning("Authenticate:DUPLICATE:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Already logged in to this Mare 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(MareConfigurationBase.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, MareClaimTypes.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(MareClaimTypes.Uid, uid),
new Claim(MareClaimTypes.CharaIdent, charaIdent),
new Claim(MareClaimTypes.Alias, alias),
new Claim(MareClaimTypes.Expires, DateTime.UtcNow.AddHours(6).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(MareClaimTypes.Continent, await _geoIPProvider.GetCountryFromIP(HttpAccessor))
});
return Content(token.RawData);
}
protected async Task EnsureBan(string uid, string? primaryUid, string charaIdent)
{
if (!MareDbContext.BannedUsers.Any(c => c.CharacterIdentification == charaIdent))
{
MareDbContext.BannedUsers.Add(new Banned()
{
CharacterIdentification = charaIdent,
Reason = "Autobanned CharacterIdent (" + uid + ")",
});
}
var uidToLookFor = primaryUid ?? uid;
var primaryUserAuth = await MareDbContext.Auth.FirstAsync(f => f.UserUID == uidToLookFor);
primaryUserAuth.MarkForBan = false;
primaryUserAuth.IsBanned = true;
var lodestone = await MareDbContext.LodeStoneAuth.Include(a => a.User).FirstOrDefaultAsync(c => c.User.UID == uidToLookFor);
if (lodestone != null)
{
if (!MareDbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.HashedLodestoneId))
{
MareDbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.HashedLodestoneId,
});
}
if (!MareDbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.DiscordId.ToString()))
{
MareDbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.DiscordId.ToString(),
});
}
}
await MareDbContext.SaveChangesAsync();
}
protected async Task<bool> IsIdentBanned(string charaIdent)
{
return await MareDbContext.BannedUsers.AsNoTracking().AnyAsync(u => u.CharacterIdentification == charaIdent).ConfigureAwait(false);
}
}

View File

@@ -2,47 +2,27 @@
using MareSynchronosAuthService.Services;
using MareSynchronosShared;
using MareSynchronosShared.Data;
using MareSynchronosShared.Models;
using MareSynchronosShared.Services;
using MareSynchronosShared.Utils;
using MareSynchronosShared.Utils.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace MareSynchronosAuthService.Controllers;
[AllowAnonymous]
[Route(MareAuth.Auth)]
public class JwtController : Controller
public class JwtController : AuthControllerBase
{
private readonly ILogger<JwtController> _logger;
private readonly IHttpContextAccessor _accessor;
private readonly IConfigurationService<AuthServiceConfiguration> _configuration;
private readonly MareDbContext _mareDbContext;
private readonly IRedisDatabase _redis;
private readonly GeoIPService _geoIPProvider;
private readonly SecretKeyAuthenticatorService _secretKeyAuthenticatorService;
public JwtController(ILogger<JwtController> logger,
IHttpContextAccessor accessor, MareDbContext mareDbContext,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IRedisDatabase redisDb, GeoIPService geoIPProvider)
: base(logger, accessor, mareDbContext, secretKeyAuthenticatorService,
configuration, redisDb, geoIPProvider)
{
_logger = logger;
_accessor = accessor;
_redis = redisDb;
_geoIPProvider = geoIPProvider;
_mareDbContext = mareDbContext;
_secretKeyAuthenticatorService = secretKeyAuthenticatorService;
_configuration = configuration;
}
[AllowAnonymous]
@@ -53,7 +33,7 @@ public class JwtController : Controller
}
[Authorize(Policy = "Authenticated")]
[HttpGet("renewToken")]
[HttpGet(MareAuth.Auth_RenewToken)]
public async Task<IActionResult> RenewToken()
{
try
@@ -62,9 +42,9 @@ public class JwtController : Controller
var ident = HttpContext.User.Claims.Single(p => string.Equals(p.Type, MareClaimTypes.CharaIdent, StringComparison.Ordinal))!.Value;
var alias = HttpContext.User.Claims.SingleOrDefault(p => string.Equals(p.Type, MareClaimTypes.Alias))?.Value ?? string.Empty;
if (await _mareDbContext.Auth.Where(u => u.UserUID == uid || u.PrimaryUserUID == uid).AnyAsync(a => a.MarkForBan))
if (await MareDbContext.Auth.Where(u => u.UserUID == uid || u.PrimaryUserUID == uid).AnyAsync(a => a.MarkForBan))
{
var userAuth = await _mareDbContext.Auth.SingleAsync(u => u.UserUID == uid);
var userAuth = await MareDbContext.Auth.SingleAsync(u => u.UserUID == uid);
await EnsureBan(uid, userAuth.PrimaryUserUID, ident);
return Unauthorized("Your Mare account is banned.");
@@ -75,144 +55,33 @@ public class JwtController : Controller
return Unauthorized("Your XIV service account is banned from using the service.");
}
_logger.LogInformation("RenewToken:SUCCESS:{id}:{ident}", uid, ident);
Logger.LogInformation("RenewToken:SUCCESS:{id}:{ident}", uid, ident);
return await CreateJwtFromId(uid, ident, alias);
}
catch (Exception ex)
{
_logger.LogError(ex, "RenewToken:FAILURE");
Logger.LogError(ex, "RenewToken:FAILURE");
return Unauthorized("Unknown error while renewing authentication token");
}
}
private async Task<IActionResult> AuthenticateInternal(string auth, string charaIdent)
protected async Task<IActionResult> AuthenticateInternal(string auth, string charaIdent)
{
try
{
if (string.IsNullOrEmpty(auth)) return BadRequest("No Authkey");
if (string.IsNullOrEmpty(charaIdent)) return BadRequest("No CharaIdent");
var ip = _accessor.GetIpAddress();
var ip = HttpAccessor.GetIpAddress();
var authResult = await _secretKeyAuthenticatorService.AuthorizeAsync(ip, auth);
var authResult = await SecretKeyAuthenticatorService.AuthorizeAsync(ip, auth);
if (await IsIdentBanned(charaIdent))
{
_logger.LogWarning("Authenticate:IDENTBAN:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Your XIV service account 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 Mare 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 banned. 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 Mare account is banned from using the service.");
}
var existingIdent = await _redis.GetAsync<string>("UID:" + authResult.Uid);
if (!string.IsNullOrEmpty(existingIdent))
{
_logger.LogWarning("Authenticate:DUPLICATE:{id}:{ident}", authResult.Uid, charaIdent);
return Unauthorized("Already logged in to this Mare 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);
return await GenericAuthResponse(charaIdent, authResult);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Authenticate:UNKNOWN");
Logger.LogWarning(ex, "Authenticate:UNKNOWN");
return Unauthorized("Unknown internal server error during authentication");
}
}
private JwtSecurityToken CreateJwt(IEnumerable<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration.GetValue<string>(nameof(MareConfigurationBase.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, MareClaimTypes.Expires, StringComparison.Ordinal)).Value!, CultureInfo.InvariantCulture), DateTimeKind.Utc),
};
var handler = new JwtSecurityTokenHandler();
return handler.CreateJwtSecurityToken(token);
}
private async Task<IActionResult> CreateJwtFromId(string uid, string charaIdent, string alias)
{
var token = CreateJwt(new List<Claim>()
{
new Claim(MareClaimTypes.Uid, uid),
new Claim(MareClaimTypes.CharaIdent, charaIdent),
new Claim(MareClaimTypes.Alias, alias),
new Claim(MareClaimTypes.Expires, DateTime.UtcNow.AddHours(6).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(MareClaimTypes.Continent, await _geoIPProvider.GetCountryFromIP(_accessor))
});
return Content(token.RawData);
}
private async Task EnsureBan(string uid, string? primaryUid, string charaIdent)
{
if (!_mareDbContext.BannedUsers.Any(c => c.CharacterIdentification == charaIdent))
{
_mareDbContext.BannedUsers.Add(new Banned()
{
CharacterIdentification = charaIdent,
Reason = "Autobanned CharacterIdent (" + uid + ")",
});
}
var uidToLookFor = primaryUid ?? uid;
var primaryUserAuth = await _mareDbContext.Auth.FirstAsync(f => f.UserUID == uidToLookFor);
primaryUserAuth.MarkForBan = false;
primaryUserAuth.IsBanned = true;
var lodestone = await _mareDbContext.LodeStoneAuth.Include(a => a.User).FirstOrDefaultAsync(c => c.User.UID == uidToLookFor);
if (lodestone != null)
{
if (!_mareDbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.HashedLodestoneId))
{
_mareDbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.HashedLodestoneId,
});
}
if (!_mareDbContext.BannedRegistrations.Any(c => c.DiscordIdOrLodestoneAuth == lodestone.DiscordId.ToString()))
{
_mareDbContext.BannedRegistrations.Add(new BannedRegistrations()
{
DiscordIdOrLodestoneAuth = lodestone.DiscordId.ToString(),
});
}
}
await _mareDbContext.SaveChangesAsync();
}
private async Task<bool> IsIdentBanned(string charaIdent)
{
return await _mareDbContext.BannedUsers.AsNoTracking().AnyAsync(u => u.CharacterIdentification == charaIdent).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,252 @@
using MareSynchronos.API.Routes;
using MareSynchronosAuthService.Services;
using MareSynchronosShared;
using MareSynchronosShared.Data;
using MareSynchronosShared.Services;
using MareSynchronosShared.Utils;
using MareSynchronosShared.Utils.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
using StackExchange.Redis.Extensions.Core.Abstractions;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
using System.Web;
namespace MareSynchronosAuthService.Controllers;
[Route(MareAuth.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, MareDbContext mareDbContext,
SecretKeyAuthenticatorService secretKeyAuthenticatorService,
IConfigurationService<AuthServiceConfiguration> configuration,
IRedisDatabase redisDb, GeoIPService geoIPProvider)
: base(logger, accessor, mareDbContext, 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");
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");
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)
{
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)
{
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)
{
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");
var mareUser = await MareDbContext.LodeStoneAuth.Include(u => u.User).SingleOrDefaultAsync(u => u.DiscordId == discordUserId);
if (mareUser == null)
return BadRequest("Could not find a Mare user associated to this Discord account.");
var jwt = CreateJwt([
new Claim(MareClaimTypes.Uid, mareUser.User!.UID),
new Claim(MareClaimTypes.Expires, DateTime.UtcNow.AddDays(14).Ticks.ToString(CultureInfo.InvariantCulture)),
new Claim(MareClaimTypes.DiscordId, discordUserId.ToString()),
new Claim(MareClaimTypes.DiscordUser, discordUserName),
new Claim(MareClaimTypes.OAuthLoginToken, true.ToString())
]);
_cookieOAuthResponse[reqId] = jwt.RawData;
_ = Task.Run(async () =>
{
bool isRemoved = false;
for (int i = 0; i < 10; i++)
{
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
if (!_cookieOAuthResponse.ContainsKey(reqId))
{
isRemoved = true;
break;
}
}
if (!isRemoved)
_cookieOAuthResponse.TryRemove(reqId, out _);
});
return Ok("The OAuth2 token was generated. The plugin will grab it automatically. You can close this browser tab.");
}
[Authorize(Policy = "OAuthToken")]
[HttpPost(MareAuth.OAuth_RenewOAuthToken)]
public IActionResult RenewOAuthToken()
{
var claims = HttpContext.User.Claims.Where(c => c.Type != MareClaimTypes.Expires).ToList();
claims.Add(new Claim(MareClaimTypes.Expires, DateTime.UtcNow.AddDays(14).Ticks.ToString(CultureInfo.InvariantCulture)));
return Content(CreateJwt(claims).RawData);
}
[AllowAnonymous]
[HttpGet(MareAuth.OAuth_GetDiscordOAuthToken)]
public async Task<IActionResult> GetDiscordOAuthToken([FromQuery] string sessionId)
{
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromSeconds(60));
while (!_cookieOAuthResponse.ContainsKey(sessionId) && !cts.Token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
}
if (cts.IsCancellationRequested)
{
return BadRequest("Did not find Discord OAuth2 response");
}
_cookieOAuthResponse.TryRemove(sessionId, out var token);
if (token == null)
return BadRequest("OAuth session was never established");
return Content(token);
}
[AllowAnonymous]
[HttpGet(MareAuth.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(MareAuth.OAuth_GetUIDs)]
public async Task<Dictionary<string, string>> GetAvailableUIDs()
{
string primaryUid = HttpContext.User.Claims.Single(c => string.Equals(c.Type, MareClaimTypes.Uid, StringComparison.Ordinal))!.Value;
var mareUser = await MareDbContext.Auth.Include(u => u.User).FirstOrDefaultAsync(f => f.UserUID == primaryUid);
if (mareUser == null || mareUser.User == null) return [];
var uid = mareUser.User.UID;
var allUids = await MareDbContext.Auth.Include(u => u.User).Where(a => a.UserUID == uid || a.PrimaryUserUID == uid).ToListAsync();
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(MareAuth.OAuth_CreateOAuth)]
public async Task<IActionResult> CreateTokenWithOAuth(string uid, string charaIdent)
{
return await AuthenticateOAuthInternal(uid, charaIdent);
}
private async Task<IActionResult> AuthenticateOAuthInternal(string requestedUid, string charaIdent)
{
try
{
string primaryUid = HttpContext.User.Claims.Single(c => string.Equals(c.Type, MareClaimTypes.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(charaIdent, authResult);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Authenticate:UNKNOWN");
return Unauthorized("Unknown internal server error during authentication");
}
}
}

View File

@@ -7,8 +7,12 @@
</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="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -2,6 +2,7 @@
using MareSynchronosAuthService.Authentication;
using MareSynchronosShared.Data;
using MareSynchronosShared.Metrics;
using MareSynchronosShared.Models;
using MareSynchronosShared.Services;
using MareSynchronosShared.Utils.Configuration;
using Microsoft.EntityFrameworkCore;
@@ -25,32 +26,37 @@ public class SecretKeyAuthenticatorService
_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);
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);
}
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, MareDbContext context, Auth? authReply)
{
var isBanned = authReply?.IsBanned ?? false;
var markedForBan = authReply?.MarkForBan ?? false;
var primaryUid = authReply?.PrimaryUserUID ?? authReply?.UserUID;
@@ -70,13 +76,37 @@ public class SecretKeyAuthenticatorService
{
_metrics.IncCounter(MetricsAPI.CounterAuthenticationSuccesses);
_metrics.IncGauge(MetricsAPI.GaugeAuthenticationCacheEntries);
return reply;
}
else
{
return AuthenticationFailure(ip);
}
}
return reply;
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)

View File

@@ -87,7 +87,7 @@ public class Startup
services.AddControllers().ConfigureApplicationPartManager(a =>
{
a.FeatureProviders.Remove(a.FeatureProviders.OfType<ControllerFeatureProvider>().First());
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController)));
a.FeatureProviders.Add(new AllowedControllersFeatureProvider(typeof(JwtController), typeof(OAuthController)));
});
}
@@ -95,6 +95,7 @@ public class Startup
{
services.AddTransient<IAuthorizationHandler, UserRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ValidTokenRequirementHandler>();
services.AddTransient<IAuthorizationHandler, ExistingUserRequirementHandler>();
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IConfigurationService<MareConfigurationBase>>((options, config) =>
@@ -121,6 +122,13 @@ public class Startup
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(MareClaimTypes.OAuthLoginToken, "True");
});
options.AddPolicy("Authenticated", policy =>
{
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);

View File

@@ -21,18 +21,18 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.7">
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="lz4net" Version="1.0.15.93" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.149">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.176">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.1.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -21,13 +21,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.14.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.149">
<PackageReference Include="Discord.Net" Version="3.16.0" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.176">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -16,12 +16,12 @@
<ItemGroup>
<PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="IDisposableAnalyzers" Version="4.0.7">
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Karambolo.Extensions.Logging.File" Version="3.5.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Karambolo.Extensions.Logging.File" Version="3.6.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.176">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -30,25 +30,25 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Core" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.4" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.1.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="prometheus-net" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="StackExchange.Redis" Version="2.7.33" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="10.2.0" />
<PackageReference Include="StackExchange.Redis.Extensions.Core" Version="10.2.0" />
<PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>

View File

@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;
namespace MareSynchronosShared.RequirementHandlers;
public class ExistingUserRequirement : IAuthorizationRequirement { }

View File

@@ -0,0 +1,30 @@
using MareSynchronosShared.Data;
using MareSynchronosShared.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StackExchange.Redis.Extensions.Core.Abstractions;
namespace MareSynchronosShared.RequirementHandlers;
public class ExistingUserRequirementHandler : AuthorizationHandler<ExistingUserRequirement>
{
private readonly MareDbContext _dbContext;
private readonly ILogger<UserRequirementHandler> _logger;
public ExistingUserRequirementHandler(MareDbContext dbContext, ILogger<UserRequirementHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ExistingUserRequirement requirement)
{
var uid = context.User.Claims.SingleOrDefault(g => string.Equals(g.Type, MareClaimTypes.Uid, StringComparison.Ordinal))?.Value;
if (uid == null) context.Fail();
var user = await _dbContext.Users.AsNoTracking().SingleOrDefaultAsync(b => b.UID == uid).ConfigureAwait(false);
if (user == null) context.Fail();
context.Succeed(requirement);
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
namespace MareSynchronosShared.RequirementHandlers;
public class UserRequirementHandler : AuthorizationHandler<UserRequirement, HubInvocationContext>
{
private readonly MareDbContext _dbContext;

View File

@@ -9,7 +9,9 @@ public class AuthServiceConfiguration : MareConfigurationBase
public int FailedAuthForTempBan { get; set; } = 5;
public int TempBanDurationInMinutes { get; set; } = 5;
public List<string> WhitelistedIps { get; set; } = new();
public Uri PublicOAuthBaseUri { get; set; } = null;
public string? DiscordOAuthClientSecret { get; set; } = null;
public string? DiscordOAuthClientId { get; set; } = null;
public override string ToString()
{
StringBuilder sb = new();

View File

@@ -8,4 +8,7 @@ public static class MareClaimTypes
public const string Internal = "internal";
public const string Expires = "expiration_date";
public const string Continent = "continent";
public const string DiscordUser = "discord_user";
public const string DiscordId = "discord_user_id";
public const string OAuthLoginToken = "oauth_login_token";
}

View File

@@ -18,16 +18,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="IDisposableAnalyzers" Version="4.0.7">
<PackageReference Include="IDisposableAnalyzers" Version="4.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="lz4net" Version="1.0.15.93" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.176">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
</ItemGroup>
<ItemGroup>