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:
parent
116d0577a7
commit
ff22530f0a
13 changed files with 196 additions and 66 deletions
|
@ -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>
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
84
Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx
Normal file
84
Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue