137 lines
4.7 KiB
JavaScript
137 lines
4.7 KiB
JavaScript
const crypto = require('crypto');
|
|
const pool = require('../db');
|
|
|
|
const DEFAULT_TOKEN_LIFETIME_SECONDS = 60 * 60;
|
|
|
|
function base64url(value) {
|
|
return Buffer.from(value).toString('base64url');
|
|
}
|
|
|
|
function getJwtSecret() {
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!secret || secret.length < 32) {
|
|
throw new Error('JWT_SECRET must be configured and contain at least 32 characters');
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function signToken(userId, { jti, tokenVersion } = {}) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const configuredLifetime = Number.parseInt(process.env.JWT_EXPIRES_IN, 10);
|
|
const lifetime = Number.isInteger(configuredLifetime) && configuredLifetime > 0
|
|
? configuredLifetime
|
|
: DEFAULT_TOKEN_LIFETIME_SECONDS;
|
|
const payload = {
|
|
user_id: userId,
|
|
iat: now,
|
|
exp: now + lifetime,
|
|
};
|
|
if (jti) payload.jti = jti;
|
|
if (tokenVersion !== undefined) payload.tvr = tokenVersion;
|
|
const encodedHeader = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
const encodedPayload = base64url(JSON.stringify(payload));
|
|
const content = `${encodedHeader}.${encodedPayload}`;
|
|
const signature = crypto
|
|
.createHmac('sha256', getJwtSecret())
|
|
.update(content)
|
|
.digest('base64url');
|
|
|
|
return { token: `${content}.${signature}`, expiresAt: payload.exp };
|
|
}
|
|
|
|
function getTokenFromRequest(req) {
|
|
const authorization = req.get('authorization') || '';
|
|
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
|
|
const cookieHeader = req.get('cookie') || '';
|
|
if (cookieHeader) {
|
|
const cookies = cookieHeader.split(';');
|
|
for (const cookie of cookies) {
|
|
const [name, ...rest] = cookie.trim().split('=');
|
|
if (name === 'fc_session_token') {
|
|
return rest.join('=');
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function authenticateToken(req, res, next) {
|
|
const token = getTokenFromRequest(req);
|
|
|
|
if (!token) {
|
|
return res.status(401).send('A valid Bearer token or session cookie is required');
|
|
}
|
|
|
|
try {
|
|
const [encodedHeader, encodedPayload, providedSignature] = token.split('.');
|
|
if (!encodedHeader || !encodedPayload || !providedSignature) {
|
|
throw new Error('Malformed token');
|
|
}
|
|
|
|
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString('utf8'));
|
|
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
if (header.alg !== 'HS256' || header.typ !== 'JWT') {
|
|
throw new Error('Unsupported token');
|
|
}
|
|
|
|
const expectedSignature = crypto
|
|
.createHmac('sha256', getJwtSecret())
|
|
.update(`${encodedHeader}.${encodedPayload}`)
|
|
.digest('base64url');
|
|
const provided = Buffer.from(providedSignature);
|
|
const expected = Buffer.from(expectedSignature);
|
|
|
|
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
throw new Error('Invalid signature');
|
|
}
|
|
if (!Number.isInteger(payload.user_id) || payload.user_id <= 0 ||
|
|
!Number.isInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) {
|
|
throw new Error('Invalid or expired token');
|
|
}
|
|
|
|
req.user = { id: payload.user_id };
|
|
|
|
if (payload.jti || payload.tvr !== undefined) {
|
|
const { rows } = await pool.query(
|
|
'SELECT token_version FROM people WHERE id = $1',
|
|
[payload.user_id],
|
|
);
|
|
if (rows.length === 0 || rows[0].token_version !== payload.tvr) {
|
|
return res.status(401).send('Session has been invalidated');
|
|
}
|
|
|
|
if (payload.jti) {
|
|
const sessionCheck = await pool.query(
|
|
'SELECT 1 FROM sessions WHERE id = $1 AND user_id = $2',
|
|
[payload.jti, payload.user_id],
|
|
);
|
|
if (sessionCheck.rowCount === 0) {
|
|
return res.status(401).send('Session has been revoked');
|
|
}
|
|
}
|
|
|
|
req.user.session_id = payload.jti || null;
|
|
}
|
|
|
|
if (payload.jti) {
|
|
pool.query('UPDATE sessions SET last_used_at = NOW() WHERE id = $1', [payload.jti])
|
|
.catch(() => {});
|
|
}
|
|
|
|
next();
|
|
} catch (err) {
|
|
if (err.message.startsWith('JWT_SECRET')) {
|
|
console.error('[auth] JWT configuration error:', err.message);
|
|
return res.status(500).send('Authentication is not configured');
|
|
}
|
|
return res.status(401).send('Invalid or expired token');
|
|
}
|
|
}
|
|
|
|
module.exports = { authenticateToken, signToken };
|