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:
@@ -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");
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user