feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -3,6 +3,7 @@ APP_URL=http://localhost:3000
|
||||
SESSION_SECRET=
|
||||
|
||||
# Admin OIDC / Keycloak
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
AUTH_SECRET=
|
||||
KEYCLOAK_ISSUER_URL=
|
||||
KEYCLOAK_CLIENT_ID=minecraft-account-manager-admin
|
||||
@@ -12,6 +13,11 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
|
||||
# Discord
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
DISCORD_INVITE_URL=https://discord.gg/your-invite
|
||||
|
||||
# Trust forwarding headers only when your reverse proxy overwrites them
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Optional VPN intelligence provider (deferred for v1)
|
||||
IP_INTELLIGENCE_PROVIDER=none
|
||||
|
||||
@@ -8,7 +8,7 @@ A Discord-first account registry for a private Java Edition Minecraft network. P
|
||||
- `apps/discord-bot` — discord.js slash-command bot
|
||||
- `packages/contracts` — shared Zod contracts and CloudEvents types
|
||||
- `packages/database` — PostgreSQL Drizzle schema and versioned migrations
|
||||
- `plugins/velocity` — Velocity admission plugin (planned)
|
||||
- `plugins/velocity` — fail-closed Velocity admission plugin
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -26,6 +26,8 @@ npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Set `DISCORD_GUILD_ID` and `DISCORD_INVITE_URL` in `.env.local` so unauthenticated visitors can reach the Discord server. The HTTPS invite is the most reliable way to open Discord or join; the landing page also offers a `discord://` app link.
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Validation
|
||||
@@ -35,6 +37,7 @@ npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run velocity:build
|
||||
```
|
||||
|
||||
## Database workflow
|
||||
@@ -48,14 +51,22 @@ npm run db:migrate
|
||||
|
||||
Do not use `drizzle push`; it bypasses the reviewed migration history and can cause destructive schema changes.
|
||||
|
||||
Provision or rotate a Velocity API token after migrating:
|
||||
|
||||
```bash
|
||||
npm run plugin:create-credential --workspace @minecraft-account-manager/database -- velocity-main
|
||||
```
|
||||
|
||||
The token is displayed once and stored only as a SHA-256 hash.
|
||||
|
||||
## Confirmed product decisions
|
||||
|
||||
- PostgreSQL and Drizzle ORM
|
||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||
- Admin-managed Discord guild configuration
|
||||
- Deployment-managed Discord guild ID and invite URL
|
||||
- discord.js bot with `/register` and `/account`
|
||||
- Java Edition online-mode accounts only
|
||||
- Velocity admission checks are fail closed
|
||||
- VPN detection is represented in the schema but may remain disabled in the first release until a provider is selected
|
||||
|
||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities.
|
||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts
|
||||
APP_URL=http://localhost:3000
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
@@ -1,5 +1,13 @@
|
||||
# Discord bot
|
||||
|
||||
The bot will provide ephemeral `/register` and `/account` responses containing short-lived one-time links. The target guild is read from admin-managed application settings rather than a deployment-only environment variable.
|
||||
The bot provides ephemeral `/register` and `/account` responses containing ten-minute, single-use links. Commands only work in the guild configured by `DISCORD_GUILD_ID`.
|
||||
|
||||
Implementation begins in Phase 2 alongside the one-time login service.
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp apps/discord-bot/.env.example apps/discord-bot/.env
|
||||
npm run commands:deploy --workspace @minecraft-account-manager/discord-bot
|
||||
npm run dev --workspace @minecraft-account-manager/discord-bot
|
||||
```
|
||||
|
||||
The bot requires permission to use application commands. Nickname management will additionally require `Manage Nicknames`, with the bot role above managed members.
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"commands:deploy": "tsx src/deploy-commands.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"discord.js": "^14.25.1"
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
|
||||
export const commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName("register")
|
||||
.setDescription("Register a Minecraft account through a private login link"),
|
||||
new SlashCommandBuilder()
|
||||
.setName("account")
|
||||
.setDescription("Open your Minecraft account dashboard through a private login link"),
|
||||
].map((command) => command.toJSON());
|
||||
@@ -0,0 +1,7 @@
|
||||
export function requiredEnvironment(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export const commandNames = ["register", "account"] as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { requiredEnvironment } from "./config";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||
const rest = new REST({ version: "10" }).setToken(token);
|
||||
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
||||
@@ -1,2 +1,79 @@
|
||||
// Discord command handling is added in Phase 2 after the one-time login service exists.
|
||||
export {};
|
||||
import "dotenv/config";
|
||||
import { createMagicLink, LoginRateLimitedError } from "@minecraft-account-manager/auth";
|
||||
import {
|
||||
createAuthRepository,
|
||||
createDatabase,
|
||||
recordEvent,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
import { commandNames, requiredEnvironment } from "./config";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const appUrl = requiredEnvironment("APP_URL");
|
||||
const databaseUrl = requiredEnvironment("DATABASE_URL");
|
||||
const discordGuildId = requiredEnvironment("DISCORD_GUILD_ID");
|
||||
const { db } = createDatabase(databaseUrl);
|
||||
const authRepository = createAuthRepository(db);
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (!commandNames.includes(interaction.commandName as (typeof commandNames)[number])) return;
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
if (interaction.guildId !== discordGuildId) {
|
||||
await interaction.editReply("This command is only available in the configured community server.");
|
||||
return;
|
||||
}
|
||||
|
||||
const magicLink = await createMagicLink(
|
||||
{
|
||||
id: interaction.user.id,
|
||||
username: interaction.user.username,
|
||||
globalName: interaction.user.globalName,
|
||||
},
|
||||
{ repository: authRepository, appUrl },
|
||||
);
|
||||
|
||||
await recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.created",
|
||||
source: "/discord-bot",
|
||||
subject: `discord-user/${interaction.user.id}`,
|
||||
data: { command: interaction.commandName, guildId: interaction.guildId },
|
||||
});
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setLabel(interaction.commandName === "register" ? "Start registration" : "Open my account")
|
||||
.setStyle(ButtonStyle.Link)
|
||||
.setURL(magicLink.url),
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
content: "This private link works once and expires in 10 minutes. Do not share it.",
|
||||
components: [row],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LoginRateLimitedError) {
|
||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||
return;
|
||||
}
|
||||
console.error("Failed to create Discord account link", error);
|
||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "Content-Security-Policy", value: contentSecurityPolicy },
|
||||
{ key: "Referrer-Policy", value: "no-referrer" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
serverExternalPackages: ["postgres"],
|
||||
transpilePackages: [
|
||||
"@minecraft-account-manager/contracts",
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"next": "^16.2.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use server";
|
||||
|
||||
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordUserEvent } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
export async function updateFirstName(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
|
||||
redirect("/account?error=invalid-name");
|
||||
}
|
||||
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
|
||||
redirect("/account?confirmNickname=1");
|
||||
}
|
||||
|
||||
export async function addMinecraftAccount(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const requestedUsername = String(formData.get("username") ?? "").trim();
|
||||
const confirmed = formData.get("confirmUnverified") === "yes";
|
||||
if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username");
|
||||
|
||||
const profile = await lookupJavaProfile(requestedUsername);
|
||||
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||
|
||||
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
await db.insert(minecraftAccounts).values({
|
||||
userId: user.id,
|
||||
minecraftUuid: profile?.uuid ?? null,
|
||||
username: profile?.username ?? requestedUsername,
|
||||
validationStatus: profile ? "verified" : "user_confirmed",
|
||||
lastVerifiedAt: profile ? new Date() : null,
|
||||
isPrimary: !existing,
|
||||
});
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
if (failed) redirect("/account?error=already-registered");
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
|
||||
username: profile?.username ?? requestedUsername,
|
||||
minecraftUuid: profile?.uuid ?? null,
|
||||
validationStatus: profile ? "verified" : "user_confirmed",
|
||||
});
|
||||
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
|
||||
}
|
||||
|
||||
export async function setPrimaryAccount(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const accountId = String(formData.get("accountId") ?? "");
|
||||
|
||||
const changed = await db.transaction(async (tx) => {
|
||||
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
if (!account) return false;
|
||||
|
||||
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
|
||||
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
||||
);
|
||||
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!changed) redirect("/account?error=unknown-account");
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
|
||||
redirect("/account?confirmNickname=1");
|
||||
}
|
||||
|
||||
export async function removeMinecraftAccount(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const accountId = String(formData.get("accountId") ?? "");
|
||||
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
if (!account) return false;
|
||||
|
||||
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
||||
if (account.isPrimary) {
|
||||
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
if (replacement) {
|
||||
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!removed) redirect("/account?error=unknown-account");
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
|
||||
redirect("/account?removed=1&confirmNickname=1");
|
||||
}
|
||||
|
||||
export async function confirmDashboardNickname() {
|
||||
const user = await requireCurrentUser();
|
||||
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
|
||||
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
|
||||
|
||||
try {
|
||||
await updateGuildNickname({
|
||||
guildId,
|
||||
discordUserId: user.discordUserId,
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
botToken,
|
||||
});
|
||||
} catch {
|
||||
redirect("/account?error=nickname-update-failed&confirmNickname=1");
|
||||
}
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
});
|
||||
redirect("/account?nicknameUpdated=1");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { logout } from "@/app/auth/actions";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import {
|
||||
addMinecraftAccount,
|
||||
confirmDashboardNickname,
|
||||
removeMinecraftAccount,
|
||||
setPrimaryAccount,
|
||||
updateFirstName,
|
||||
} from "./actions";
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-name": "Enter a valid name between 1 and 50 characters.",
|
||||
"invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||
"already-registered": "That Minecraft account is already registered.",
|
||||
"unknown-account": "That account is no longer available.",
|
||||
"nickname-not-configured": "Discord nickname updates are not configured.",
|
||||
"nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.",
|
||||
};
|
||||
|
||||
export default async function AccountPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const user = await requireCurrentUser("/account");
|
||||
const query = await searchParams;
|
||||
const [accounts, observations] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(minecraftAccounts)
|
||||
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||
db
|
||||
.select()
|
||||
.from(ipObservations)
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(20),
|
||||
]);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
const desiredNickname = user.firstName && primary
|
||||
? formatDiscordNickname(user.firstName, primary.username)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
|
||||
<section className="mx-auto max-w-6xl">
|
||||
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Account registry</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? user.discordUsername}</h1>
|
||||
</div>
|
||||
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
|
||||
</header>
|
||||
|
||||
{query.error && (
|
||||
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
|
||||
{errorMessages[query.error] ?? "The requested change could not be completed."}
|
||||
</p>
|
||||
)}
|
||||
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>}
|
||||
|
||||
{query.confirmNickname && desiredNickname && (
|
||||
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
|
||||
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
|
||||
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
|
||||
</div>
|
||||
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Whitelist identities</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Minecraft accounts</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{accounts.length} active</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-line">
|
||||
{accounts.map((account) => (
|
||||
<article className="grid gap-4 py-6 sm:grid-cols-[1fr_auto] sm:items-center" key={account.id}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="font-mono text-lg font-bold">{account.username}</h3>
|
||||
{account.isPrimary && <span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase tracking-widest text-canvas">Primary</span>}
|
||||
<span className="border border-line px-2 py-1 font-mono text-[9px] uppercase tracking-wider text-muted">{account.validationStatus === "verified" ? "UUID verified" : "User confirmed"}</span>
|
||||
</div>
|
||||
<p className="mt-2 break-all font-mono text-[10px] text-muted">{account.minecraftUuid ?? "UUID will be learned at game login"}</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
|
||||
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
|
||||
</div>
|
||||
|
||||
{query.unverified ? (
|
||||
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{query.unverified}”</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
|
||||
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
|
||||
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
|
||||
</form>
|
||||
) : (
|
||||
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
|
||||
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
|
||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
||||
{observations.length ? (
|
||||
<div className="divide-y divide-line font-mono text-xs">
|
||||
{observations.map((observation) => <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><span>{observation.ipAddress}</span><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>)}
|
||||
</div>
|
||||
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
||||
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
|
||||
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function saveDiscordSettings(formData: FormData) {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? [];
|
||||
if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login");
|
||||
|
||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||
|
||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
||||
redirect("/admin?error=invalid-message");
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(appSettings)
|
||||
.values({
|
||||
id: "default",
|
||||
registrationMessage,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: {
|
||||
registrationMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
redirect("/admin?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { events } from "@minecraft-account-manager/database";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1>
|
||||
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{recentEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
|
||||
<td className="p-4 font-mono font-bold">{event.type}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={4}>No events have been recorded.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { recordEvent } from "@minecraft-account-manager/database";
|
||||
import { headers } from "next/headers";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AdminSignOutButton } from "@/components/admin-sign-out-button";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) redirect("/admin/login");
|
||||
|
||||
const roles = (session.user as typeof session.user & { roles?: string[] })?.roles ?? [];
|
||||
if (!roles.includes(requiredAdminRole)) redirect("/admin/login?error=forbidden");
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
await recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.ui.accessed",
|
||||
source: "/web/admin",
|
||||
subject: "admin-console",
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { adminEmail: session.user?.email ?? null },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-ink">
|
||||
<header className="border-b border-line bg-panel">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
||||
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
|
||||
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||
<Link className="hover:text-accent" href="/admin">Settings</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { db } from "@/lib/database";
|
||||
import { saveDiscordSettings } from "./actions";
|
||||
|
||||
export default async function AdminPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ saved?: string; error?: string }>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
|
||||
<section>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
|
||||
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
|
||||
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
|
||||
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
|
||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
|
||||
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
||||
<textarea
|
||||
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={message}
|
||||
id="registrationMessage"
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name="registrationMessage"
|
||||
required
|
||||
/>
|
||||
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AdminSignInButton } from "@/components/admin-sign-in-button";
|
||||
import { isAdminOidcConfigured } from "@/lib/auth/admin-auth";
|
||||
|
||||
export default async function AdminLoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-ink px-6 text-canvas">
|
||||
<section className="w-full max-w-md border border-[#4a4d46] bg-[#20221e] p-8 shadow-[10px_10px_0_#bc3f24]">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-signal">Restricted console</p>
|
||||
<h1 className="mt-5 font-display text-4xl font-black uppercase tracking-tight">Operator access</h1>
|
||||
<p className="mb-8 mt-4 leading-7 text-[#b7b8ae]">Authenticate through Keycloak. The configured admin realm role is required.</p>
|
||||
{error && <p className="mb-5 border-l-2 border-accent pl-4 text-sm text-[#efb5a8]">Your identity does not have access to this console.</p>}
|
||||
{isAdminOidcConfigured ? (
|
||||
<AdminSignInButton />
|
||||
) : (
|
||||
<p className="border border-[#5c5e56] p-4 font-mono text-xs leading-6 text-[#d8b46e]">OIDC is not configured. Add the Keycloak environment variables before signing in.</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { adminAuthOptions } from "@/lib/auth/admin-auth";
|
||||
|
||||
const handler = NextAuth(adminAuthOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,179 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
events,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
pluginCredentials,
|
||||
pluginRequests,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authorization = request.headers.get("authorization") ?? "";
|
||||
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||
if (!token) return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ allowed: false, message: "Invalid access request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const occurredAt = new Date(input.occurredAt);
|
||||
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
|
||||
return NextResponse.json({ allowed: false, message: "Expired access request" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [credential] = await db
|
||||
.select({ secretHash: pluginCredentials.secretHash })
|
||||
.from(pluginCredentials)
|
||||
.where(and(eq(pluginCredentials.serverId, input.serverId), isNull(pluginCredentials.revokedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!credential || !verifyHashedToken(token, credential.secretHash)) {
|
||||
return NextResponse.json({ allowed: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
|
||||
try {
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||
await tx.insert(pluginRequests).values({
|
||||
requestId: input.requestId,
|
||||
serverId: input.serverId,
|
||||
receivedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
});
|
||||
|
||||
let [account] = await tx
|
||||
.select({
|
||||
id: minecraftAccounts.id,
|
||||
userId: minecraftAccounts.userId,
|
||||
minecraftUuid: minecraftAccounts.minecraftUuid,
|
||||
username: minecraftAccounts.username,
|
||||
})
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!account) {
|
||||
[account] = await tx
|
||||
.select({
|
||||
id: minecraftAccounts.id,
|
||||
userId: minecraftAccounts.userId,
|
||||
minecraftUuid: minecraftAccounts.minecraftUuid,
|
||||
username: minecraftAccounts.username,
|
||||
})
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(minecraftAccounts.minecraftUuid),
|
||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
subject: `minecraft-account/${input.minecraftUuid}`,
|
||||
time: occurredAt,
|
||||
data: { username: input.username, reason: "not_registered" },
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
await tx.insert(ipObservations).values({
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
}
|
||||
|
||||
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||
await tx
|
||||
.update(minecraftAccounts)
|
||||
.set({
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
lastVerifiedAt: occurredAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(minecraftAccounts.id, account.id));
|
||||
|
||||
if (account.username !== input.username) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.minecraft-account.username-changed",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: { previousUsername: account.username, username: input.username },
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.insert(ipObservations).values({
|
||||
userId: account.userId,
|
||||
minecraftAccountId: account.id,
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: "unknown",
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.allowed",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: {
|
||||
username: input.username,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
previousUsername: account.username === input.username ? null : account.username,
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
|
||||
return { allowed: true as const, message: "Account approved." };
|
||||
});
|
||||
|
||||
return NextResponse.json(decision);
|
||||
} catch (error) {
|
||||
console.error("Velocity access decision failed", error);
|
||||
return NextResponse.json(
|
||||
{ allowed: false, message: denialMessage },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { sessions } from "@minecraft-account-manager/database";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function logout() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (token) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(sessions.tokenHash, hashToken(token)));
|
||||
}
|
||||
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
redirect("/");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { exchangeMagicLink, InvalidLoginCodeError, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const code = request.nextUrl.searchParams.get("code") ?? "";
|
||||
|
||||
try {
|
||||
const result = await exchangeMagicLink(code, {
|
||||
repository: createAuthRepository(db),
|
||||
});
|
||||
const ipAddress = getClientIp(request.headers, process.env.TRUST_PROXY === "true");
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.consumed",
|
||||
source: "/web/auth/discord",
|
||||
subject: `user/${result.user.id}`,
|
||||
actorUserId: result.user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { isNewUser: result.isNewUser },
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: result.user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires: result.sessionExpiresAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginErrorPage() {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 text-ink">
|
||||
<section className="max-w-lg border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Link rejected</p>
|
||||
<h1 className="mt-4 font-display text-4xl font-black uppercase tracking-tight">That gate key no longer works.</h1>
|
||||
<p className="mt-5 leading-7 text-muted">Login links expire after ten minutes and can only be used once. Return to Discord and run <strong>/account</strong> for a fresh link.</p>
|
||||
<Link className="mt-7 inline-block border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" href="/">Back home</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,15 @@ const steps = [
|
||||
["03", "Join the server", "Add your Java account and connect once approved."],
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ portal?: string }>;
|
||||
}) {
|
||||
const { portal } = await searchParams;
|
||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen overflow-hidden bg-canvas text-ink">
|
||||
<div className="terrain" aria-hidden="true" />
|
||||
@@ -22,6 +30,12 @@ export default function HomePage() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{portal && (
|
||||
<div className="mt-8 border border-accent bg-panel px-5 py-4 font-mono text-xs leading-6 shadow-[5px_5px_0_var(--color-accent)]">
|
||||
The account portal starts in Discord. Join the server, then run <strong>/register</strong> or <strong>/account</strong> to receive your private sign-in link.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section id="top" className="grid flex-1 items-center gap-14 py-20 lg:grid-cols-[1.15fr_0.85fr] lg:py-24">
|
||||
<div>
|
||||
<p className="mb-7 font-mono text-xs font-semibold uppercase tracking-[0.3em] text-accent">
|
||||
@@ -57,6 +71,16 @@ export default function HomePage() {
|
||||
<span className="text-signal">></span> Open Discord and type <strong>/register</strong>
|
||||
<span className="cursor ml-1 inline-block h-4 w-2 bg-signal align-middle" />
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{inviteUrl ? (
|
||||
<a className="border border-ink bg-accent px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest text-canvas" href={inviteUrl} rel="noreferrer" target="_blank">Join Discord server</a>
|
||||
) : (
|
||||
<span className="border border-line px-4 py-3 text-center font-mono text-[10px] uppercase tracking-widest text-muted">Invite not configured</span>
|
||||
)}
|
||||
{guildId && (
|
||||
<a className="border border-ink px-4 py-3 text-center font-mono text-[10px] font-bold uppercase tracking-widest" href={`discord://-/channels/${guildId}`}>Open Discord app</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use server";
|
||||
|
||||
import { lookupJavaProfile, updateGuildNickname, formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordUserEvent } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
export async function saveFirstName(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||
|
||||
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
|
||||
redirect("/welcome?error=invalid-name");
|
||||
}
|
||||
|
||||
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
|
||||
redirect("/welcome/minecraft");
|
||||
}
|
||||
|
||||
export async function addFirstMinecraftAccount(formData: FormData) {
|
||||
const user = await requireCurrentUser();
|
||||
const requestedUsername = String(formData.get("username") ?? "").trim();
|
||||
const confirmed = formData.get("confirmUnverified") === "yes";
|
||||
|
||||
if (!USERNAME_PATTERN.test(requestedUsername)) {
|
||||
redirect("/welcome/minecraft?error=invalid-format");
|
||||
}
|
||||
|
||||
const profile = await lookupJavaProfile(requestedUsername);
|
||||
if (!profile && !confirmed) {
|
||||
redirect(`/welcome/minecraft?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||
}
|
||||
|
||||
const [existingAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
await db.insert(minecraftAccounts).values({
|
||||
userId: user.id,
|
||||
minecraftUuid: profile?.uuid ?? null,
|
||||
username: profile?.username ?? requestedUsername,
|
||||
validationStatus: profile ? "verified" : "user_confirmed",
|
||||
lastVerifiedAt: profile ? new Date() : null,
|
||||
isPrimary: !existingAccount,
|
||||
});
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (failed) redirect("/welcome/minecraft?error=already-registered");
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
|
||||
username: profile?.username ?? requestedUsername,
|
||||
minecraftUuid: profile?.uuid ?? null,
|
||||
validationStatus: profile ? "verified" : "user_confirmed",
|
||||
});
|
||||
redirect("/welcome/discord");
|
||||
}
|
||||
|
||||
export async function confirmInitialNickname() {
|
||||
const user = await requireCurrentUser();
|
||||
const [account] = await db
|
||||
.select({ username: minecraftAccounts.username })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.userId, user.id),
|
||||
eq(minecraftAccounts.isPrimary, true),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
|
||||
if (!user.firstName || !account || !guildId || !botToken) {
|
||||
redirect("/welcome/discord?error=not-configured");
|
||||
}
|
||||
|
||||
try {
|
||||
await updateGuildNickname({
|
||||
guildId,
|
||||
discordUserId: user.discordUserId,
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
botToken,
|
||||
});
|
||||
} catch {
|
||||
redirect("/welcome/discord?error=discord-update");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ onboardingCompletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
|
||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
redirect("/account");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/database";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { confirmInitialNickname } from "../actions";
|
||||
|
||||
export default async function DiscordStepPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const user = await requireCurrentUser("/welcome/discord");
|
||||
const query = await searchParams;
|
||||
if (!user.firstName) redirect("/welcome");
|
||||
|
||||
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
if (!account) redirect("/welcome/minecraft");
|
||||
const nickname = formatDiscordNickname(user.firstName, account.username);
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 3 / 3 · Confirm identity</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">One name everywhere.</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-muted">Your Discord nickname in the configured community server will become:</p>
|
||||
<div className="mt-7 border border-line bg-canvas px-6 py-5 font-display text-2xl font-black">{nickname}</div>
|
||||
{query.error && <p className="mt-5 border-l-2 border-accent pl-4 text-sm leading-6 text-accent">We couldn’t update Discord. Ask an admin to check the guild and bot nickname permissions, then retry.</p>}
|
||||
<form action={confirmInitialNickname} className="mt-8">
|
||||
<button className="border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Confirm and update Discord</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { addFirstMinecraftAccount } from "../actions";
|
||||
|
||||
const errors: Record<string, string> = {
|
||||
"invalid-format": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||
"already-registered": "That Minecraft account is already registered.",
|
||||
};
|
||||
|
||||
export default async function MinecraftStepPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; unverified?: string }>;
|
||||
}) {
|
||||
const user = await requireCurrentUser("/welcome/minecraft");
|
||||
if (!user.firstName) redirect("/welcome");
|
||||
const query = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 2 / 3 · Java Edition</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Add your player.</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-muted">We’ll verify the username with Mojang and store its UUID for secure matching.</p>
|
||||
|
||||
{query.unverified ? (
|
||||
<form action={addFirstMinecraftAccount} className="mt-9 border-l-2 border-accent pl-6">
|
||||
<h2 className="font-display text-2xl font-black uppercase">We couldn’t verify “{query.unverified}”.</h2>
|
||||
<p className="mt-3 leading-7 text-muted">Check the spelling. If you’re certain it is correct, continue without a UUID. The server can associate it after a successful online-mode login.</p>
|
||||
<input name="username" type="hidden" value={query.unverified} />
|
||||
<input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Yes, continue</button>
|
||||
<a className="border border-line px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider" href="/welcome/minecraft">Check spelling</a>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form action={addFirstMinecraftAccount} className="mt-9">
|
||||
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="username">Minecraft username</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 font-mono text-lg outline-none focus:border-accent" id="username" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" required autoFocus />
|
||||
{query.error && <p className="mt-3 text-sm text-accent">{errors[query.error] ?? "We could not add that account."}</p>}
|
||||
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Verify account</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||
import { saveFirstName } from "./actions";
|
||||
|
||||
export default async function WelcomePage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const user = await requireCurrentUser("/welcome");
|
||||
const query = await searchParams;
|
||||
if (user.firstName) redirect("/welcome/minecraft");
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-canvas px-6 py-12 text-ink">
|
||||
<section className="w-full max-w-2xl border border-line bg-panel p-8 shadow-[10px_10px_0_var(--color-shadow)] sm:p-12">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Step 1 / 3 · Discord connected</p>
|
||||
<h1 className="mt-5 font-display text-5xl font-black uppercase leading-none tracking-tight">Welcome, {user.discordUsername}.</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-muted">Let’s get started. What should we call you?</p>
|
||||
<form action={saveFirstName} className="mt-9">
|
||||
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">Your first name</label>
|
||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-4 text-lg outline-none focus:border-accent" id="firstName" maxLength={50} name="firstName" required autoFocus />
|
||||
{query.error && <p className="mt-3 text-sm text-accent">Enter a name between 1 and 50 characters.</p>}
|
||||
<button className="mt-7 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas hover:bg-accent" type="submit">Continue</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
export function AdminSignInButton() {
|
||||
return (
|
||||
<button
|
||||
className="w-full border border-ink bg-ink px-5 py-4 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas transition-transform hover:-translate-y-0.5"
|
||||
onClick={() => signIn("keycloak", { callbackUrl: "/admin" })}
|
||||
type="button"
|
||||
>
|
||||
Continue with SSO
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export function AdminSignOutButton() {
|
||||
return (
|
||||
<button
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline decoration-line underline-offset-4 hover:text-ink"
|
||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||
type="button"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { ipObservations, recordEvent } from "@minecraft-account-manager/database";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||
|
||||
export async function recordUserEvent(
|
||||
user: { id: string },
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
return recordEvent(db, {
|
||||
type,
|
||||
source: "/web",
|
||||
subject: `user/${user.id}`,
|
||||
actorUserId: user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAuthenticatedUiAccess(
|
||||
user: { id: string },
|
||||
path: string,
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
|
||||
await Promise.all([
|
||||
recordEvent(db, {
|
||||
type: UI_ACCESSED,
|
||||
source: "/web",
|
||||
subject: `user/${user.id}`,
|
||||
actorUserId: user.id,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
data: { path },
|
||||
}),
|
||||
ipAddress
|
||||
? db.insert(ipObservations).values({
|
||||
userId: user.id,
|
||||
source: "web",
|
||||
ipAddress,
|
||||
classification: "unknown",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { extractOidcRoles } from "@minecraft-account-manager/auth";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET?.trim() ?? "";
|
||||
export const requiredAdminRole =
|
||||
process.env.KEYCLOAK_REQUIRED_ROLE?.trim() || "minecraft-account-manager-admin";
|
||||
|
||||
export const isAdminOidcConfigured = Boolean(issuer && clientId && clientSecret);
|
||||
|
||||
export const adminAuthOptions: NextAuthOptions = {
|
||||
secret: process.env.AUTH_SECRET,
|
||||
providers: isAdminOidcConfigured
|
||||
? [
|
||||
KeycloakProvider({
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
pages: { signIn: "/admin/login" },
|
||||
session: { strategy: "jwt" },
|
||||
callbacks: {
|
||||
async signIn({ profile, account }) {
|
||||
if (!isAdminOidcConfigured) return false;
|
||||
return extractOidcRoles({
|
||||
clientId,
|
||||
profile,
|
||||
idToken: account?.id_token,
|
||||
accessToken: account?.access_token,
|
||||
}).includes(requiredAdminRole);
|
||||
},
|
||||
async jwt({ token, profile, account }) {
|
||||
if (profile || account?.id_token || account?.access_token) {
|
||||
token.roles = extractOidcRoles({
|
||||
clientId,
|
||||
profile,
|
||||
idToken: account?.id_token,
|
||||
accessToken: account?.access_token,
|
||||
});
|
||||
}
|
||||
token.roles ??= [];
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as typeof session.user & { roles: string[] }).roles = Array.isArray(token.roles)
|
||||
? token.roles.filter((role): role is string => typeof role === "string")
|
||||
: [];
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hashToken, SESSION_COOKIE_NAME } from "@minecraft-account-manager/auth";
|
||||
import { findUserBySessionToken } from "@minecraft-account-manager/database";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordAuthenticatedUiAccess } from "@/lib/audit";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function getCurrentUser() {
|
||||
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
return findUserBySessionToken(db, hashToken(token));
|
||||
}
|
||||
|
||||
export async function requireCurrentUser(accessPath?: string) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/?portal=1");
|
||||
if (accessPath) await recordAuthenticatedUiAccess(user, accessPath);
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createDatabase } from "@minecraft-account-manager/database";
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts";
|
||||
|
||||
const globalDatabase = globalThis as typeof globalThis & {
|
||||
accountManagerDatabase?: ReturnType<typeof createDatabase>;
|
||||
};
|
||||
|
||||
export const database =
|
||||
globalDatabase.accountManagerDatabase ?? createDatabase(databaseUrl);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalDatabase.accountManagerDatabase = database;
|
||||
}
|
||||
|
||||
export const db = database.db;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
roles?: string[];
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ The admin console will use Keycloak OIDC and JWT-backed Auth.js sessions, follow
|
||||
## Application environment
|
||||
|
||||
- `AUTH_SECRET`
|
||||
- `APP_URL`
|
||||
- `NEXTAUTH_URL`
|
||||
- `KEYCLOAK_ISSUER_URL`
|
||||
- `KEYCLOAK_CLIENT_ID`
|
||||
- `KEYCLOAK_CLIENT_SECRET`
|
||||
|
||||
@@ -10,7 +10,7 @@ User authentication begins with an opaque, short-lived, single-use token created
|
||||
|
||||
### Discord bot
|
||||
|
||||
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the guild selected in admin settings.
|
||||
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
|
||||
|
||||
### Velocity plugin
|
||||
|
||||
@@ -23,7 +23,7 @@ The decision is fail closed. Unknown players, invalid responses, expired request
|
||||
- Browser input is untrusted. Minecraft profile resolution occurs on the server.
|
||||
- Forwarded IP headers are accepted only from configured reverse proxies.
|
||||
- Discord IDs come from bot-authenticated requests or one-time-code records, not browser fields.
|
||||
- Velocity requests will use per-server credentials, timestamps, and request IDs to support authentication and replay prevention.
|
||||
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
|
||||
- Session and one-time-code values are random and stored only as hashes.
|
||||
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Security review
|
||||
|
||||
Review date: 2026-08-01
|
||||
|
||||
## Scope
|
||||
|
||||
Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin authentication, and the Velocity admission plugin.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- Semgrep `auto`: 0 findings
|
||||
- `npm audit`: 0 known vulnerabilities after dependency overrides
|
||||
- TypeScript, ESLint, unit tests, Next.js production build: passing
|
||||
- Velocity Java tests and shaded plugin build: passing
|
||||
|
||||
## Implemented controls
|
||||
|
||||
- Discord login and session tokens use cryptographically secure randomness and are stored only as SHA-256 hashes.
|
||||
- Login links expire after ten minutes, are single use, and are rate limited per Discord user with a PostgreSQL advisory lock.
|
||||
- Session cookies are `httpOnly`, `sameSite=lax`, path-scoped, and secure in production.
|
||||
- Admin access uses Keycloak OIDC and a required role.
|
||||
- User mutations verify ownership server-side.
|
||||
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
||||
- Velocity and its API fail closed.
|
||||
- ORM-parameterized queries are used throughout.
|
||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||
- Secrets are excluded from logs and repository configuration.
|
||||
|
||||
## Outstanding production requirements
|
||||
|
||||
- Select and implement a VPN/proxy intelligence provider before enabling VPN-based account-addition blocking. The current classification is explicitly `unknown`.
|
||||
- Define and automate retention for exact IP addresses and audit events.
|
||||
- Add monitoring and alerts for repeated login denials, plugin authentication failures, and Discord API failures.
|
||||
- Use HTTPS for the public application and Velocity API URL. Protect the Velocity configuration file because it contains the one-time-displayed API token.
|
||||
- Restrict database credentials so normal application roles cannot update or delete historical event rows outside approved application paths.
|
||||
- Validate migrations in staging before production. Local migration application was unavailable during development because the Docker daemon was not running.
|
||||
Generated
+932
-1555
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -13,7 +13,15 @@
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"db:generate": "npm run db:generate --workspace @minecraft-account-manager/database",
|
||||
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database"
|
||||
"db:migrate": "npm run db:migrate --workspace @minecraft-account-manager/database",
|
||||
"velocity:build": "cd plugins/velocity && ./gradlew clean test shadowJar"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.25.12",
|
||||
"next@16.2.12": {
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/auth",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const LOGIN_CODE_TTL_MS = 10 * 60 * 1_000;
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
export const SESSION_COOKIE_NAME = "minecraft_account_session";
|
||||
|
||||
type ClaimMap = Record<string, unknown>;
|
||||
|
||||
function claimMap(value: unknown): ClaimMap {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as ClaimMap) : {};
|
||||
}
|
||||
|
||||
function stringList(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function tokenClaims(token: string | undefined): ClaimMap {
|
||||
const payload = token?.split(".")[1];
|
||||
if (!payload) return {};
|
||||
|
||||
try {
|
||||
return claimMap(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function extractOidcRoles(input: {
|
||||
clientId: string;
|
||||
profile?: unknown;
|
||||
idToken?: string;
|
||||
accessToken?: string;
|
||||
}) {
|
||||
const sources = [claimMap(input.profile), tokenClaims(input.idToken), tokenClaims(input.accessToken)];
|
||||
const roles: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
roles.push(...stringList(source.roles), ...stringList(source.groups));
|
||||
roles.push(...stringList(claimMap(source.realm_access).roles));
|
||||
|
||||
const clientRoles = claimMap(claimMap(source.resource_access)[input.clientId]);
|
||||
roles.push(...stringList(clientRoles.roles));
|
||||
}
|
||||
|
||||
return [...new Set(roles)];
|
||||
}
|
||||
|
||||
export interface DiscordIdentity {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName?: string | null;
|
||||
}
|
||||
|
||||
export interface PendingLoginCode {
|
||||
tokenHash: string;
|
||||
discordUserId: string;
|
||||
discordUsername: string;
|
||||
discordGlobalName: string | null;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
discordUserId: string;
|
||||
discordUsername: string;
|
||||
firstName: string | null;
|
||||
}
|
||||
|
||||
export interface AuthRepository {
|
||||
saveLoginCode(code: PendingLoginCode): Promise<void>;
|
||||
exchangeLoginCode(input: {
|
||||
loginCodeHash: string;
|
||||
sessionTokenHash: string;
|
||||
sessionExpiresAt: Date;
|
||||
now: Date;
|
||||
}): Promise<{ user: AuthUser; isNewUser: boolean } | null>;
|
||||
}
|
||||
|
||||
interface MagicLinkDependencies {
|
||||
repository: AuthRepository;
|
||||
appUrl: string;
|
||||
now?: () => Date;
|
||||
randomToken?: () => string;
|
||||
}
|
||||
|
||||
interface ExchangeDependencies {
|
||||
repository: AuthRepository;
|
||||
now?: () => Date;
|
||||
randomToken?: () => string;
|
||||
}
|
||||
|
||||
export class LoginRateLimitedError extends Error {
|
||||
constructor() {
|
||||
super("Please wait before requesting another login link.");
|
||||
this.name = "LoginRateLimitedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidLoginCodeError extends Error {
|
||||
constructor() {
|
||||
super("The login link is invalid, expired, or has already been used.");
|
||||
this.name = "InvalidLoginCodeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||
const expected = Buffer.from(expectedHash, "utf8");
|
||||
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
export function isRequestTimestampFresh(occurredAt: Date, now: Date, maxClockSkewMs: number) {
|
||||
return (
|
||||
Number.isFinite(occurredAt.getTime()) &&
|
||||
Math.abs(now.getTime() - occurredAt.getTime()) <= maxClockSkewMs
|
||||
);
|
||||
}
|
||||
|
||||
function secureToken() {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export async function createMagicLink(
|
||||
identity: DiscordIdentity,
|
||||
dependencies: MagicLinkDependencies,
|
||||
) {
|
||||
const now = dependencies.now?.() ?? new Date();
|
||||
const token = dependencies.randomToken?.() ?? secureToken();
|
||||
const expiresAt = new Date(now.getTime() + LOGIN_CODE_TTL_MS);
|
||||
|
||||
await dependencies.repository.saveLoginCode({
|
||||
tokenHash: hashToken(token),
|
||||
discordUserId: identity.id,
|
||||
discordUsername: identity.username,
|
||||
discordGlobalName: identity.globalName ?? null,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const url = new URL("/auth/discord", dependencies.appUrl);
|
||||
url.searchParams.set("code", token);
|
||||
|
||||
return { url: url.toString(), expiresAt };
|
||||
}
|
||||
|
||||
export async function exchangeMagicLink(code: string, dependencies: ExchangeDependencies) {
|
||||
if (!code) {
|
||||
throw new InvalidLoginCodeError();
|
||||
}
|
||||
|
||||
const now = dependencies.now?.() ?? new Date();
|
||||
const sessionToken = dependencies.randomToken?.() ?? secureToken();
|
||||
const result = await dependencies.repository.exchangeLoginCode({
|
||||
loginCodeHash: hashToken(code),
|
||||
sessionTokenHash: hashToken(sessionToken),
|
||||
sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
now,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new InvalidLoginCodeError();
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessionToken,
|
||||
sessionExpiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMagicLink,
|
||||
exchangeMagicLink,
|
||||
InvalidLoginCodeError,
|
||||
type AuthRepository,
|
||||
type PendingLoginCode,
|
||||
} from "../src/index";
|
||||
|
||||
class MemoryAuthRepository implements AuthRepository {
|
||||
loginCode: PendingLoginCode | undefined;
|
||||
consumedHash: string | undefined;
|
||||
|
||||
async saveLoginCode(code: PendingLoginCode) {
|
||||
this.loginCode = code;
|
||||
}
|
||||
|
||||
async exchangeLoginCode(input: Parameters<AuthRepository["exchangeLoginCode"]>[0]) {
|
||||
this.consumedHash = input.loginCodeHash;
|
||||
if (
|
||||
!this.loginCode ||
|
||||
this.loginCode.tokenHash !== input.loginCodeHash ||
|
||||
this.loginCode.expiresAt <= input.now
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.loginCode = undefined;
|
||||
return {
|
||||
user: {
|
||||
id: "01JQ0000000000000000000000",
|
||||
discordUserId: "123456789012345678",
|
||||
discordUsername: "steve",
|
||||
firstName: null,
|
||||
},
|
||||
isNewUser: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const identity = {
|
||||
id: "123456789012345678",
|
||||
username: "steve",
|
||||
globalName: "Steve",
|
||||
};
|
||||
const now = new Date("2026-08-01T12:00:00.000Z");
|
||||
|
||||
function hash(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
describe("Discord magic-link authentication", () => {
|
||||
it("returns a link while persisting only the token hash", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
const result = await createMagicLink(identity, {
|
||||
repository,
|
||||
appUrl: "https://accounts.example.com",
|
||||
now: () => now,
|
||||
randomToken: () => "private-login-token",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://accounts.example.com/auth/discord?code=private-login-token");
|
||||
expect(result.expiresAt).toEqual(new Date("2026-08-01T12:10:00.000Z"));
|
||||
expect(repository.loginCode).toMatchObject({
|
||||
tokenHash: hash("private-login-token"),
|
||||
discordUserId: identity.id,
|
||||
discordUsername: identity.username,
|
||||
});
|
||||
expect(JSON.stringify(repository.loginCode)).not.toContain("private-login-token");
|
||||
});
|
||||
|
||||
it("exchanges a valid one-time code for a session", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
await createMagicLink(identity, {
|
||||
repository,
|
||||
appUrl: "https://accounts.example.com",
|
||||
now: () => now,
|
||||
randomToken: () => "private-login-token",
|
||||
});
|
||||
|
||||
const result = await exchangeMagicLink("private-login-token", {
|
||||
repository,
|
||||
now: () => new Date("2026-08-01T12:01:00.000Z"),
|
||||
randomToken: () => "private-session-token",
|
||||
});
|
||||
|
||||
expect(result.sessionToken).toBe("private-session-token");
|
||||
expect(result.user.discordUserId).toBe(identity.id);
|
||||
expect(repository.consumedHash).toBe(hash("private-login-token"));
|
||||
});
|
||||
|
||||
it("rejects an expired or already-consumed code", async () => {
|
||||
const repository = new MemoryAuthRepository();
|
||||
|
||||
await expect(
|
||||
exchangeMagicLink("missing-token", {
|
||||
repository,
|
||||
now: () => now,
|
||||
randomToken: () => "private-session-token",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidLoginCodeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractOidcRoles } from "../src/index";
|
||||
|
||||
function unsignedToken(payload: Record<string, unknown>) {
|
||||
return `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.signature`;
|
||||
}
|
||||
|
||||
describe("OIDC role extraction", () => {
|
||||
it("combines realm and configured-client roles from Keycloak tokens", () => {
|
||||
const roles = extractOidcRoles({
|
||||
clientId: "minecraft-account-manager-admin",
|
||||
profile: { groups: ["support"] },
|
||||
accessToken: unsignedToken({
|
||||
realm_access: { roles: ["minecraft-account-manager-admin"] },
|
||||
resource_access: {
|
||||
"minecraft-account-manager-admin": { roles: ["settings-editor"] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(roles).toEqual([
|
||||
"support",
|
||||
"minecraft-account-manager-admin",
|
||||
"settings-editor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats malformed token payloads as having no roles", () => {
|
||||
expect(extractOidcRoles({ clientId: "admin", accessToken: "invalid" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hashToken, isRequestTimestampFresh, verifyHashedToken } from "../src/index";
|
||||
|
||||
describe("plugin request authentication", () => {
|
||||
it("compares an opaque token with its stored hash", () => {
|
||||
const storedHash = hashToken("correct-high-entropy-token");
|
||||
expect(verifyHashedToken("correct-high-entropy-token", storedHash)).toBe(true);
|
||||
expect(verifyHashedToken("wrong-token", storedHash)).toBe(false);
|
||||
expect(verifyHashedToken("correct-high-entropy-token", "malformed")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale and excessively future-dated requests", () => {
|
||||
const now = new Date("2026-08-01T12:00:00.000Z");
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:30.000Z"), now, 45_000)).toBe(true);
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T11:59:14.000Z"), now, 45_000)).toBe(false);
|
||||
expect(isRequestTimestampFresh(new Date("2026-08-01T12:00:46.000Z"), now, 45_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "plugin_requests" (
|
||||
"request_id" uuid PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"received_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp (3) with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plugin_requests" ADD CONSTRAINT "plugin_requests_server_id_plugin_credentials_server_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."plugin_credentials"("server_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "plugin_requests_expires_idx" ON "plugin_requests" USING btree ("expires_at");--> statement-breakpoint
|
||||
ALTER TABLE "app_settings" DROP COLUMN "discord_guild_id";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1785603505066,
|
||||
"tag": "0000_supreme_human_fly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785605590058,
|
||||
"tag": "0001_silent_ultragirl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,14 +10,17 @@
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"plugin:create-credential": "tsx scripts/create-plugin-credential.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"postgres": "^3.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDatabase, pluginCredentials } from "../src/index";
|
||||
|
||||
const serverId = process.argv[2]?.trim();
|
||||
if (!serverId || !/^[a-z0-9][a-z0-9_-]{1,99}$/i.test(serverId)) {
|
||||
throw new Error("Usage: npm run plugin:create-credential --workspace @minecraft-account-manager/database -- <server-id>");
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required");
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const { db, client } = createDatabase(databaseUrl);
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(pluginCredentials)
|
||||
.values({ serverId, secretHash: hashToken(token) })
|
||||
.onConflictDoUpdate({
|
||||
target: pluginCredentials.serverId,
|
||||
set: { secretHash: hashToken(token), revokedAt: null, updatedAt: now },
|
||||
});
|
||||
|
||||
console.log(`Server ID: ${serverId}`);
|
||||
console.log(`API token: ${token}`);
|
||||
console.log("Store this token in the Velocity plugin configuration now; it will not be shown again.");
|
||||
await client.end();
|
||||
@@ -0,0 +1,132 @@
|
||||
import { LoginRateLimitedError, type AuthRepository } from "@minecraft-account-manager/auth";
|
||||
import { and, desc, eq, gt, isNull, lt, sql } from "drizzle-orm";
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import * as schema from "./schema";
|
||||
import { loginCodes, sessions, users } from "./schema";
|
||||
|
||||
type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
export function createAuthRepository(db: Database): AuthRepository {
|
||||
return {
|
||||
async saveLoginCode(code) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${code.discordUserId}))`);
|
||||
const [latestCode] = await tx
|
||||
.select({ createdAt: loginCodes.createdAt })
|
||||
.from(loginCodes)
|
||||
.where(eq(loginCodes.discordUserId, code.discordUserId))
|
||||
.orderBy(desc(loginCodes.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (latestCode && latestCode.createdAt > new Date(code.createdAt.getTime() - 30_000)) {
|
||||
throw new LoginRateLimitedError();
|
||||
}
|
||||
|
||||
await tx.delete(loginCodes).where(lt(loginCodes.expiresAt, code.createdAt));
|
||||
await tx
|
||||
.update(loginCodes)
|
||||
.set({ consumedAt: code.createdAt })
|
||||
.where(
|
||||
and(
|
||||
eq(loginCodes.discordUserId, code.discordUserId),
|
||||
isNull(loginCodes.consumedAt),
|
||||
),
|
||||
);
|
||||
await tx.insert(loginCodes).values({
|
||||
tokenHash: code.tokenHash,
|
||||
discordUserId: code.discordUserId,
|
||||
discordUsername: code.discordUsername,
|
||||
discordGlobalName: code.discordGlobalName,
|
||||
expiresAt: code.expiresAt,
|
||||
createdAt: code.createdAt,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async exchangeLoginCode(input) {
|
||||
return db.transaction(async (tx) => {
|
||||
const [loginCode] = await tx
|
||||
.update(loginCodes)
|
||||
.set({ consumedAt: input.now })
|
||||
.where(
|
||||
and(
|
||||
eq(loginCodes.tokenHash, input.loginCodeHash),
|
||||
isNull(loginCodes.consumedAt),
|
||||
gt(loginCodes.expiresAt, input.now),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!loginCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [existingUser] = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.discordUserId, loginCode.discordUserId))
|
||||
.limit(1);
|
||||
|
||||
const [user] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
discordUserId: loginCode.discordUserId,
|
||||
discordUsername: loginCode.discordUsername,
|
||||
discordGlobalName: loginCode.discordGlobalName,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.discordUserId,
|
||||
set: {
|
||||
discordUsername: loginCode.discordUsername,
|
||||
discordGlobalName: loginCode.discordGlobalName,
|
||||
updatedAt: input.now,
|
||||
},
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
discordUserId: users.discordUserId,
|
||||
discordUsername: users.discordUsername,
|
||||
firstName: users.firstName,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create or update the Discord user");
|
||||
}
|
||||
|
||||
await tx.insert(sessions).values({
|
||||
userId: user.id,
|
||||
tokenHash: input.sessionTokenHash,
|
||||
expiresAt: input.sessionExpiresAt,
|
||||
lastSeenAt: input.now,
|
||||
createdAt: input.now,
|
||||
});
|
||||
|
||||
return { user, isNewUser: !existingUser };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(db: Database, tokenHash: string, now = new Date()) {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
discordUserId: users.discordUserId,
|
||||
discordUsername: users.discordUsername,
|
||||
firstName: users.firstName,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
sessionExpiresAt: sessions.expiresAt,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.tokenHash, tokenHash),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return user ?? null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import * as schema from "./schema";
|
||||
import { events } from "./schema";
|
||||
|
||||
type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
export interface RecordEventInput {
|
||||
type: string;
|
||||
source: string;
|
||||
subject?: string;
|
||||
data?: Record<string, unknown>;
|
||||
actorUserId?: string;
|
||||
ipAddress?: string;
|
||||
correlationId?: string;
|
||||
time?: Date;
|
||||
}
|
||||
|
||||
export async function recordEvent(db: Database, input: RecordEventInput) {
|
||||
const id = randomUUID();
|
||||
await db.insert(events).values({
|
||||
id,
|
||||
specVersion: "1.0",
|
||||
source: input.source,
|
||||
type: input.type,
|
||||
subject: input.subject,
|
||||
time: input.time ?? new Date(),
|
||||
dataContentType: "application/json",
|
||||
data: input.data ?? {},
|
||||
actorUserId: input.actorUserId,
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.correlationId,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
@@ -10,4 +10,6 @@ export function createDatabase(databaseUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export * from "./auth-repository";
|
||||
export * from "./events";
|
||||
export * from "./schema";
|
||||
|
||||
@@ -134,7 +134,6 @@ export const sessions = pgTable(
|
||||
|
||||
export const appSettings = pgTable("app_settings", {
|
||||
id: text("id").primaryKey().default("default"),
|
||||
discordGuildId: text("discord_guild_id"),
|
||||
registrationMessage: text("registration_message")
|
||||
.notNull()
|
||||
.default("Please register your Minecraft account before joining."),
|
||||
@@ -186,6 +185,21 @@ export const pluginCredentials = pgTable(
|
||||
(table) => [uniqueIndex("plugin_credentials_server_id_uidx").on(table.serverId)],
|
||||
);
|
||||
|
||||
export const pluginRequests = pgTable(
|
||||
"plugin_requests",
|
||||
{
|
||||
requestId: uuid("request_id").primaryKey(),
|
||||
serverId: text("server_id")
|
||||
.notNull()
|
||||
.references(() => pluginCredentials.serverId, { onDelete: "cascade" }),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true, mode: "date", precision: 3 })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date", precision: 3 }).notNull(),
|
||||
},
|
||||
(table) => [index("plugin_requests_expires_idx").on(table.expiresAt)],
|
||||
);
|
||||
|
||||
export const events = pgTable(
|
||||
"events",
|
||||
{
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "drizzle.config.ts"]
|
||||
"include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/minecraft",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
const MINECRAFT_USERNAME = /^[A-Za-z0-9_]{3,16}$/;
|
||||
const MINECRAFT_UUID = /^[0-9a-f]{32}$/i;
|
||||
const DISCORD_NICKNAME_LIMIT = 32;
|
||||
|
||||
export interface JavaProfile {
|
||||
uuid: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function lookupJavaProfile(
|
||||
username: string,
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<JavaProfile | null> {
|
||||
const candidate = username.trim();
|
||||
if (!MINECRAFT_USERNAME.test(candidate)) return null;
|
||||
|
||||
const response = await request(
|
||||
`https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(candidate)}`,
|
||||
{ headers: { accept: "application/json" }, cache: "no-store" },
|
||||
);
|
||||
|
||||
if (response.status === 204 || response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Mojang profile lookup failed (${response.status})`);
|
||||
|
||||
const profile: unknown = await response.json();
|
||||
if (!profile || typeof profile !== "object") return null;
|
||||
const { id, name } = profile as { id?: unknown; name?: unknown };
|
||||
if (typeof id !== "string" || !MINECRAFT_UUID.test(id)) return null;
|
||||
if (typeof name !== "string" || !MINECRAFT_USERNAME.test(name)) return null;
|
||||
|
||||
return { uuid: id.toLowerCase(), username: name };
|
||||
}
|
||||
|
||||
export function formatDiscordNickname(firstName: string, minecraftUsername: string) {
|
||||
const suffix = ` (${minecraftUsername})`;
|
||||
const availableCharacters = DISCORD_NICKNAME_LIMIT - [...suffix].length;
|
||||
const shortenedName = [...firstName.trim()].slice(0, Math.max(1, availableCharacters)).join("").trimEnd();
|
||||
return `${shortenedName}${suffix}`;
|
||||
}
|
||||
|
||||
export async function updateGuildNickname(
|
||||
input: {
|
||||
guildId: string;
|
||||
discordUserId: string;
|
||||
nickname: string;
|
||||
botToken: string;
|
||||
},
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
const response = await request(
|
||||
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bot ${input.botToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ nick: input.nickname }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord nickname update failed (${response.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { updateGuildNickname } from "../src/index";
|
||||
|
||||
describe("Discord nickname updates", () => {
|
||||
it("updates a member in the configured guild using bot authentication", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
await updateGuildNickname(
|
||||
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
|
||||
request,
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/123456789012345678/members/987654321098765432",
|
||||
expect.objectContaining({ method: "PATCH", body: JSON.stringify({ nick: "Sam (Notch)" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports Discord permission failures without pretending the nickname changed", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response("Missing Permissions", { status: 403 }));
|
||||
await expect(
|
||||
updateGuildNickname(
|
||||
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow("Discord nickname update failed (403)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatDiscordNickname, lookupJavaProfile } from "../src/index";
|
||||
|
||||
describe("Java Edition profiles", () => {
|
||||
it("returns Mojang's canonical UUID and username", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "069a79f444e94726a5befca90e38aaf5", name: "Notch" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(lookupJavaProfile("notch", request)).resolves.toEqual({
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5",
|
||||
username: "Notch",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when Mojang does not recognize the username", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(lookupJavaProfile("UnknownPlayer", request)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the Minecraft username while fitting Discord's nickname limit", () => {
|
||||
expect(formatDiscordNickname("Alexandria Catherine", "SixteenCharName1")).toBe(
|
||||
"Alexandria Ca (SixteenCharName1)",
|
||||
);
|
||||
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/network",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" },
|
||||
"devDependencies": { "@types/node": "^25.0.3", "typescript": "^5.9.3", "vitest": "^4.1.0" }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export type IpClassification = "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
|
||||
|
||||
export interface IpIntelligenceResult {
|
||||
classification: IpClassification;
|
||||
provider: string | null;
|
||||
rawResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IpIntelligenceProvider {
|
||||
classify(ipAddress: string): Promise<IpIntelligenceResult>;
|
||||
}
|
||||
|
||||
export class NoopIpIntelligenceProvider implements IpIntelligenceProvider {
|
||||
async classify(_ipAddress: string): Promise<IpIntelligenceResult> {
|
||||
return { classification: "unknown", provider: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientIp(headers: Headers, trustProxy: boolean) {
|
||||
if (!trustProxy) return null;
|
||||
|
||||
const candidate =
|
||||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
headers.get("x-real-ip")?.trim() ||
|
||||
null;
|
||||
|
||||
return candidate && isIP(candidate) ? candidate : null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getClientIp, NoopIpIntelligenceProvider } from "../src/index";
|
||||
|
||||
describe("client IP extraction", () => {
|
||||
it("ignores spoofable forwarding headers unless a trusted proxy is configured", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": "203.0.113.1" });
|
||||
expect(getClientIp(headers, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the first valid address supplied by a trusted proxy", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": "203.0.113.1, 10.0.0.2" });
|
||||
expect(getClientIp(headers, true)).toBe("203.0.113.1");
|
||||
});
|
||||
|
||||
it("rejects malformed proxy values", () => {
|
||||
expect(getClientIp(new Headers({ "x-forwarded-for": "not-an-ip" }), true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VPN intelligence", () => {
|
||||
it("explicitly reports unknown when no provider is configured", async () => {
|
||||
await expect(new NoopIpIntelligenceProvider().classify("203.0.113.1")).resolves.toEqual({
|
||||
classification: "unknown",
|
||||
provider: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["node", "vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.gradle/
|
||||
build/
|
||||
@@ -1,3 +1,24 @@
|
||||
# Velocity plugin
|
||||
# Velocity admission plugin
|
||||
|
||||
The fail-closed Velocity admission plugin will be implemented after the web API contract is complete. Unknown players and unavailable API responses will be denied with the configured registration message.
|
||||
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd plugins/velocity
|
||||
./gradlew clean test shadowJar
|
||||
```
|
||||
|
||||
Copy `build/libs/minecraft-account-manager-velocity-0.1.0.jar` to Velocity's `plugins/` directory and start Velocity once to create `plugins/minecraft-account-manager/config.properties`.
|
||||
|
||||
## Provision a credential
|
||||
|
||||
From the repository root, with `DATABASE_URL` configured:
|
||||
|
||||
```bash
|
||||
npm run plugin:create-credential --workspace @minecraft-account-manager/database -- velocity-main
|
||||
```
|
||||
|
||||
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
||||
|
||||
The proxy must run in online mode. Unknown players and API failures receive the configured registration message.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
plugins {
|
||||
java
|
||||
id("com.gradleup.shadow") version "8.3.9"
|
||||
}
|
||||
|
||||
group = "games.twentyfaces"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://repo.papermc.io/repository/maven-public/")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT")
|
||||
annotationProcessor("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT")
|
||||
implementation("com.google.code.gson:gson:2.13.2")
|
||||
testImplementation(platform("org.junit:junit-bom:5.14.3"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveClassifier.set("")
|
||||
relocate("com.google.gson", "games.twentyfaces.accountmanager.lib.gson")
|
||||
}
|
||||
|
||||
tasks.build {
|
||||
dependsOn(tasks.shadowJar)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "minecraft-account-manager-velocity"
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
record AccessDecision(boolean allowed, String message) {
|
||||
static AccessDecision denied(String message) {
|
||||
return new AccessDecision(false, message);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
final class AccountManagerClient {
|
||||
private final PluginConfig config;
|
||||
private final HttpClient httpClient;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
AccountManagerClient(PluginConfig config) {
|
||||
this(config, HttpClient.newBuilder().connectTimeout(config.timeout()).build());
|
||||
}
|
||||
|
||||
AccountManagerClient(PluginConfig config, HttpClient httpClient) {
|
||||
this.config = config;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
AccessDecision check(UUID minecraftUuid, String username, String ipAddress) {
|
||||
String compactUuid = minecraftUuid.toString().replace("-", "").toLowerCase();
|
||||
AccessRequest payload = new AccessRequest(
|
||||
UUID.randomUUID().toString(),
|
||||
config.serverId(),
|
||||
compactUuid,
|
||||
username,
|
||||
ipAddress,
|
||||
Instant.now().toString()
|
||||
);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.apiUrl() + "/api/velocity/access"))
|
||||
.timeout(config.timeout())
|
||||
.header("Authorization", "Bearer " + config.apiToken())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) return AccessDecision.denied(config.registrationMessage());
|
||||
AccessDecision decision = gson.fromJson(response.body(), AccessDecision.class);
|
||||
if (decision == null) return AccessDecision.denied(config.registrationMessage());
|
||||
if (!decision.allowed() && (decision.message() == null || decision.message().isBlank())) {
|
||||
return AccessDecision.denied(config.registrationMessage());
|
||||
}
|
||||
return decision;
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return AccessDecision.denied(config.registrationMessage());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
return AccessDecision.denied(config.registrationMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private record AccessRequest(
|
||||
String requestId,
|
||||
String serverId,
|
||||
String minecraftUuid,
|
||||
String username,
|
||||
String ipAddress,
|
||||
String occurredAt
|
||||
) {}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.velocitypowered.api.event.EventTask;
|
||||
import com.velocitypowered.api.event.Subscribe;
|
||||
import com.velocitypowered.api.event.ResultedEvent;
|
||||
import com.velocitypowered.api.event.connection.LoginEvent;
|
||||
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
|
||||
import com.velocitypowered.api.plugin.Plugin;
|
||||
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
@Plugin(
|
||||
id = "minecraft-account-manager",
|
||||
name = "Minecraft Account Manager",
|
||||
version = "0.1.0",
|
||||
description = "Fail-closed admission checks for registered Java accounts"
|
||||
)
|
||||
public final class MinecraftAccountManagerPlugin {
|
||||
private final Logger logger;
|
||||
private final Path dataDirectory;
|
||||
private volatile AccountManagerClient accountManagerClient;
|
||||
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
|
||||
|
||||
@Inject
|
||||
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) {
|
||||
this.logger = logger;
|
||||
this.dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onProxyInitialization(ProxyInitializeEvent event) {
|
||||
try {
|
||||
PluginConfig config = PluginConfig.load(dataDirectory);
|
||||
fallbackMessage = config.registrationMessage();
|
||||
accountManagerClient = new AccountManagerClient(config);
|
||||
logger.info("Minecraft account admission checks configured for server {}", config.serverId());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
accountManagerClient = null;
|
||||
logger.error("Account manager configuration failed; all joins will be denied", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public EventTask onLogin(LoginEvent event) {
|
||||
return EventTask.async(() -> {
|
||||
AccountManagerClient client = accountManagerClient;
|
||||
if (client == null) {
|
||||
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(fallbackMessage)));
|
||||
return;
|
||||
}
|
||||
|
||||
String ipAddress = event.getPlayer().getRemoteAddress().getAddress().getHostAddress();
|
||||
AccessDecision decision = client.check(
|
||||
event.getPlayer().getUniqueId(),
|
||||
event.getPlayer().getUsername(),
|
||||
ipAddress
|
||||
);
|
||||
|
||||
if (!decision.allowed()) {
|
||||
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(decision.message())));
|
||||
logger.info("Denied Minecraft login for {} ({})", event.getPlayer().getUsername(), event.getPlayer().getUniqueId());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Properties;
|
||||
|
||||
record PluginConfig(String apiUrl, String serverId, String apiToken, Duration timeout, String registrationMessage) {
|
||||
static PluginConfig load(Path dataDirectory) throws IOException {
|
||||
Files.createDirectories(dataDirectory);
|
||||
Path configPath = dataDirectory.resolve("config.properties");
|
||||
if (Files.notExists(configPath)) {
|
||||
try (InputStream defaults = PluginConfig.class.getResourceAsStream("/config.properties")) {
|
||||
if (defaults == null) throw new IOException("Bundled config.properties is missing");
|
||||
Files.copy(defaults, configPath);
|
||||
}
|
||||
}
|
||||
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(configPath)) {
|
||||
properties.load(input);
|
||||
}
|
||||
|
||||
String apiUrl = required(properties, "api-url").replaceAll("/+$", "");
|
||||
String serverId = required(properties, "server-id");
|
||||
String apiToken = required(properties, "api-token");
|
||||
long timeoutMillis = Long.parseLong(properties.getProperty("request-timeout-ms", "3000"));
|
||||
String message = properties.getProperty(
|
||||
"registration-message",
|
||||
"Please register your Minecraft account in Discord before joining."
|
||||
).trim();
|
||||
return new PluginConfig(apiUrl, serverId, apiToken, Duration.ofMillis(timeoutMillis), message);
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String name) {
|
||||
String value = properties.getProperty(name, "").trim();
|
||||
if (value.isEmpty()) throw new IllegalArgumentException(name + " must be configured");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Base URL of the Next.js service, without a trailing slash
|
||||
api-url=http://localhost:3000
|
||||
server-id=velocity-main
|
||||
api-token=replace-with-generated-token
|
||||
request-timeout-ms=3000
|
||||
registration-message=Please register your Minecraft account in Discord before joining.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AccountManagerClientTest {
|
||||
@Test
|
||||
void deniesPlayersWhenTheApiCannotBeReached() {
|
||||
PluginConfig config = new PluginConfig(
|
||||
"http://127.0.0.1:1",
|
||||
"velocity-test",
|
||||
"test-token",
|
||||
Duration.ofMillis(100),
|
||||
"Register through Discord."
|
||||
);
|
||||
|
||||
AccessDecision decision = new AccountManagerClient(config).check(
|
||||
UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"),
|
||||
"Notch",
|
||||
"203.0.113.10"
|
||||
);
|
||||
|
||||
assertFalse(decision.allowed());
|
||||
assertEquals("Register through Discord.", decision.message());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user