add oauth or something
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user