- Layered API (router -> controller -> model -> db), serverlinkr pattern - Public / auth / admin route groups; posts, wiki, settings, users, activity models - JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS) - Site LIVE/MAINTENANCE mode with admin preview bypass - Dual file+console logging (info/warn/error/debug) + HTTP access logs - Docker Compose (app + MariaDB), schema.sql + seed, .env.example - Verified end-to-end against MariaDB (27/27 smoke checks) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
2.6 KiB
JavaScript
97 lines
2.6 KiB
JavaScript
const jwt = require('jsonwebtoken')
|
|
require('dotenv').config()
|
|
|
|
const log = require('./logger')('auth')
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET
|
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'
|
|
const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token'
|
|
|
|
if (!JWT_SECRET) {
|
|
log.warn('JWT_SECRET is not set — set it in .env before going to production')
|
|
}
|
|
|
|
function signToken(user) {
|
|
const payload = { id: user.id, username: user.username, role: user.role }
|
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN })
|
|
}
|
|
|
|
function verifyToken(token) {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET)
|
|
} catch (err) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m).
|
|
function cookieMaxAge() {
|
|
const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim())
|
|
if (!m) return 24 * 60 * 60 * 1000
|
|
const n = Number(m[1])
|
|
const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]]
|
|
return n * unit
|
|
}
|
|
|
|
/**
|
|
* Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure,
|
|
* which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain
|
|
* HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy').
|
|
*/
|
|
function cookieSecure(req) {
|
|
const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase()
|
|
if (mode === 'true') return true
|
|
if (mode === 'false') return false
|
|
return Boolean(req.secure)
|
|
}
|
|
|
|
function cookieOptions(req) {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: cookieSecure(req),
|
|
path: '/',
|
|
}
|
|
}
|
|
|
|
function setAuthCookie(req, res, token) {
|
|
res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() })
|
|
}
|
|
|
|
function clearAuthCookie(req, res) {
|
|
res.clearCookie(COOKIE_NAME, cookieOptions(req))
|
|
}
|
|
|
|
// Extract a token from the cookie or an Authorization: Bearer header.
|
|
function extractToken(req) {
|
|
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]
|
|
const header = req.headers.authorization
|
|
if (header && header.startsWith('Bearer ')) return header.substring(7)
|
|
return null
|
|
}
|
|
|
|
// Returns the decoded user or null without rejecting the request.
|
|
function getUserFromRequest(req) {
|
|
const token = extractToken(req)
|
|
if (!token) return null
|
|
return verifyToken(token)
|
|
}
|
|
|
|
// Gate middleware for protected (admin) routes.
|
|
function isLoggedIn(req, res, next) {
|
|
const user = getUserFromRequest(req)
|
|
if (!user) return res.status(401).json({ message: 'Unauthorized' })
|
|
req.user = user
|
|
return next()
|
|
}
|
|
|
|
module.exports = {
|
|
COOKIE_NAME,
|
|
signToken,
|
|
verifyToken,
|
|
setAuthCookie,
|
|
clearAuthCookie,
|
|
getUserFromRequest,
|
|
isLoggedIn,
|
|
}
|