feat: implement incident reporting and management system
- Add incident reporting routes and logic to handle creation, updating, and retrieval of incidents. - Implement visibility rules for users based on roles (reporters, supervisors). - Create tests for incident reporting functionality to ensure correctness and reliability. - Introduce site and area management routes, including validation for area hierarchy. - Establish user management routes with role-based access control for creating and updating users. - Add setup routes for initializing the application with an organization and admin user. - Implement shared enums and schemas for consistent validation and data handling across the application. - Create a test helper for setting up an in-memory database and API client for testing. - Add TypeScript configuration for server-side code with strict type checking and module resolution.
This commit is contained in:
1 parent
a382fa904a
commit
c69eccb5c1
47 files changed
+6297
-45
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import type { Context, MiddlewareHandler } from 'hono'
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||
import { hasRole, type Role } from '../../shared/enums.ts'
|
||||
import { currentUser, fail, type AppEnv } from '../lib/http.ts'
|
||||
import { createSession, deleteSession, SESSION_COOKIE, validateSession } from './sessions.ts'
|
||||
|
||||
function writeCookie(c: Context<AppEnv>, token: string, expires: Date) {
|
||||
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: c.get('config').secureCookies, path: '/', expires })
|
||||
}
|
||||
|
||||
/** Loads the user from the session cookie, if any. */
|
||||
export const loadSession: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE)
|
||||
const session = token ? await validateSession(c.get('db'), token) : null
|
||||
if (token && !session) deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||
if (token && session?.renewedUntil) writeCookie(c, token, session.renewedUntil)
|
||||
c.set('user', session?.user ?? null)
|
||||
await next()
|
||||
}
|
||||
|
||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
currentUser(c)
|
||||
await next()
|
||||
}
|
||||
|
||||
export function requireRole(minimum: Role): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
if (!hasRole(currentUser(c).role, minimum)) throw fail(403, 'You do not have permission to do that')
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSession(c: Context<AppEnv>, userId: string): Promise<void> {
|
||||
const { token, expiresAt } = await createSession(c.get('db'), userId)
|
||||
writeCookie(c, token, expiresAt)
|
||||
}
|
||||
|
||||
export async function endSession(c: Context<AppEnv>): Promise<void> {
|
||||
const token = getCookie(c, SESSION_COOKIE)
|
||||
if (token) await deleteSession(c.get('db'), token)
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hashPassword, verifyPassword } from './password.ts'
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('verifies the right password and rejects the wrong one', async () => {
|
||||
const hash = await hashPassword('correct horse battery')
|
||||
expect(hash).toMatch(/^scrypt\$32768\$8\$3\$/)
|
||||
expect(await verifyPassword('correct horse battery', hash)).toBe(true)
|
||||
expect(await verifyPassword('wrong horse battery', hash)).toBe(false)
|
||||
})
|
||||
|
||||
it('salts each hash', async () => {
|
||||
expect(await hashPassword('same password')).not.toBe(await hashPassword('same password'))
|
||||
})
|
||||
|
||||
it('rejects malformed hashes', async () => {
|
||||
expect(await verifyPassword('anything', 'not-a-hash')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
|
||||
|
||||
// scrypt parameters from the OWASP Password Storage Cheat Sheet (N=2^15, r=8, p=3: ~32 MiB per hash).
|
||||
const params = { N: 2 ** 15, r: 8, p: 3 }
|
||||
const keyLength = 64
|
||||
|
||||
function derive(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) =>
|
||||
scrypt(password.normalize('NFKC'), salt, keyLength, { ...options, maxmem: 64 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))),
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns `scrypt$N$r$p$salt$hash` so parameters can be raised later without breaking old hashes. */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16)
|
||||
const key = await derive(password, salt, params)
|
||||
return ['scrypt', params.N, params.r, params.p, salt.toString('base64'), key.toString('base64')].join('$')
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [algorithm, N, r, p, salt, hash] = stored.split('$')
|
||||
if (algorithm !== 'scrypt' || !salt || !hash) return false
|
||||
const expected = Buffer.from(hash, 'base64')
|
||||
const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(N), r: Number(r), p: Number(p) })
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
/** Burns comparable time when the user doesn't exist, so login timing doesn't reveal valid emails. */
|
||||
let dummyHash: Promise<string> | undefined
|
||||
export async function verifyAgainstDummy(password: string): Promise<false> {
|
||||
dummyHash ??= hashPassword('dummy-password-for-timing')
|
||||
await verifyPassword(password, await dummyHash)
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Fixed-window in-memory limiter. Fine for a single server process; move to the database if we scale out. */
|
||||
export class RateLimiter {
|
||||
private hits = new Map<string, { count: number; resetAt: number }>()
|
||||
private readonly limit: number
|
||||
private readonly windowMs: number
|
||||
|
||||
constructor(limit: number, windowMs: number) {
|
||||
this.limit = limit
|
||||
this.windowMs = windowMs
|
||||
}
|
||||
|
||||
/** Records an attempt; returns false once the key has exceeded the limit. */
|
||||
attempt(key: string, now = Date.now()): boolean {
|
||||
const entry = this.hits.get(key)
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
this.hits.set(key, { count: 1, resetAt: now + this.windowMs })
|
||||
if (this.hits.size > 10_000) this.prune(now)
|
||||
return true
|
||||
}
|
||||
entry.count += 1
|
||||
return entry.count <= this.limit
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.hits.delete(key)
|
||||
}
|
||||
|
||||
private prune(now: number) {
|
||||
for (const [key, entry] of this.hits) if (entry.resetAt <= now) this.hits.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import type { Role } from '../../shared/enums.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { sessions, users } from '../db/schema.ts'
|
||||
|
||||
export const SESSION_COOKIE = 'safetylog_session'
|
||||
export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
organizationId: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex')
|
||||
|
||||
export async function createSession(db: Db, userId: string): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
|
||||
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt })
|
||||
return { token, expiresAt }
|
||||
}
|
||||
|
||||
/** Returns the session's user, extending the session once it is past half its lifetime. */
|
||||
export async function validateSession(db: Db, token: string): Promise<{ user: SessionUser; renewedUntil?: Date } | null> {
|
||||
const id = hashToken(token)
|
||||
const [row] = await db
|
||||
.select({
|
||||
expiresAt: sessions.expiresAt,
|
||||
user: { id: users.id, organizationId: users.organizationId, email: users.email, name: users.name, role: users.role },
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.active, true)))
|
||||
if (!row) return null
|
||||
|
||||
if (row.expiresAt.getTime() - Date.now() < SESSION_TTL_MS / 2) {
|
||||
const renewedUntil = new Date(Date.now() + SESSION_TTL_MS)
|
||||
await db.update(sessions).set({ expiresAt: renewedUntil }).where(eq(sessions.id, id))
|
||||
return { user: row.user, renewedUntil }
|
||||
}
|
||||
return { user: row.user }
|
||||
}
|
||||
|
||||
export async function deleteSession(db: Db, token: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, hashToken(token)))
|
||||
}
|
||||
|
||||
export async function deleteUserSessions(db: Db, userId: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId))
|
||||
}
|
||||
Reference in new issue
Block a user