commit 372e71c8f5815dcd00a72cd58bcaf6e2225a3a94 Author: jtricerolph Date: Wed Jul 1 12:09:54 2026 +0000 Initial commit: auth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc5925e --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://auth:CHANGE_ME@10.10.10.100:5432/auth_db +CENTRAL_AUTH_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32 +DOMAIN=manage.hotelnumberfour.com +ADMIN_EMAIL=admin@hotelnumberfour.com +ADMIN_PASSWORD=CHANGE_ME +OFFICE_IP_CHECK=disabled +SESSION_DAYS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4ce5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ +.pnp/ +.pnp.js + +# Build output +dist/ +build/ +.next/ +out/ + +# Environment / secrets +.env +.env.local +.env.*.local +!.env.example + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Docker volumes (if any are mounted locally) +postgres-data/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# Temp +*.tar.gz +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35a6156 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e106abf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + auth: + build: . + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - DOMAIN=${DOMAIN} + - ADMIN_EMAIL=${ADMIN_EMAIL} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + - SESSION_DAYS=${SESSION_DAYS:-30} + - NODE_ENV=production + ports: + - "3001:3001" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 20s + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..c83fa60 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "hnf-auth", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "bcryptjs": "^2.4.3", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "pg": "^8.13.1" + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..ed1cdbe --- /dev/null +++ b/src/db.js @@ -0,0 +1,67 @@ +import pg from 'pg' +import { hashPassword } from './jwt.js' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + offsite_allowed BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS apps ( + id SERIAL PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + base_path TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📋', + theme_color TEXT NOT NULL DEFAULT '#1e3a5f', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS user_app_perms ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + app_id INTEGER NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, app_id) + ); + `) + + // Seed built-in apps + await pool.query(` + INSERT INTO apps (slug, name, description, base_path, icon, theme_color) + VALUES + ('noticeboard', 'Noticeboard', 'Staff notices and announcements', '/notices', '📋', '#1e3a5f'), + ('kitchen', 'Kitchen Flash', 'Invoice processing and GP tracking', '/kitchen', '🍳', '#e85d04'), + ('cashup', 'Cash Up', 'Hotel daily cashing up', '/cashup', '💷', '#6b2d8b'), + ('housekeeping','Housekeeping', 'Room status and task management', '/hk', '🛏️', '#2d6a4f'), + ('forecasting', 'Forecasting', 'Revenue forecasting and reporting', '/forecast', '📈', '#0077b6'), + ('rates', 'Rate Scraper', 'Competitor rate monitoring', '/rates', '🔍', '#7b4f00') + ON CONFLICT (slug) DO NOTHING + `) + + // Seed first admin user if table is empty + const { rows } = await pool.query('SELECT COUNT(*) FROM users') + if (parseInt(rows[0].count) === 0) { + const email = process.env.ADMIN_EMAIL + const password = process.env.ADMIN_PASSWORD + if (email && password) { + await pool.query( + `INSERT INTO users (email, name, password_hash, is_admin, offsite_allowed) + VALUES ($1, $2, $3, true, true)`, + [email, 'Admin', await hashPassword(password)] + ) + console.log(`Created initial admin: ${email}`) + } + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ea61e34 --- /dev/null +++ b/src/index.js @@ -0,0 +1,27 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { authRoutes } from './routes/auth.js' +import { adminRoutes } from './routes/admin.js' + +const app = Fastify({ logger: true, trustProxy: true }) + +await app.register(cookie) +await app.register(cors, { + origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : false, + credentials: true, +}) + +app.get('/health', async () => ({ status: 'healthy' })) + +await app.register(authRoutes, { prefix: '/api/auth' }) +await app.register(adminRoutes, { prefix: '/api/auth/admin' }) + +try { + await initDb() + await app.listen({ port: 3001, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/src/ip-check.js b/src/ip-check.js new file mode 100644 index 0000000..382f664 --- /dev/null +++ b/src/ip-check.js @@ -0,0 +1,40 @@ +import dns from 'dns/promises' + +// OFFICE_IP_CHECK supports: static IP, CIDR range, DDNS hostname, or 'disabled' +const config = process.env.OFFICE_IP_CHECK || 'disabled' +let cachedIP = null +let cacheExpiry = 0 + +function ipInCidr(ip, cidr) { + const [range, bits] = cidr.split('/') + const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 + const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 + return (toInt(ip) & mask) === (toInt(range) & mask) +} + +async function getOfficeIP() { + if (config === 'disabled') return null + if (Date.now() < cacheExpiry && cachedIP) return cachedIP + + const isHostname = /[a-zA-Z]/.test(config) && !config.includes('/') + if (isHostname) { + try { + const addrs = await dns.resolve4(config) + cachedIP = addrs[0] + cacheExpiry = Date.now() + 5 * 60 * 1000 // 5 min TTL + } catch { + // DNS failed — keep last known good IP rather than locking everyone out + } + } else { + cachedIP = config + cacheExpiry = Infinity + } + return cachedIP +} + +export async function isOnsite(requestIP) { + if (config === 'disabled') return true + const officeIP = await getOfficeIP() + if (!officeIP) return true + return officeIP.includes('/') ? ipInCidr(requestIP, officeIP) : requestIP === officeIP +} diff --git a/src/jwt.js b/src/jwt.js new file mode 100644 index 0000000..5ac67c6 --- /dev/null +++ b/src/jwt.js @@ -0,0 +1,26 @@ +import { SignJWT, jwtVerify } from 'jose' +import bcrypt from 'bcryptjs' + +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || 'dev-secret-change-me') +const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') + +export async function hashPassword(password) { + return bcrypt.hash(password, 12) +} + +export async function verifyPassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export async function signToken(payload) { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(`${SESSION_DAYS}d`) + .sign(secret) +} + +export async function verifyToken(token) { + const { payload } = await jwtVerify(token, secret) + return payload +} diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..9a337fa --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,126 @@ +import { pool } from '../db.js' +import { hashPassword, verifyToken } from '../jwt.js' + +async function requireAdmin(request, reply) { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + try { + const payload = await verifyToken(token) + if (!payload.is_admin) return reply.status(403).send({ error: 'Admins only' }) + request.adminPayload = payload + } catch { + return reply.status(401).send({ error: 'Invalid session' }) + } +} + +export async function adminRoutes(app) { + app.addHook('preHandler', requireAdmin) + + // ── Users ────────────────────────────────────────────────────────────────── + + app.get('/users', async () => { + const { rows } = await pool.query( + `SELECT u.id, u.email, u.name, u.active, u.is_admin, u.offsite_allowed, u.created_at, + COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs + FROM users u + LEFT JOIN user_app_perms p ON p.user_id = u.id + LEFT JOIN apps a ON a.id = p.app_id + GROUP BY u.id + ORDER BY u.created_at` + ) + return rows + }) + + app.post('/users', async (request, reply) => { + const { email, name, password, is_admin = false, offsite_allowed = false } = request.body || {} + if (!email || !name || !password) return reply.status(400).send({ error: 'email, name and password required' }) + + const { rows: [user] } = await pool.query( + `INSERT INTO users (email, name, password_hash, is_admin, offsite_allowed) + VALUES ($1, $2, $3, $4, $5) RETURNING id, email, name, is_admin, offsite_allowed, active`, + [email.toLowerCase().trim(), name, await hashPassword(password), is_admin, offsite_allowed] + ) + return reply.status(201).send(user) + }) + + app.patch('/users/:id', async (request, reply) => { + const { id } = request.params + const { active, is_admin, offsite_allowed, name } = request.body || {} + + const updates = [] + const values = [] + if (active !== undefined) { updates.push(`active = $${values.push(active)}`) } + if (is_admin !== undefined) { updates.push(`is_admin = $${values.push(is_admin)}`) } + if (offsite_allowed !== undefined) { updates.push(`offsite_allowed = $${values.push(offsite_allowed)}`) } + if (name !== undefined) { updates.push(`name = $${values.push(name)}`) } + + if (!updates.length) return reply.status(400).send({ error: 'Nothing to update' }) + + values.push(id) + const { rows: [user] } = await pool.query( + `UPDATE users SET ${updates.join(', ')} WHERE id = $${values.length} RETURNING *`, + values + ) + if (!user) return reply.status(404).send({ error: 'User not found' }) + return user + }) + + app.delete('/users/:id', async (request, reply) => { + const { rows: [u] } = await pool.query( + 'DELETE FROM users WHERE id = $1 RETURNING id', [request.params.id] + ) + if (!u) return reply.status(404).send({ error: 'User not found' }) + return reply.status(204).send() + }) + + // ── App permissions ──────────────────────────────────────────────────────── + + app.post('/users/:userId/apps/:slug', async (request, reply) => { + const { userId, slug } = request.params + const { rows: [app] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug]) + if (!app) return reply.status(404).send({ error: 'App not found' }) + await pool.query( + 'INSERT INTO user_app_perms (user_id, app_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, app.id] + ) + return { ok: true } + }) + + app.delete('/users/:userId/apps/:slug', async (request, reply) => { + const { userId, slug } = request.params + const { rows: [app] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug]) + if (!app) return reply.status(404).send({ error: 'App not found' }) + await pool.query('DELETE FROM user_app_perms WHERE user_id = $1 AND app_id = $2', [userId, app.id]) + return reply.status(204).send() + }) + + // ── Apps registry ────────────────────────────────────────────────────────── + + app.get('/apps', async () => { + const { rows } = await pool.query('SELECT * FROM apps ORDER BY name') + return rows + }) + + app.post('/apps', async (request, reply) => { + const { slug, name, description, base_path, icon = '📋', theme_color = '#1e3a5f' } = request.body || {} + if (!slug || !name || !base_path) return reply.status(400).send({ error: 'slug, name and base_path required' }) + const { rows: [app] } = await pool.query( + `INSERT INTO apps (slug, name, description, base_path, icon, theme_color) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (slug) DO UPDATE SET name=$2, description=$3, base_path=$4, icon=$5, theme_color=$6 + RETURNING *`, + [slug, name, description, base_path, icon, theme_color] + ) + return reply.status(201).send(app) + }) + + app.patch('/apps/:slug', async (request, reply) => { + const { active } = request.body || {} + const { rows: [app] } = await pool.query( + 'UPDATE apps SET active = $1 WHERE slug = $2 RETURNING *', + [active, request.params.slug] + ) + if (!app) return reply.status(404).send({ error: 'App not found' }) + return app + }) +} diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..7b6ff81 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,118 @@ +import { pool } from '../db.js' +import { verifyPassword, signToken, verifyToken } from '../jwt.js' +import { isOnsite } from '../ip-check.js' + +const DOMAIN = process.env.DOMAIN || 'localhost' +const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') +const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60 + +function cookieOpts(clear = false) { + return { + httpOnly: true, + secure: process.env.NODE_ENV !== 'development', + sameSite: 'lax', + domain: DOMAIN === 'localhost' ? undefined : `.${DOMAIN}`, + path: '/', + maxAge: clear ? 0 : COOKIE_MAX_AGE, + } +} + +async function getUserWithApps(userId) { + const { rows: [user] } = await pool.query( + 'SELECT id, email, name, is_admin, offsite_allowed FROM users WHERE id = $1 AND active = true', + [userId] + ) + if (!user) return null + + const { rows: apps } = await pool.query( + `SELECT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color + FROM apps a + JOIN user_app_perms p ON p.app_id = a.id + WHERE p.user_id = $1 AND a.active = true + ORDER BY a.name`, + [userId] + ) + + return { ...user, apps } +} + +export async function authRoutes(app) { + // POST /api/auth/login + app.post('/login', async (request, reply) => { + const { email, password } = request.body || {} + if (!email || !password) return reply.status(400).send({ error: 'Email and password required' }) + + const { rows: [user] } = await pool.query( + 'SELECT id, password_hash, active FROM users WHERE email = $1', + [email.toLowerCase().trim()] + ) + + if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) { + return reply.status(401).send({ error: 'Invalid email or password' }) + } + + const full = await getUserWithApps(user.id) + const token = await signToken({ + sub: full.email, + name: full.name, + user_id: full.id, + is_admin: full.is_admin, + offsite_allowed: full.offsite_allowed, + apps: full.apps.map(a => a.slug), + }) + + reply.setCookie('hnf_session', token, cookieOpts()) + return full + }) + + // POST /api/auth/logout + app.post('/logout', async (request, reply) => { + reply.clearCookie('hnf_session', cookieOpts(true)) + return { ok: true } + }) + + // GET /api/auth/me + app.get('/me', async (request, reply) => { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + + let payload + try { payload = await verifyToken(token) } + catch { return reply.status(401).send({ error: 'Invalid session' }) } + + const user = await getUserWithApps(payload.user_id) + if (!user) return reply.status(401).send({ error: 'User not found or inactive' }) + + return user + }) + + // GET /api/auth/verify?app=slug + app.get('/verify', async (request, reply) => { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + + let payload + try { payload = await verifyToken(token) } + catch { return reply.status(401).send({ error: 'Invalid session' }) } + + const { app: appSlug } = request.query + if (appSlug && !payload.apps?.includes(appSlug)) { + return reply.status(403).send({ error: 'No permission for this app' }) + } + + if (!payload.offsite_allowed) { + const clientIP = request.headers['x-real-ip'] || request.ip + if (!(await isOnsite(clientIP))) { + return reply.status(403).send({ error: 'Access restricted to site network' }) + } + } + + return { + user_id: payload.user_id, + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin, + app: appSlug || null, + } + }) +}