DodoTransfer/lib/auth.js
2026-11-05 10:28:10 +09:00

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 };