Last active
July 30, 2024 13:15
-
-
Save kritollm/d263c4ffd8f7ae3e0b0ff64c3d370389 to your computer and use it in GitHub Desktop.
Simple digestauth with got
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as crypto from 'crypto'; | |
import { OptionsInit, got } from 'got-scraping'; | |
export interface GotDigestAuthOpts { | |
password: string; | |
username: string; | |
} | |
export default class GotDigestAuth { | |
private count: number; | |
private readonly password: string; | |
private readonly username: string; | |
constructor({ password, username }: GotDigestAuthOpts) { | |
this.count = 0; | |
this.password = password; | |
this.username = username; | |
} | |
private computeHa1(username: string, realm: string, password: string, nonce: string, cnonce: string, algorithm: string): string { | |
const baseHa1 = crypto.createHash('md5').update(`${username}:${realm}:${password}`).digest('hex'); | |
if (algorithm.toLowerCase() === 'md5-sess') { | |
return crypto.createHash('md5').update(`${baseHa1}:${nonce}:${cnonce}`).digest('hex'); | |
} | |
return baseHa1; | |
} | |
public async request(opts: OptionsInit) { | |
let shouldRetry = true; | |
let lastError: any = null; | |
do { | |
try { | |
const reqOpts = { | |
url: opts.url, | |
method: opts.method || 'GET', | |
headers: { | |
...(opts.headers || {}), | |
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', | |
'Accept-Encoding': 'gzip, deflate, br', | |
'Accept-Language': 'nb-NO,nb;q=0.9,no;q=0.8,nn;q=0.7,en-US;q=0.6,en;q=0.5', | |
'Cache-Control': 'max-age=0', | |
'Connection': 'keep-alive', | |
'Sec-Fetch-Dest': 'document', | |
'Sec-Fetch-Mode': 'navigate', | |
'Sec-Fetch-Site': 'same-origin', | |
'Sec-Fetch-User': '?1', | |
'Upgrade-Insecure-Requests': '1', | |
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36', | |
'sec-ch-ua': '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"', | |
'sec-ch-ua-mobile': '?0', | |
'sec-ch-ua-platform': 'Windows' | |
} | |
}; | |
const response = await got(reqOpts); | |
shouldRetry = false; | |
return response; | |
} catch (error: any) { | |
if (error.response === undefined || error.response.statusCode !== 401 || !error.response.headers['www-authenticate']?.includes('nonce')) { | |
throw error; | |
} | |
const authDetails = error.response.headers['www-authenticate'].split(',').map((v: string) => v.split('=')); | |
const stale = authDetails.find((el: any) => el[0].toLowerCase().indexOf('stale') > -1); | |
if (this.count > 2) { | |
throw new Error('Maksimalt antall forsøk nådd, sjekk brukernavn eller passord'); | |
} | |
if (stale && stale[1].toLowerCase() === 'true') { | |
// If stale=true, retry the request | |
this.count = 0; | |
return await this.request({ | |
url: opts.url, | |
method: opts.method | |
}); | |
} | |
++this.count; | |
const nonceCount = ('00000000' + this.count).slice(-8); | |
const cnonce = crypto.randomBytes(8).toString('hex'); | |
const realm = authDetails.find((el: any) => el[0].toLowerCase().indexOf('realm') > -1)[1].replace(/"/g, ''); | |
const nonce = authDetails.find((el: any) => el[0].toLowerCase().indexOf('nonce') > -1)[1].replace(/"/g, ''); | |
const qopValue = authDetails.find((el: any) => el[0].toLowerCase().indexOf('qop') > -1)[1].replace(/"/g, ''); | |
const algorithmValue = authDetails.find((el: any) => el[0].toLowerCase().indexOf('algorithm') > -1)[1].replace(/"/g, ''); | |
//const charsetValue = authDetails.find((el: any) => el[0].toLowerCase().indexOf('charset') > -1)[1].replace(/"/g, ''); | |
const ha1 = this.computeHa1(this.username, realm, this.password, nonce, cnonce, algorithmValue); | |
const parsedUrl = new URL(typeof opts.url === 'string' ? opts.url : (opts.url as URL).href); | |
const path = parsedUrl.pathname + (parsedUrl.search || ''); | |
const ha2 = crypto.createHash('md5').update(`${opts.method ?? 'GET'}:${path}`).digest('hex'); | |
const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`).digest('hex'); | |
const authorization = `Digest username="${this.username}",realm="${realm}",` + | |
`nonce="${nonce}",uri="${path}",qop="${qopValue}",algorithm="${algorithmValue}",` + | |
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`; //,` + | |
//`charset="${charsetValue}"`; | |
if (opts.headers) { | |
opts.headers['Authorization'] = authorization; | |
} else { | |
opts.headers = { Authorization: authorization }; | |
} | |
lastError = error; | |
} | |
} while (shouldRetry); | |
throw lastError; | |
} | |
} | |
// Example | |
const url = 'https://api.domain.no/service/service.asmx'; | |
const APIKey = 'xxx-xxx-xxxx-xxx-xxxxxx'; | |
const username = 'your-user-name'; | |
const password = 'your-password' | |
const digestAuth = new GotDigestAuth({ | |
username, | |
password | |
}); | |
async function getPriceList() { | |
const resp = await digestAuth.request({ | |
url: `${url}/GetPriceList?APIKey=${APIKey}`, | |
}).catch(e => null); | |
if (!resp) { | |
return null; | |
} | |
await writeFile('getpricelist.xml', resp.body, 'utf8').catch(() => null); | |
const json = await convertXmlToJson(resp.body); | |
if (!json) { | |
return null; | |
} | |
await prettyJSON('getpricelist.json', json); | |
return json; | |
} | |
import { parseStringPromise, processors } from 'xml2js'; | |
async function convertXmlToJson(xml: string): Promise<any> { | |
try { | |
const options = { | |
mergeAttrs: true, | |
trim: true, | |
normalizeTags: false, | |
explicitArray: false, | |
ignoreAttrs: false, | |
tagNameProcessors: [processors.firstCharLowerCase], | |
attrNameProcessors: [processors.firstCharLowerCase], | |
valueProcessors: [ | |
(value: string, name: string) => { | |
if (typeof value === 'string') { | |
return value.replace(/\u00a0/g, ' '); | |
} | |
return value; | |
}, | |
], | |
}; | |
const json = await parseStringPromise(xml, options); | |
return json; | |
} catch (error) { | |
console.error('Error converting XML to JSON:', error); | |
} | |
} | |
import { readFile, writeFile } from 'node:fs/promises'; | |
import { logger } from './logger.js'; | |
async function prettyJSON(filename, data?) { | |
if (data) { | |
await writeFile(filename, JSON.stringify(data, null, 2)).catch(e => { | |
logger.error(e); | |
}); | |
} else { | |
await writeFile(filename, JSON.stringify(JSON.parse(await readFile(filename, 'utf8')), null, 2)); | |
} | |
} | |
import { createLogger, format, transports } from "winston"; | |
const logger = createLogger({ | |
level: 'info', | |
format: format.json(), | |
defaultMeta: { service: 'Lager oppdatering' }, | |
transports: [ | |
// | |
// - Write all logs with level `error` and below to `error.log` | |
// - Write all logs with level `info` and below to `combined.log` | |
// | |
new transports.File({ filename: 'error.log', level: 'error' }), | |
new transports.File({ filename: 'combined.log' }), | |
], | |
}); | |
// | |
// If we're not in production then log to the `console` with the format: | |
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` | |
// | |
if (process.env.NODE_ENV !== 'production') { | |
logger.add(new transports.Console({ | |
format: format.simple(), | |
})); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment