From 103ba24555345e3ff33aedb97c7358edc28eb13f Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 14 Sep 2024 16:37:27 +0200 Subject: [PATCH 1/2] feat(frontend): create account from discord, better error alert --- .../app/components/ErrorAlert.tsx | 112 +++++++++++++++++- Foxnouns.Frontend/app/lib/api/error.ts | 30 ++++- .../routes/auth.callback.discord/route.tsx | 106 +++++++++++++++-- Foxnouns.Frontend/public/locales/en.json | 110 +++++++++-------- 4 files changed, 292 insertions(+), 66 deletions(-) diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx index e30c516..cedb69a 100644 --- a/Foxnouns.Frontend/app/components/ErrorAlert.tsx +++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx @@ -1,7 +1,13 @@ import { TFunction } from "i18next"; import Alert from "react-bootstrap/Alert"; -import { useTranslation } from "react-i18next"; -import { ApiError, ErrorCode } from "~/lib/api/error"; +import { Trans, useTranslation } from "react-i18next"; +import { + ApiError, + ErrorCode, + ValidationError, + validationErrorType, + ValidationErrorType, +} from "~/lib/api/error"; export default function ErrorAlert({ error }: { error: ApiError }) { const { t } = useTranslation(); @@ -10,10 +16,112 @@ export default function ErrorAlert({ error }: { error: ApiError }) { {t("error.heading")} {errorCodeDesc(t, error.code)} + {error.errors && ( + + )} ); } +function ValidationErrors({ errorKey, errors }: { errorKey: string; errors: ValidationError[] }) { + return ( +
  • + + {errorKey} + + : + +
  • + ); +} + +function ValidationErrorEntry({ error }: { error: ValidationError }) { + const { t } = useTranslation(); + + const { + min_length: minLength, + max_length: maxLength, + actual_length: actualLength, + message: reason, + actual_value: actualValue, + allowed_values: allowedValues, + } = error; + + switch (validationErrorType(error)) { + case ValidationErrorType.LengthError: + if (error.actual_length! > error.max_length!) { + return ( + + Value is too long, maximum length is {{ maxLength }}, current length is{" "} + {{ actualLength }}. + + ); + } + + if (error.actual_length! < error.min_length!) { + return ( + + Value is too short, minimum length is {{ minLength }}, current length is{" "} + {{ actualLength }}. + + ); + } + + break; + + case ValidationErrorType.DisallowedValueError: + return ( + v.toString()).join(", "), + }} + > + {/* @ts-expect-error i18next handles interpolation */} + The value {{ actualValue }} is not allowed here. Allowed values are:{" "} + {/* @ts-expect-error i18next handles interpolation */} + {{ allowedValues }} + + ); + + default: + if (error.actual_value) { + return ( + + {/* @ts-expect-error i18next handles interpolation */} + The value {{ actualValue }} is not allowed here. Reason: {{ reason }} + + ); + } + + return <>{t("error.validation.generic-no-value", { reason: error.message })}; + } +} + export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { switch (code) { case ErrorCode.AuthenticationError: diff --git a/Foxnouns.Frontend/app/lib/api/error.ts b/Foxnouns.Frontend/app/lib/api/error.ts index 02e871c..0a3d9b9 100644 --- a/Foxnouns.Frontend/app/lib/api/error.ts +++ b/Foxnouns.Frontend/app/lib/api/error.ts @@ -3,7 +3,7 @@ export type ApiError = { status: number; message: string; code: ErrorCode; - errors?: ValidationError[]; + errors?: Array<{ key: string; errors: ValidationError[] }>; }; export enum ErrorCode { @@ -26,3 +26,31 @@ export type ValidationError = { allowed_values?: any[]; actual_value?: any; }; + +/** + * Returns the first error for the value `key` in `error`. + * @param error The error object to traverse. + * @param key The JSON key to find. + */ +export const firstErrorFor = (error: ApiError, key: string): ValidationError | undefined => { + if (!error.errors) return undefined; + const field = error.errors.find((e) => e.key == key); + if (!field?.errors) return undefined; + return field.errors.length != 0 ? field.errors[0] : undefined; +}; + +export enum ValidationErrorType { + LengthError = 0, + DisallowedValueError = 1, + GenericValidationError = 2, +} + +export const validationErrorType = (error: ValidationError) => { + if (error.min_length && error.max_length && error.actual_length) { + return ValidationErrorType.LengthError; + } + if (error.allowed_values && error.actual_value) { + return ValidationErrorType.DisallowedValueError; + } + return ValidationErrorType.GenericValidationError; +}; diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index f946ee0..31d4d64 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -1,11 +1,23 @@ -import { json, LoaderFunctionArgs } from "@remix-run/node"; -import { type ApiError, ErrorCode } from "~/lib/api/error"; +import { ActionFunctionArgs, json, redirect, LoaderFunctionArgs } from "@remix-run/node"; +import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error"; import serverRequest, { writeCookie } from "~/lib/request.server"; -import { CallbackResponse } from "~/lib/api/auth"; -import { Form as RemixForm, Link, useLoaderData } from "@remix-run/react"; +import { AuthResponse, CallbackResponse } from "~/lib/api/auth"; +import { + Form as RemixForm, + Link, + useActionData, + useLoaderData, + ShouldRevalidateFunction, +} from "@remix-run/react"; import { Trans, useTranslation } from "react-i18next"; import Form from "react-bootstrap/Form"; import Button from "react-bootstrap/Button"; +import ErrorAlert from "~/components/ErrorAlert"; +import Alert from "react-bootstrap/Alert"; + +export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => { + return !actionResult; +}; export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); @@ -17,7 +29,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code or state" } as ApiError; const resp = await serverRequest("POST", "/auth/discord/callback", { - body: { code, state } + body: { code, state }, }); if (resp.has_account) { @@ -25,9 +37,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { { hasAccount: true, user: resp.user!, ticket: null, remoteUser: null }, { headers: { - "Set-Cookie": writeCookie("pronounscc-token", resp.token!) - } - } + "Set-Cookie": writeCookie("pronounscc-token", resp.token!), + }, + }, ); } @@ -35,26 +47,62 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { hasAccount: false, user: null, ticket: resp.ticket!, - remoteUser: resp.remote_username! + remoteUser: resp.remote_username!, }); }; -// TODO: action function +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await request.formData(); + const username = data.get("username") as string | null; + const ticket = data.get("ticket") as string | null; + + if (!username || !ticket) + return json({ + error: { + status: 403, + code: ErrorCode.BadRequest, + message: "Invalid username or ticket", + } as ApiError, + user: null, + }); + + try { + const resp = await serverRequest("POST", "/auth/discord/register", { + body: { username, ticket }, + }); + + return redirect("/auth/welcome", { + headers: { + "Set-Cookie": writeCookie("pronounscc-token", resp.token), + }, + status: 303, + }); + } catch (e) { + JSON.stringify(e); + + return json({ error: e as ApiError }); + } +}; export default function DiscordCallbackPage() { const { t } = useTranslation(); const data = useLoaderData(); + const actionData = useActionData(); if (data.hasAccount) { const username = data.user!.username; - + return ( <>

    {t("log-in.callback.success")}

    - + {/* @ts-expect-error react-i18next handles interpolation here */} - Welcome back, @{{username}}! + Welcome back, @{{ username }}!
    {t("log-in.callback.redirect-hint")} @@ -66,6 +114,7 @@ export default function DiscordCallbackPage() { return (

    + {actionData?.error && } {t("log-in.callback.remote-username.discord")} @@ -82,3 +131,34 @@ export default function DiscordCallbackPage() { ); } + +function RegisterError({ error }: { error: ApiError }) { + const { t } = useTranslation(); + + // TODO: maybe turn these messages into their own error codes? + const ticketMessage = firstErrorFor(error, "ticket")?.message; + const usernameMessage = firstErrorFor(error, "username")?.message; + + if (ticketMessage === "Invalid ticket") { + return ( + + {t("error.heading")} + + Invalid ticket (it might have been too long since you logged in with Discord), please{" "} + try again. + + + ); + } + + if (usernameMessage === "Username is already taken") { + return ( + + {t("log-in.callback.invalid-username")} + {t("log-in.callback.username-taken")} + + ); + } + + return ; +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 83455f7..f2dfbf2 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -1,52 +1,62 @@ { - "error": { - "heading": "An error occurred", - "errors": { - "authentication-error": "There was an error validating your credentials.", - "authentication-required": "You need to log in.", - "bad-request": "Server rejected your input, please check anything for errors.", - "forbidden": "You are not allowed to perform that action.", - "generic-error": "An unknown error occurred.", - "internal-server-error": "Server experienced an internal error, please try again later.", - "member-not-found": "Member not found, please check your spelling and try again.", - "user-not-found": "User not found, please check your spelling and try again." - }, - "title": "Error" - }, - "navbar": { - "view-profile": "View profile", - "settings": "Settings", - "log-out": "Log out", - "log-in": "Log in or sign up", - "theme": "Theme", - "theme-auto": "Automatic", - "theme-dark": "Dark", - "theme-light": "Light" - }, - "log-in": { - "callback": { - "success": "Successfully logged in!", - "success-link": "Welcome back, <1>@{{username}}!", - "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", - "remote-username": { - "discord": "Your discord username" - }, - "username": "Username", - "sign-up-button": "Sign up" - }, - "title": "Log in", - "form-title": "Log in with email", - "email": "Email address", - "password": "Password", - "log-in-button": "Log in", - "register-with-email": "Register with email", - "3rd-party": { - "title": "Log in with another service", - "desc": "If you prefer, you can also log in with one of these services:", - "discord": "Log in with Discord", - "google": "Log in with Google", - "tumblr": "Log in with Tumblr" - }, - "invalid-credentials": "Invalid email address or password, please check your spelling and try again." - } + "error": { + "heading": "An error occurred", + "validation": { + "too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.", + "too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.", + "disallowed-value": "The value <1>{{actualValue}} is not allowed here. Allowed values are: <4>{{allowedValues}}", + "generic": "The value <1>{{actualValue}} is not allowed here. Reason: {{reason}}", + "generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}" + }, + "errors": { + "authentication-error": "There was an error validating your credentials.", + "authentication-required": "You need to log in.", + "bad-request": "Server rejected your input, please check anything for errors.", + "forbidden": "You are not allowed to perform that action.", + "generic-error": "An unknown error occurred.", + "internal-server-error": "Server experienced an internal error, please try again later.", + "member-not-found": "Member not found, please check your spelling and try again.", + "user-not-found": "User not found, please check your spelling and try again." + }, + "title": "Error" + }, + "navbar": { + "view-profile": "View profile", + "settings": "Settings", + "log-out": "Log out", + "log-in": "Log in or sign up", + "theme": "Theme", + "theme-auto": "Automatic", + "theme-dark": "Dark", + "theme-light": "Light" + }, + "log-in": { + "callback": { + "success": "Successfully logged in!", + "success-link": "Welcome back, <1>@{{username}}!", + "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", + "remote-username": { + "discord": "Your discord username" + }, + "username": "Username", + "sign-up-button": "Sign up", + "invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again.", + "invalid-username": "Invalid username", + "username-taken": "That username is already taken, please try something else." + }, + "title": "Log in", + "form-title": "Log in with email", + "email": "Email address", + "password": "Password", + "log-in-button": "Log in", + "register-with-email": "Register with email", + "3rd-party": { + "title": "Log in with another service", + "desc": "If you prefer, you can also log in with one of these services:", + "discord": "Log in with Discord", + "google": "Log in with Google", + "tumblr": "Log in with Tumblr" + }, + "invalid-credentials": "Invalid email address or password, please check your spelling and try again." + } } From 2cef7523d23055d03d6977bb055f3afda8d0b0e9 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 14 Sep 2024 16:37:52 +0200 Subject: [PATCH 2/2] chore(backend): silence some more resharper errors --- Foxnouns.Backend/Config.cs | 1 - .../Authentication/AuthController.cs | 2 +- .../Authentication/DiscordAuthController.cs | 23 +++++++++++-------- .../Authentication/EmailAuthController.cs | 3 ++- .../Controllers/InternalController.cs | 6 ++--- .../Controllers/MembersController.cs | 8 +++---- .../Database/DatabaseQueryExtensions.cs | 3 +-- .../Database/Models/Application.cs | 2 +- Foxnouns.Backend/ExpectedError.cs | 6 ++--- Foxnouns.Backend/Foxnouns.Backend.csproj | 1 + .../Middleware/ErrorHandlerMiddleware.cs | 1 - Foxnouns.Backend/Services/AuthService.cs | 1 + Foxnouns.Backend/Services/KeyCacheService.cs | 1 - .../Services/RemoteAuthService.cs | 15 ++++++++---- 14 files changed, 38 insertions(+), 35 deletions(-) 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);