Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Digest Authentication support. You can turn off WS-Security via useWSSecurity: false option in the constructor and use only Digest auth #343

Merged
merged 2 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 115 additions & 22 deletions lib/cam.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ const http = require('http'),

/**
* @typedef Cam~Options
* @property {boolean} useSecure Set true if `https:`, defaults to false
* @property {object} secureOpts Set options for https like ca, cert, ciphers, rejectUnauthorized, secureOptions, secureProtocol, etc.
* @property {boolean} [useSecure] Set true if `https:`, defaults to false
* @property {object} [secureOpts] Set options for https like ca, cert, ciphers, rejectUnauthorized, secureOptions, secureProtocol, etc.
* @property {boolean} [useWSSecurity] Use WS-Security SOAP headers
* @property {string} hostname
* @property {string} [username]
* @property {string} [password]
Expand Down Expand Up @@ -90,6 +91,12 @@ var Cam = function(options, callback) {
this.path = options.path || '/onvif/device_service';
this.timeout = options.timeout || 120000;
this.agent = options.agent || false;
/**
* Use WS-Security SOAP header
* @type {boolean}
*/
this.useWSSecurity = typeof options.useWSSecurity === 'boolean' ? options.useWSSecurity : true;
this._nc = 0;
/**
* Force using hostname and port from constructor for the services
* @type {boolean}
Expand Down Expand Up @@ -213,6 +220,7 @@ Cam.prototype.connect = function(callback) {
* Common camera request
* @param {object} options
* @param {string} [options.service] Name of service (ptz, media, etc)
* @param {string} [options.action] Name of the action. Not required, but desired
* @param {string} options.body SOAP body
* @param {string} [options.url] Defines another url to request instead of using the URLs from GetCapabilities/GetServices
* @param {number} options.replyTimeout timeout in milliseconds for the reply (after the socket has connected)
Expand Down Expand Up @@ -255,10 +263,23 @@ Cam.prototype._request = function(options, callback) {
}
};

/**
* Common camera request part 2
* @param {Object} options
* @param {Object} options.headers If some of the headers must present (Digest auth)
* @param {string} options.action Name of the action
* @param {string} [options.service] Name of service (ptz, media, etc)
* @param {string} options.body SOAP body
* @param {string} [options.url] Defines another url to request instead of using the URLs from GetCapabilities/GetServices
* @param {number} options.replyTimeout timeout in milliseconds for the reply (after the socket has connected)
* @param {Cam~RequestCallback} callback response callback
* @private
*/
Cam.prototype._requestPart2 = function(options, callback) {
var _this = this;
var callbackExecuted = false;
var reqOptions = options.url || {
const _this = this;
let callbackExecuted = false;
options.headers = options.headers || {};
let reqOptions = options.url || {
hostname: this.hostname,
port: this.port,
agent: this.agent //Supports things like https://www.npmjs.com/package/proxy-agent which provide SOCKS5 and other connections
Expand All @@ -267,20 +288,28 @@ Cam.prototype._requestPart2 = function(options, callback) {
(this.uri && this.uri[options.service] ? this.uri[options.service].path : options.service) : this.path,
timeout: this.timeout
};
reqOptions.headers = {
reqOptions.headers = options.headers;
Object.assign(reqOptions.headers, {
'Content-Type': `application/soap+xml;charset=utf-8;action="${options.action}"`,
'Content-Length': Buffer.byteLength(options.body, 'utf8') //options.body.length chinese will be wrong here
,
charset: 'utf-8'
};
});

reqOptions.method = 'POST';
var httpLib = this.useSecure ? https : http;
const httpLib = this.useSecure ? https : http;
reqOptions = this.useSecure ? Object.assign({}, this.secureOpts, reqOptions) : reqOptions;
var req = httpLib.request(reqOptions, function(res) {
const req = httpLib.request(reqOptions, function(res) {
const wwwAuthenticate = res.headers['www-authenticate'];
const statusCode = res.statusCode;
var bufs = [],
length = 0;
if (statusCode === 401 && wwwAuthenticate !== undefined) {
// Re-request with digest auth header
res.destroy();
options.headers.Authorization = _this.digestAuth(wwwAuthenticate, reqOptions);
_this._requestPart2(options, callback);
}
const bufs = [];
let length = 0;
res.on('data', function(chunk) {
bufs.push(chunk);
length += chunk.length;
Expand Down Expand Up @@ -314,7 +343,7 @@ Cam.prototype._requestPart2 = function(options, callback) {
callbackExecuted = true;
}
callback(new Error('Network timeout'));
req.abort();
req.destroy();
});

req.on('error', function(err) {
Expand Down Expand Up @@ -342,6 +371,70 @@ Cam.prototype._requestPart2 = function(options, callback) {
req.end();
};

Cam.prototype._parseChallenge = function(digest) {
const prefix = 'Digest ';
const challenge = digest.substring(digest.indexOf(prefix) + prefix.length);
const parts = challenge.split(',')
.map(part => part.match(/^\s*?([a-zA-Z0-9]+)="?([^"]*)"?\s*?$/).slice(1));
return Object.fromEntries(parts);
};

Cam.prototype.updateNC = function() {
this._nc += 1;
if (this._nc > 99999999) {
this._nc = 1;
}
return String(this._nc).padStart(8, '0');
};

Cam.prototype.digestAuth = function(wwwAuthenticate, reqOptions) {
const challenge = this._parseChallenge(wwwAuthenticate);
const ha1 = crypto.createHash('md5');
ha1.update([this.username, challenge.realm, this.password].join(':'));
const ha2 = crypto.createHash('md5');
ha2.update([reqOptions.method, reqOptions.path].join(':'));

let cnonce = null;
let nc = null;
if (typeof challenge.qop === 'string') {
const cnonceHash = crypto.createHash('md5');
cnonceHash.update(Math.random().toString(36));
cnonce = cnonceHash.digest('hex').substring(0, 8);
nc = this.updateNC();
}

const response = crypto.createHash('md5');
const responseParams = [
ha1.digest('hex'),
challenge.nonce
];
if (cnonce) {
responseParams.push(nc);
responseParams.push(cnonce);
}

responseParams.push(challenge.qop);
responseParams.push(ha2.digest('hex'));
response.update(responseParams.join(':'));

const authParams = {
username: this.username,
realm: challenge.realm,
nonce: challenge.nonce,
uri: reqOptions.path,
qop: challenge.qop,
response: response.digest('hex'),
};
if (challenge.opaque) {
authParams.opaque = challenge.opaque;
}
if (cnonce) {
authParams.nc = nc;
authParams.cnonce = cnonce;
}
return 'Digest ' + Object.entries(authParams).map(([key, value]) => `${key}="${value}"`).join(',');
};

/**
* @callback Cam~DateTimeCallback
* @property {?Error} error
Expand Down Expand Up @@ -1056,19 +1149,19 @@ Cam.prototype._passwordDigest = function() {
* @private
*/
Cam.prototype._envelopeHeader = function(openHeader) {
var header = '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">' +
let header = '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">' +
'<s:Header>';
// Only insert Security if there is a username and password
if (this.username && this.password) {
var req = this._passwordDigest();
if (this.useWSSecurity && this.username && this.password) {
const req = this._passwordDigest();
header += '<Security s:mustUnderstand="1" xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">' +
'<UsernameToken>' +
'<Username>' + this.username + '</Username>' +
'<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">' + req.passdigest + '</Password>' +
'<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">' + req.nonce + '</Nonce>' +
'<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">' + req.timestamp + '</Created>' +
'</UsernameToken>' +
'</Security>';
'<UsernameToken>' +
'<Username>' + this.username + '</Username>' +
'<Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">' + req.passdigest + '</Password>' +
'<Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">' + req.nonce + '</Nonce>' +
'<Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">' + req.timestamp + '</Created>' +
'</UsernameToken>' +
'</Security>';
}
if (!(openHeader !== undefined && openHeader)) {
header += '</s:Header>' +
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
],
"license": "MIT",
"engines": {
"node": ">=10.0"
"node": ">=12.0"
},
"devDependencies": {
"dot": "^1.1.3",
Expand Down