feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user