UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

714 lines (637 loc) 26.3 kB
/** * Module for managing identity and authorization * @module src/server/auth.js * @namespace Auth */ import jwt from 'jsonwebtoken'; import { loggerFactory } from './logger.js'; import crypto from 'crypto'; import { promisify } from 'util'; import { UserDto } from '../api/user/user.model.js'; import { commonAdminGuard, commonModeratorGuard, commonUserGuard, validatePassword, } from '../client/components/core/CommonJs.js'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import slowDown from 'express-slow-down'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import { DataBaseProviderService } from '../db/DataBaseProvider.js'; import { isDevProxyContext } from './conf.js'; const logger = loggerFactory(import.meta); // Promisified crypto functions const pbkdf2 = promisify(crypto.pbkdf2); // Config with sane defaults and parsing const config = { hashBytes: Number(process.env.PBKDF2_HASH_BYTES) || 32, saltBytes: Number(process.env.PBKDF2_SALT_BYTES) || 16, iterations: Number(process.env.PBKDF2_ITERATIONS) || 150_000, digest: process.env.PBKDF2_DIGEST || 'sha512', refreshTokenBytes: Number(process.env.REFRESH_TOKEN_BYTES) || 48, jwtAlgorithm: process.env.JWT_ALGORITHM || 'HS512', // consider RS256 with keys }; // ---------- Password hashing (async) ---------- /** * Hash password asynchronously using PBKDF2. * Stored format: iterations$salt$hash * @param {string} password The password to hash. * @returns {Promise<string>} The hashed password string. * @memberof Auth */ async function hashPassword(password) { const salt = crypto.randomBytes(config.saltBytes).toString('hex'); const derived = await pbkdf2(password, salt, config.iterations, config.hashBytes, config.digest); return `${config.iterations}$${salt}$${derived.toString('hex')}`; } /** * Verify password using constant-time comparison * @param {string} password The password to verify. * @param {string} combined The stored hashed password string (iterations$salt$hash). * @returns {Promise<boolean>} True if the password is valid, false otherwise. * @memberof Auth */ async function verifyPassword(password, combined) { if (!combined) return false; const parts = combined.split('$'); if (parts.length !== 3) return false; const [itersStr, salt, originalHex] = parts; const iterations = parseInt(itersStr, 10); const derived = await pbkdf2(password, salt, iterations, Buffer.from(originalHex, 'hex').length, config.digest); const original = Buffer.from(originalHex, 'hex'); const ok = crypto.timingSafeEqual(derived, original); return ok; } // ---------- Token hashing & utilities ---------- /** * Hashes a token using SHA256. * @param {string} token The token to hash. * @returns {string|null} The hashed token as a hex string, or null if token is falsy. * @memberof Auth */ function hashToken(token) { if (!token) return null; return crypto.createHash('sha256').update(token).digest('hex'); } /** * Generates a cryptographically secure random hex string. * @param {number} [bytes=config.refreshTokenBytes] The number of bytes to generate. * @returns {string} The random hex string. * @memberof Auth */ function generateRandomHex(bytes = config.refreshTokenBytes) { return crypto.randomBytes(bytes).toString('hex'); } /** * Generates a JWT issuer and audience based on the host and path. * @param {object} options The options object. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {object} The issuer and audience. * @memberof Auth */ function jwtIssuerAudienceFactory(options = { host: '', path: '' }) { const audience = `${options.host}${options.path === '/' ? '' : options.path}`; const issuer = `${audience}/api`; return { issuer, audience }; } // ---------- JWT helpers ---------- /** * Signs a JWT payload. * @param {object} payload The payload to sign. * @param {object} [options={}] Additional JWT sign options. * @param {string} options.host The host name. * @param {string} options.path The path name. * @param {number} accessExpireMinutes The access token expiration in minutes. * @param {number} refreshExpireMinutes The refresh token expiration in minutes. * @returns {string} The signed JWT. * @throws {Error} If JWT key is not configured. * @memberof Auth */ function jwtSign( payload, options = { host: '', path: '' }, accessExpireMinutes = process.env.ACCESS_EXPIRE_MINUTES, refreshExpireMinutes = process.env.REFRESH_EXPIRE_MINUTES, ) { const { issuer, audience } = jwtIssuerAudienceFactory(options); const signOptions = { algorithm: config.jwtAlgorithm, expiresIn: `${accessExpireMinutes}m`, issuer, audience, }; if (!payload.jwtid) signOptions.jwtid = generateRandomHex(); if (!process.env.JWT_SECRET) throw new Error('JWT key not configured'); // Add refreshExpiresAt to the payload, which is the same as the token's own expiry. const now = Date.now(); payload.refreshExpiresAt = now + refreshExpireMinutes * 60 * 1000; logger.info('JWT signed', { payload, signOptions, accessExpireMinutes, refreshExpireMinutes }); return jwt.sign(payload, process.env.JWT_SECRET, signOptions); } /** * Verifies a JWT. * @param {string} token The JWT to verify. * @param {object} [options={}] Additional JWT verify options. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {object} The decoded payload. * @throws {jwt.JsonWebTokenError} If the token is invalid or expired. * @memberof Auth */ function jwtVerify(token, options = { host: '', path: '' }) { try { const { issuer, audience } = jwtIssuerAudienceFactory(options); const verifyOptions = { algorithms: [config.jwtAlgorithm], issuer, audience, }; return jwt.verify(token, process.env.JWT_SECRET, verifyOptions); } catch (err) { throw err; } } // ---------- Request helpers ---------- /** * Extracts the Bearer token from the request headers. * @param {import('express').Request} req The Express request object. * @returns {string} The token, or an empty string if not found. * @memberof Auth */ const getBearerToken = (req) => { const header = String(req.headers['authorization'] || req.headers['Authorization'] || ''); if (header.startsWith('Bearer ')) return header.slice(7).trim(); return ''; }; /** * Checks if the request is a refresh token request. * @param {import('express').Request} req The Express request object. * @returns {boolean} True if the request is a refresh token request, false otherwise. * @memberof Auth */ const isRefreshTokenReq = (req) => req.method === 'GET' && req.params.id === 'auth'; // ---------- Middleware ---------- /** * Creates a middleware to authenticate requests using a JWT Bearer token. * @param {object} options The options object. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {function} The middleware function. * @memberof Auth */ const authMiddlewareFactory = (options = { host: '', path: '' }) => { /** * Express middleware to authenticate requests using a JWT Bearer token. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('express').NextFunction} next The next middleware function. * @memberof Auth */ const authMiddleware = async (req, res, next) => { try { const token = getBearerToken(req); if (!token) return res.status(401).json({ status: 'error', message: 'unauthorized: token missing' }); const payload = jwtVerify(token, options); // Validate IP and User-Agent to mitigate token theft if (payload.ip && payload.ip !== req.ip) { logger.warn(`IP mismatch for ${payload._id}: jwt(${payload.ip}) !== req(${req.ip})`); return res.status(401).json({ status: 'error', message: 'unauthorized: ip mismatch' }); } if (payload.userAgent && payload.userAgent !== req.headers['user-agent']) { logger.warn(`UA mismatch for ${payload._id}`); return res.status(401).json({ status: 'error', message: 'unauthorized device' }); } // Non-guest verify session exists if (payload.jwtid && payload.role !== 'guest') { const User = DataBaseProviderService.getModel('user', { host: payload.host, path: payload.path }); const user = await User.findOne({ _id: payload._id, 'activeSessions._id': payload.jwtid }).lean(); if (!user) { return res.status(401).json({ status: 'error', message: 'unauthorized: invalid session' }); } const session = user.activeSessions.find((s) => s._id.toString() === payload.jwtid); if (!session) { return res.status(401).json({ status: 'error', message: 'unauthorized: invalid session' }); } // check session ip if (session.ip !== req.ip) { logger.warn(`IP mismatch for ${payload._id}: jwt(${session.ip}) !== req(${req.ip})`); return res.status(401).json({ status: 'error', message: 'unauthorized: ip mismatch' }); } // check session userAgent if (session.userAgent !== req.headers['user-agent']) { logger.warn(`UA mismatch for ${payload._id}`); return res.status(401).json({ status: 'error', message: 'unauthorized device' }); } // compare payload host and path with session host and path if (payload.host !== session.host || payload.path !== session.path) { logger.warn(`Host or path mismatch for ${payload._id}`); return res.status(401).json({ status: 'error', message: 'unauthorized: host or path mismatch' }); } if (!isRefreshTokenReq(req) && session.expiresAt < new Date()) { return res.status(401).json({ status: 'error', message: 'unauthorized: session expired' }); } } req.auth = { user: payload }; return next(); } catch (err) { logger.warn('authMiddleware error', err && err.message); return res.status(401).json({ status: 'error', message: 'unauthorized' }); } }; return authMiddleware; }; /** * Express middleware to guard routes for admin users. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('express').NextFunction} next The next middleware function. * @memberof Auth */ const adminGuard = (req, res, next) => { try { if (!req.auth || !commonAdminGuard(req.auth.user.role)) return res.status(403).json({ status: 'error', message: 'Insufficient permission' }); return next(); } catch (err) { logger.error(err); return res.status(400).json({ status: 'error', message: 'bad request' }); } }; /** * Express middleware to guard routes for moderator or admin users. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('express').NextFunction} next The next middleware function. * @memberof Auth */ const moderatorGuard = (req, res, next) => { try { if (!req.auth || !commonModeratorGuard(req.auth.user.role)) return res.status(403).json({ status: 'error', message: 'Insufficient permission' }); return next(); } catch (err) { logger.error(err); return res.status(400).json({ status: 'error', message: 'bad request' }); } }; /** * Express middleware to guard routes for authenticated users (any non-guest role). * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('express').NextFunction} next The next middleware function. * @memberof Auth */ const userGuard = (req, res, next) => { try { if (!req.auth || !commonUserGuard(req.auth.user.role)) return res.status(403).json({ status: 'error', message: 'Insufficient permission' }); return next(); } catch (err) { logger.error(err); return res.status(400).json({ status: 'error', message: 'bad request' }); } }; // ---------- Password validation middleware (server-side) ---------- /** * Validates the password from the request body. * @param {import('express').Request} req The Express request object. * @returns {{status: 'success'}|{status: 'error', message: string}} Validation result. * @memberof Auth */ const validatePasswordMiddleware = (req) => { const errors = req.body && 'password' in req.body ? validatePassword(req.body.password) : []; if (errors.length) { return { status: 'error', message: 'Password: ' + errors.map((e) => e[req.lang] || e.en || e).join(', ') }; } return { status: 'success' }; }; // ---------- Session & Refresh token management ---------- /** * Creates cookie options for the refresh token. * @param {import('express').Request} req The Express request object. * @param {string} host The host name. * @returns {object} Cookie options. * @memberof Auth */ const cookieOptionsFactory = (req, host) => { const isProduction = process.env.NODE_ENV === 'production'; // Determine if request is secure: respect X-Forwarded-Proto when behind proxy const forwardedProto = (req.headers && req.headers['x-forwarded-proto']) || ''; const reqIsSecure = Boolean(req.secure || forwardedProto.split(',')[0] === 'https'); // secure must be true for SameSite=None to work across sites const secure = isProduction ? reqIsSecure : req.protocol === 'https'; const sameSite = secure ? 'None' : 'Lax'; // Safe parse of maxAge minutes const maxAge = parseInt(process.env.ACCESS_EXPIRE_MINUTES) * 60 * 1000; const opts = { httpOnly: true, secure, sameSite, path: '/', domain: process.env.NODE_ENV === 'production' || isDevProxyContext() ? host : 'localhost', maxAge, }; return opts; }; /** * Create session and set refresh cookie. Rotating and hashed stored token. * @param {object} user The user object. * @param {import('mongoose').Model} User The Mongoose User model. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {object} options Additional options. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {Promise<{jwtid: string}>} The session ID. * @memberof Auth */ async function createSessionAndUserToken(user, User, req, res, options = { host: '', path: '' }) { const refreshToken = hashToken(generateRandomHex()); const now = Date.now(); const expiresAt = new Date(now + parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000); const newSession = { tokenHash: refreshToken, ip: req.ip, userAgent: req.headers['user-agent'], host: options.host, path: options.path, createdAt: new Date(now), expiresAt, }; // push session const updatedUser = await User.findByIdAndUpdate( user._id, { $push: { activeSessions: newSession } }, { returnDocument: 'after' }, ); const session = updatedUser.activeSessions[updatedUser.activeSessions.length - 1]; const jwtid = session._id.toString(); // Secure cookie settings res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host)); return { jwtid }; } /** * Removes a session by its ID for a given user. * @param {import('mongoose').Model} User The Mongoose User model. * @param {string} userId The ID of the user. * @param {string} sessionId The ID of the session to remove. * @returns {Promise<void>} * @memberof Auth */ async function removeSession(User, userId, sessionId) { return await User.updateOne({ _id: userId }, { $pull: { activeSessions: { _id: sessionId } } }); } /** * Logs out a user session by removing it from the database and clearing the refresh token cookie. * @param {import('mongoose').Model} User The Mongoose User model. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @returns {Promise<boolean>} True if a session was found and removed, false otherwise. * @memberof Auth */ async function logoutSession(User, req, res) { const refreshToken = req.cookies?.refreshToken; if (!refreshToken) { return false; } const user = await User.findOne({ 'activeSessions.tokenHash': refreshToken }); if (!user) { return false; } const session = user.activeSessions.find((s) => s.tokenHash === refreshToken); const result = await removeSession(User, user._id, session._id); res.clearCookie('refreshToken', { path: '/' }); return result.modifiedCount > 0; } /** * Create user and immediate session + access token * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('mongoose').Model} User The Mongoose User model. * @param {object} [options={}] Additional options. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {Promise<{token: string, user: object}>} The access token and user object. * @throws {Error} If password validation fails. * @memberof Auth */ async function createUserAndSession(req, res, User, options = { host: '', path: '' }) { const pwdCheck = validatePasswordMiddleware(req); if (pwdCheck.status === 'error') throw new Error(pwdCheck.message); req.body.password = await hashPassword(req.body.password); req.body.role = req.body.role === 'guest' ? 'guest' : 'user'; const saved = await new User(req.body).save(); const user = await User.findOne({ _id: saved._id }).select(UserDto.select.get()); const { jwtid } = await createSessionAndUserToken(user, User, req, res, options); const token = jwtSign( UserDto.auth.payload(user, jwtid, req.ip, req.headers['user-agent'], options.host, options.path), options, ); return { token, user }; } /** * Refresh session and rotate refresh token. * Detect token reuse: if a refresh token is presented but not found, consider * it a possible theft and revoke all sessions for that user. * @param {import('express').Request} req The Express request object. * @param {import('express').Response} res The Express response object. * @param {import('mongoose').Model} User The Mongoose User model. * @param {object} options Additional options. * @param {string} options.host The host name. * @param {string} options.path The path name. * @returns {Promise<{token: string}>} The new access token. * @throws {Error} If the refresh token is missing, invalid, or expired. * @memberof Auth */ async function refreshSessionAndToken(req, res, User, options = { host: '', path: '' }) { const currentRefreshToken = req.cookies.refreshToken; if (!currentRefreshToken) throw new Error('Refresh token missing'); // Find user owning that token const user = await User.findOne({ 'activeSessions.tokenHash': currentRefreshToken }); if (!user) { // Possible token reuse: look up user by some other signals? If not possible, log and throw. // TODO: on cors requests, this will throw an error, because the cookie is not sent. logger.warn('Refresh token reuse or invalid token detected'); // Optional: revoke by clearing cookie and returning unauthorized res.clearCookie('refreshToken', { path: '/' }); throw new Error('Invalid refresh token'); } // Locate session const session = user.activeSessions.find((s) => s.tokenHash === currentRefreshToken); if (!session) { // Shouldn't happen, but safe-guard res.clearCookie('refreshToken', { path: '/' }); throw new Error('Session not found'); } // Check expiry if (!isRefreshTokenReq(req) && session.expiresAt && session.expiresAt < new Date()) { const result = await removeSession(User, user._id, session._id); if (result) throw new Error('Refresh token expired'); else throw new Error('Session not found'); } // Rotate: generate new token, update stored hash and metadata const refreshToken = hashToken(generateRandomHex()); session.tokenHash = refreshToken; session.expiresAt = new Date(Date.now() + parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000); session.ip = req.ip; session.userAgent = req.headers['user-agent']; await user.save({ validateBeforeSave: false }); logger.warn('Refreshed session for user ' + user.email); res.cookie('refreshToken', refreshToken, cookieOptionsFactory(req, options.host)); return jwtSign( UserDto.auth.payload(user, session._id.toString(), req.ip, req.headers['user-agent'], options.host, options.path), options, ); } // ---------- Security middleware composition ---------- /** * Applies a set of security-related middleware to an Express app. * @param {import('express').Application} app The Express application. * @param {object} [opts={}] Options for security middleware. * @param {string[]} opts.origin Allowed origins for CORS. * @param {object} opts.rate Rate limiting options for `express-rate-limit`. * @param {object} opts.slowdown Slow down options for `express-slow-down`. * @param {object} opts.cookie Cookie options. * @param {string[]} opts.frameAncestors Allowed frame ancestors for CSP. * @memberof Auth */ function applySecurity(app, opts = {}) { const { origin, rate = { windowMs: 15 * 60 * 1000, max: 500 }, slowdown = { windowMs: 15 * 60 * 1000, delayAfter: 50, delayMs: () => 500 }, cookie = { secure: process.env.NODE_ENV === 'production', sameSite: 'Strict' }, frameAncestors = ["'self'"], } = opts; app.disable('x-powered-by'); // Generate nonce per request and attach to res.locals for templates app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); next(); }); // Basic header hardening with Helmet app.use( helmet({ // We'll customize CSP below because many apps need tailored directives crossOriginEmbedderPolicy: true, crossOriginOpenerPolicy: { policy: 'same-origin' }, crossOriginResourcePolicy: { policy: 'same-origin' }, originAgentCluster: false, // Permissions-Policy (formerly Feature-Policy) — limit powerful features permissionsPolicy: { // example: disable geolocation, camera, microphone, payment features: { fullscreen: ["'self'"], geolocation: [], camera: [], microphone: [], payment: [], }, }, }), ); // Strict HSTS (only enable in production over TLS) // maxAge in seconds (e.g. 31536000 = 1 year). Use includeSubDomains and preload carefully. if (process.env.NODE_ENV === 'production') { app.use( helmet.hsts({ maxAge: 31536000, includeSubDomains: true, preload: true, }), ); } // Other helpful Helmet policies app.use(helmet.noSniff()); // X-Content-Type-Options: nosniff app.use(helmet.frameguard({ action: 'deny' })); // X-Frame-Options: DENY app.use(helmet.referrerPolicy({ policy: 'no-referrer-when-downgrade' })); // Content-Security-Policy: include nonce from res.locals // Note: We avoid 'unsafe-inline' on script/style. Use nonces or hashes. const httpDirective = process.env.NODE_ENV === 'production' ? 'https:' : 'http:'; app.use( helmet.contentSecurityPolicy({ useDefaults: true, directives: { defaultSrc: ["'self'"], baseUri: ["'self'"], blockAllMixedContent: [], fontSrc: ["'self'", httpDirective, 'data:'], frameAncestors: frameAncestors, imgSrc: ["'self'", 'data:', httpDirective, 'https:', 'blob:'], objectSrc: ["'none'"], // script-src and script-src-elem: use 'unsafe-inline' for swagger (no nonce, otherwise // the nonce causes 'unsafe-inline' to be ignored per CSP3 spec), nonce for everything else. scriptSrc: ["'self'", (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`)], scriptSrcElem: [ "'self'", (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`), ], // style-src: avoid 'unsafe-inline' when possible; if you must inline styles, // use a nonce for them too (or hash). styleSrc: [ "'self'", httpDirective, (req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : `'nonce-${res.locals.nonce}'`), ], styleSrcAttr: [(req, res) => (res.locals.isSwagger ? "'unsafe-inline'" : "'none'")], // deny plugins objectSrc: ["'none'"], }, }), ); // CORS - be explicit. In production, pass allowed origin(s) as opts.origin app.use( cors({ origin: origin || false, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], credentials: true, allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'withcredentials', 'credentials'], maxAge: 600, }), ); logger.info('Cors origin', origin); if (!process.env.DISABLE_API_RATE_LIMIT) { // Rate limiting + slow down const limiter = rateLimit({ windowMs: rate.windowMs, max: rate.max, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests, please try again later.' }, }); app.use(limiter); const speedLimiter = slowDown({ windowMs: slowdown.windowMs, delayAfter: slowdown.delayAfter, delayMs: () => slowdown.delayMs, }); app.use(speedLimiter); } // Cookie parsing app.use(cookieParser(process.env.JWT_SECRET)); } export { authMiddlewareFactory, hashPassword, verifyPassword, hashToken, jwtSign, jwtVerify, jwtSign as hashJWT, jwtVerify as verifyJWT, adminGuard, moderatorGuard, userGuard, validatePasswordMiddleware, getBearerToken, createSessionAndUserToken, createUserAndSession, refreshSessionAndToken, logoutSession, removeSession, applySecurity, isRefreshTokenReq, };