feat(frontend): add discord callback page

this only handles existing accounts for now, still need to write an action function
This commit is contained in:
sam 2024-09-13 14:56:38 +02:00
parent 116d0577a7
commit ff22530f0a
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
13 changed files with 196 additions and 66 deletions

View file

@ -2,5 +2,8 @@
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RequiredAttributes" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myAdditionalRequiredHtmlAttributes" value="column" />
</inspection_tool>
</profile> </profile>
</component> </component>

View file

@ -2,6 +2,7 @@ using System.Web;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication; 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" + $"https://discord.com/oauth2/authorize?response_type=code" +
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" + $"&client_id={config.DiscordAuth.ClientId}&scope=identify" +
$"&prompt=none&state={state}" + $"&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)); return Ok(new UrlsResponse(discord, null, null));
} }
@ -45,8 +46,16 @@ public class AuthController(Config config, KeyCacheService keyCache, ILogger log
public record CallbackResponse( public record CallbackResponse(
bool HasAccount, // If true, user has an account, but it's deleted bool HasAccount, // If true, user has an account, but it's deleted
string Ticket, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? RemoteUsername 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); public record OauthRegisterRequest(string Ticket, string Username);

View file

@ -25,7 +25,6 @@ public class DiscordAuthController(
[HttpPost("callback")] [HttpPost("callback")]
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response // TODO: duplicating attribute doesn't work, find another way to mark both as possible response
// leaving it here for documentation purposes // leaving it here for documentation purposes
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default)
{ {
@ -42,7 +41,14 @@ public class DiscordAuthController(
var ticket = AuthUtils.RandomToken(); var ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); 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")] [HttpPost("register")]
@ -64,7 +70,7 @@ public class DiscordAuthController(
return Ok(await GenerateUserTokenAsync(user, ct)); return Ok(await GenerateUserTokenAsync(user, ct));
} }
private async Task<AuthController.AuthResponse> GenerateUserTokenAsync(User user, CancellationToken ct = default) private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(User user, CancellationToken ct = default)
{ {
var frontendApp = await db.GetFrontendApplicationAsync(ct); var frontendApp = await db.GetFrontendApplicationAsync(ct);
_logger.Debug("Logging user {Id} in with Discord", user.Id); _logger.Debug("Logging user {Id} in with Discord", user.Id);
@ -77,10 +83,13 @@ public class DiscordAuthController(
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return new AuthController.AuthResponse( return new AuthController.CallbackResponse(
await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), HasAccount: true,
tokenStr, Ticket: null,
token.ExpiresAt RemoteUsername: null,
User: await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct),
Token: tokenStr,
ExpiresAt: token.ExpiresAt
); );
} }

View file

@ -59,7 +59,8 @@ public class EmailAuthController(
var ticket = AuthUtils.RandomToken(); var ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); 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")] [HttpPost("complete-registration")]

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
@ -6,14 +5,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Template;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
[ApiController] [ApiController]
[Route("/api/internal")] [Route("/api/internal")]
public partial class InternalController(DatabaseContext db, IClock clock) : ControllerBase public partial class InternalController(DatabaseContext db) : ControllerBase
{ {
[GeneratedRegex(@"(\{\w+\})")] [GeneratedRegex(@"(\{\w+\})")]
private static partial Regex PathVarRegex(); private static partial Regex PathVarRegex();

View file

@ -11,7 +11,7 @@ public static class KeyCacheExtensions
public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService, public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheService,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var state = AuthUtils.RandomToken(); var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
return state; return state;
} }

View file

@ -1,13 +1,10 @@
using System.Security.Cryptography;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Middleware; 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) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {

View file

@ -3,8 +3,9 @@ using System.Web;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class RemoteAuthService(Config config) public class RemoteAuthService(Config config, ILogger logger)
{ {
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
private readonly HttpClient _httpClient = new(); private readonly HttpClient _httpClient = new();
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
@ -12,7 +13,7 @@ public class RemoteAuthService(Config config)
public async Task<RemoteUser> RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) public async Task<RemoteUser> 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( var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent(
new Dictionary<string, string> new Dictionary<string, string>
{ {
@ -23,6 +24,13 @@ public class RemoteAuthService(Config config)
{ "redirect_uri", redirectUri } { "redirect_uri", redirectUri }
} }
), ct); ), 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(); resp.EnsureSuccessStatusCode();
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct); var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct);
if (token == null) throw new FoxnounsError("Discord token response was null"); if (token == null) throw new FoxnounsError("Discord token response was null");

