From ff22530f0a20f6768db1b60c16aa001b8cec0420 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 13 Sep 2024 14:56:38 +0200 Subject: [PATCH] feat(frontend): add discord callback page this only handles existing accounts for now, still need to write an action function --- .../inspectionProfiles/Project_Default.xml | 3 + .../Authentication/AuthController.cs | 15 +++- .../Authentication/DiscordAuthController.cs | 23 +++-- .../Authentication/EmailAuthController.cs | 3 +- .../Controllers/InternalController.cs | 5 +- .../Extensions/KeyCacheExtensions.cs | 2 +- .../Middleware/AuthenticationMiddleware.cs | 5 +- .../Services/RemoteAuthService.cs | 12 ++- Foxnouns.Frontend/app/lib/api/auth.ts | 9 ++ Foxnouns.Frontend/app/root.tsx | 5 +- .../routes/auth.callback.discord/route.tsx | 84 +++++++++++++++++ .../app/routes/auth.log-in/route.tsx | 6 +- Foxnouns.Frontend/public/locales/en.json | 90 ++++++++++--------- 13 files changed, 196 insertions(+), 66 deletions(-) create mode 100644 Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx diff --git a/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml index 03d9549..ec33848 100644 --- a/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/.idea.Foxnouns.NET/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,8 @@ \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 92267e6..4c83ce4 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -2,6 +2,7 @@ using System.Web; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; @@ -26,7 +27,7 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log $"https://discord.com/oauth2/authorize?response_type=code" + $"&client_id={config.DiscordAuth.ClientId}&scope=identify" + $"&prompt=none&state={state}" + - $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/login/discord")}"; + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; return Ok(new UrlsResponse(discord, null, null)); } @@ -45,8 +46,16 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log public record CallbackResponse( bool HasAccount, // If true, user has an account, but it's deleted - string Ticket, - string? RemoteUsername + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? Ticket, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? RemoteUsername, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + UserRendererService.UserResponse? User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? Token, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + Instant? ExpiresAt ); public record OauthRegisterRequest(string Ticket, string Username); diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 068c8e9..20840ad 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -25,7 +25,6 @@ public class DiscordAuthController( [HttpPost("callback")] // TODO: duplicating attribute doesn't work, find another way to mark both as possible response // leaving it here for documentation purposes - [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) { @@ -42,7 +41,14 @@ public class DiscordAuthController( var ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); - return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); + return Ok(new AuthController.CallbackResponse( + HasAccount: false, + Ticket: ticket, + RemoteUsername: remoteUser.Username, + User: null, + Token: null, + ExpiresAt: null + )); } [HttpPost("register")] @@ -64,7 +70,7 @@ public class DiscordAuthController( return Ok(await GenerateUserTokenAsync(user, ct)); } - 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); @@ -77,10 +83,13 @@ public class DiscordAuthController( await db.SaveChangesAsync(ct); - return new AuthController.AuthResponse( - await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), - tokenStr, - token.ExpiresAt + return new AuthController.CallbackResponse( + HasAccount: true, + Ticket: null, + RemoteUsername: null, + User: await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), + Token: tokenStr, + ExpiresAt: token.ExpiresAt ); } diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index e396b9c..b7e8ff4 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -59,7 +59,8 @@ public class EmailAuthController( var ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); - return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); + return Ok(new AuthController.CallbackResponse(HasAccount: false, Ticket: ticket, RemoteUsername: state.Email, + User: null, Token: null, ExpiresAt: null)); } [HttpPost("complete-registration")] diff --git a/Foxnouns.Backend/Controllers/InternalController.cs b/Foxnouns.Backend/Controllers/InternalController.cs index cda2edb..265cf3d 100644 --- a/Foxnouns.Backend/Controllers/InternalController.cs +++ b/Foxnouns.Backend/Controllers/InternalController.cs @@ -1,4 +1,3 @@ -using System.Security.Cryptography; using System.Text.RegularExpressions; using Foxnouns.Backend.Database; using Foxnouns.Backend.Utils; @@ -6,14 +5,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Routing.Template; -using Microsoft.EntityFrameworkCore; -using NodaTime; namespace Foxnouns.Backend.Controllers; [ApiController] [Route("/api/internal")] -public partial class InternalController(DatabaseContext db, IClock clock) : ControllerBase +public partial class InternalController(DatabaseContext db) : ControllerBase { [GeneratedRegex(@"(\{\w+\})")] private static partial Regex PathVarRegex(); diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 7de7396..4f70bee 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -11,7 +11,7 @@ public static class KeyCacheExtensions public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService, CancellationToken ct = default) { - var state = AuthUtils.RandomToken(); + var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } diff --git a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs index 36cbcb3..5c69b79 100644 --- a/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs @@ -1,13 +1,10 @@ -using System.Security.Cryptography; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; -using Microsoft.EntityFrameworkCore; -using NodaTime; namespace Foxnouns.Backend.Middleware; -public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddleware +public class AuthenticationMiddleware(DatabaseContext db) : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs index 389412f..b08b0a5 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -3,8 +3,9 @@ using System.Web; namespace Foxnouns.Backend.Services; -public class RemoteAuthService(Config config) +public class RemoteAuthService(Config config, ILogger logger) { + private readonly ILogger _logger = logger.ForContext(); private readonly HttpClient _httpClient = new(); private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); @@ -12,7 +13,7 @@ public class RemoteAuthService(Config config) public async Task RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) { - var redirectUri = $"{config.BaseUrl}/auth/login/discord"; + var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( new Dictionary { @@ -23,6 +24,13 @@ public class RemoteAuthService(Config config) { "redirect_uri", redirectUri } } ), ct); + 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); + 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"); diff --git a/Foxnouns.Frontend/app/lib/api/auth.ts b/Foxnouns.Frontend/app/lib/api/auth.ts index bda9925..a0d5bf1 100644 --- a/Foxnouns.Frontend/app/lib/api/auth.ts +++ b/Foxnouns.Frontend/app/lib/api/auth.ts @@ -6,6 +6,15 @@ export type AuthResponse = { expires_at: string; }; +export type CallbackResponse = { + has_account: boolean; + ticket?: string; + remote_username?: string; + user?: User; + token?: string; + expires_at?: string; +}; + export type AuthUrls = { discord?: string; google?: string; diff --git a/Foxnouns.Frontend/app/root.tsx b/Foxnouns.Frontend/app/root.tsx index 8452919..a3296b4 100644 --- a/Foxnouns.Frontend/app/root.tsx +++ b/Foxnouns.Frontend/app/root.tsx @@ -23,6 +23,7 @@ import "./app.scss"; import getLocalSettings from "./lib/settings.server"; import { LANGUAGE } from "~/env.server"; import { errorCodeDesc } from "./components/ErrorAlert"; +import { Container } from "react-bootstrap"; export const loader = async ({ request }: LoaderFunctionArgs) => { const meta = await serverRequest("GET", "/meta"); @@ -141,7 +142,9 @@ export default function App() { return ( <> - + + + ); } diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx new file mode 100644 index 0000000..f946ee0 --- /dev/null +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -0,0 +1,84 @@ +import { json, LoaderFunctionArgs } from "@remix-run/node"; +import { type ApiError, ErrorCode } 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 { Trans, useTranslation } from "react-i18next"; +import Form from "react-bootstrap/Form"; +import Button from "react-bootstrap/Button"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + if (!code || !state) + throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code or state" } as ApiError; + + const resp = await serverRequest("POST", "/auth/discord/callback", { + body: { code, state } + }); + + if (resp.has_account) { + return json( + { hasAccount: true, user: resp.user!, ticket: null, remoteUser: null }, + { + headers: { + "Set-Cookie": writeCookie("pronounscc-token", resp.token!) + } + } + ); + } + + return json({ + hasAccount: false, + user: null, + ticket: resp.ticket!, + remoteUser: resp.remote_username! + }); +}; + +// TODO: action function + +export default function DiscordCallbackPage() { + const { t } = useTranslation(); + const data = useLoaderData(); + + 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}}! + +
+ {t("log-in.callback.redirect-hint")} +

+ + ); + } + + return ( + +
+ + {t("log-in.callback.remote-username.discord")} + + + + {t("log-in.callback.username")} + + + + +
+
+ ); +} diff --git a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx index 3d0d387..1f55fda 100644 --- a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx @@ -10,7 +10,7 @@ import Form from "react-bootstrap/Form"; import Button from "react-bootstrap/Button"; import ButtonGroup from "react-bootstrap/ButtonGroup"; import ListGroup from "react-bootstrap/ListGroup"; -import { Container, Row, Col } from "react-bootstrap"; +import { Row, Col } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import i18n from "~/i18next.server"; import serverRequest, { getToken, writeCookie } from "~/lib/request.server"; @@ -72,7 +72,7 @@ export default function LoginPage() { const actionData = useActionData(); return ( - + <>

{t("log-in.form-title")}

@@ -121,7 +121,7 @@ export default function LoginPage() {
-
+ ); } diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index d383266..83455f7 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -1,42 +1,52 @@ { - "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": { - "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", + "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." + } }