Initial commit: auth

This commit is contained in:
jtricerolph 2026-07-01 12:09:54 +00:00
commit 372e71c8f5
11 changed files with 500 additions and 0 deletions

7
.env.example Normal file
View file

@ -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

45
.gitignore vendored Normal file
View file

@ -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

7
Dockerfile Normal file
View file

@ -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"]

20
docker-compose.yml Normal file
View file

@ -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

17
package.json Normal file
View file

@ -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"
}
}

67
src/db.js Normal file
View file

@ -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}`)
}
}
}

27
src/index.js Normal file
View file

@ -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)
}

40
src/ip-check.js Normal file
View file

@ -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
}

26
src/jwt.js Normal file
View file

@ -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
}

126
src/routes/admin.js Normal file
View file

@ -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
})
}

118
src/routes/auth.js Normal file
View file

@ -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,
}
})
}