feat: register client

This commit is contained in:
Quan HL
2023-06-14 18:41:31 +07:00
parent 26a5ded625
commit 7767249271
6 changed files with 306 additions and 61 deletions
+6 -45
View File
@@ -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
View File
@@ -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}`);
}
+273
View File
@@ -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;
+15
View File
@@ -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
};
+3 -13
View File
@@ -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
View File
@@ -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",