360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
|
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
|
import {
|
|
appSettings,
|
|
events,
|
|
groupAccessWindows,
|
|
groups,
|
|
ipObservations,
|
|
minecraftAccounts,
|
|
pluginCredentials,
|
|
pluginRequests,
|
|
userGroupMemberships,
|
|
} from "@minecraft-account-manager/database";
|
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
|
import { NextResponse } from "next/server";
|
|
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings";
|
|
import { db } from "@/lib/database";
|
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
|
import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy";
|
|
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
|
import { logger } from "@/lib/logger";
|
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
|
|
|
const MAX_CLOCK_SKEW_MS = 45_000;
|
|
|
|
function methodNotAllowed(request: Request) {
|
|
const response = problemResponse(problemDetails(
|
|
"urn:error:method-not-allowed",
|
|
"Method not allowed",
|
|
405,
|
|
"This endpoint only accepts POST requests.",
|
|
problemInstance(request),
|
|
));
|
|
response.headers.set("allow", "POST");
|
|
return response;
|
|
}
|
|
|
|
export const GET = methodNotAllowed;
|
|
export const PUT = methodNotAllowed;
|
|
export const PATCH = methodNotAllowed;
|
|
export const DELETE = methodNotAllowed;
|
|
|
|
async function handleVelocityAccess(request: Request) {
|
|
const instance = problemInstance(request);
|
|
const authorization = request.headers.get("authorization") ?? "";
|
|
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
|
if (!token) {
|
|
return problemResponse(problemDetails(
|
|
"urn:error:unauthorized",
|
|
"Unauthorized",
|
|
401,
|
|
"A valid Velocity server credential is required.",
|
|
instance,
|
|
));
|
|
}
|
|
|
|
const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
if (mediaType !== "application/json") {
|
|
return problemResponse(problemDetails(
|
|
"urn:error:unsupported-media-type",
|
|
"Unsupported media type",
|
|
415,
|
|
"Velocity access requests must use application/json.",
|
|
instance,
|
|
));
|
|
}
|
|
|
|
const parsed = velocityAccessRequestSchema.safeParse(await request.json().catch(() => null));
|
|
if (!parsed.success) {
|
|
return problemResponse(problemDetails(
|
|
"urn:error:invalid-velocity-access-request",
|
|
"Invalid Velocity access request",
|
|
400,
|
|
"The request body does not match the required Velocity access contract.",
|
|
instance,
|
|
{
|
|
issues: parsed.error.issues.map((issue) => ({
|
|
path: issue.path.join("."),
|
|
message: issue.message,
|
|
code: issue.code,
|
|
})),
|
|
},
|
|
));
|
|
}
|
|
|
|
const input = parsed.data;
|
|
const occurredAt = new Date(input.occurredAt);
|
|
if (!isRequestTimestampFresh(occurredAt, new Date(), MAX_CLOCK_SKEW_MS)) {
|
|
return problemResponse(problemDetails(
|
|
"urn:error:expired-velocity-access-request",
|
|
"Expired Velocity access request",
|
|
401,
|
|
"The request timestamp is outside the allowed clock-skew window.",
|
|
instance,
|
|
));
|
|
}
|
|
|
|
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 problemResponse(problemDetails(
|
|
"urn:error:unauthorized",
|
|
"Unauthorized",
|
|
401,
|
|
"The Velocity server credential is invalid or revoked.",
|
|
instance,
|
|
));
|
|
}
|
|
|
|
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
|
const admissionMessages = {
|
|
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
|
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
|
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
|
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
|
};
|
|
|
|
const intelligence = await getIpIntelligence(input.ipAddress);
|
|
const auditIpData = toAuditIpData(intelligence);
|
|
const decisionAt = new Date();
|
|
|
|
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",
|
|
ipIntelligence: auditIpData,
|
|
},
|
|
ipAddress: input.ipAddress,
|
|
correlationId: input.requestId,
|
|
});
|
|
await tx.insert(ipObservations).values({
|
|
source: "game",
|
|
ipAddress: input.ipAddress,
|
|
minecraftUuid: input.minecraftUuid,
|
|
username: input.username,
|
|
classification: intelligence.classification,
|
|
observedAt: occurredAt,
|
|
});
|
|
return {
|
|
allowed: false as const,
|
|
message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), {
|
|
player: input.username,
|
|
group: "everyone",
|
|
}),
|
|
};
|
|
}
|
|
|
|
const [explicitGroup] = await tx
|
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
|
.from(userGroupMemberships)
|
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
|
.where(eq(userGroupMemberships.userId, account.userId))
|
|
.limit(1);
|
|
const [defaultGroup] = await tx
|
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
|
.from(groups)
|
|
.where(eq(groups.isDefault, true))
|
|
.limit(1);
|
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
|
const accessWindows = effectiveGroup
|
|
? await tx.select({
|
|
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
|
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
|
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, effectiveGroup.id))
|
|
: [];
|
|
const policyDecision = evaluateRegisteredPlayerAdmission({
|
|
group: effectiveGroup ?? null,
|
|
windows: accessWindows,
|
|
classification: intelligence.classification,
|
|
now: decisionAt,
|
|
player: input.username,
|
|
messages: admissionMessages,
|
|
});
|
|
if (!policyDecision.allowed) {
|
|
const denialReason = policyDecision.reason;
|
|
await tx.insert(events).values({
|
|
id: randomUUID(),
|
|
source: `/velocity/${input.serverId}`,
|
|
type: "games.minecraft.account-manager.game.login.denied",
|
|
subject: `minecraft-account/${account.id}`,
|
|
time: occurredAt,
|
|
actorUserId: account.userId,
|
|
data: {
|
|
username: input.username,
|
|
reason: denialReason,
|
|
accessGroup: effectiveGroup?.name ?? null,
|
|
accessGroupId: effectiveGroup?.id ?? null,
|
|
nextScheduleWindow: policyDecision.nextWindow ? {
|
|
start: policyDecision.nextWindow.start.toISOString(),
|
|
end: policyDecision.nextWindow.end.toISOString(),
|
|
} : null,
|
|
ipIntelligence: auditIpData,
|
|
},
|
|
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: intelligence.classification,
|
|
observedAt: occurredAt,
|
|
});
|
|
return {
|
|
allowed: false as const,
|
|
message: policyDecision.message,
|
|
};
|
|
}
|
|
|
|
if (!effectiveGroup) throw new Error("Effective access group is unavailable after admission approval");
|
|
|
|
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: intelligence.classification,
|
|
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,
|
|
ipIntelligence: auditIpData,
|
|
accessGroup: effectiveGroup.name,
|
|
accessGroupId: effectiveGroup.id,
|
|
},
|
|
ipAddress: input.ipAddress,
|
|
correlationId: input.requestId,
|
|
});
|
|
|
|
return { allowed: true as const, message: "Account approved." };
|
|
}, { isolationLevel: "repeatable read" });
|
|
|
|
return NextResponse.json(decision);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const instance = problemInstance(request);
|
|
try {
|
|
return await handleVelocityAccess(request);
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, "plugin_requests_pkey")) {
|
|
return problemResponse(problemDetails(
|
|
"urn:error:replayed-velocity-access-request",
|
|
"Replayed Velocity access request",
|
|
409,
|
|
"The request ID has already been processed.",
|
|
instance,
|
|
));
|
|
}
|
|
|
|
logger.error(
|
|
{ err: error, event: "velocity.access_failed", instance },
|
|
"Velocity access request failed",
|
|
);
|
|
return problemResponse(problemDetails(
|
|
"urn:error:service-unavailable",
|
|
"Service unavailable",
|
|
503,
|
|
"The access decision could not be completed.",
|
|
instance,
|
|
));
|
|
}
|
|
}
|