Phase1
This commit is contained in:
@@ -6,10 +6,17 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"otplib": "^12.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerified: boolean;
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export { hashPassword, verifyPassword } from './password';
|
||||
export {
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
verifyTokenHash,
|
||||
} from './tokens';
|
||||
export {
|
||||
generateUsernameFromEmail,
|
||||
sanitizeUsername,
|
||||
validateUsername,
|
||||
MIN_USERNAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
type UsernameValidationResult,
|
||||
} from './username';
|
||||
export {
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
buildOtpAuthUrl,
|
||||
} from './totp';
|
||||
export {
|
||||
generateRecoveryCodes,
|
||||
hashRecoveryCode,
|
||||
verifyRecoveryCode,
|
||||
} from './recovery-codes';
|
||||
export {
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_DURATION_MS,
|
||||
SESSION_ABSOLUTE_MAX_DURATION_MS,
|
||||
SESSION_REMEMBER_ME_DURATION_MS,
|
||||
createSessionExpiry,
|
||||
} from './session';
|
||||
|
||||
19
packages/auth/src/password.test.ts
Normal file
19
packages/auth/src/password.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
|
||||
describe("password", () => {
|
||||
it("hashes and verifies a password roundtrip", async () => {
|
||||
const plain = "super-secure-password-123!";
|
||||
const hashed = await hashPassword(plain);
|
||||
|
||||
expect(hashed).not.toBe(plain);
|
||||
expect(await verifyPassword(plain, hashed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an incorrect password", async () => {
|
||||
const hashed = await hashPassword("correct-password");
|
||||
|
||||
expect(await verifyPassword("wrong-password", hashed)).toBe(false);
|
||||
});
|
||||
});
|
||||
16
packages/auth/src/password.ts
Normal file
16
packages/auth/src/password.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
plain: string,
|
||||
hashed: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(hashed, plain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
packages/auth/src/recovery-codes.ts
Normal file
28
packages/auth/src/recovery-codes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { hashToken, verifyTokenHash } from "./tokens";
|
||||
|
||||
const RECOVERY_CODE_SEGMENT_LENGTH = 4;
|
||||
|
||||
function formatRecoveryCode(bytes: Buffer): string {
|
||||
const hex = bytes.toString("hex").toUpperCase();
|
||||
return `${hex.slice(0, RECOVERY_CODE_SEGMENT_LENGTH)}-${hex.slice(RECOVERY_CODE_SEGMENT_LENGTH)}`;
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(count = 10): string[] {
|
||||
return Array.from({ length: count }, () =>
|
||||
formatRecoveryCode(randomBytes(RECOVERY_CODE_SEGMENT_LENGTH)),
|
||||
);
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code: string): string {
|
||||
return hashToken(normalizeRecoveryCode(code));
|
||||
}
|
||||
|
||||
export function verifyRecoveryCode(code: string, hashed: string): boolean {
|
||||
return verifyTokenHash(normalizeRecoveryCode(code), hashed);
|
||||
}
|
||||
|
||||
function normalizeRecoveryCode(code: string): string {
|
||||
return code.trim().toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
9
packages/auth/src/session.ts
Normal file
9
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const SESSION_COOKIE_NAME = 'hgc_session';
|
||||
|
||||
export const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const SESSION_ABSOLUTE_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const SESSION_REMEMBER_ME_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function createSessionExpiry(from: Date = new Date()): Date {
|
||||
return new Date(from.getTime() + SESSION_DURATION_MS);
|
||||
}
|
||||
25
packages/auth/src/tokens.test.ts
Normal file
25
packages/auth/src/tokens.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
verifyTokenHash,
|
||||
} from "./tokens";
|
||||
|
||||
describe("tokens", () => {
|
||||
it("produces consistent hashes for the same token", () => {
|
||||
const token = generateSecureToken();
|
||||
const firstHash = hashToken(token);
|
||||
const secondHash = hashToken(token);
|
||||
|
||||
expect(firstHash).toBe(secondHash);
|
||||
expect(verifyTokenHash(token, firstHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects tokens that do not match the hash", () => {
|
||||
const token = generateSecureToken();
|
||||
const hashed = hashToken(token);
|
||||
|
||||
expect(verifyTokenHash("different-token", hashed)).toBe(false);
|
||||
});
|
||||
});
|
||||
21
packages/auth/src/tokens.ts
Normal file
21
packages/auth/src/tokens.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function generateSecureToken(bytes = 32): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyTokenHash(token: string, hashed: string): boolean {
|
||||
const computed = hashToken(token);
|
||||
const computedBuffer = Buffer.from(computed, 'hex');
|
||||
const hashedBuffer = Buffer.from(hashed, 'hex');
|
||||
|
||||
if (computedBuffer.length !== hashedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(computedBuffer, hashedBuffer);
|
||||
}
|
||||
27
packages/auth/src/totp.test.ts
Normal file
27
packages/auth/src/totp.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { authenticator } from "otplib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildOtpAuthUrl,
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
} from "./totp";
|
||||
|
||||
describe("totp", () => {
|
||||
it("generates a secret and verifies a valid code", () => {
|
||||
const secret = generateTotpSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
expect(secret.length).toBeGreaterThan(0);
|
||||
expect(verifyTotpCode(secret, code)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an otpauth URL", () => {
|
||||
const secret = generateTotpSecret();
|
||||
const url = buildOtpAuthUrl("HexaHost", "user@example.com", secret);
|
||||
|
||||
expect(url).toContain("otpauth://totp/");
|
||||
expect(url).toContain("issuer=HexaHost");
|
||||
expect(url).toContain(encodeURIComponent("user@example.com"));
|
||||
});
|
||||
});
|
||||
21
packages/auth/src/totp.ts
Normal file
21
packages/auth/src/totp.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
try {
|
||||
return authenticator.verify({ token: code.replace(/\s+/gu, ''), secret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOtpAuthUrl(
|
||||
issuer: string,
|
||||
accountName: string,
|
||||
secret: string,
|
||||
): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
}
|
||||
109
packages/auth/src/username.ts
Normal file
109
packages/auth/src/username.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 32;
|
||||
|
||||
const USERNAME_PATTERN = /^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$/;
|
||||
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
"admin",
|
||||
"administrator",
|
||||
"api",
|
||||
"auth",
|
||||
"billing",
|
||||
"dashboard",
|
||||
"help",
|
||||
"hexahost",
|
||||
"login",
|
||||
"logout",
|
||||
"moderator",
|
||||
"null",
|
||||
"register",
|
||||
"root",
|
||||
"settings",
|
||||
"support",
|
||||
"system",
|
||||
"undefined",
|
||||
"user",
|
||||
"www",
|
||||
]);
|
||||
|
||||
export interface UsernameValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function sanitizeUsername(input: string): string {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!/^[a-z]/.test(normalized)) {
|
||||
return `u_${normalized}`.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
return normalized.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
export function generateUsernameFromEmail(email: string): string {
|
||||
const localPart = email.trim().toLowerCase().split("@")[0] ?? "";
|
||||
let username = sanitizeUsername(localPart);
|
||||
|
||||
if (!username) {
|
||||
username = "user";
|
||||
}
|
||||
|
||||
if (username.length < MIN_USERNAME_LENGTH) {
|
||||
username = username.padEnd(MIN_USERNAME_LENGTH, "0");
|
||||
}
|
||||
|
||||
if (username.length > MAX_USERNAME_LENGTH) {
|
||||
username = username.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
if (!/^[a-z]/.test(username)) {
|
||||
username = `u${username}`.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): UsernameValidationResult {
|
||||
const normalized = username.trim().toLowerCase();
|
||||
|
||||
if (normalized.length < MIN_USERNAME_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.length > MAX_USERNAME_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Username must be at most ${MAX_USERNAME_LENGTH} characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!USERNAME_PATTERN.test(normalized)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Username must start with a letter, contain only lowercase letters, numbers, and underscores, and not end with an underscore.",
|
||||
};
|
||||
}
|
||||
|
||||
if (RESERVED_USERNAMES.has(normalized)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "This username is reserved.",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
17
packages/auth/vitest.config.ts
Normal file
17
packages/auth/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
103
packages/contracts/src/auth.ts
Normal file
103
packages/contracts/src/auth.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const emailSchema = z.string().email().max(320);
|
||||
const passwordSchema = z.string().min(12).max(128);
|
||||
|
||||
export const registerRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
username: z.string().min(3).max(32).optional(),
|
||||
displayName: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export const loginRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const verifyEmailRequestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const forgotPasswordRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
|
||||
export const resetPasswordRequestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
|
||||
export const twoFactorConfirmRequestSchema = z.object({
|
||||
code: z.string().min(6).max(16),
|
||||
});
|
||||
|
||||
export const twoFactorVerifyRequestSchema = z.object({
|
||||
challengeId: z.string().uuid(),
|
||||
code: z.string().min(6).max(16),
|
||||
});
|
||||
|
||||
export const twoFactorDisableRequestSchema = z.object({
|
||||
password: z.string().min(1).max(128),
|
||||
code: z.string().min(6).max(16),
|
||||
});
|
||||
|
||||
export const authUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
emailVerified: z.boolean(),
|
||||
roles: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const loginSuccessResponseSchema = z.object({
|
||||
user: authUserSchema,
|
||||
});
|
||||
|
||||
export const loginTwoFactorRequiredResponseSchema = z.object({
|
||||
twoFactorRequired: z.literal(true),
|
||||
challengeId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const loginResponseSchema = z.union([
|
||||
loginSuccessResponseSchema,
|
||||
loginTwoFactorRequiredResponseSchema,
|
||||
]);
|
||||
|
||||
export const twoFactorSetupResponseSchema = z.object({
|
||||
secret: z.string(),
|
||||
qrUrl: z.string(),
|
||||
});
|
||||
|
||||
export const sessionSummarySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
ipAddress: z.string().nullable(),
|
||||
userAgent: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
expiresAt: z.string().datetime(),
|
||||
current: z.boolean(),
|
||||
});
|
||||
|
||||
export const sessionListResponseSchema = z.object({
|
||||
sessions: z.array(sessionSummarySchema),
|
||||
});
|
||||
|
||||
export const messageResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
||||
export type VerifyEmailRequest = z.infer<typeof verifyEmailRequestSchema>;
|
||||
export type ForgotPasswordRequest = z.infer<typeof forgotPasswordRequestSchema>;
|
||||
export type ResetPasswordRequest = z.infer<typeof resetPasswordRequestSchema>;
|
||||
export type TwoFactorConfirmRequest = z.infer<typeof twoFactorConfirmRequestSchema>;
|
||||
export type TwoFactorVerifyRequest = z.infer<typeof twoFactorVerifyRequestSchema>;
|
||||
export type TwoFactorDisableRequest = z.infer<typeof twoFactorDisableRequestSchema>;
|
||||
export type AuthUser = z.infer<typeof authUserSchema>;
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
||||
export type TwoFactorSetupResponse = z.infer<typeof twoFactorSetupResponseSchema>;
|
||||
export type SessionSummary = z.infer<typeof sessionSummarySchema>;
|
||||
export type SessionListResponse = z.infer<typeof sessionListResponseSchema>;
|
||||
export type MessageResponse = z.infer<typeof messageResponseSchema>;
|
||||
@@ -21,3 +21,36 @@ export {
|
||||
type PaginationQuery,
|
||||
type PaginationMeta,
|
||||
} from './pagination';
|
||||
|
||||
export {
|
||||
registerRequestSchema,
|
||||
loginRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
forgotPasswordRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
authUserSchema,
|
||||
loginSuccessResponseSchema,
|
||||
loginTwoFactorRequiredResponseSchema,
|
||||
loginResponseSchema,
|
||||
twoFactorSetupResponseSchema,
|
||||
sessionSummarySchema,
|
||||
sessionListResponseSchema,
|
||||
messageResponseSchema,
|
||||
type RegisterRequest,
|
||||
type LoginRequest,
|
||||
type VerifyEmailRequest,
|
||||
type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest,
|
||||
type TwoFactorConfirmRequest,
|
||||
type TwoFactorVerifyRequest,
|
||||
type TwoFactorDisableRequest,
|
||||
type AuthUser,
|
||||
type LoginResponse,
|
||||
type TwoFactorSetupResponse,
|
||||
type SessionSummary,
|
||||
type SessionListResponse,
|
||||
type MessageResponse,
|
||||
} from './auth';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,15 +9,18 @@
|
||||
"typecheck": "prisma generate && tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push"
|
||||
"db:push": "prisma db push",
|
||||
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"prisma": "^6.3.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Phase 1: Authentication models and user extensions
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "displayName" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMPTZ(3);
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "email_verification_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "password_reset_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "totp_credentials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"enabledAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "totp_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "recovery_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"totpCredentialId" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "recovery_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash");
|
||||
CREATE INDEX IF NOT EXISTS "email_verification_tokens_userId_idx" ON "email_verification_tokens"("userId");
|
||||
CREATE INDEX IF NOT EXISTS "email_verification_tokens_expiresAt_idx" ON "email_verification_tokens"("expiresAt");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash");
|
||||
CREATE INDEX IF NOT EXISTS "password_reset_tokens_userId_idx" ON "password_reset_tokens"("userId");
|
||||
CREATE INDEX IF NOT EXISTS "password_reset_tokens_expiresAt_idx" ON "password_reset_tokens"("expiresAt");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "totp_credentials_userId_key" ON "totp_credentials"("userId");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "recovery_codes_totpCredentialId_idx" ON "recovery_codes"("totpCredentialId");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "totp_credentials" ADD CONSTRAINT "totp_credentials_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "recovery_codes" ADD CONSTRAINT "recovery_codes_totpCredentialId_fkey" FOREIGN KEY ("totpCredentialId") REFERENCES "totp_credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Seed platform roles
|
||||
INSERT INTO "platform_roles" ("id", "name", "description", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'USER', 'Standard platform user', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'SUPPORT', 'Support staff', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'MODERATOR', 'Content moderator', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'ADMIN', 'Platform administrator', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'SUPER_ADMIN', 'Super administrator', NOW(), NOW())
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -40,18 +40,25 @@ enum JobStatus {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
status UserStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
displayName String?
|
||||
status UserStatus @default(ACTIVE)
|
||||
emailVerifiedAt DateTime? @db.Timestamptz(3)
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
credentials UserCredential[]
|
||||
sessions UserSession[]
|
||||
platformRoles UserPlatformRole[]
|
||||
gameServers GameServer[]
|
||||
auditEvents AuditEvent[]
|
||||
credentials UserCredential[]
|
||||
sessions UserSession[]
|
||||
platformRoles UserPlatformRole[]
|
||||
gameServers GameServer[]
|
||||
auditEvents AuditEvent[]
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -87,6 +94,63 @@ model UserSession {
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("email_verification_tokens")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
model TotpCredential {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
secret String
|
||||
enabledAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
recoveryCodes RecoveryCode[]
|
||||
|
||||
@@map("totp_credentials")
|
||||
}
|
||||
|
||||
model RecoveryCode {
|
||||
id String @id @default(uuid())
|
||||
totpCredentialId String
|
||||
codeHash String
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
totpCredential TotpCredential @relation(fields: [totpCredentialId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([totpCredentialId])
|
||||
@@map("recovery_codes")
|
||||
}
|
||||
|
||||
model PlatformRole {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
||||
158
packages/database/src/cli/bootstrap-admin.ts
Normal file
158
packages/database/src/cli/bootstrap-admin.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
generateUsernameFromEmail,
|
||||
hashPassword,
|
||||
validateUsername,
|
||||
} from '@hexahost/auth';
|
||||
|
||||
const PLATFORM_ROLES = [
|
||||
'USER',
|
||||
'SUPPORT',
|
||||
'MODERATOR',
|
||||
'ADMIN',
|
||||
'SUPER_ADMIN',
|
||||
] as const;
|
||||
|
||||
interface CliArgs {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const key = argv[i];
|
||||
const value = argv[i + 1];
|
||||
if (key?.startsWith('--') && value) {
|
||||
args[key.slice(2)] = value;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!args['email'] || !args['password']) {
|
||||
console.error(
|
||||
'Usage: bootstrap-admin --email <email> --password <password> [--username <name>] [--display-name <name>]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args['password'].length < 12) {
|
||||
console.error('Password must be at least 12 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
email: args['email'].trim().toLowerCase(),
|
||||
password: args['password'],
|
||||
username: args['username'],
|
||||
displayName: args['display-name'],
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePlatformRoles(prisma: PrismaClient): Promise<void> {
|
||||
for (const name of PLATFORM_ROLES) {
|
||||
await prisma.platformRole.upsert({
|
||||
where: { name },
|
||||
create: { name, description: `${name} platform role` },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const input = parseArgs(process.argv.slice(2));
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
await ensurePlatformRoles(prisma);
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const superAdminRole = await prisma.platformRole.findUnique({
|
||||
where: { name: 'SUPER_ADMIN' },
|
||||
});
|
||||
|
||||
if (!superAdminRole) {
|
||||
throw new Error('SUPER_ADMIN role missing after seed');
|
||||
}
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: existing.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: existing.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
console.log(`User ${input.email} already exists — SUPER_ADMIN role ensured.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let username = input.username?.trim().toLowerCase();
|
||||
if (username) {
|
||||
const validation = validateUsername(username);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error ?? 'Invalid username');
|
||||
}
|
||||
} else {
|
||||
username = generateUsernameFromEmail(input.email);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
const superAdminRole = await prisma.platformRole.findUniqueOrThrow({
|
||||
where: { name: 'SUPER_ADMIN' },
|
||||
});
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
username,
|
||||
displayName: input.displayName ?? 'Administrator',
|
||||
emailVerifiedAt: new Date(),
|
||||
credentials: {
|
||||
create: { passwordHash },
|
||||
},
|
||||
platformRoles: {
|
||||
create: { roleId: superAdminRole.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
action: 'admin.bootstrap',
|
||||
entityType: 'user',
|
||||
entityId: created.id,
|
||||
metadata: { source: 'bootstrap-cli' },
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
console.log(`Admin user created: ${user.email} (${user.id})`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,10 @@ export type {
|
||||
User,
|
||||
UserCredential,
|
||||
UserSession,
|
||||
EmailVerificationToken,
|
||||
PasswordResetToken,
|
||||
TotpCredential,
|
||||
RecoveryCode,
|
||||
PlatformRole,
|
||||
UserPlatformRole,
|
||||
GameServer,
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/cli/**/*"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user