View file

@ -6,6 +6,15 @@ export type AuthResponse = {
expires_at: string; expires_at: string;
}; };
export type CallbackResponse = {
has_account: boolean;
ticket?: string;
remote_username?: string;
user?: User;
token?: string;
expires_at?: string;
};
export type AuthUrls = { export type AuthUrls = {
discord?: string; discord?: string;
google?: string; google?: string;

View file

@ -23,6 +23,7 @@ import "./app.scss";
import getLocalSettings from "./lib/settings.server"; import getLocalSettings from "./lib/settings.server";
import { LANGUAGE } from "~/env.server"; import { LANGUAGE } from "~/env.server";
import { errorCodeDesc } from "./components/ErrorAlert"; import { errorCodeDesc } from "./components/ErrorAlert";
import { Container } from "react-bootstrap";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const meta = await serverRequest<Meta>("GET", "/meta"); const meta = await serverRequest<Meta>("GET", "/meta");
@ -141,7 +142,9 @@ export default function App() {
return ( return (
<> <>
<Navbar meta={meta} user={meUser} settings={settings} /> <Navbar meta={meta} user={meUser} settings={settings} />
<Container>
<Outlet /> <Outlet />
</Container>
</> </>
); );
} }

View file

@ -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<CallbackResponse>("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<typeof loader>();
if (data.hasAccount) {
const username = data.user!.username;
return (
<>
<h1>{t("log-in.callback.success")}</h1>
<p>
<Trans t={t} i18nKey={"log-in.callback.success-link"} values={{ username: data.user!.username }}>
{/* @ts-expect-error react-i18next handles interpolation here */}
Welcome back, <Link to={`/@${data.user!.username}`}>@{{username}}</Link>!
</Trans>
<br />
{t("log-in.callback.redirect-hint")}
</p>
</>
);
}
return (
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="remote-username">
<Form.Label>{t("log-in.callback.remote-username.discord")}</Form.Label>
<Form.Control type="text" readOnly={true} value={data.remoteUser!} />
</Form.Group>
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("log-in.callback.username")}</Form.Label>
<Form.Control name="username" type="text" required />
</Form.Group>
<input type="hidden" name="ticket" value={data.ticket!} />
<Button variant="primary" type="submit">
{t("log-in.callback.sign-up-button")}
</Button>
</Form>
</RemixForm>
);
}

View file

@ -10,7 +10,7 @@ import Form from "react-bootstrap/Form";
import Button from "react-bootstrap/Button"; import Button from "react-bootstrap/Button";
import ButtonGroup from "react-bootstrap/ButtonGroup"; import ButtonGroup from "react-bootstrap/ButtonGroup";
import ListGroup from "react-bootstrap/ListGroup"; 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 { useTranslation } from "react-i18next";
import i18n from "~/i18next.server"; import i18n from "~/i18next.server";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server"; import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
@ -72,7 +72,7 @@ export default function LoginPage() {
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
return ( return (
<Container> <>
<Row> <Row>
<Col md className="mb-4"> <Col md className="mb-4">
<h2>{t("log-in.form-title")}</h2> <h2>{t("log-in.form-title")}</h2>
@ -121,7 +121,7 @@ export default function LoginPage() {
</ListGroup> </ListGroup>
</Col> </Col>
</Row> </Row>
</Container> </>
); );
} }

View file

@ -24,6 +24,16 @@
"theme-light": "Light" "theme-light": "Light"
}, },
"log-in": { "log-in": {
"callback": {
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"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", "title": "Log in",
"form-title": "Log in with email", "form-title": "Log in with email",
"email": "Email address", "email": "Email address",