Initial commit: auth
This commit is contained in:
commit
372e71c8f5
11 changed files with 500 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
45
.gitignore
vendored
Normal 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
7
Dockerfile
Normal 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
20
docker-compose.yml
Normal 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
17
package.json
Normal 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
67
src/db.js
Normal 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
27
src/index.js
Normal 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
40
src/ip-check.js
Normal 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
26
src/jwt.js
Normal 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
126
src/routes/admin.js
Normal 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
118
src/routes/auth.js
Normal 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue