feat(auth): enhance authentication system with multi-user support and admin role management

- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
This commit is contained in:
Adrian Acala
2026-01-18 09:43:32 -08:00
parent 20ef4ee295
commit 1a52fe80f3
27 changed files with 1692 additions and 237 deletions
+6
View File
@@ -5,3 +5,9 @@ DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
# Optional auth cookie settings: lax | strict | none
AUTH_COOKIE_SAMESITE=lax
# Optional auth bootstrap (creates initial admin)
AUTH_USERNAME=admin
AUTH_EMAIL=admin@example.com
# If not set, a random password is generated and logged
AUTH_PASSWORD=
AUTH_MIN_PASSWORD_LENGTH=7
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT,
"email" TEXT,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+17
View File
@@ -40,3 +40,20 @@ model Library {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(uuid())
username String? @unique
email String? @unique
passwordHash String
role String @default("USER")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemConfig {
id String @id @default("default")
registrationEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import {
cleanupTestDb,
getTestDatabaseUrl,
getTestPrisma,
initTestDb,
setupTestDb,
} from "./testUtils";
let prisma = getTestPrisma();
describe("Authentication flows", () => {
let app: any;
beforeAll(async () => {
process.env.DATABASE_URL = getTestDatabaseUrl();
process.env.AUTH_SESSION_SECRET = "test-secret";
process.env.NODE_ENV = "test";
setupTestDb();
prisma = getTestPrisma();
await initTestDb(prisma);
const appModule = await import("../index");
app = appModule.default || appModule.app || appModule;
});
beforeEach(async () => {
await cleanupTestDb(prisma);
await initTestDb(prisma);
});
const fetchCsrfToken = async () => {
const csrf = await request(app).get("/csrf-token");
return csrf.body?.token as string;
};
const createAdminSession = async () => {
let token = await fetchCsrfToken();
const bootstrap = await request(app)
.post("/auth/bootstrap")
.set("x-csrf-token", token)
.send({ username: "admin", password: "password123" });
if (bootstrap.status !== 201) {
throw new Error(`Bootstrap failed: ${bootstrap.status} ${JSON.stringify(bootstrap.body)}`);
}
token = await fetchCsrfToken();
const login = await request(app)
.post("/auth/login")
.set("x-csrf-token", token)
.send({ username: "admin", password: "password123" });
return login.headers["set-cookie"] as string[] | undefined;
};
afterAll(async () => {
await prisma.$disconnect();
});
it("requires bootstrap before registration", async () => {
const token = await fetchCsrfToken();
const response = await request(app)
.post("/auth/register")
.set("x-csrf-token", token)
.send({ username: "user1", password: "password123" });
expect(response.status).toBe(409);
});
it("bootstraps first admin and logs in", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
});
it("toggles registration when admin", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
const token = await fetchCsrfToken();
const toggle = await request(app)
.post("/auth/registration/toggle")
.set("Cookie", cookie)
.set("x-csrf-token", token)
.send({ enabled: true });
expect(toggle.status).toBe(200);
expect(toggle.body.registrationEnabled).toBe(true);
});
it("registers a new user when enabled", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
let token = await fetchCsrfToken();
await request(app)
.post("/auth/registration/toggle")
.set("Cookie", cookie)
.set("x-csrf-token", token)
.send({ enabled: true });
token = await fetchCsrfToken();
const register = await request(app)
.post("/auth/register")
.set("x-csrf-token", token)
.send({ username: "user1", password: "password123" });
expect(register.status).toBe(201);
expect(register.body.user.username).toBe("user1");
});
});
+30 -18
View File
@@ -2,31 +2,36 @@ import { describe, it, expect, vi } from "vitest";
import {
buildAuthConfig,
createAuthSessionToken,
generateRandomPassword,
getAuthSessionFromCookie,
hashPassword,
isPasswordValid,
validateAuthSessionToken,
verifyCredentials,
verifyPassword,
} from "../auth";
describe("Auth utilities", () => {
it("disables auth when credentials are missing", () => {
it("builds auth config defaults", () => {
const config = buildAuthConfig({});
expect(config.enabled).toBe(false);
expect(config.enabled).toBe(true);
expect(config.minPasswordLength).toBe(7);
});
it("verifies credentials and validates issued session tokens", () => {
it("hashes and verifies passwords", () => {
const hashed = hashPassword("super-secret");
expect(verifyPassword("super-secret", hashed)).toBe(true);
expect(verifyPassword("wrong", hashed)).toBe(false);
});
it("validates issued session tokens", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "super-secret",
AUTH_SESSION_SECRET: "test-secret",
});
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true);
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
const session = validateAuthSessionToken(config, token);
expect(session).not.toBeNull();
expect(session?.username).toBe("admin");
expect(session?.userId).toBe("user-123");
});
it("rejects expired session tokens", () => {
@@ -34,13 +39,11 @@ describe("Auth utilities", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds
});
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
expect(validateAuthSessionToken(config, token)).toBeNull();
@@ -49,14 +52,23 @@ describe("Auth utilities", () => {
it("extracts session tokens from cookies", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
});
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
const session = getAuthSessionFromCookie(cookieHeader, config);
expect(session?.username).toBe("admin");
expect(session?.userId).toBe("user-123");
});
it("validates password length", () => {
const config = buildAuthConfig({ AUTH_MIN_PASSWORD_LENGTH: "9" });
expect(isPasswordValid(config, "12345678")).toBe(false);
expect(isPasswordValid(config, "123456789")).toBe(true);
});
it("generates random passwords", () => {
const password = generateRandomPassword(32);
expect(password).toHaveLength(32);
});
});
@@ -314,10 +314,11 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
let prisma: ReturnType<typeof getTestPrisma>;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
await initTestDb(prisma);
});
+29 -7
View File
@@ -5,19 +5,24 @@ import { PrismaClient } from "../generated/client";
import path from "path";
import { execSync } from "child_process";
const testDbSuffix =
process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || String(process.pid);
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
const TEST_DB_PATH = path.resolve(__dirname, `../../prisma/test-${testDbSuffix}.db`);
const TEST_DATABASE_URL = `file:${TEST_DB_PATH}`;
export const getTestDatabaseUrl = () => TEST_DATABASE_URL;
/**
* Get a test Prisma client pointing to the test database
*/
export const getTestPrisma = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
process.env.DATABASE_URL = TEST_DATABASE_URL;
return new PrismaClient({
datasources: {
db: {
url: databaseUrl,
url: TEST_DATABASE_URL,
},
},
});
@@ -27,14 +32,23 @@ export const getTestPrisma = () => {
* Setup the test database by running migrations
*/
export const setupTestDb = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
process.env.DATABASE_URL = TEST_DATABASE_URL;
// Remove existing DB to avoid locked state in parallel runs
try {
execSync(`rm -f "${TEST_DB_PATH}"`, {
cwd: path.resolve(__dirname, "../../"),
stdio: "pipe",
});
} catch {
// ignore cleanup failures
}
// Run Prisma migrations to create the test database
try {
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL },
stdio: "pipe",
});
} catch (error) {
@@ -52,6 +66,8 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
await prisma.collection.deleteMany({
where: { id: { not: "trash" } },
});
await prisma.user.deleteMany({});
await prisma.systemConfig.deleteMany({});
};
/**
@@ -67,6 +83,12 @@ export const initTestDb = async (prisma: PrismaClient) => {
data: { id: "trash", name: "Trash" },
});
}
await prisma.systemConfig.upsert({
where: { id: "default" },
update: {},
create: { id: "default", registrationEnabled: false },
});
};
/**
+67 -24
View File
@@ -4,16 +4,15 @@ export type AuthSameSite = "lax" | "strict" | "none";
export type AuthConfig = {
enabled: boolean;
username: string;
password: string;
sessionTtlMs: number;
cookieName: string;
cookieSameSite: AuthSameSite;
secret: Buffer;
minPasswordLength: number;
};
export type AuthSession = {
username: string;
userId: string;
iat: number;
exp: number;
};
@@ -46,6 +45,14 @@ const parseSessionTtlHours = (rawValue?: string): number => {
return parsed;
};
const parseMinPasswordLength = (rawValue?: string): number => {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 7;
}
return Math.floor(parsed);
};
const parseSameSite = (rawValue?: string): AuthSameSite => {
if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
const normalized = rawValue.trim().toLowerCase();
@@ -73,50 +80,83 @@ const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer =>
};
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
const username = (env.AUTH_USERNAME || "").trim();
const password = env.AUTH_PASSWORD || "";
const enabled = username.length > 0 && password.length > 0;
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
return {
enabled,
username,
password,
enabled: true,
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
cookieSameSite,
secret: resolveAuthSecret(enabled, env),
secret: resolveAuthSecret(true, env),
minPasswordLength,
};
};
const signToken = (secret: Buffer, payloadB64: string): Buffer =>
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
const safeCompare = (left: string, right: string): boolean => {
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest();
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest();
return crypto.timingSafeEqual(leftHash, rightHash);
const PASSWORD_SALT_BYTES = 16;
const PASSWORD_HASH_BYTES = 64;
const PASSWORD_SCRYPT_OPTIONS = {
N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
};
export const verifyCredentials = (
config: AuthConfig,
inputUsername: string,
inputPassword: string
): boolean => {
if (!config.enabled) return false;
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
export const hashPassword = (password: string): string => {
const salt = crypto.randomBytes(PASSWORD_SALT_BYTES);
const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derived.toString("hex")}`;
};
export const createAuthSessionToken = (config: AuthConfig, username: string): string => {
export const verifyPassword = (password: string, storedHash: string): boolean => {
const [saltHex, derivedHex] = storedHash.split(":");
if (!saltHex || !derivedHex) return false;
const salt = Buffer.from(saltHex, "hex");
const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS);
const expected = Buffer.from(derivedHex, "hex");
if (expected.length !== derived.length) return false;
return crypto.timingSafeEqual(expected, derived);
};
export const isPasswordValid = (config: AuthConfig, password: string): boolean => {
if (typeof password !== "string") return false;
return password.trim().length >= config.minPasswordLength;
};
export const isEmailValid = (value: string | null | undefined): boolean => {
if (!value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
};
export const isUsernameValid = (value: string | null | undefined): boolean => {
if (!value) return false;
return /^[a-zA-Z0-9._-]+$/.test(value.trim());
};
export const generateRandomPassword = (length: number = 32): string => {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
const bytes = crypto.randomBytes(length);
let result = "";
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % chars.length];
}
return result;
};
export const createAuthSessionToken = (config: AuthConfig, userId: string): string => {
if (!config.enabled) {
throw new Error("Authentication is not enabled.");
}
const issuedAt = Date.now();
const payload: AuthSession = {
username,
userId,
iat: issuedAt,
exp: issuedAt + config.sessionTtlMs,
};
@@ -151,7 +191,7 @@ export const validateAuthSessionToken = (
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
if (
typeof payload.username !== "string" ||
typeof payload.userId !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.exp !== "number"
) {
@@ -190,6 +230,9 @@ export const getAuthSessionFromCookie = (
return validateAuthSessionToken(config, token);
};
export const buildAuthIdentifier = (user: { username?: string | null; email?: string | null }) =>
user.username || user.email || "";
export const buildAuthCookieOptions = (
secure: boolean,
sameSite: AuthSameSite,
+501 -50
View File
File diff suppressed because it is too large Load Diff