From 2554fa6d0e34efbd5591c77ec7cc6757aa58055a Mon Sep 17 00:00:00 2001 From: Stanley Dimant Date: Tue, 29 Oct 2024 12:27:55 +0100 Subject: [PATCH] add oauth or something --- MareAPI | 2 +- .../Controllers/AuthControllerBase.cs | 159 +++++++++++ .../Controllers/JwtController.cs | 157 +---------- .../Controllers/OAuthController.cs | 252 ++++++++++++++++++ .../MareSynchronosAuthService.csproj | 6 +- .../Services/SecretKeyAuthenticatorService.cs | 68 +++-- .../MareSynchronosAuthService/Startup.cs | 10 +- .../MareSynchronosServer.csproj | 10 +- .../MareSynchronosServices.csproj | 12 +- .../MareSynchronosShared.csproj | 28 +- .../ExistingUserRequirement.cs | 5 + .../ExistingUserRequirementHandler.cs | 30 +++ .../UserRequirementHandler.cs | 1 + .../Configuration/AuthServiceConfiguration.cs | 4 +- .../Utils/MareClaimTypes.cs | 3 + .../MareSynchronosStaticFilesServer.csproj | 6 +- 16 files changed, 560 insertions(+), 193 deletions(-) create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Controllers/AuthControllerBase.cs create mode 100644 MareSynchronosServer/MareSynchronosAuthService/Controllers/OAuthController.cs create mode 100644 MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirement.cs create mode 100644 MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirementHandler.cs diff --git a/MareAPI b/MareAPI index 4e939e8..040add0 160000 --- a/MareAPI +++ b/MareAPI @@ -1 +1 @@ -Subproject commit 4e939e8cd8f3531c3aa610413cbc3419872c48d8 +Subproject commit 040add06083f76e5b41e64b8842339266a046871 diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/AuthControllerBase.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/AuthControllerBase.cs new file mode 100644 index 0000000..b7e59fa --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/AuthControllerBase.cs @@ -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 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 configuration, + IRedisDatabase redisDb, GeoIPService geoIPProvider) + { + Logger = logger; + HttpAccessor = accessor; + _redis = redisDb; + _geoIPProvider = geoIPProvider; + MareDbContext = mareDbContext; + SecretKeyAuthenticatorService = secretKeyAuthenticatorService; + Configuration = configuration; + } + + protected async Task 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("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 authClaims) + { + var authSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Configuration.GetValue(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 CreateJwtFromId(string uid, string charaIdent, string alias) + { + var token = CreateJwt(new List() + { + 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 IsIdentBanned(string charaIdent) + { + return await MareDbContext.BannedUsers.AsNoTracking().AnyAsync(u => u.CharacterIdentification == charaIdent).ConfigureAwait(false); + } +} diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs index dc7f24e..2424cfd 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/JwtController.cs @@ -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 _logger; - private readonly IHttpContextAccessor _accessor; - private readonly IConfigurationService _configuration; - private readonly MareDbContext _mareDbContext; - private readonly IRedisDatabase _redis; - private readonly GeoIPService _geoIPProvider; - private readonly SecretKeyAuthenticatorService _secretKeyAuthenticatorService; - public JwtController(ILogger logger, IHttpContextAccessor accessor, MareDbContext mareDbContext, SecretKeyAuthenticatorService secretKeyAuthenticatorService, IConfigurationService 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 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 AuthenticateInternal(string auth, string charaIdent) + protected async Task 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("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 authClaims) - { - var authSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration.GetValue(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 CreateJwtFromId(string uid, string charaIdent, string alias) - { - var token = CreateJwt(new List() - { - 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 IsIdentBanned(string charaIdent) - { - return await _mareDbContext.BannedUsers.AsNoTracking().AnyAsync(u => u.CharacterIdentification == charaIdent).ConfigureAwait(false); - } } \ No newline at end of file diff --git a/MareSynchronosServer/MareSynchronosAuthService/Controllers/OAuthController.cs b/MareSynchronosServer/MareSynchronosAuthService/Controllers/OAuthController.cs new file mode 100644 index 0000000..9303589 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosAuthService/Controllers/OAuthController.cs @@ -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 _cookieOAuthResponse = []; + + public OAuthController(ILogger logger, + IHttpContextAccessor accessor, MareDbContext mareDbContext, + SecretKeyAuthenticatorService secretKeyAuthenticatorService, + IConfigurationService 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(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null); + var discordClientSecret = Configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null); + var discordClientId = Configuration.GetValueOrDefault(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 + { + { "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 DiscordOAuthCallback([FromQuery] string code) + { + var reqId = Request.Cookies["DiscordOAuthSessionCookie"]; + + var discordOAuthUri = Configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null); + var discordClientSecret = Configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null); + var discordClientId = Configuration.GetValueOrDefault(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 + { + { "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 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(nameof(AuthServiceConfiguration.PublicOAuthBaseUri), null); + var discordClientSecret = Configuration.GetValueOrDefault(nameof(AuthServiceConfiguration.DiscordOAuthClientSecret), null); + var discordClientId = Configuration.GetValueOrDefault(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> 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 CreateTokenWithOAuth(string uid, string charaIdent) + { + return await AuthenticateOAuthInternal(uid, charaIdent); + } + + private async Task 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"); + } + } +} diff --git a/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj b/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj index c46484a..67f87f7 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj +++ b/MareSynchronosServer/MareSynchronosAuthService/MareSynchronosAuthService.csproj @@ -7,8 +7,12 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + diff --git a/MareSynchronosServer/MareSynchronosAuthService/Services/SecretKeyAuthenticatorService.cs b/MareSynchronosServer/MareSynchronosAuthService/Services/SecretKeyAuthenticatorService.cs index 3fb0c05..0f57261 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Services/SecretKeyAuthenticatorService.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Services/SecretKeyAuthenticatorService.cs @@ -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 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 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 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) diff --git a/MareSynchronosServer/MareSynchronosAuthService/Startup.cs b/MareSynchronosServer/MareSynchronosAuthService/Startup.cs index 96ebdb7..884075c 100644 --- a/MareSynchronosServer/MareSynchronosAuthService/Startup.cs +++ b/MareSynchronosServer/MareSynchronosAuthService/Startup.cs @@ -87,7 +87,7 @@ public class Startup services.AddControllers().ConfigureApplicationPartManager(a => { a.FeatureProviders.Remove(a.FeatureProviders.OfType().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(); services.AddTransient(); + services.AddTransient(); services.AddOptions(JwtBearerDefaults.AuthenticationScheme) .Configure>((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); diff --git a/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj b/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj index 59dd8a7..e01d6cf 100644 --- a/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj +++ b/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj @@ -21,18 +21,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/MareSynchronosServer/MareSynchronosServices/MareSynchronosServices.csproj b/MareSynchronosServer/MareSynchronosServices/MareSynchronosServices.csproj index db6d3b3..c7ddb90 100644 --- a/MareSynchronosServer/MareSynchronosServices/MareSynchronosServices.csproj +++ b/MareSynchronosServer/MareSynchronosServices/MareSynchronosServices.csproj @@ -21,13 +21,17 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/MareSynchronosServer/MareSynchronosShared/MareSynchronosShared.csproj b/MareSynchronosServer/MareSynchronosShared/MareSynchronosShared.csproj index f1e1092..37aceff 100644 --- a/MareSynchronosServer/MareSynchronosShared/MareSynchronosShared.csproj +++ b/MareSynchronosServer/MareSynchronosShared/MareSynchronosShared.csproj @@ -16,12 +16,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -30,25 +30,25 @@ - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + - + diff --git a/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirement.cs b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirement.cs new file mode 100644 index 0000000..0811a78 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirement.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Authorization; + +namespace MareSynchronosShared.RequirementHandlers; + +public class ExistingUserRequirement : IAuthorizationRequirement { } \ No newline at end of file diff --git a/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirementHandler.cs b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirementHandler.cs new file mode 100644 index 0000000..933e858 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/ExistingUserRequirementHandler.cs @@ -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 +{ + private readonly MareDbContext _dbContext; + private readonly ILogger _logger; + + public ExistingUserRequirementHandler(MareDbContext dbContext, ILogger 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); + } +} \ No newline at end of file diff --git a/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/UserRequirementHandler.cs b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/UserRequirementHandler.cs index 1070739..b51ee71 100644 --- a/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/UserRequirementHandler.cs +++ b/MareSynchronosServer/MareSynchronosShared/RequirementHandlers/UserRequirementHandler.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; namespace MareSynchronosShared.RequirementHandlers; + public class UserRequirementHandler : AuthorizationHandler { private readonly MareDbContext _dbContext; diff --git a/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/AuthServiceConfiguration.cs b/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/AuthServiceConfiguration.cs index e85f6b7..f182e79 100644 --- a/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/AuthServiceConfiguration.cs +++ b/MareSynchronosServer/MareSynchronosShared/Utils/Configuration/AuthServiceConfiguration.cs @@ -9,7 +9,9 @@ public class AuthServiceConfiguration : MareConfigurationBase public int FailedAuthForTempBan { get; set; } = 5; public int TempBanDurationInMinutes { get; set; } = 5; public List 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(); diff --git a/MareSynchronosServer/MareSynchronosShared/Utils/MareClaimTypes.cs b/MareSynchronosServer/MareSynchronosShared/Utils/MareClaimTypes.cs index 2b78904..a153fb0 100644 --- a/MareSynchronosServer/MareSynchronosShared/Utils/MareClaimTypes.cs +++ b/MareSynchronosServer/MareSynchronosShared/Utils/MareClaimTypes.cs @@ -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"; } diff --git a/MareSynchronosServer/MareSynchronosStaticFilesServer/MareSynchronosStaticFilesServer.csproj b/MareSynchronosServer/MareSynchronosStaticFilesServer/MareSynchronosStaticFilesServer.csproj index 100136c..cadb2b6 100644 --- a/MareSynchronosServer/MareSynchronosStaticFilesServer/MareSynchronosStaticFilesServer.csproj +++ b/MareSynchronosServer/MareSynchronosStaticFilesServer/MareSynchronosStaticFilesServer.csproj @@ -18,16 +18,16 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - +