mirror of
https://github.com/jambonz/sbc-sip-sidecar.git
synced 2026-10-04 02:04:20 +00:00
feat: register client
This commit is contained in:
@@ -44,7 +44,7 @@ const { initLocals, rejectIpv4, checkCache, checkAccountLimits } = require('./li
|
||||
const responseTime = require('drachtio-mw-response-time');
|
||||
const regParser = require('drachtio-mw-registration-parser');
|
||||
const Registrar = require('@jambonz/mw-registrar');
|
||||
const Emitter = require('events');
|
||||
const digestChallenge = require('./lib/register-authenticator');
|
||||
const debug = require('debug')('jambonz:sbc-registrar');
|
||||
const {
|
||||
lookupAuthHook,
|
||||
@@ -54,7 +54,8 @@ const {
|
||||
lookupAccountCapacitiesBySid,
|
||||
addSbcAddress,
|
||||
cleanSbcAddresses,
|
||||
updateVoipCarriersRegisterStatus
|
||||
updateVoipCarriersRegisterStatus,
|
||||
lookupClientByAccountAndUsername
|
||||
} = require('@jambonz/db-helpers')({
|
||||
host: process.env.JAMBONES_MYSQL_HOST,
|
||||
user: process.env.JAMBONES_MYSQL_USER,
|
||||
@@ -100,7 +101,8 @@ srf.locals = {
|
||||
lookupSipGatewaysByCarrier,
|
||||
lookupAccountBySipRealm,
|
||||
lookupAccountCapacitiesBySid,
|
||||
updateVoipCarriersRegisterStatus
|
||||
updateVoipCarriersRegisterStatus,
|
||||
lookupClientByAccountAndUsername
|
||||
},
|
||||
realtimeDbHelpers: {
|
||||
addKey,
|
||||
@@ -174,47 +176,6 @@ const rttMetric = (req, res, time) => {
|
||||
}
|
||||
};
|
||||
|
||||
class RegOutcomeReporter extends Emitter {
|
||||
constructor() {
|
||||
super();
|
||||
this
|
||||
.on('regHookOutcome', ({ rtt, status }) => {
|
||||
stats.histogram('app.hook.response_time', rtt, ['hook_type:auth', `status:${status}`]);
|
||||
if (![200, 403].includes(status)) {
|
||||
stats.increment('app.hook.error.count', ['hook_type:auth', `status:${status}`]);
|
||||
}
|
||||
})
|
||||
.on('error', async(err, req) => {
|
||||
logger.error({ err }, 'http webhook failed');
|
||||
const { account_sid } = req.locals;
|
||||
if (account_sid) {
|
||||
let opts = { account_sid };
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE, url: err.hook };
|
||||
}
|
||||
else if (err.code === 'ENOTFOUND') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE, url: err.hook };
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_STATUS_FAILURE, url: err.hook, status: err.statusCode };
|
||||
}
|
||||
|
||||
if (opts.alert_type) {
|
||||
try {
|
||||
await writeAlerts(opts);
|
||||
} catch (err) {
|
||||
logger.error({ err, opts }, 'Error writing alert');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authenticator = require('@jambonz/http-authenticator')(lookupAuthHook, logger, {
|
||||
emitter: new RegOutcomeReporter()
|
||||
});
|
||||
|
||||
// middleware
|
||||
srf.use('register', [
|
||||
initLocals,
|
||||
@@ -223,7 +184,7 @@ srf.use('register', [
|
||||
regParser,
|
||||
checkCache,
|
||||
checkAccountLimits,
|
||||
authenticator]);
|
||||
digestChallenge]);
|
||||
|
||||
srf.use('options', [
|
||||
initLocals
|
||||
|
||||
+5
-1
@@ -67,7 +67,11 @@ const checkAccountLimits = async(req, res, next) => {
|
||||
req.locals = {
|
||||
...req.locals,
|
||||
account_sid: account.account_sid,
|
||||
webhook_secret: account.webhook_secret
|
||||
webhook_secret: account.webhook_secret,
|
||||
registration_hook_url: account.url,
|
||||
registration_hook_method: account.method,
|
||||
registration_hook_username: account.username,
|
||||
registration_hook_password: account.password
|
||||
};
|
||||
debug(account, `checkAccountLimits: retrieved account for realm: ${realm}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
const nonce = require('nonce')();
|
||||
const debug = require('debug')('jambonz:sbc-registrar');
|
||||
const bent = require('bent');
|
||||
const qs = require('qs');
|
||||
const crypto = require('crypto');
|
||||
const { decrypt } = require('./utils');
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
function respondChallenge(req, res) {
|
||||
const nonceValue = nonce();
|
||||
const {realm} = req.locals;
|
||||
const headers = {
|
||||
'WWW-Authenticate': `Digest realm="${realm}", algorithm=MD5, qop="auth", nonce="${nonceValue}"`
|
||||
};
|
||||
debug('sending a 401 challenge');
|
||||
res.send(401, {headers});
|
||||
}
|
||||
|
||||
function parseAuthHeader(hdrValue) {
|
||||
const pieces = { scheme: 'digest'} ;
|
||||
['username', 'realm', 'nonce', 'uri', 'algorithm', 'response', 'qop', 'nc', 'cnonce', 'opaque']
|
||||
.forEach((tok) => {
|
||||
const re = new RegExp(`[,\\s]{1}${tok}="?(.+?)[",]`) ;
|
||||
const arr = re.exec(hdrValue) ;
|
||||
if (arr) {
|
||||
pieces[tok] = arr[1];
|
||||
if (pieces[tok] && pieces[tok] === '"') pieces[tok] = '';
|
||||
}
|
||||
}) ;
|
||||
|
||||
pieces.algorithm = pieces.algorithm || 'MD5' ;
|
||||
|
||||
// this is kind of lame...nc= (or qop=) at the end fails the regex above,
|
||||
// should figure out how to fix that
|
||||
if (!pieces.nc && /nc=/.test(hdrValue)) {
|
||||
const arr = /nc=(.*)$/.exec(hdrValue) ;
|
||||
if (arr) {
|
||||
pieces.nc = arr[1];
|
||||
}
|
||||
}
|
||||
if (!pieces.qop && /qop=/.test(hdrValue)) {
|
||||
const arr = /qop=(.*)$/.exec(hdrValue) ;
|
||||
if (arr) {
|
||||
pieces.qop = arr[1];
|
||||
}
|
||||
}
|
||||
|
||||
// check mandatory fields
|
||||
['username', 'realm', 'nonce', 'uri', 'response'].forEach((tok) => {
|
||||
if (!pieces[tok]) throw new Error(`missing authorization component: ${tok}`);
|
||||
}) ;
|
||||
debug(`parsed header: ${JSON.stringify(pieces)}`);
|
||||
return pieces ;
|
||||
}
|
||||
|
||||
function computeSignature(payload, timestamp, secret) {
|
||||
const data = 'string' === payload ?
|
||||
payload :
|
||||
JSON.stringify(payload);
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${timestamp}.${data}`, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function generateSigHeader(payload, secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = computeSignature(payload, timestamp, secret);
|
||||
const scheme = 'v1';
|
||||
return {
|
||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
async function httpAuthenticate(logger, data, url, hook_method, secret, username, password, req) {
|
||||
const {AlertType, writeAlerts} = req.srf.locals;
|
||||
const {account_sid} = req.locals;
|
||||
try {
|
||||
let uri = url;
|
||||
let body;
|
||||
const method = hook_method ? hook_method.toUpperCase() : 'POST';
|
||||
if ('GET' === method) {
|
||||
const str = qs.stringify(data);
|
||||
uri = `${uri}?${str}`;
|
||||
} else {
|
||||
body = data;
|
||||
}
|
||||
const headers = {
|
||||
...(username &&
|
||||
password &&
|
||||
basicAuth(username, password)),
|
||||
...(secret && generateSigHeader(body || 'null', secret))
|
||||
};
|
||||
const request = bent(
|
||||
'json',
|
||||
200,
|
||||
method,
|
||||
headers
|
||||
);
|
||||
const json = await request(uri, body, headers);
|
||||
return {
|
||||
...json,
|
||||
statusCode: 200
|
||||
};
|
||||
} catch (err) {
|
||||
logger.info(`Error from calling auth callback: ${err}`);
|
||||
let opts = { account_sid };
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE, url: err.hook };
|
||||
}
|
||||
else if (err.code === 'ENOTFOUND') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE, url: err.hook };
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = { ...opts, alert_type: AlertType.WEBHOOK_STATUS_FAILURE, url: err.hook, status: err.statusCode };
|
||||
}
|
||||
if (opts.alert_type) {
|
||||
try {
|
||||
await writeAlerts(opts);
|
||||
} catch (err) {
|
||||
logger.error({ err, opts }, 'Error writing alert');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
statusCode: err.statusCode || 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function calculateResponse({username, realm, method, nonce, uri, nc, cnonce, qop}, password) {
|
||||
const ha1 = crypto.createHash('md5');
|
||||
ha1.update([username, realm, password].join(':'));
|
||||
const ha2 = crypto.createHash('md5');
|
||||
ha2.update([method, uri].join(':'));
|
||||
|
||||
// Generate response hash
|
||||
const response = crypto.createHash('md5');
|
||||
const responseParams = [
|
||||
ha1.digest('hex'),
|
||||
nonce
|
||||
];
|
||||
|
||||
if (cnonce) {
|
||||
responseParams.push(nc);
|
||||
responseParams.push(cnonce);
|
||||
}
|
||||
|
||||
if (qop) {
|
||||
responseParams.push(qop);
|
||||
}
|
||||
responseParams.push(ha2.digest('hex'));
|
||||
response.update(responseParams.join(':'));
|
||||
|
||||
return response.digest('hex');
|
||||
}
|
||||
|
||||
async function clientAuthentication(logger, data, req) {
|
||||
const {username, response} = data;
|
||||
const {account_sid} = req.locals;
|
||||
const {lookupClientByAccountAndUsername} = req.srf.locals.dbHelpers;
|
||||
|
||||
const clients = await lookupClientByAccountAndUsername(account_sid, username);
|
||||
if (clients.length) {
|
||||
// Only take the first result.
|
||||
const client = clients[0];
|
||||
const password = decrypt(client.password);
|
||||
if (calculateResponse(data, password) === response) {
|
||||
return {
|
||||
status: 'ok',
|
||||
statusCode: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
statusCode: 200
|
||||
};
|
||||
}
|
||||
|
||||
const digestChallenge = async(req, res, next) => {
|
||||
const {logger} = req.locals;
|
||||
const {stats} = req.srf.locals;
|
||||
const {
|
||||
account_sid,
|
||||
registration_hook_url,
|
||||
registration_hook_method,
|
||||
registration_hook_username,
|
||||
registration_hook_password,
|
||||
webhook_secret
|
||||
} = req.locals;
|
||||
// Cannot detect account, reject register request
|
||||
try {
|
||||
if (!account_sid) {
|
||||
return res.send(403, {
|
||||
headers: {
|
||||
'X-Reason': 'Unknown or invalid realm'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// challenge requests without credentials
|
||||
if (!req.has('Authorization')) return respondChallenge(req, res);
|
||||
|
||||
const pieces = parseAuthHeader(req.get('Authorization'));
|
||||
const expires = req.registration ? req.registration.expires : null;
|
||||
const data = {
|
||||
source_address: req.source_address,
|
||||
source_port: req.source_port,
|
||||
method: req.method,
|
||||
...('POST' === registration_hook_method && {headers: req.headers}),
|
||||
expires,
|
||||
...pieces
|
||||
};
|
||||
logger.debug(data, 'Authorization data');
|
||||
|
||||
const startAt = process.hrtime();
|
||||
// Authenticate via HTTP server
|
||||
let autheResult;
|
||||
if (registration_hook_url) {
|
||||
autheResult = await httpAuthenticate(
|
||||
logger,
|
||||
data,
|
||||
registration_hook_url,
|
||||
registration_hook_method,
|
||||
webhook_secret,
|
||||
registration_hook_username,
|
||||
registration_hook_password,
|
||||
req
|
||||
);
|
||||
} else {
|
||||
// Check if client is available in DB.
|
||||
autheResult = await clientAuthentication(logger, data, req);
|
||||
}
|
||||
const diff = process.hrtime(startAt);
|
||||
const rtt = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
|
||||
if (autheResult.statusCode !== 200) {
|
||||
// Error happens
|
||||
return res.send(autheResult.statusCode);
|
||||
} else if (autheResult.status.toLowerCase() !== 'ok') {
|
||||
// Authentication failed
|
||||
res.send(403, {headers: {
|
||||
'X-Reason': autheResult.blacklist === true ?
|
||||
`detected potential spammer from ${req.source_address}:${req.source_port}` :
|
||||
'Invalid credentials'
|
||||
}});
|
||||
stats.histogram('app.hook.response_time', rtt.toFixed(0), ['hook_type:auth', `status:${403}`]);
|
||||
return;
|
||||
} else {
|
||||
// Authentication success
|
||||
req.authorization = {
|
||||
challengeResponse: pieces,
|
||||
grant: autheResult
|
||||
};
|
||||
stats.histogram('app.hook.response_time', rtt.toFixed(0), ['hook_type:auth', `status:${200}`]);
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.error(`Error ${err}, rejecting with 403`);
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = digestChallenge;
|
||||
@@ -1,3 +1,10 @@
|
||||
const crypto = require('crypto');
|
||||
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
|
||||
const secretKey = crypto.createHash('sha256')
|
||||
.update(process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET)
|
||||
.digest('base64')
|
||||
.substring(0, 32);
|
||||
|
||||
function isUacBehindNat(req) {
|
||||
|
||||
// no need for nat handling if wss or tcp being used
|
||||
@@ -14,8 +21,16 @@ function getSipProtocol(req) {
|
||||
if (req.getParsedHeader('Via')[0].protocol.toLowerCase().startsWith('udp')) return 'udp';
|
||||
}
|
||||
|
||||
const decrypt = (data) => {
|
||||
const hash = JSON.parse(data);
|
||||
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
|
||||
const decrpyted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
||||
return decrpyted.toString();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isUacBehindNat,
|
||||
getSipProtocol,
|
||||
decrypt,
|
||||
NAT_EXPIRES: 30
|
||||
};
|
||||
|
||||
Generated
+3
-13
@@ -10,16 +10,18 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.9.0",
|
||||
"@jambonz/http-authenticator": "^0.2.2",
|
||||
"@jambonz/mw-registrar": "^0.2.4",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.6",
|
||||
"@jambonz/stats-collector": "^0.1.8",
|
||||
"@jambonz/time-series": "^0.2.5",
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"drachtio-mw-registration-parser": "^0.1.0",
|
||||
"drachtio-mw-response-time": "^1.0.2",
|
||||
"drachtio-srf": "^4.5.21",
|
||||
"nonce": "^1.0.4",
|
||||
"pino": "^6.14.0",
|
||||
"qs": "^6.9.4",
|
||||
"short-uuid": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -640,18 +642,6 @@
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jambonz/http-authenticator": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@jambonz/http-authenticator/-/http-authenticator-0.2.2.tgz",
|
||||
"integrity": "sha512-yl6CajF8c8BOTrXEB/AbTXgqrT6XeymwVZbJWeJG8HZA21UXkKCcM26b8f0P9qqokSvFj0ObjCk22Ks2ytSLNg==",
|
||||
"dependencies": {
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.1",
|
||||
"drachtio-srf": "^4.4.63",
|
||||
"nonce": "^1.0.4",
|
||||
"qs": "^6.9.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@jambonz/mw-registrar": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@jambonz/mw-registrar/-/mw-registrar-0.2.4.tgz",
|
||||
|
||||
+4
-2
@@ -28,7 +28,6 @@
|
||||
"homepage": "https://github.com/jambonz/sbc-sip-sidecar#readme",
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.9.0",
|
||||
"@jambonz/http-authenticator": "^0.2.2",
|
||||
"@jambonz/mw-registrar": "^0.2.4",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.6",
|
||||
"@jambonz/stats-collector": "^0.1.8",
|
||||
@@ -38,7 +37,10 @@
|
||||
"drachtio-mw-response-time": "^1.0.2",
|
||||
"drachtio-srf": "^4.5.21",
|
||||
"pino": "^6.14.0",
|
||||
"short-uuid": "^4.2.2"
|
||||
"short-uuid": "^4.2.2",
|
||||
"nonce": "^1.0.4",
|
||||
"bent": "^7.3.12",
|
||||
"qs": "^6.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clear-module": "^4.1.2",
|
||||
|
||||
Reference in New Issue
Block a user