From f1cf6da17807215e4487148984399a24da2e9e75 Mon Sep 17 00:00:00 2001 From: KingRainbow44 Date: Tue, 16 May 2023 02:38:01 -0400 Subject: [PATCH] Implement proper handbook authentication (pt. 1) --- .github/workflows/build.yml | 2 +- .gitignore | 4 +- src/handbook/src/backend/server.ts | 80 +++++++- src/handbook/src/backend/types.ts | 1 + src/handbook/src/css/App.scss | 6 + src/handbook/src/css/pages/EntitiesPage.scss | 2 +- src/handbook/src/css/views/Overlay.scss | 12 ++ src/handbook/src/css/views/SideBar.scss | 6 + .../src/css/widgets/ServerSettings.scss | 117 ++++++++++++ src/handbook/src/main.tsx | 2 + src/handbook/src/ui/App.tsx | 13 +- src/handbook/src/ui/views/Overlay.tsx | 61 ++++++ src/handbook/src/ui/views/SideBar.tsx | 45 ++++- .../src/ui/widgets/ServerSettings.tsx | 176 ++++++++++++++++++ .../auth/AuthenticationSystem.java | 10 +- .../auth/DefaultAuthentication.java | 12 +- .../auth/DefaultAuthenticators.java | 68 ++++++- .../auth/HandbookAuthenticator.java | 34 ++++ .../emu/grasscutter/game/player/Player.java | 3 +- .../server/dispatch/DispatchClient.java | 51 +++-- .../server/dispatch/DispatchServer.java | 33 +++- .../server/dispatch/IDispatcher.java | 84 ++++++++- .../server/dispatch/PacketIds.java | 4 + .../grasscutter/server/http/HttpServer.java | 4 + .../http/documentation/HandbookHandler.java | 55 +++++- .../emu/grasscutter/utils/DispatchUtils.java | 137 +++++++++++++- .../utils/objects/HandbookBody.java | 4 + src/main/resources/html/handbook_auth.html | 46 +++++ 28 files changed, 1019 insertions(+), 53 deletions(-) create mode 100644 src/handbook/src/css/views/Overlay.scss create mode 100644 src/handbook/src/css/widgets/ServerSettings.scss create mode 100644 src/handbook/src/ui/views/Overlay.tsx create mode 100644 src/handbook/src/ui/widgets/ServerSettings.tsx create mode 100644 src/main/java/emu/grasscutter/auth/HandbookAuthenticator.java create mode 100644 src/main/resources/html/handbook_auth.html diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ea446776..c80e7f3ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: uses: suisei-cn/actions-download-file@v1.4.0 with: url: https://api.grasscutter.io/static/handbook.html - target: src/main/resources/ + target: src/main/resources/html/ - name: Run Gradle run: ./gradlew && ./gradlew jar - name: Upload build diff --git a/.gitignore b/.gitignore index b0f366ea9..33047ac1d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,8 @@ tmp/ /*.sh GM Handbook*.txt +handbook.html + config.json mitmdump.exe mongod.exe @@ -75,8 +77,6 @@ mappings.js BuildConfig.java data/hk4e/announcement/ -src/main/resources/handbook.html - # lombok /.apt_generated/ diff --git a/src/handbook/src/backend/server.ts b/src/handbook/src/backend/server.ts index 40ec54e7a..29d670cc7 100644 --- a/src/handbook/src/backend/server.ts +++ b/src/handbook/src/backend/server.ts @@ -1,22 +1,86 @@ import type { CommandResponse } from "@backend/types"; import emitter from "@backend/events"; -let targetPlayer = 0; // The UID of the target player. +let playerToken: string | null = null; // The session token for the player. +export let targetPlayer = 0; // The UID of the target player. + +// The server's address and port. +export let address: string = "127.0.0.1", port: string = "443"; +export let encrypted: boolean = true; + +export let lockedPlayer = false; // Whether the UID field is locked. export let connected = false; // Whether the server is connected. +/** + * Loads the server details from local storage. + */ +export function setup(): void { + // Load the server details from local storage. + const storedAddress = localStorage.getItem("address"); + const storedPort = localStorage.getItem("port"); + + // Set the server details. + if (storedAddress) address = storedAddress; + if (storedPort) port = storedPort; +} + +/** + * Returns the formed URL. + * This assumes that the server upgrades to HTTPS. + */ +export function url(): string { + // noinspection HttpUrlsUsage + return `http${window.isSecureContext || encrypted ? "s" : ""}://${address}:${port}`; +} + /** * Sets the target player. * * @param player The UID of the target player. + * @param token The session token for the player. */ -export function setTargetPlayer(player: number): void { +export function setTargetPlayer(player: number, token: string | null = null): void { + playerToken = token; targetPlayer = player; + + // Determine connected status. connected = !isNaN(player) && player > 0; + // Determine locked status. + lockedPlayer = connected && token != null; // Emit the connected event. emitter.emit("connected", connected); } +/** + * Sets the server details. + * + * @param newAddress The server's address. + * @param newPort The server's port. + */ +export async function setServerDetails(newAddress: string | null, newPort: string | null): Promise { + // Apply the new details. + if (newAddress != null) { + address = newAddress; + localStorage.setItem("address", newAddress); + } + if (newPort != null) { + port = newPort; + localStorage.setItem("port", newPort); + } + + // Check if the server is encrypted. + return new Promise((resolve) => { + encrypted = true; + fetch(`${url()}`) + .catch(() => { + encrypted = false; + resolve(); + }) + .then(() => resolve()); + }); +} + /** * Validates a number. * @@ -44,9 +108,10 @@ export async function grantAvatar( if (invalid(avatar) || invalid(level) || invalid(constellations) || invalid(talents)) return { status: -1, message: "Invalid arguments." }; - return await fetch(`https://localhost:443/handbook/avatar`, { + return await fetch(`${url()}/handbook/avatar`, { method: "POST", body: JSON.stringify({ + playerToken, player: targetPlayer.toString(), avatar: avatar.toString(), level, @@ -68,9 +133,10 @@ export async function giveItem(item: number, amount = 1): Promise { // Validate the number. if (isNaN(scene) || scene < 1) return { status: -1, message: "Invalid scene." }; - return await fetch(`https://localhost:443/handbook/teleport`, { + return await fetch(`${url()}/handbook/teleport`, { method: "POST", body: JSON.stringify({ + playerToken, player: targetPlayer.toString(), scene: scene.toString() }) @@ -108,9 +175,10 @@ export async function spawnEntity(entity: number, amount = 1, level = 1): Promis if (isNaN(entity) || isNaN(amount) || isNaN(level) || amount < 1 || level < 1 || level > 200) return { status: -1, message: "Invalid arguments." }; - return await fetch(`https://localhost:443/handbook/spawn`, { + return await fetch(`${url()}/handbook/spawn`, { method: "POST", body: JSON.stringify({ + playerToken, player: targetPlayer.toString(), entity: entity.toString(), amount, diff --git a/src/handbook/src/backend/types.ts b/src/handbook/src/backend/types.ts index 857f1aebb..0fc6f8e6b 100644 --- a/src/handbook/src/backend/types.ts +++ b/src/handbook/src/backend/types.ts @@ -1,4 +1,5 @@ export type Page = "Home" | "Commands" | "Avatars" | "Items" | "Entities" | "Scenes"; +export type Overlays = "None" | "ServerSettings"; export type Days = "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday"; export type Command = { diff --git a/src/handbook/src/css/App.scss b/src/handbook/src/css/App.scss index ba120df98..228e80a85 100644 --- a/src/handbook/src/css/App.scss +++ b/src/handbook/src/css/App.scss @@ -46,6 +46,12 @@ body { height: 100%; } +button { + background-color: transparent; + border: none; + cursor: pointer; +} + ::-webkit-scrollbar { width: 5px; } diff --git a/src/handbook/src/css/pages/EntitiesPage.scss b/src/handbook/src/css/pages/EntitiesPage.scss index 3da203659..b2e1e3d34 100644 --- a/src/handbook/src/css/pages/EntitiesPage.scss +++ b/src/handbook/src/css/pages/EntitiesPage.scss @@ -53,7 +53,7 @@ } .EntitiesPage_Input { - background-color: transparent; + background: none; border: none; color: var(--text-primary-color); diff --git a/src/handbook/src/css/views/Overlay.scss b/src/handbook/src/css/views/Overlay.scss new file mode 100644 index 000000000..2169a0b8a --- /dev/null +++ b/src/handbook/src/css/views/Overlay.scss @@ -0,0 +1,12 @@ +.Overlay { + display: flex; + position: absolute; + + justify-content: center; + align-items: center; + + width: 100vw; + height: 100vh; + + background-color: rgb(0, 0, 0, 0.35); +} diff --git a/src/handbook/src/css/views/SideBar.scss b/src/handbook/src/css/views/SideBar.scss index 1c810663d..d2b51bd95 100644 --- a/src/handbook/src/css/views/SideBar.scss +++ b/src/handbook/src/css/views/SideBar.scss @@ -72,3 +72,9 @@ color: var(--text-secondary-color); opacity: 1; } + +.SideBar_Input:disabled { + cursor: not-allowed; + border-radius: 10px ; + background-color: var(--background-color); +} diff --git a/src/handbook/src/css/widgets/ServerSettings.scss b/src/handbook/src/css/widgets/ServerSettings.scss new file mode 100644 index 000000000..f73568b5d --- /dev/null +++ b/src/handbook/src/css/widgets/ServerSettings.scss @@ -0,0 +1,117 @@ +.ServerSettings { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + border-radius: 10px; + + background-color: var(--accent-color); + + width: 100%; + height: 100%; + max-width: 620px; + max-height: 400px; + + padding: 10px; + box-sizing: border-box; +} + +.ServerSettings_Content { + display: flex; + align-items: center; + flex-direction: column; + + width: 100%; + + gap: 15px; +} + +.ServerSettings_Top { + height: 80%; +} + +.ServerSettings_Frame { + width: 100%; + height: 100%; + + border: 0; +} + +.ServerSettings_Title { + font-weight: bold; + font-size: 34px; + + text-align: center; + margin-bottom: 15px; + user-select: none; +} + +.ServerSettings_Details { + display: flex; + flex-direction: row; + justify-content: space-between; + + border-radius: 10px; + background-color: var(--secondary-color); + + width: 100%; + height: 100%; + max-width: 590px; + max-height: 50px; + + padding: 10px; + box-sizing: border-box; + + p { + font-size: 20px; + user-select: none; + } + + div { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + gap: 5px; + } + + input { + border: none; + background: none; + + font-size: 18px; + color: var(--text-primary-color); + + &:focus, &:active { + outline: none; + } + } +} + +.ServerSettings_Authenticate { + font-size: 20px; + border-radius: 10px; + background-color: var(--secondary-color); + + width: 100%; + height: 100%; + max-width: 210px; + max-height: 46px; + + cursor: pointer; + color: white; +} + +.ServerSettings_Save { + font-size: 20px; + border-radius: 10px; + background-color: var(--secondary-color); + + width: 100%; + height: 46px; + max-width: 120px; + + cursor: pointer; + color: white; +} diff --git a/src/handbook/src/main.tsx b/src/handbook/src/main.tsx index f80fd024a..91db20e49 100644 --- a/src/handbook/src/main.tsx +++ b/src/handbook/src/main.tsx @@ -3,12 +3,14 @@ import { createRoot } from "react-dom/client"; import * as data from "@backend/data"; import * as events from "@backend/events"; +import * as server from "@backend/server"; import App from "@ui/App"; // Call initial setup functions. data.setup(); events.setup(); +server.setup(); // Render the application. createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/handbook/src/ui/App.tsx b/src/handbook/src/ui/App.tsx index eae4e7194..72fe0b0cf 100644 --- a/src/handbook/src/ui/App.tsx +++ b/src/handbook/src/ui/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import SideBar from "@views/SideBar"; import Content from "@views/Content"; +import Overlay from "@views/Overlay"; import PlainText from "@views/PlainText"; import type { Page } from "@backend/types"; @@ -25,7 +26,6 @@ class App extends React.Component<{}, IState> { // Check if the window's href is a page. let targetPage = null; const page = window.location.href.split("/").pop(); - console.log(page); if (page != undefined && page != "") { // Convert the page to a Page type. @@ -44,7 +44,16 @@ class App extends React.Component<{}, IState> { return (
- {this.state.plain ? : <Content initial={this.state.initial} />} + + { + this.state.plain ? + <PlainText /> : + <Content + initial={this.state.initial} + /> + } + + <Overlay /> </div> ); } diff --git a/src/handbook/src/ui/views/Overlay.tsx b/src/handbook/src/ui/views/Overlay.tsx new file mode 100644 index 000000000..5bdb402f1 --- /dev/null +++ b/src/handbook/src/ui/views/Overlay.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +import ServerSettings from "@widgets/ServerSettings"; + +import type { Overlays } from "@backend/types"; + +import "@css/views/Overlay.scss"; +import events from "@backend/events"; + +interface IState { + page: Overlays; +} + +class Overlay extends React.Component<{}, IState> { + constructor(props: {}) { + super(props); + + this.state = { + page: "None" + }; + } + + /** + * Sets the page to display. + * + * @param page The page to display. + */ + private setPage(page: Overlays): void { + this.setState({ page }); + } + + /** + * Gets the page to display. + */ + private getPage(): React.ReactNode { + switch (this.state.page) { + default: + return undefined; + case "ServerSettings": + return <ServerSettings />; + } + } + + componentDidMount() { + events.on("overlay", this.setPage.bind(this)); + } + + componentWillUnmount() { + events.off("overlay", this.setPage.bind(this)); + } + + render() { + return this.state.page != "None" ? ( + <div className={"Overlay"}> + {this.getPage()} + </div> + ) : undefined; + } +} + +export default Overlay; diff --git a/src/handbook/src/ui/views/SideBar.tsx b/src/handbook/src/ui/views/SideBar.tsx index 565bea6e7..e712ae142 100644 --- a/src/handbook/src/ui/views/SideBar.tsx +++ b/src/handbook/src/ui/views/SideBar.tsx @@ -10,13 +10,14 @@ import Icon_Map from "@assets/Icon_Map.webp"; import Icon_Quests from "@assets/Icon_Quests.webp"; import Icon_Achievements from "@assets/Icon_Achievements.webp"; -import { navigate } from "@backend/events"; -import { setTargetPlayer } from "@backend/server"; +import events, { navigate } from "@backend/events"; +import { targetPlayer, lockedPlayer, setTargetPlayer } from "@backend/server"; import "@css/views/SideBar.scss"; interface IState { uid: string | null; + uidLocked: boolean; } class SideBar extends React.Component<{}, IState> { @@ -24,10 +25,22 @@ class SideBar extends React.Component<{}, IState> { super(props); this.state = { - uid: null + uid: targetPlayer > 0 ? targetPlayer.toString() : null, + uidLocked: lockedPlayer }; } + /** + * Invoked when the player's UID changes. + * @private + */ + private updateUid(): void { + this.setState({ + uid: targetPlayer > 0 ? targetPlayer.toString() : null, + uidLocked: lockedPlayer + }); + } + /** * Invoked when the UID input changes. * @@ -39,10 +52,32 @@ class SideBar extends React.Component<{}, IState> { const uid = input == "" ? null : input; if (uid && uid.length > 10) return; - this.setState({ uid }); setTargetPlayer(parseInt(uid ?? "0")); } + /** + * Invoked when the UID input is right-clicked. + * + * @param event The event. + * @private + */ + private onRightClick(event: React.MouseEvent<HTMLInputElement, MouseEvent>): void { + // Remove focus from the input. + event.currentTarget.blur(); + event.preventDefault(); + + // Open the server settings overlay. + events.emit("overlay", "ServerSettings"); + } + + componentDidMount() { + events.on("connected", this.updateUid.bind(this)); + } + + componentWillUnmount() { + events.off("connected", this.updateUid.bind(this)); + } + render() { return ( <div className={"SideBar"}> @@ -74,7 +109,9 @@ class SideBar extends React.Component<{}, IState> { className={"SideBar_Input"} placeholder={"Enter UID..."} value={this.state.uid ?? undefined} + disabled={this.state.uidLocked} onChange={this.onChange.bind(this)} + onContextMenu={this.onRightClick.bind(this)} /> </div> </div> diff --git a/src/handbook/src/ui/widgets/ServerSettings.tsx b/src/handbook/src/ui/widgets/ServerSettings.tsx new file mode 100644 index 000000000..d9f9a4a79 --- /dev/null +++ b/src/handbook/src/ui/widgets/ServerSettings.tsx @@ -0,0 +1,176 @@ +import React from "react"; + +import emitter from "@backend/events"; +import { + targetPlayer, address, port, + setServerDetails, url, setTargetPlayer +} from "@backend/server"; + +import "@css/widgets/ServerSettings.scss"; + +interface IState { + webview: boolean; + + address: string; + port: number; +} + +class ServerSettings extends React.Component<{}, IState> { + constructor(props: {}) { + super(props); + + this.state = { + webview: false, + address: address, + port: Number(port) + }; + } + + componentDidMount() { + window.addEventListener("keyup", this.escapeListener.bind(this)); + } + + componentWillUnmount() { + window.removeEventListener("keyup", this.escapeListener.bind(this)); + window.removeEventListener("message", this.handleAuthentication.bind(this)); + } + + /** + * Invoked when the escape key is pressed. + * + * @param e The keyboard event. + * @private + */ + private escapeListener(e: KeyboardEvent): void { + if (e.key === "Escape") { + // Hide the overlay. + emitter.emit("overlay", "None"); + } + } + + /** + * Invoked when the component tries to authenticate. + * @private + */ + private authenticate(): void { + setServerDetails(null, null).then(() => { + this.setState({ webview: true }); + }); + + // Add the event listener for authentication. + window.addEventListener("message", this.handleAuthentication.bind(this)); + } + + /** + * Finishes the authentication process. + * + * @param e The message event. + * @private + */ + private handleAuthentication(e: MessageEvent): void { + const data = e.data; // The data sent from the server. + if (data == null) return; // If the data is null, return. + + // Check if the data is an object. + if (typeof data != "object") return; + // Get the data type. + const type = data["type"] ?? null; + if (type != "handbook-auth") return; + + // Get the data. + const uid = data["uid"] ?? null; + const token = data["token"] ?? null; + + // Hide the overlay. + emitter.emit("overlay", "None"); + // Set the token and user ID. + setTargetPlayer(Number(uid), token); + } + + /** + * Invoked when the save button is clicked. + * @private + */ + private save(): void { + // Hide the overlay. + emitter.emit("overlay", "None"); + + // Save the server settings. + setServerDetails( + this.state.address, + this.state.port.toString() + ); + } + + render() { + return ( + <div className={"ServerSettings"}> + { + this.state.webview ? ( + <iframe + className={"ServerSettings_Frame"} + src={`${url()}/handbook/authenticate?uid=${targetPlayer}`} + /> + ) : <> + <div className={"ServerSettings_Content ServerSettings_Top"}> + <h1 className={"ServerSettings_Title"}>Server Settings</h1> + + <div className={"ServerSettings_Details"}> + <div> + <p>Address:</p> + <input + type={"text"} + value={this.state.address} + onChange={(e) => { + const target = e.target as HTMLInputElement; + const value = target.value; + + this.setState({ address: value }); + }} + /> + </div> + + <div> + <p>Port:</p> + <input + type={"text"} + value={this.state.port == 0 ? "" : this.state.port} + onChange={(e) => { + const target = e.target as HTMLInputElement; + const value = target.value; + + if (isNaN(Number(value)) + || value.length > 5) { + return; + } + + this.setState({ port: Number(value) }); + }} + /> + </div> + </div> + + <button + className={"ServerSettings_Authenticate"} + onClick={this.authenticate.bind(this)} + > + Authenticate + </button> + </div> + + <div className={"ServerSettings_Content"}> + <button + className={"ServerSettings_Save"} + onClick={this.save.bind(this)} + > + Save + </button> + </div> + </> + } + </div> + ); + } +} + +export default ServerSettings; diff --git a/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java b/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java index c7f1b832e..6747f5ae4 100644 --- a/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java +++ b/src/main/java/emu/grasscutter/auth/AuthenticationSystem.java @@ -4,11 +4,12 @@ import emu.grasscutter.game.Account; import emu.grasscutter.server.http.objects.*; import emu.grasscutter.utils.DispatchUtils; import io.javalin.http.Context; -import javax.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import javax.annotation.Nullable; + /** Defines an authenticator for the server. Can be changed by plugins. */ public interface AuthenticationSystem { @@ -130,6 +131,13 @@ public interface AuthenticationSystem { */ OAuthAuthenticator getOAuthAuthenticator(); + /** + * This is the authenticator used for handling handbook authentication requests. + * + * @return An authenticator. + */ + HandbookAuthenticator getHandbookAuthenticator(); + /** A data container that holds relevant data for authenticating a client. */ @Builder @AllArgsConstructor diff --git a/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java b/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java index d210705b1..3b5ba5bab 100644 --- a/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java +++ b/src/main/java/emu/grasscutter/auth/DefaultAuthentication.java @@ -1,14 +1,14 @@ package emu.grasscutter.auth; -import static emu.grasscutter.config.Configuration.ACCOUNT; -import static emu.grasscutter.utils.Language.translate; - import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.DefaultAuthenticators.*; import emu.grasscutter.game.Account; import emu.grasscutter.server.http.objects.ComboTokenResJson; import emu.grasscutter.server.http.objects.LoginResultJson; +import static emu.grasscutter.config.Configuration.ACCOUNT; +import static emu.grasscutter.utils.Language.translate; + /** * The default Grasscutter authentication implementation. Allows all users to access any account. */ @@ -20,6 +20,7 @@ public final class DefaultAuthentication implements AuthenticationSystem { private final Authenticator<Account> sessionTokenValidator = new SessionTokenValidator(); private final ExternalAuthenticator externalAuthenticator = new ExternalAuthentication(); private final OAuthAuthenticator oAuthAuthenticator = new OAuthAuthentication(); + private final HandbookAuthenticator handbookAuthenticator = new HandbookAuthentication(); public DefaultAuthentication() { if (ACCOUNT.EXPERIMENTAL_RealPassword) { @@ -75,4 +76,9 @@ public final class DefaultAuthentication implements AuthenticationSystem { public OAuthAuthenticator getOAuthAuthenticator() { return this.oAuthAuthenticator; } + + @Override + public HandbookAuthenticator getHandbookAuthenticator() { + return this.handbookAuthenticator; + } } diff --git a/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java b/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java index fdb1bc24c..06f55aefc 100644 --- a/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java +++ b/src/main/java/emu/grasscutter/auth/DefaultAuthenticators.java @@ -1,8 +1,5 @@ package emu.grasscutter.auth; -import static emu.grasscutter.config.Configuration.ACCOUNT; -import static emu.grasscutter.utils.Language.translate; - import at.favre.lib.crypto.bcrypt.BCrypt; import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; @@ -12,15 +9,21 @@ import emu.grasscutter.server.dispatch.IDispatcher; import emu.grasscutter.server.dispatch.PacketIds; import emu.grasscutter.server.http.objects.ComboTokenResJson; import emu.grasscutter.server.http.objects.LoginResultJson; +import emu.grasscutter.utils.DispatchUtils; import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.Utils; +import io.javalin.http.ContentType; + +import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.interfaces.RSAPrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import javax.crypto.Cipher; + +import static emu.grasscutter.config.Configuration.ACCOUNT; +import static emu.grasscutter.utils.Language.translate; /** A class containing default authenticators. */ public final class DefaultAuthenticators { @@ -372,4 +375,61 @@ public final class DefaultAuthenticators { } } } + + /** Handles authentication for the web GM Handbook. */ + public static class HandbookAuthentication implements HandbookAuthenticator { + private final String authPage; + + public HandbookAuthentication() { + try { + this.authPage = new String( + FileUtils.readResource("/html/handbook_auth.html")); + } catch (Exception ignored) { + throw new RuntimeException("Failed to load handbook auth page."); + } + } + + @Override + public void presentPage(AuthenticationRequest request) { + var ctx = request.getContext(); + if (ctx == null) return; + + // Respond with the handbook auth page. + ctx.contentType(ContentType.TEXT_HTML) + .result(this.authPage); + } + + @Override + public Response authenticate(AuthenticationRequest request) { + var ctx = request.getContext(); + if (ctx == null) return null; + + // Get the body data. + var playerId = ctx.formParam("playerid"); + if (playerId == null) { + return Response.builder().status(400) + .body("Invalid player ID.").build(); + } + + try { + // Get the player's session token. + var sessionKey = DispatchUtils.fetchSessionKey( + Integer.parseInt(playerId)); + if (sessionKey == null) { + return Response.builder().status(400) + .body("Invalid player ID.").build(); + } + + // Check if the account is banned. + return Response.builder().status(200) + .body(this.authPage.replace("{{VALUE}}", "true") + .replace("{{SESSION_TOKEN}}", sessionKey) + .replace("{{PLAYER_ID}}", playerId)) + .build(); + } catch (NumberFormatException ignored) { + return Response.builder().status(500) + .body("Invalid player ID.").build(); + } + } + } } diff --git a/src/main/java/emu/grasscutter/auth/HandbookAuthenticator.java b/src/main/java/emu/grasscutter/auth/HandbookAuthenticator.java new file mode 100644 index 000000000..4aeb46301 --- /dev/null +++ b/src/main/java/emu/grasscutter/auth/HandbookAuthenticator.java @@ -0,0 +1,34 @@ +package emu.grasscutter.auth; + +import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; +import lombok.Builder; +import lombok.Getter; + +/** Handles player authentication for the web GM handbook. */ +public interface HandbookAuthenticator { + @Getter @Builder + class Response { + private final int status; + private final String body; + } + + /** + * Invoked when the user requests to authenticate. + * This should respond with a page that allows the user to authenticate. + * + * @route GET /handbook/authenticate + * @param request The authentication request. + */ + void presentPage(AuthenticationRequest request); + + /** + * Invoked when the user requests to authenticate. + * This is called when the user submits the authentication form. + * This should respond with HTML that sends a message to the GM Handbook. + * See the default handbook authentication page for an example. + * + * @param request The authentication request. + * @return The response to send to the client. + */ + Response authenticate(AuthenticationRequest request); +} diff --git a/src/main/java/emu/grasscutter/game/player/Player.java b/src/main/java/emu/grasscutter/game/player/Player.java index b4c47bdb2..c4ea75232 100644 --- a/src/main/java/emu/grasscutter/game/player/Player.java +++ b/src/main/java/emu/grasscutter/game/player/Player.java @@ -94,7 +94,8 @@ import static emu.grasscutter.config.Configuration.GAME_OPTIONS; @Entity(value = "players", useDiscriminator = false) public class Player implements PlayerHook { @Id private int id; - @Indexed(options = @IndexOptions(unique = true)) private String accountId; + @Indexed(options = @IndexOptions(unique = true)) + @Getter private String accountId; @Setter private transient Account account; @Getter @Setter private transient GameSession session; diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java index 7a847e66a..59cca9f59 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchClient.java @@ -1,7 +1,5 @@ package emu.grasscutter.server.dispatch; -import static emu.grasscutter.config.Configuration.DISPATCH_INFO; - import com.google.gson.JsonElement; import com.google.gson.JsonObject; import emu.grasscutter.Grasscutter; @@ -12,21 +10,25 @@ import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.DispatchUtils; import emu.grasscutter.utils.JsonUtils; import emu.grasscutter.utils.objects.HandbookBody; -import java.net.ConnectException; -import java.net.URI; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.BiConsumer; -import java.util.function.Consumer; import lombok.Getter; import org.java_websocket.WebSocket; import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; import org.slf4j.Logger; +import java.net.ConnectException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; + public final class DispatchClient extends WebSocketClient implements IDispatcher { @Getter private final Logger logger = Grasscutter.getLogger(); @Getter private final Map<Integer, BiConsumer<WebSocket, JsonElement>> handlers = new HashMap<>(); @@ -41,6 +43,7 @@ public final class DispatchClient extends WebSocketClient implements IDispatcher this.registerHandler(PacketIds.GachaHistoryReq, this::fetchGachaHistory); this.registerHandler(PacketIds.GmTalkReq, this::handleHandbookAction); + this.registerHandler(PacketIds.GetPlayerFieldsReq, this::fetchPlayerFields); } /** @@ -105,6 +108,32 @@ public final class DispatchClient extends WebSocketClient implements IDispatcher this.sendMessage(PacketIds.GmTalkRsp, response); } + /** + * Fetches the fields of an online player. + * + * @param socket The socket the packet was received from. + * @param object The packet data. + */ + private void fetchPlayerFields(WebSocket socket, JsonElement object) { + var message = IDispatcher.decode(object); + var playerId = message.get("playerId").getAsInt(); + var fieldsRaw = message.get("fields").getAsJsonArray(); + + // Get the player with the specified ID. + var player = Grasscutter.getGameServer().getPlayerByUid(playerId, true); + if (player == null) return; + + // Convert the fields array. + var fieldsList = new ArrayList<String>(); + for (var field : fieldsRaw) + fieldsList.add(field.getAsString()); + var fields = fieldsList.toArray(new String[0]); + + // Return the response object. + this.sendMessage(PacketIds.GetPlayerFieldsRsp, + DispatchUtils.getPlayerFields(playerId, fields)); + } + /** * Sends a serialized encrypted message to the server. * diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java index 8aaa09f5c..cc0b4adc5 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java @@ -1,12 +1,16 @@ package emu.grasscutter.server.dispatch; -import static emu.grasscutter.config.Configuration.DISPATCH_INFO; - import com.google.gson.JsonElement; import com.google.gson.JsonObject; import emu.grasscutter.Grasscutter; import emu.grasscutter.database.DatabaseHelper; import emu.grasscutter.utils.Crypto; +import lombok.Getter; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; +import org.slf4j.Logger; + import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; @@ -15,11 +19,8 @@ import java.util.List; import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; -import lombok.Getter; -import org.java_websocket.WebSocket; -import org.java_websocket.handshake.ClientHandshake; -import org.java_websocket.server.WebSocketServer; -import org.slf4j.Logger; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; /* Internal communications server. */ public final class DispatchServer extends WebSocketServer implements IDispatcher { @@ -39,6 +40,7 @@ public final class DispatchServer extends WebSocketServer implements IDispatcher this.registerHandler(PacketIds.LoginNotify, this::handleLogin); this.registerHandler(PacketIds.TokenValidateReq, this::validateToken); + this.registerHandler(PacketIds.GetAccountReq, this::fetchAccount); } /** @@ -84,6 +86,23 @@ public final class DispatchServer extends WebSocketServer implements IDispatcher this.sendMessage(socket, PacketIds.TokenValidateRsp, response); } + /** + * Fetches an account by its ID. + * + * @param socket The socket the packet was received from. + * @param object The packet data. + */ + private void fetchAccount(WebSocket socket, JsonElement object) { + var message = IDispatcher.decode(object); + var accountId = message.get("accountId").getAsString(); + + // Get the account from the database. + var account = DatabaseHelper.getAccountById(accountId); + // Send the account. + this.sendMessage(socket, PacketIds.GetAccountRsp, + JSON.toJsonTree(account)); + } + /** * Broadcasts an encrypted message to all connected clients. * diff --git a/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java b/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java index 95e53bdc0..6bea30043 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java +++ b/src/main/java/emu/grasscutter/server/dispatch/IDispatcher.java @@ -1,21 +1,25 @@ package emu.grasscutter.server.dispatch; -import static emu.grasscutter.config.Configuration.DISPATCH_INFO; - import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.JsonAdapters.ByteArrayAdapter; +import org.java_websocket.WebSocket; +import org.slf4j.Logger; + import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Consumer; -import org.java_websocket.WebSocket; -import org.slf4j.Logger; +import java.util.function.Function; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; public interface IDispatcher { Gson JSON = @@ -24,6 +28,9 @@ public interface IDispatcher { .registerTypeAdapter(byte[].class, new ByteArrayAdapter()) .create(); + Function<JsonElement, JsonObject> DEFAULT_PARSER = (packet) -> + IDispatcher.decode(packet, JsonObject.class); + /** * Decodes an escaped JSON message. * @@ -61,6 +68,75 @@ public interface IDispatcher { } } + /** + * Waits for a request from the other server to be fulfilled. + * + * @param request The request data. + * @param requestId The request packet ID. + * @param responseId the response packet ID. + * @param parser The parser for the response data. + * @return The fulfilled data, or null. + * @param <T> The type of data to be returned. + */ + default <T> T await(JsonObject request, int requestId, int responseId, + Function<JsonElement, T> parser) { + // Perform the setup for the request. + var future = this.async(request, requestId, responseId, parser); + + try { + // Try to return the value. + return future.get(5L, TimeUnit.SECONDS); + } catch (Exception ignored) { + return null; + } + } + + /** + * Registers a callback for a packet to be received. + * Sends a packet with the provided request. + * + * @param request The request object. + * @param requestId The packet ID of the request packet. + * @param responseId The packet ID of the response packet. + * @return A promise containing the parsed JSON data. + */ + default CompletableFuture<JsonObject> async(JsonObject request, int requestId, int responseId) { + return this.async(request, requestId, responseId, DEFAULT_PARSER); + } + + /** + * Registers a callback for a packet to be received. + * Sends a packet with the provided request. + * + * @param request The request object. + * @param requestId The packet ID of the request packet. + * @param responseId The packet ID of the response packet. + * @param parser The parser for the received data. + * @return A promise containing the parsed JSON data. + */ + default <T> CompletableFuture<T> async( + JsonObject request, int requestId, int responseId, + Function<JsonElement, T> parser + ) { + // Create the future. + var future = new CompletableFuture<T>(); + // Listen for the response. + this.registerCallback(responseId, packet -> + future.complete(parser.apply(packet))); + // Broadcast the packet. + this.sendMessage(requestId, request); + + return future; + } + + /** + * Internally used method to broadcast a packet. + * + * @param packetId The packet ID. + * @param message The packet data. + */ + void sendMessage(int packetId, Object message); + /** * Decodes a message from the client. * diff --git a/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java b/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java index 4e131ab17..314cd4e05 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java +++ b/src/main/java/emu/grasscutter/server/dispatch/PacketIds.java @@ -11,4 +11,8 @@ public interface PacketIds { int GachaHistoryRsp = 5; int GmTalkReq = PacketOpcodes.GmTalkReq; int GmTalkRsp = PacketOpcodes.GmTalkRsp; + int GetAccountReq = 6; + int GetAccountRsp = 7; + int GetPlayerFieldsReq = 8; + int GetPlayerFieldsRsp = 9; } diff --git a/src/main/java/emu/grasscutter/server/http/HttpServer.java b/src/main/java/emu/grasscutter/server/http/HttpServer.java index 278c016d7..aa6bef68f 100644 --- a/src/main/java/emu/grasscutter/server/http/HttpServer.java +++ b/src/main/java/emu/grasscutter/server/http/HttpServer.java @@ -5,6 +5,7 @@ import emu.grasscutter.Grasscutter.ServerDebugMode; import emu.grasscutter.utils.FileUtils; import io.javalin.Javalin; import io.javalin.http.ContentType; +import io.javalin.json.JavalinGson; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.util.ssl.SslContextFactory; @@ -57,6 +58,9 @@ public final class HttpServer { if (DISPATCH_INFO.logRequests == ServerDebugMode.ALL) config.plugins.enableDevLogging(); + // Set the JSON mapper. + config.jsonMapper(new JavalinGson()); + // Static files should be added like this https://javalin.io/documentation#static-files }); diff --git a/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java b/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java index 24f892c08..85918d15a 100644 --- a/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java +++ b/src/main/java/emu/grasscutter/server/http/documentation/HandbookHandler.java @@ -1,15 +1,18 @@ package emu.grasscutter.server.http.documentation; -import static emu.grasscutter.config.Configuration.HANDBOOK; - +import emu.grasscutter.Grasscutter; +import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; import emu.grasscutter.server.http.Router; import emu.grasscutter.utils.DispatchUtils; import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.objects.HandbookBody; import emu.grasscutter.utils.objects.HandbookBody.Action; import io.javalin.Javalin; +import io.javalin.http.ContentType; import io.javalin.http.Context; +import static emu.grasscutter.config.Configuration.HANDBOOK; + /** Handles requests for the new GM Handbook. */ public final class HandbookHandler implements Router { private final byte[] handbook; @@ -20,7 +23,7 @@ public final class HandbookHandler implements Router { * found. */ public HandbookHandler() { - this.handbook = FileUtils.readResource("/handbook.html"); + this.handbook = FileUtils.readResource("/html/handbook.html"); this.serve = HANDBOOK.enable && this.handbook.length > 0; } @@ -30,6 +33,9 @@ public final class HandbookHandler implements Router { // The handbook content. (built from src/handbook) javalin.get("/handbook", this::serveHandbook); + // The handbook authentication page. + javalin.get("/handbook/authenticate", this::authenticate); + javalin.post("/handbook/authenticate", this::performAuthentication); // Handbook control routes. javalin.post("/handbook/avatar", this::grantAvatar); @@ -59,6 +65,49 @@ public final class HandbookHandler implements Router { } } + /** + * Serves the handbook authentication page. + * + * @route GET /handbook/authenticate + * @param ctx The Javalin request context. + */ + private void authenticate(Context ctx) { + if (!this.serve) { + ctx.status(500).result("Handbook not found."); + } else { + // Pass the request to the authenticator. + Grasscutter.getAuthenticationSystem() + .getHandbookAuthenticator().presentPage( + AuthenticationRequest.builder().context(ctx).build()); + } + } + + /** + * Performs authentication for the handbook. + * + * @route POST /handbook/authenticate + * @param ctx The Javalin request context. + */ + private void performAuthentication(Context ctx) { + if (!this.serve) { + ctx.status(500).result("Handbook not found."); + } else { + // Pass the request to the authenticator. + var result = Grasscutter.getAuthenticationSystem() + .getHandbookAuthenticator().authenticate( + AuthenticationRequest.builder().context(ctx).build()); + if (result == null) { + ctx.status(500).result("Authentication failed."); + } else { + ctx + .status(result.getStatus()) + .result(result.getBody()) + .contentType(result.getBody().contains("html") ? + ContentType.TEXT_HTML : ContentType.TEXT_PLAIN); + } + } + } + /** * Grants the avatar to the user. * diff --git a/src/main/java/emu/grasscutter/utils/DispatchUtils.java b/src/main/java/emu/grasscutter/utils/DispatchUtils.java index 4d8cb3dbe..b164cfd18 100644 --- a/src/main/java/emu/grasscutter/utils/DispatchUtils.java +++ b/src/main/java/emu/grasscutter/utils/DispatchUtils.java @@ -1,7 +1,6 @@ package emu.grasscutter.utils; -import static emu.grasscutter.config.Configuration.DISPATCH_INFO; - +import com.google.gson.JsonNull; import com.google.gson.JsonObject; import emu.grasscutter.Grasscutter; import emu.grasscutter.auth.AuthenticationSystem.AuthenticationRequest; @@ -14,10 +13,16 @@ import emu.grasscutter.server.http.handlers.GachaHandler; import emu.grasscutter.server.http.objects.LoginTokenRequestJson; import emu.grasscutter.utils.objects.HandbookBody; import emu.grasscutter.utils.objects.HandbookBody.*; + +import javax.annotation.Nullable; +import java.lang.reflect.Field; import java.net.http.HttpClient; +import java.util.Arrays; +import java.util.HashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import javax.annotation.Nullable; + +import static emu.grasscutter.config.Configuration.DISPATCH_INFO; public interface DispatchUtils { /** HTTP client used for dispatch queries. */ @@ -62,6 +67,132 @@ public interface DispatchUtils { }; } + /** + * Fetches the session key for the specified player ID. + * + * @param playerId The player ID. + * @return The session key. + */ + @Nullable static String fetchSessionKey(int playerId) { + return switch (Grasscutter.getRunMode()) { + case GAME_ONLY -> { + // Fetch the player from the game server. + var player = DatabaseHelper.getPlayerByUid(playerId); + if (player == null) yield null; + + // Fetch the account from the dispatch server. + var accountId = player.getAccountId(); + var account = DispatchUtils.getAccountById(accountId); + + // Return the session key. + yield account == null ? null : account.getSessionKey(); + } + case DISPATCH_ONLY -> { + // Fetch the player's account ID from the game server. + var playerFields = DispatchUtils.getPlayerFields(playerId, "accountId"); + if (playerFields == null) yield null; + + // Get the account ID. + var accountId = playerFields.get("accountId").getAsString(); + if (accountId == null) yield null; + + // Fetch the account from the dispatch server. + var account = DatabaseHelper.getAccountById(accountId); + // Return the session key. + yield account == null ? null : account.getSessionKey(); + } + case HYBRID -> { + // Fetch the player from the game server. + var player = DatabaseHelper.getPlayerByUid(playerId); + if (player == null) yield null; + + // Fetch the account from the database. + var account = player.getAccount(); + // Return the session key. + yield account == null ? null : account.getSessionKey(); + } + }; + } + + /** + * Fetches an account by its ID. + * + * @param accountId The account ID. + * @return The account. + */ + @Nullable static Account getAccountById(String accountId) { + return switch (Grasscutter.getRunMode()) { + case GAME_ONLY -> { + // Create a request for account information. + var request = new JsonObject(); + request.addProperty("accountId", accountId); + + // Wait for the request to complete. + yield Grasscutter.getGameServer().getDispatchClient() + .await(request, PacketIds.GetAccountReq, PacketIds.GetAccountRsp, + packet -> IDispatcher.decode(packet, Account.class)); + } + case HYBRID, DISPATCH_ONLY -> + DatabaseHelper.getAccountById(accountId); + }; + } + + /** + * Fetches the values of fields for a player. + * + * @param playerId The player's ID. + * @param fields The fields to fetch. + * @return An object holding the field values. + */ + @Nullable static JsonObject getPlayerFields(int playerId, String... fields) { + return switch (Grasscutter.getRunMode()) { + case DISPATCH_ONLY -> { + // Create a request for player fields. + var request = new JsonObject(); + request.addProperty("playerId", playerId); + request.add("fields", IDispatcher.JSON.toJsonTree(fields)); + + // Wait for the request to complete. + yield Grasscutter.getDispatchServer() + .await(request, PacketIds.GetPlayerFieldsReq, PacketIds.GetPlayerFieldsRsp, + IDispatcher.DEFAULT_PARSER); + } + case HYBRID, GAME_ONLY -> { + // Get the player by ID. + var player = Grasscutter.getGameServer() + .getPlayerByUid(playerId, true); + if (player == null) yield null; + + // Prepare field properties. + var fieldValues = new JsonObject(); + var fieldMap = new HashMap<String, Field>(); + Arrays.stream(player.getClass().getDeclaredFields()) + .forEach(field -> fieldMap.put(field.getName(), field)); + + // Find the values of all requested fields. + for (var fieldName : fields) { + try { + var field = fieldMap.get(fieldName); + if (field == null) + fieldValues.add(fieldName, JsonNull.INSTANCE); + else { + var wasAccessible = field.canAccess(player); + field.setAccessible(true); + fieldValues.add(fieldName, + IDispatcher.JSON.toJsonTree(field.get(player))); + field.setAccessible(wasAccessible); + } + } catch (Exception exception) { + exception.printStackTrace(); + } + } + + // Return the values. + yield fieldValues; + } + }; + } + /** * Fetches the gacha history for the specified account. * diff --git a/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java b/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java index a3c31b4e5..1b83683aa 100644 --- a/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java +++ b/src/main/java/emu/grasscutter/utils/objects/HandbookBody.java @@ -23,6 +23,7 @@ public interface HandbookBody { @Getter class GrantAvatar { private String player; // Parse into online player ID. + private String playerToken; // Parse into session token. private String avatar; // Parse into avatar ID. private int level = 90; // Range between 1 - 90. @@ -33,6 +34,7 @@ public interface HandbookBody { @Getter class GiveItem { private String player; // Parse into online player ID. + private String playerToken; // Parse into session token. private String item; // Parse into item ID. private long amount = 1; // Range between 1 - Long.MAX_VALUE. @@ -41,12 +43,14 @@ public interface HandbookBody { @Getter class TeleportTo { private String player; // Parse into online player ID. + private String playerToken; // Parse into session token. private String scene; // Parse into a scene ID. } @Getter class SpawnEntity { private String player; // Parse into online player ID. + private String playerToken; // Parse into session token. private String entity; // Parse into entity ID. private long amount = 1; // Range between 1 - Long.MAX_VALUE. diff --git a/src/main/resources/html/handbook_auth.html b/src/main/resources/html/handbook_auth.html new file mode 100644 index 000000000..72895af5b --- /dev/null +++ b/src/main/resources/html/handbook_auth.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <style> + body { + margin: 0; + width: 100vw; + height: 100vh; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + p { + margin: 2px; + } + </style> + <title>Handbook Authentication</title> + </head> + + <body> + <script type="application/javascript"> + if ("{{VALUE}}" === "true") { + parent.postMessage({ + type: "handbook-auth", + token: "{{SESSION_TOKEN}}", + uid: "{{PLAYER_ID}}" + }, "*"); + } + </script> + + <p>Input your Player UID here.</p> + <form method="post"> + <label> + <input + name="playerid" + type="number" + /> + </label> + + <input type="submit" /> + </form> + </body> +</html>