Add 2FA, email + password reset, and batch-7 pages
Auth (hand-built on the auth core): - 2FA: User model gains two_factor_secret/recovery_codes/confirmed_at (+ idempotent MariaDB migration). authorize() requires a valid TOTP code when 2FA is confirmed (secret decrypted via Laravel APP_KEY, fail-closed). Two-step login (precheckLogin reveals the code field). /settings/2fa enable/confirm/disable flow. - Password reset: nodemailer email service; PasswordReset model + migration; /forgot (request, generic response) + /reset (token sha256 + 1h TTL, sets argon2id hash). Login links to forgot. Batch 7 (parallel agents): /admin/commandocentrum (RCON controls + emulator_errors), social write actions (friend request + guild forum new thread), /help/[category], /badges (public). env: APP_KEY, APP_URL, SMTP_*. Nav extended. Verified: tsc exit 0, vitest 48/48, next build exit 0 (64 page routes).
This commit is contained in:
1 parent
486ce51559
commit
e668fa85ec
24 files changed
+1223
-34
No files matched your search
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/env";
|
||||
|
||||
export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||
|
||||
/**
|
||||
* Validates username+password WITHOUT creating a session, and reports whether a
|
||||
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
|
||||
*/
|
||||
export async function precheckLogin(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PrecheckResult> {
|
||||
const u = String(username ?? "").trim();
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null = null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
select: { password: true, twoFactorConfirmedAt: true },
|
||||
});
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (!user) return "invalid";
|
||||
|
||||
const res = await checkLogin(p, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
if (!res.valid) return "invalid";
|
||||
|
||||
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
|
||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||
export async function updateCatalog(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
// RCON is best-effort; a dead socket must not 500 the admin page.
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||
export async function updateWordFilter(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||
export async function updateNavigator(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.send("updatenavigator", null);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, 512);
|
||||
if (!message) return;
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
|
||||
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
export async function requestReset(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
try {
|
||||
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
|
||||
if (user) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.passwordReset.upsert({
|
||||
where: { email },
|
||||
update: { token: sha256(token), createdAt: new Date() },
|
||||
create: { email, token: sha256(token), createdAt: new Date() },
|
||||
});
|
||||
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
await sendMail(
|
||||
email,
|
||||
`${env.HOTEL_NAME} — password reset`,
|
||||
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// swallow — generic response below
|
||||
}
|
||||
}
|
||||
|
||||
redirect("/forgot?sent=1");
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "").trim().toLowerCase();
|
||||
const token = String(formData.get("token") ?? "").trim();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const row = await prisma.passwordReset.findUnique({ where: { email } });
|
||||
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false;
|
||||
const a = Buffer.from(sha256(token), "hex");
|
||||
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
|
||||
const match = row != null && a.length === b.length && timingSafeEqual(a, b);
|
||||
|
||||
if (!row || !fresh || !match) {
|
||||
error = "This reset link is invalid or has expired";
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
|
||||
if (!user) {
|
||||
error = "Account not found";
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: await hashPassword(password) },
|
||||
});
|
||||
await prisma.passwordReset.delete({ where: { email } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = "Could not reset the password — try again";
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
redirect(`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Guild forum subjects are VARCHAR(255); the comment/message body lives in
|
||||
// guilds_forums_comments.message which is TEXT. Keep the first post's message
|
||||
// bounded defensively even though the column is large.
|
||||
const SUBJECT_MAX = 255;
|
||||
const MESSAGE_MAX = 10000;
|
||||
|
||||
/**
|
||||
* Send a friend request to another user.
|
||||
*
|
||||
* The REQUESTER (user_from_id) is re-read from the session via auth() and is
|
||||
* never trusted from the submitted FormData, so a crafted form cannot send a
|
||||
* request "from" someone else. Only the TARGET user id is taken from the form.
|
||||
*
|
||||
* Writes into messenger_friendrequests (userFromId = requester, userToId =
|
||||
* target). The emulator surfaces the pending request in the in-game messenger.
|
||||
*/
|
||||
export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const fromId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(fromId) || fromId <= 0) return;
|
||||
|
||||
const toId = Number(formData.get("userId"));
|
||||
if (!Number.isInteger(toId) || toId <= 0) return;
|
||||
|
||||
// Can't befriend yourself.
|
||||
if (toId === fromId) return;
|
||||
|
||||
try {
|
||||
// Guard against duplicate pending requests and already-existing friendships.
|
||||
const [existingRequest, existingFriendship] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: fromId, userToId: toId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: fromId, userTwoId: toId },
|
||||
{ userOneId: toId, userTwoId: fromId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existingRequest || existingFriendship) return;
|
||||
|
||||
await prisma.messengerFriendrequests.create({
|
||||
data: { userFromId: fromId, userToId: toId },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: revalidate the target profile if a username was supplied, purely
|
||||
// to refresh any request-state UI rendered there.
|
||||
const username = String(formData.get("username") ?? "").trim();
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new thread in a guild's forum.
|
||||
*
|
||||
* The AUTHOR (opener_id) is re-read from the session via auth() and is never
|
||||
* trusted from the submitted FormData. Only the guild id, subject, and message
|
||||
* come from the form.
|
||||
*
|
||||
* AtomCMS/Arcturus splits a thread into a header row (guilds_forums_threads)
|
||||
* plus the opening post stored as the first comment (guilds_forums_comments).
|
||||
* We create both in a transaction so the thread always has its first post, then
|
||||
* stamp posts_count = 1 to match the emulator's bookkeeping.
|
||||
*/
|
||||
export async function postThread(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const openerId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(openerId) || openerId <= 0) return;
|
||||
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||
|
||||
const subject = String(formData.get("subject") ?? "").trim().slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
// Confirm the guild exists (and has a forum) before opening a thread.
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guild) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const thread = await tx.guildsForumsThreads.create({
|
||||
data: {
|
||||
guildId,
|
||||
openerId,
|
||||
subject,
|
||||
postsCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: 0,
|
||||
pinned: 0,
|
||||
locked: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: openerId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function sessionUserId(): Promise<number> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
return Number(session.user.id);
|
||||
}
|
||||
|
||||
/** Step 1: generate a secret, store it encrypted but UNconfirmed. */
|
||||
export async function beginTwoFactor(): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const secret = generateTotpSecret();
|
||||
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { twoFactorSecret: encrypted, twoFactorConfirmedAt: null },
|
||||
});
|
||||
revalidatePath("/settings/2fa");
|
||||
}
|
||||
|
||||
/** Step 2: verify a code against the pending secret, then confirm. */
|
||||
export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { twoFactorSecret: true },
|
||||
});
|
||||
|
||||
let ok = false;
|
||||
if (user?.twoFactorSecret && code) {
|
||||
try {
|
||||
const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret);
|
||||
ok = verifyTotp(code, secret);
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
|
||||
redirect("/settings/2fa?enabled=1");
|
||||
}
|
||||
|
||||
export async function disableTwoFactor(): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
twoFactorSecret: null,
|
||||
twoFactorRecoveryCodes: null,
|
||||
twoFactorConfirmedAt: null,
|
||||
},
|
||||
});
|
||||
redirect("/settings/2fa?disabled=1");
|
||||
}
|
||||
Reference in new issue
Block a user