Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)
- 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>
This commit is contained in:
96
server/src/utils/auth.js
Normal file
96
server/src/utils/auth.js
Normal file
@@ -0,0 +1,96 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user