From 77672492718369e694d892435abb956c5242384e Mon Sep 17 00:00:00 2001 From: Quan HL Date: Wed, 14 Jun 2023 18:41:31 +0700 Subject: [PATCH] feat: register client --- app.js | 51 +------ lib/middleware.js | 6 +- lib/register-authenticator.js | 273 ++++++++++++++++++++++++++++++++++ lib/utils.js | 15 ++ package-lock.json | 16 +- package.json | 6 +- 6 files changed, 306 insertions(+), 61 deletions(-) create mode 100644 lib/register-authenticator.js diff --git a/app.js b/app.js index d075f80..916a5e7 100644 --- a/app.js +++ b/app.js @@ -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 diff --git a/lib/middleware.js b/lib/middleware.js index e79c498..1b5d20c 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -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}`); } diff --git a/lib/register-authenticator.js b/lib/register-authenticator.js new file mode 100644 index 0000000..de82b5e --- /dev/null +++ b/lib/register-authenticator.js @@ -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; diff --git a/lib/utils.js b/lib/utils.js index b120283..dd79625 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json index a75daf0..14e8b31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 447238d..9498b99 100644 --- a/package.json +++ b/package.json @@ -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",