Public Access
feat: enhance security with token blacklisting and input sanitization; add helmet for security headers
This commit is contained in:
1 parent
4935251125
commit
1b5fd04040
25 files changed
+294
-90
No files matched your search
@@ -27,10 +27,8 @@ jobs:
|
||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Write Inventory
|
||||
# StrictHostKeyChecking=no is intentional for a known home-network host.
|
||||
# To harden, add the host fingerprint as a secret and use a known_hosts file.
|
||||
run: |
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
|
||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
|
||||
"${{ secrets.ANSIBLE_HOST }}" \
|
||||
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
||||
|
||||
|
||||
Generated
+10
@@ -14,6 +14,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3",
|
||||
"zod": "^3.24.2"
|
||||
@@ -1862,6 +1863,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@@ -28,7 +28,7 @@ export const config = {
|
||||
jwtSecret: env('JWT_SECRET'),
|
||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||
bodyLimit: env('BODY_LIMIT', '10mb'),
|
||||
bodyLimit: env('BODY_LIMIT', '100kb'),
|
||||
|
||||
email: {
|
||||
smtpHost: env('SMTP_HOST', ''),
|
||||
|
||||
+18
-1
@@ -1,6 +1,8 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { getDb, closeDb } from './db/database.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import examRoutes from './routes/exams.js'
|
||||
@@ -20,9 +22,25 @@ const PORT = config.port
|
||||
|
||||
const app = express()
|
||||
|
||||
// Security headers
|
||||
app.use(helmet())
|
||||
|
||||
// CORS
|
||||
app.use(cors({ origin: config.corsOrigin }))
|
||||
|
||||
// Body parsing with reasonable limit
|
||||
app.use(express.json({ limit: config.bodyLimit }))
|
||||
|
||||
// Global API rate limiting
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200,
|
||||
message: { error: 'Too many requests, please try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
})
|
||||
app.use('/api/', globalLimiter)
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now()
|
||||
@@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => {
|
||||
res.status(overall === 'healthy' ? 200 : 503).json({
|
||||
status: overall,
|
||||
timestamp: Date.now(),
|
||||
version: process.env['npm_package_version'] ?? '0.0.1',
|
||||
checks,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
|
||||
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
|
||||
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
try {
|
||||
const token = header.slice(7)
|
||||
req.user = verifyToken(token)
|
||||
|
||||
// Check if the token has been blacklisted (e.g., after logout)
|
||||
if (isTokenBlacklisted(hashToken(token))) {
|
||||
res.status(401).json({ error: 'Token has been revoked' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' })
|
||||
|
||||
@@ -17,15 +17,21 @@ export function errorHandler(
|
||||
_next: any
|
||||
) {
|
||||
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
const isOperational = err instanceof AppError && err.isOperational;
|
||||
|
||||
console.error(`[Error] ${statusCode} - ${message}`);
|
||||
if (!(err instanceof AppError) || !err.isOperational) {
|
||||
// Log full details server-side
|
||||
console.error(`[Error] ${statusCode} - ${err.message}`);
|
||||
if (!isOperational) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Never leak internal error details to the client for unexpected 500s
|
||||
const clientMessage = statusCode === 500 && !isOperational
|
||||
? 'Internal Server Error'
|
||||
: (err.message || 'Internal Server Error');
|
||||
|
||||
res.status(statusCode).json({
|
||||
status: 'error',
|
||||
message,
|
||||
message: clientMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,14 +63,14 @@ export function getUserById(id: number): DbUser | undefined {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserByVerifyToken(token: string): DbUser | undefined {
|
||||
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
|
||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function getUserByResetToken(token: string): DbUser | undefined {
|
||||
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
|
||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
|
||||
}
|
||||
|
||||
export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import * as authService from '../services/authService.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { hashToken } from '../utils/tokens.js'
|
||||
import { blacklistToken } from '../utils/tokenBlacklist.js'
|
||||
import {
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
@@ -113,6 +115,23 @@ router.post(
|
||||
})
|
||||
)
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post(
|
||||
'/logout',
|
||||
requireAuth,
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const header = req.headers.authorization
|
||||
if (header?.startsWith('Bearer ')) {
|
||||
const token = header.slice(7)
|
||||
const tokenHash = hashToken(token)
|
||||
// Blacklist the token so it cannot be reused after logout
|
||||
// Expiry is 24h from now (matching JWT expiry)
|
||||
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
|
||||
}
|
||||
res.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// DELETE /api/auth/me
|
||||
router.delete(
|
||||
'/me',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import * as examRepo from '../repositories/examRepo.js'
|
||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const router = Router()
|
||||
@@ -41,7 +42,7 @@ router.post(
|
||||
'/',
|
||||
validateBody(saveExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const exam = req.body.exam
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const { id } = examRepo.createExam(
|
||||
req.user!.userId,
|
||||
exam.title,
|
||||
@@ -59,7 +60,7 @@ router.put(
|
||||
validateBody(updateExamSchema),
|
||||
asyncHandler(async (req: Request, res) => {
|
||||
const id = Number(req.params.id)
|
||||
const exam = req.body.exam
|
||||
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||
const updated = examRepo.updateExam(
|
||||
id,
|
||||
req.user!.userId,
|
||||
|
||||
+54
-42
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
|
||||
import { validateBody } from '../middleware/validate.js'
|
||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import { getDb } from '../db/database.js'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||
import { setupSchema } from '../validators/setupSchemas.js'
|
||||
@@ -33,54 +34,65 @@ router.post(
|
||||
'/',
|
||||
validateBody(setupSchema),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (getSiteTitle()) {
|
||||
throw new AppError(400, 'Setup already completed')
|
||||
}
|
||||
const db = getDb()
|
||||
|
||||
if (userRepo.countUsers() > 0) {
|
||||
throw new AppError(400, 'Users already exist')
|
||||
}
|
||||
// Atomic transaction prevents race conditions between concurrent setup requests
|
||||
const tx = db.transaction(() => {
|
||||
const title = settingsRepo.getSetting('site_title')
|
||||
if (title) {
|
||||
throw new AppError(400, 'Setup already completed')
|
||||
}
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
siteTitle,
|
||||
language,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpPass,
|
||||
smtpFrom,
|
||||
smtpSecure,
|
||||
appUrl,
|
||||
} = req.body
|
||||
const userCount = userRepo.countUsers()
|
||||
if (userCount > 0) {
|
||||
throw new AppError(400, 'Users already exist')
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
siteTitle,
|
||||
language,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpPass,
|
||||
smtpFrom,
|
||||
smtpSecure,
|
||||
appUrl,
|
||||
} = req.body
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash,
|
||||
displayName,
|
||||
role: 'admin',
|
||||
emailVerified: 1,
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
email,
|
||||
passwordHash,
|
||||
displayName,
|
||||
role: 'admin',
|
||||
emailVerified: 1,
|
||||
})
|
||||
|
||||
const settings: Array<[string, string]> = [
|
||||
['site_title', siteTitle?.trim() || 'BrainWave'],
|
||||
['site_language', language || 'en'],
|
||||
['maintenance_mode', 'false'],
|
||||
]
|
||||
|
||||
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
|
||||
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
|
||||
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
|
||||
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
|
||||
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
|
||||
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
|
||||
if (appUrl !== undefined) settings.push(['app_url', appUrl])
|
||||
|
||||
settingsRepo.setSettingsBatch(settings)
|
||||
|
||||
return { id, email, displayName }
|
||||
})
|
||||
|
||||
const settings: Array<[string, string]> = [
|
||||
['site_title', siteTitle?.trim() || 'BrainWave'],
|
||||
['site_language', language || 'en'],
|
||||
['maintenance_mode', 'false'],
|
||||
]
|
||||
|
||||
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
|
||||
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
|
||||
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
|
||||
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
|
||||
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
|
||||
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
|
||||
if (appUrl !== undefined) settings.push(['app_url', appUrl])
|
||||
|
||||
settingsRepo.setSettingsBatch(settings)
|
||||
const { id, email, displayName } = tx()
|
||||
|
||||
const token = signToken({
|
||||
userId: id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import * as userRepo from '../repositories/userRepo.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||
import { AppError } from '../middleware/error.js'
|
||||
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
||||
@@ -22,7 +22,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||
const role = isFirstUser ? 'admin' : 'user'
|
||||
const verifyToken = generateVerificationToken()
|
||||
const rawVerifyToken = generateVerificationToken()
|
||||
const verifyTokenHash = hashToken(rawVerifyToken)
|
||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||
|
||||
const { id } = userRepo.createUser({
|
||||
@@ -31,14 +32,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
||||
displayName: input.displayName,
|
||||
role,
|
||||
emailVerified: 0,
|
||||
verifyToken,
|
||||
verifyToken: verifyTokenHash,
|
||||
verifyExpires,
|
||||
})
|
||||
|
||||
const user = userRepo.getUserById(id)!
|
||||
|
||||
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||
sendVerificationEmail(user.email, verifyToken).catch(() => {})
|
||||
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
||||
|
||||
return {
|
||||
token: signToken({
|
||||
@@ -67,6 +68,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
|
||||
throw new AppError(401, 'Invalid email or password')
|
||||
}
|
||||
|
||||
if (!user.email_verified) {
|
||||
throw new AppError(403, 'Please verify your email before logging in')
|
||||
}
|
||||
|
||||
return {
|
||||
token: signToken({
|
||||
userId: user.id,
|
||||
@@ -118,7 +123,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
|
||||
}
|
||||
|
||||
export async function verifyEmail(input: VerifyEmailInput) {
|
||||
const user = userRepo.getUserByVerifyToken(input.token)
|
||||
const tokenHash = hashToken(input.token)
|
||||
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
|
||||
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
||||
throw new AppError(400, 'Invalid or expired verification token')
|
||||
}
|
||||
@@ -141,17 +147,19 @@ export async function forgotPassword(email?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const resetToken = generateVerificationToken()
|
||||
const rawResetToken = generateVerificationToken()
|
||||
const resetTokenHash = hashToken(rawResetToken)
|
||||
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||
|
||||
userRepo.updateUser(user.id, { resetToken, resetExpires })
|
||||
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
|
||||
|
||||
// Fire and forget
|
||||
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
|
||||
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
|
||||
}
|
||||
|
||||
export async function resetPassword(input: ResetPasswordInput) {
|
||||
const user = userRepo.getUserByResetToken(input.token)
|
||||
const tokenHash = hashToken(input.token)
|
||||
const user = userRepo.getUserByResetTokenHash(tokenHash)
|
||||
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
||||
throw new AppError(400, 'Invalid or expired reset token')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Server-side sanitization for user-provided exam content.
|
||||
* Removes script tags and event handlers from string fields.
|
||||
*/
|
||||
|
||||
function sanitizeString(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value
|
||||
return value
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*\/>/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/on\w+\s*=/gi, '')
|
||||
}
|
||||
|
||||
function sanitizeObject(obj: unknown): unknown {
|
||||
if (typeof obj === 'string') return sanitizeString(obj)
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeObject)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
result[key] = sanitizeObject(val)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
|
||||
return sanitizeObject(exam) as Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* In-memory token blacklist with SQLite persistence.
|
||||
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
|
||||
*/
|
||||
|
||||
import { getDb } from '../db/database.js'
|
||||
|
||||
const memoryCache = new Set<string>()
|
||||
let initialized = false
|
||||
|
||||
function initCache(): void {
|
||||
if (initialized) return
|
||||
const db = getDb()
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
||||
`)
|
||||
// Load recent blacklisted tokens into memory
|
||||
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
|
||||
.all(Date.now()) as { token_hash: string }[]
|
||||
for (const row of rows) {
|
||||
memoryCache.add(row.token_hash)
|
||||
}
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export function isTokenBlacklisted(tokenHash: string): boolean {
|
||||
initCache()
|
||||
return memoryCache.has(tokenHash)
|
||||
}
|
||||
|
||||
export function blacklistToken(tokenHash: string, expiresAt: number): void {
|
||||
initCache()
|
||||
memoryCache.add(tokenHash)
|
||||
const db = getDb()
|
||||
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
|
||||
.run(tokenHash, expiresAt)
|
||||
}
|
||||
|
||||
export function cleanupExpiredTokens(): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
|
||||
}
|
||||
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
|
||||
export function generateVerificationToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
|
||||
|
||||
describe('updateSettingSchema', () => {
|
||||
it('accepts valid setting', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('updateSettingSchema', () => {
|
||||
})
|
||||
|
||||
it('rejects empty value', () => {
|
||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
||||
const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,26 @@ export const updateUserRoleSchema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
})
|
||||
|
||||
const ALLOWED_SETTING_KEYS = [
|
||||
'site_title',
|
||||
'site_language',
|
||||
'maintenance_mode',
|
||||
'maintenance_message',
|
||||
'allow_guest_mode',
|
||||
'registration_enabled',
|
||||
'default_theme',
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_user',
|
||||
'smtp_pass',
|
||||
'smtp_from',
|
||||
'smtp_secure',
|
||||
'app_url',
|
||||
'max_upload_mb',
|
||||
] as const
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
key: z.enum(ALLOWED_SETTING_KEYS),
|
||||
value: z.string().min(1),
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
|
||||
it('accepts valid registration', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
password: 'Password123!',
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
|
||||
it('rejects invalid email', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
password: 'password123',
|
||||
password: 'Password123!',
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
|
||||
it('rejects empty display name', () => {
|
||||
const result = registerSchema.safeParse({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
password: 'Password123!',
|
||||
displayName: '',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
|
||||
|
||||
describe('resetPasswordSchema', () => {
|
||||
it('accepts valid reset', () => {
|
||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
|
||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
|
||||
it('accepts valid change', () => {
|
||||
const result = changePasswordSchema.safeParse({
|
||||
currentPassword: 'oldpass',
|
||||
newPassword: 'newpass123',
|
||||
newPassword: 'NewPass123!',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||
})
|
||||
|
||||
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, 'Token is required'),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
newPassword: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||
})
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
newPassword: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
|
||||
it('accepts valid setup', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
password: 'adminpass123',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
siteTitle: 'BrainWave',
|
||||
language: 'en',
|
||||
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
|
||||
it('rejects invalid email', () => {
|
||||
const result = setupSchema.safeParse({
|
||||
email: 'bad',
|
||||
password: 'adminpass123',
|
||||
password: 'AdminPass123!',
|
||||
displayName: 'Admin',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||
|
||||
export const setupSchema = z.object({
|
||||
email: z.string().email().max(255),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
||||
language: z.enum(['en', 'nl']).optional(),
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "brainwave",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]}
|
||||
Generated
+16
-1
@@ -2,5 +2,20 @@
|
||||
"name": "brainwave",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"helmet": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "brainwave",
|
||||
"private": true,
|
||||
"version": "0.0.1"
|
||||
}
|
||||
Reference in new issue
Block a user