94 lines
3.1 KiB
JavaScript
94 lines
3.1 KiB
JavaScript
'use strict';
|
|
const { createHmac, createHash, timingSafeEqual } = require('node:crypto');
|
|
const config = require('../config');
|
|
|
|
/**
|
|
* Compare two secrets in constant time (hashing first hides the length).
|
|
*/
|
|
function safeEqual(a, b) {
|
|
const ha = createHash('sha256').update(String(a ?? '')).digest();
|
|
const hb = createHash('sha256').update(String(b ?? '')).digest();
|
|
return timingSafeEqual(ha, hb);
|
|
}
|
|
|
|
/**
|
|
* Signing key. Stateless on purpose: it only depends on the configuration, so
|
|
* tokens survive container restarts and are invalidated when a password changes.
|
|
*/
|
|
function secret() {
|
|
if (config.tokenSecret) return String(config.tokenSecret);
|
|
return createHash('sha256')
|
|
.update(`psitransfer-token-v1\0${ config.uploadPass || '' }\0${ config.adminPass || '' }`)
|
|
.digest('hex');
|
|
}
|
|
|
|
function sign(payload, purpose) {
|
|
return createHmac('sha256', `${ secret() }:${ purpose }`).update(payload).digest('base64url');
|
|
}
|
|
|
|
/**
|
|
* @param {object} data extra payload
|
|
* @param {number} ttlSec lifetime in seconds
|
|
* @param {string} purpose tokens of one purpose are not valid for another
|
|
*/
|
|
function issue(data, ttlSec, purpose = 'session', now = Date.now()) {
|
|
const exp = now + ttlSec * 1000;
|
|
const payload = Buffer.from(JSON.stringify({ ...data, exp })).toString('base64url');
|
|
return { token: `${ payload }.${ sign(payload, purpose) }`, exp };
|
|
}
|
|
|
|
function verify(token, purpose = 'session', now = Date.now()) {
|
|
if (typeof token !== 'string') return null;
|
|
const dot = token.indexOf('.');
|
|
if (dot < 1) return null;
|
|
const payload = token.slice(0, dot);
|
|
const given = Buffer.from(token.slice(dot + 1));
|
|
const expected = Buffer.from(sign(payload, purpose));
|
|
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
|
|
let data;
|
|
try {
|
|
data = JSON.parse(Buffer.from(payload, 'base64url').toString());
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (!data || typeof data.exp !== 'number' || data.exp <= now) return null;
|
|
return data;
|
|
}
|
|
|
|
function bearer(req) {
|
|
const m = /^Bearer\s+(\S+)$/i.exec(req.get('authorization') || '');
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
/** Does the request carry a valid (not expired) login token? */
|
|
function hasSession(req) {
|
|
const token = bearer(req);
|
|
return !!token && !!verify(token);
|
|
}
|
|
|
|
/**
|
|
* Upload authentication.
|
|
* @returns {'open'|'token'|'password'|null} null when the request is not allowed
|
|
*/
|
|
function uploadAuth(req) {
|
|
if (!config.uploadPass) return 'open';
|
|
if (hasSession(req)) return 'token';
|
|
// legacy: raw password header, keeps scripts (curl) working
|
|
const pass = req.get('x-passwd');
|
|
if (pass && safeEqual(pass, config.uploadPass)) return 'password';
|
|
return null;
|
|
}
|
|
|
|
function isAdmin(req) {
|
|
return !!config.adminPass && safeEqual(req.get('x-passwd'), config.adminPass);
|
|
}
|
|
|
|
/** Answer a rejected request slowly to make brute force attacks harder. */
|
|
function deny(req, res, delay = 500) {
|
|
const given = req.get('authorization') || req.get('x-passwd');
|
|
const status = given ? 403 : 401;
|
|
setTimeout(() => res.status(status).send(status === 401 ? 'Unauthorized' : 'Forbidden'), delay);
|
|
}
|
|
|
|
module.exports = { safeEqual, issue, verify, hasSession, uploadAuth, isAdmin, deny };
|