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