mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
Link Discord from the user page and document the setup
The user page gets a Link Discord button. It asks the controller for a one-time ticket bound to the logged in user, then goes through the authorize route with it. The callback links the Discord account to that user directly, without the in-game code, and goes back to the user page. The link flow always shows the Discord consent screen. The README covers registering the redirect URL and the config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e9f780cc2
commit
e4fda18276
6 files changed
+261
-30
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# ExpGaming - Discord
|
||||
|
||||
Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an application in the [Discord developer portal](https://discord.com/developers/applications).
|
||||
2. Under OAuth2, copy the Client ID and reset the Client Secret to get one.
|
||||
3. Under OAuth2 → Redirects, add `<controller.public_url>api/exp_discord/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord/callback`. It must match exactly, including the trailing path and http or https.
|
||||
4. Set the config on the controller:
|
||||
|
||||
```
|
||||
npx clusterioctl controller config set controller.public_url https://example.com/cluster/
|
||||
npx clusterioctl controller config set exp_discord.client_id <client id>
|
||||
npx clusterioctl controller config set exp_discord.client_secret <client secret>
|
||||
```
|
||||
|
||||
`controller.public_url` has to be set, because the redirect URL is built from it and Discord rejects any URL that isn't registered.
|
||||
|
||||
## Config
|
||||
|
||||
All fields are on the controller.
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `exp_discord.client_id` | | Application ID of the Discord application. |
|
||||
| `exp_discord.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. |
|
||||
| `exp_discord.max_accounts` | 3 | How many Factorio accounts one Discord account can link. |
|
||||
|
||||
## Linking
|
||||
|
||||
There are two ways to link a Factorio account:
|
||||
|
||||
- On the user page, a logged in user clicks Link Discord and approves it on Discord.
|
||||
- On the login page, a player logs in with Discord and gets a code, then types `/link-discord CODE` on any server. This is how a player who can't log in to the web interface yet links their account.
|
||||
|
||||
Linking a Factorio account that is already linked to another Discord account moves it. `/link-discord` without a code shows the current link, and `/unlink-discord` removes it. The user page has an Unlink button too.
|
||||
|
||||
Users can always see and remove their own link. The `exp_discord.link.get` and `exp_discord.link.delete` permissions allow it for other users.
|
||||
|
||||
Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way.
|
||||
|
||||
Links are stored in `database/exp_discord/accounts.json` on the controller.
|
||||
+84
-27
@@ -15,6 +15,7 @@ const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
type DiscordUser = { id: string, name: string };
|
||||
type Session = DiscordUser & { expiresMs: number, code?: string };
|
||||
type LinkCode = { sessionId: string, expiresMs: number };
|
||||
type WebLink = { playerName: string, expiresMs: number };
|
||||
|
||||
/** What an http route answers with, kept apart from express so it can be tested. */
|
||||
export type RouteResult =
|
||||
@@ -55,6 +56,10 @@ export class ControllerPlugin {
|
||||
sessions = new Map<string, Session>();
|
||||
/** Codes waiting to be used in game, by code */
|
||||
codes = new Map<string, LinkCode>();
|
||||
/** Links started from the user page, by ticket until authorize turns them into a state */
|
||||
linkTickets = new Map<string, WebLink>();
|
||||
/** Links waiting for the Discord callback, by OAuth state */
|
||||
pendingLinks = new Map<string, WebLink>();
|
||||
cleanupInterval?: ReturnType<typeof setInterval>;
|
||||
|
||||
private constructor(
|
||||
@@ -80,6 +85,7 @@ export class ControllerPlugin {
|
||||
controller.handle(messages.DiscordLinkRequest, plugin.handleLinkRequest.bind(plugin));
|
||||
controller.handle(messages.DiscordUnlinkRequest, plugin.handleUnlinkRequest.bind(plugin));
|
||||
controller.handle(messages.DiscordLinkGetRequest, plugin.handleLinkGetRequest.bind(plugin));
|
||||
controller.handle(messages.DiscordLinkStartRequest, plugin.handleLinkStartRequest.bind(plugin));
|
||||
|
||||
plugin.addRoutes();
|
||||
plugin.cleanupInterval = setInterval(() => plugin.removeExpired(), 60e3);
|
||||
@@ -105,9 +111,11 @@ export class ControllerPlugin {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [code, entry] of this.codes) {
|
||||
if (entry.expiresMs < now) {
|
||||
this.codes.delete(code);
|
||||
for (const map of [this.codes, this.linkTickets, this.pendingLinks]) {
|
||||
for (const [key, entry] of map) {
|
||||
if (entry.expiresMs < now) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +141,7 @@ export class ControllerPlugin {
|
||||
}
|
||||
);
|
||||
|
||||
app.get("/api/exp_discord/authorize", route(async () => this.authorize()));
|
||||
app.get("/api/exp_discord/authorize", route(async req => this.authorize(req.query.link)));
|
||||
app.get("/api/exp_discord/callback", route(async req => this.callback(
|
||||
req.query, readCookie(req.headers.cookie, "exp_discord_state"),
|
||||
)));
|
||||
@@ -152,19 +160,36 @@ export class ControllerPlugin {
|
||||
}
|
||||
|
||||
/** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */
|
||||
webRedirect(key: "session" | "error", value: string): RouteResult {
|
||||
const url = new URL(this.controller.getControllerUrl());
|
||||
webRedirect(key: "session" | "error", value: string, page = ""): RouteResult {
|
||||
const url = new URL(page, this.controller.getControllerUrl());
|
||||
url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`;
|
||||
return { redirect: url.href };
|
||||
}
|
||||
|
||||
async authorize(): Promise<RouteResult> {
|
||||
userPage(playerName: string) {
|
||||
return `users/${encodeURIComponent(playerName)}/view`;
|
||||
}
|
||||
|
||||
async authorize(linkTicket?: unknown): Promise<RouteResult> {
|
||||
let link: WebLink | undefined;
|
||||
if (linkTicket !== undefined) {
|
||||
link = typeof linkTicket === "string" ? this.linkTickets.get(linkTicket) : undefined;
|
||||
if (!link || link.expiresMs < Date.now()) {
|
||||
return this.webRedirect("error", "Discord link expired, please try again", "users");
|
||||
}
|
||||
this.linkTickets.delete(linkTicket as string);
|
||||
}
|
||||
const page = link ? this.userPage(link.playerName) : "";
|
||||
|
||||
const clientId = this.controller.config.get("exp_discord.client_id");
|
||||
if (!clientId || !this.controller.config.get("exp_discord.client_secret")) {
|
||||
return this.webRedirect("error", "Discord login is not configured on this controller");
|
||||
return this.webRedirect("error", "Discord login is not configured on this controller", page);
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString("base64url");
|
||||
if (link) {
|
||||
this.pendingLinks.set(state, { playerName: link.playerName, expiresMs: Date.now() + stateTimeoutS * 1e3 });
|
||||
}
|
||||
const url = new URL("https://discord.com/oauth2/authorize");
|
||||
url.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
@@ -172,7 +197,8 @@ export class ControllerPlugin {
|
||||
scope: "identify",
|
||||
state,
|
||||
redirect_uri: this.redirectUri(),
|
||||
prompt: "none",
|
||||
// Linking always shows the Discord consent screen, so it can't happen without the user noticing
|
||||
...(link ? {} : { prompt: "none" }),
|
||||
}).toString();
|
||||
|
||||
const callbackUrl = new URL(this.redirectUri());
|
||||
@@ -184,14 +210,24 @@ export class ControllerPlugin {
|
||||
}
|
||||
|
||||
async callback(query: Record<string, unknown>, cookieState: string | undefined): Promise<RouteResult> {
|
||||
if (typeof query.error === "string") {
|
||||
return this.webRedirect("error", query.error === "access_denied" ? "Discord login was cancelled" : query.error);
|
||||
const link = typeof query.state === "string" ? this.pendingLinks.get(query.state) : undefined;
|
||||
if (link) {
|
||||
this.pendingLinks.delete(query.state as string);
|
||||
}
|
||||
if (typeof query.state !== "string" || !cookieState || query.state !== cookieState) {
|
||||
return this.webRedirect("error", "Discord login expired, please try again");
|
||||
const page = link ? this.userPage(link.playerName) : "";
|
||||
|
||||
if (typeof query.error === "string") {
|
||||
const message = query.error === "access_denied" ? "Discord login was cancelled" : query.error;
|
||||
return this.webRedirect("error", message, page);
|
||||
}
|
||||
if (
|
||||
typeof query.state !== "string" || !cookieState || query.state !== cookieState
|
||||
|| (link && link.expiresMs < Date.now())
|
||||
) {
|
||||
return this.webRedirect("error", "Discord login expired, please try again", page);
|
||||
}
|
||||
if (typeof query.code !== "string") {
|
||||
return this.webRedirect("error", "Discord did not return a login code");
|
||||
return this.webRedirect("error", "Discord did not return a login code", page);
|
||||
}
|
||||
|
||||
let discordUser;
|
||||
@@ -199,7 +235,16 @@ export class ControllerPlugin {
|
||||
discordUser = await this.fetchDiscordUser(query.code);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Discord login failed: ${err.message}`);
|
||||
return this.webRedirect("error", "Discord login failed, please try again");
|
||||
return this.webRedirect("error", "Discord login failed, please try again", page);
|
||||
}
|
||||
|
||||
if (link) {
|
||||
try {
|
||||
this.linkAccount(discordUser, link.playerName);
|
||||
} catch (err: any) {
|
||||
return this.webRedirect("error", err.message, page);
|
||||
}
|
||||
return { redirect: new URL(page, this.controller.getControllerUrl()).href };
|
||||
}
|
||||
|
||||
const account = this.accounts.getMutable(discordUser.id);
|
||||
@@ -351,34 +396,46 @@ export class ControllerPlugin {
|
||||
throw new lib.RequestError("This code is invalid or has expired, get a new one from the website");
|
||||
}
|
||||
|
||||
const name = request.playerName.toLowerCase();
|
||||
const existing = this.accounts.get(session.id);
|
||||
const linked = this.linkAccount(session, request.playerName);
|
||||
this.codes.delete(code);
|
||||
delete session.code;
|
||||
return linked;
|
||||
}
|
||||
|
||||
/** Link a player who proved they own the Factorio account, it moves over from any other Discord account. */
|
||||
linkAccount(discordUser: DiscordUser, playerName: string) {
|
||||
const name = playerName.toLowerCase();
|
||||
const existing = this.accounts.get(discordUser.id);
|
||||
const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name);
|
||||
const maxAccounts = this.controller.config.get("exp_discord.max_accounts");
|
||||
if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) {
|
||||
throw new lib.RequestError(
|
||||
`${session.name} already has ${maxAccounts} Factorio accounts linked, unlink one first`
|
||||
`${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first`
|
||||
);
|
||||
}
|
||||
|
||||
this.codes.delete(code);
|
||||
delete session.code;
|
||||
if (!alreadyLinked) {
|
||||
// The player proved they own this Factorio account, so it moves over from any other Discord account
|
||||
this.removeLink(request.playerName);
|
||||
const account = this.accounts.getMutable(session.id)
|
||||
?? new messages.DiscordAccountRecord(session.id, session.name, []);
|
||||
account.name = session.name;
|
||||
account.factorioNames = [...account.factorioNames, request.playerName];
|
||||
this.removeLink(playerName);
|
||||
const account = this.accounts.getMutable(discordUser.id)
|
||||
?? new messages.DiscordAccountRecord(discordUser.id, discordUser.name, []);
|
||||
account.name = discordUser.name;
|
||||
account.factorioNames = [...account.factorioNames, playerName];
|
||||
this.accounts.set(account);
|
||||
}
|
||||
return { id: session.id, name: session.name };
|
||||
return { id: discordUser.id, name: discordUser.name };
|
||||
}
|
||||
|
||||
async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) {
|
||||
return this.removeLink(request.playerName);
|
||||
}
|
||||
|
||||
/** A ticket for /api/exp_discord/authorize?link= which links the Discord account to the user. */
|
||||
async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) {
|
||||
const ticket = crypto.randomBytes(16).toString("base64url");
|
||||
this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) {
|
||||
const account = this.findAccount(request.playerName);
|
||||
return account ? { id: account.id, name: account.name } : null;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages.DiscordLinkRequest,
|
||||
messages.DiscordUnlinkRequest,
|
||||
messages.DiscordLinkGetRequest,
|
||||
messages.DiscordLinkStartRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
|
||||
@@ -64,6 +64,14 @@ function selfOrPermission(permission: lib.PermissionName) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the user themselves, no permission lets someone link another user to their Discord. */
|
||||
function selfOnly(user: lib.IUser, message: lib.MessageRequest) {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() !== playerName.toLowerCase()) {
|
||||
throw new lib.PermissionError("You can only link your own account");
|
||||
}
|
||||
}
|
||||
|
||||
const LinkResponse = lib.plainJson(Type.Union([
|
||||
Type.Null(),
|
||||
Type.Object({
|
||||
@@ -157,3 +165,30 @@ export class DiscordLinkGetRequest {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start linking the Discord account from the user page, responds with a ticket for the authorize route. */
|
||||
export class DiscordLinkStartRequest {
|
||||
declare ["constructor"]: typeof DiscordLinkStartRequest;
|
||||
static plugin = "exp_discord" as const;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOnly;
|
||||
static Response = lib.plainJson(Type.String());
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,60 @@ t.test("linking", t2 => {
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("linking from the user page", t2 => {
|
||||
/** Start a link for playerName and follow authorize, returning the OAuth state. */
|
||||
async function start(plugin, playerName) {
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName));
|
||||
const result = await plugin.authorize(ticket);
|
||||
return new URL(result.redirect).searchParams;
|
||||
}
|
||||
|
||||
t2.test("links the Discord account and goes back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
t3.equal(params.get("prompt"), null, "consent screen is shown");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view");
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]);
|
||||
t3.equal(plugin.sessions.size, 0, "no login session is started");
|
||||
t3.equal(plugin.pendingLinks.size, 0);
|
||||
});
|
||||
|
||||
t2.test("a ticket can only be used once", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1"));
|
||||
await plugin.authorize(ticket);
|
||||
const result = await plugin.authorize(ticket);
|
||||
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_error=Discord%20link%20expired/);
|
||||
});
|
||||
|
||||
t2.test("errors go back to the user page", async t3 => {
|
||||
const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 1 } });
|
||||
await link(plugin, await discordLogin(plugin, alice), "Player2");
|
||||
|
||||
let params = await start(plugin, "Player1");
|
||||
let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=Discord%20login%20was%20cancelled$/);
|
||||
|
||||
params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => alice;
|
||||
result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
||||
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=.*already%20has%201/);
|
||||
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]);
|
||||
});
|
||||
|
||||
t2.test("a state without the cookie does not link", async t3 => {
|
||||
const { plugin } = await startPlugin(t3);
|
||||
const params = await start(plugin, "Player1");
|
||||
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
||||
const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined);
|
||||
t3.match(result.redirect, "/users/Player1/view#exp_discord_error=");
|
||||
t3.notOk(plugin.accounts.has(alice.id));
|
||||
});
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("permissions", t2 => {
|
||||
const user = (name, granted = []) => ({
|
||||
name,
|
||||
@@ -228,6 +282,15 @@ t.test("permissions", t2 => {
|
||||
});
|
||||
const request = name => ({ data: { player_name: name } });
|
||||
|
||||
t2.test("DiscordLinkStartRequest", async t3 => {
|
||||
const permission = messages.DiscordLinkStartRequest.permission;
|
||||
t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account");
|
||||
t3.throws(
|
||||
() => permission(user("Alice", ["exp_discord.link.get", "exp_discord.link.delete"]), request("Bob")),
|
||||
"never someone else's",
|
||||
);
|
||||
});
|
||||
|
||||
for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) {
|
||||
t2.test(Request.name, async t3 => {
|
||||
const permission = Request === messages.DiscordUnlinkRequest
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Descriptions, Popconfirm } from "antd";
|
||||
import { Alert, Button, Descriptions, Popconfirm } from "antd";
|
||||
|
||||
import {
|
||||
ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount,
|
||||
} from "@clusterio/web_ui";
|
||||
|
||||
import { DiscordLinkGetRequest, DiscordUnlinkRequest } from "../../messages.js";
|
||||
import { DiscordLinkGetRequest, DiscordLinkStartRequest, DiscordUnlinkRequest } from "../../messages.js";
|
||||
|
||||
declare const webRoot: string;
|
||||
|
||||
type Link = { id: string, name: string } | null;
|
||||
|
||||
/** An error from linking on Discord, taken out of the url so a reload does not show it again. */
|
||||
function readError() {
|
||||
const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_error");
|
||||
if (error) {
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** The Discord account a user is linked to, shown on their user page. */
|
||||
export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) {
|
||||
const control = useContext(ControlContext);
|
||||
@@ -17,6 +28,7 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag
|
||||
const canGet = own || account.hasPermission("exp_discord.link.get");
|
||||
const canDelete = own || account.hasPermission("exp_discord.link.delete");
|
||||
const [link, setLink] = useState<Link | undefined>(undefined);
|
||||
const [error, setError] = useState(readError);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGet) {
|
||||
@@ -36,8 +48,21 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag
|
||||
setLink(null);
|
||||
}
|
||||
|
||||
async function startLink() {
|
||||
const ticket = await control.send(new DiscordLinkStartRequest(props.user.name));
|
||||
window.location.href = `${webRoot}api/exp_discord/authorize?link=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader title="Discord" />
|
||||
{error && <Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>}
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Linked account">
|
||||
{link ? <>
|
||||
@@ -48,7 +73,14 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag
|
||||
>
|
||||
<Button danger size="small" style={{ marginLeft: 8 }}>Unlink</Button>
|
||||
</Popconfirm>}
|
||||
</> : "Not linked"}
|
||||
</> : <>
|
||||
Not linked
|
||||
{own && <Button
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => { startLink().catch(notifyErrorHandler("Error linking Discord")); }}
|
||||
>Link Discord</Button>}
|
||||
</>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>;
|
||||
|
||||
Reference in new issue
Block a user