diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 0781443..6b31a40 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using Serilog.Events; namespace Foxnouns.Backend; diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 4c83ce4..b9570c0 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -45,7 +45,7 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log ); public record CallbackResponse( - bool HasAccount, // If true, user has an account, but it's deleted + bool HasAccount, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 20840ad..a1c3eed 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; +using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -11,7 +12,7 @@ namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/discord")] public class DiscordAuthController( - Config config, + [UsedImplicitly] Config config, ILogger logger, IClock clock, DatabaseContext db, @@ -26,14 +27,15 @@ public class DiscordAuthController( // TODO: duplicating attribute doesn't work, find another way to mark both as possible response // leaving it here for documentation purposes [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) + public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, + CancellationToken ct = default) { CheckRequirements(); await keyCacheService.ValidateAuthStateAsync(req.State, ct); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); - if (user != null) return Ok(await GenerateUserTokenAsync(user,ct)); + if (user != null) return Ok(await GenerateUserTokenAsync(user, ct)); _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id); @@ -53,24 +55,25 @@ public class DiscordAuthController( [HttpPost("register")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req, CancellationToken ct = default) + public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) { - var remoteUser = await keyCacheService.GetKeyAsync($"discord:{req.Ticket}",ct:ct); + var remoteUser = await keyCacheService.GetKeyAsync($"discord:{req.Ticket}"); if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); - if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id, ct)) + if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) { _logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", remoteUser.Id); - throw new FoxnounsError("Discord ticket was issued for user with existing link"); + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); } var user = await authService.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, - remoteUser.Username, ct: ct); + remoteUser.Username); - return Ok(await GenerateUserTokenAsync(user, ct)); + return Ok(await GenerateUserTokenAsync(user)); } - private async Task GenerateUserTokenAsync(User user, CancellationToken ct = default) + private async Task GenerateUserTokenAsync(User user, + CancellationToken ct = default) { var frontendApp = await db.GetFrontendApplicationAsync(ct); _logger.Debug("Logging user {Id} in with Discord", user.Id); diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index b7e8ff4..1649948 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; +using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -11,8 +12,8 @@ namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/email")] public class EmailAuthController( + [UsedImplicitly] Config config, DatabaseContext db, - Config config, AuthService authService, MailService mailService, KeyCacheService keyCacheService, diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index 265cf3d..e63b579 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -17,7 +17,7 @@ public partial class InternalController(DatabaseContext db) : ControllerBase private static string GetCleanedTemplate(string template) { - if (template.StartsWith("api/v2")) template = template.Substring("api/v2".Length); + if (template.StartsWith("api/v2")) template = template["api/v2".Length..]; template = PathVarRegex() .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` .Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}` @@ -50,7 +50,7 @@ public partial class InternalController(DatabaseContext db) : ControllerBase Snowflake? UserId, string Template); - private static Endpoint? GetEndpoint(HttpContext httpContext, string url, string requestMethod) + private static RouteEndpoint? GetEndpoint(HttpContext httpContext, string url, string requestMethod) { var endpointDataSource = httpContext.RequestServices.GetService(); if (endpointDataSource == null) return null; @@ -60,7 +60,7 @@ public partial class InternalController(DatabaseContext db) : ControllerBase { if (endpoint.RoutePattern.RawText == null) continue; - var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText), new()); + var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText), new RouteValueDictionary()); if (!templateMatcher.TryMatch(url, new())) continue; var httpMethodAttribute = endpoint.Metadata.GetMetadata(); if (httpMethodAttribute != null && diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index f051ca1..a92f947 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -88,19 +88,17 @@ public class MembersController( [HttpDelete("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] - public async Task DeleteMemberAsync(string memberRef, CancellationToken ct = default) + public async Task DeleteMemberAsync(string memberRef) { - var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef, ct); + var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id) - .ExecuteDeleteAsync(ct); + .ExecuteDeleteAsync(); if (deleteCount == 0) { _logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id); return NoContent(); } - await db.SaveChangesAsync(ct); - if (member.Avatar != null) await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar); return NoContent(); } diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index f8a544c..60d4499 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; -using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -95,7 +94,7 @@ public static class DatabaseQueryExtensions { Id = new Snowflake(0), ClientId = RandomNumberGenerator.GetHexString(32, true), - ClientSecret = AuthUtils.RandomToken(48), + ClientSecret = AuthUtils.RandomToken(), Name = "pronouns.cc", Scopes = ["*"], RedirectUris = [], diff --git a/Foxnouns.Backend/Database/Models/Application.cs b/Foxnouns.Backend/Database/Models/Application.cs index f64bfc9..49b711e 100644 --- a/Foxnouns.Backend/Database/Models/Application.cs +++ b/Foxnouns.Backend/Database/Models/Application.cs @@ -9,7 +9,7 @@ public class Application : BaseModel public required string ClientSecret { get; init; } public required string Name { get; init; } public required string[] Scopes { get; init; } - public required string[] RedirectUris { get; set; } + public required string[] RedirectUris { get; init; } public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes, string[] redirectUrls) diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index f277265..3c1c355 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -1,6 +1,4 @@ -using System.Collections.ObjectModel; using System.Net; -using Foxnouns.Backend.Middleware; using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -51,7 +49,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo { { "status", (int)HttpStatusCode.BadRequest }, { "message", Message }, - { "code", ErrorCode.BadRequest.ToString() } + { "code", "BAD_REQUEST" } }; if (errors == null) return o; @@ -84,7 +82,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo { { "status", (int)HttpStatusCode.BadRequest }, { "message", Message }, - { "code", ErrorCode.BadRequest.ToString() } + { "code", "BAD_REQUEST" } }; if (modelState == null) return o; diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 92abc6a..6c4ea28 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -10,6 +10,7 @@ + diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs index c52c3f0..6b6da6d 100644 --- a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -1,7 +1,6 @@ using System.Net; using Foxnouns.Backend.Utils; using Newtonsoft.Json; -using Newtonsoft.Json.Converters; namespace Foxnouns.Backend.Middleware; diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index f69441a..f1e907b 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -74,6 +74,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// /// The user's email address /// The user's password, in plain text + /// Cancellation token /// A tuple of the authenticated user and whether multi-factor authentication is required /// Thrown if the email address is not associated with any user /// or if the password is incorrect diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 33b595f..78ea3ae 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -1,6 +1,5 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs index b08b0a5..fefaf16 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -1,5 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using System.Web; +using JetBrains.Annotations; namespace Foxnouns.Backend.Services; @@ -27,10 +27,11 @@ public class RemoteAuthService(Config config, ILogger logger) if (!resp.IsSuccessStatusCode) { var respBody = await resp.Content.ReadAsStringAsync(ct); - _logger.Error("Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", (int)resp.StatusCode, respBody); + _logger.Error("Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", + (int)resp.StatusCode, respBody); throw new FoxnounsError("Invalid Discord OAuth response"); } - + resp.EnsureSuccessStatusCode(); var token = await resp.Content.ReadFromJsonAsync(ct); if (token == null) throw new FoxnounsError("Discord token response was null"); @@ -46,10 +47,14 @@ public class RemoteAuthService(Config config, ILogger logger) return new RemoteUser(user.id, user.username); } - [SuppressMessage("ReSharper", "InconsistentNaming")] + [SuppressMessage("ReSharper", "InconsistentNaming", + Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] + [UsedImplicitly] private record DiscordTokenResponse(string access_token, string token_type); - [SuppressMessage("ReSharper", "InconsistentNaming")] + [SuppressMessage("ReSharper", "InconsistentNaming", + Justification = "Easier to use snake_case here, rather than passing in JSON converter options")] + [UsedImplicitly] private record DiscordUserResponse(string id, string username); public record RemoteUser(string Id, string Username);