Created
October 14, 2022 11:34
-
-
Save advename/1b0fd1e8192c00581a9619bd5ad3ae9c to your computer and use it in GitHub Desktop.
This file contains 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 tls from "tls"; | |
import crypto from "crypto"; | |
/** | |
* CPR Direkte Interface (NodeJS) | |
* | |
* CPR Direkte allows for personal data retrieval (Address, Maritial Status,...) from the Danish CPR register. | |
* | |
* Unlike other service interfaces, CPR Direkte has no REST API endpoints. | |
* The connection is made using TCP/IP protocol. | |
* To quickly understand what this means, a connection to a CPR Direkte | |
* endpoint is made and data inbetween is send in data segments (streams). | |
* (It's kind of writing to a remote file, and reading responses from a remote file - where this | |
* interface writes to it and CPR Direkte reads our lines, and they respond with writing to the same file | |
* which we read from. Line by line.) | |
* Using predefined sequences (custom commands), one can query/manage responses. | |
* | |
* Before making CPR lookup requests, we need to make an authentication request to receive | |
* a token (30min lifetime) which then is attached to lookup requests. | |
* | |
* Lookup responses are one single long string, with data values always available at exact position in the string. | |
* For documentation regarding data contained in records, refer to "Teknisk dokumentation" | |
* for "CPR Direkte - PNR" on | |
* @see https://cprservicedesk.atlassian.net/ | |
* | |
* @example | |
* Set the following env variables: | |
* CPR_DIREKTE_URL=direkte.cpr.dk | |
* CPR_DIREKTE_TRANSACTION_CODE=PRIV | |
* CPR_DIREKTE_IS_TEST=N | |
* CPR_DIREKTE_PORT=5000 | |
* CPR_DIREKTE_CUSTOMER_NUMBER= | |
* CPR_DIREKTE_USERNAME= | |
* CPR_DIREKTE_PASSWORD= | |
* | |
* Then you're good to go using the interface: | |
* 1. Get a token: | |
* | |
* cprDirekteToken = await cprDirekte.getToken(); | |
* | |
* 2. Use said token to lookup a CPR: | |
* | |
* const cprLookup = await cprDirekte.lookupCPR("1234567890", cprDirekteToken); | |
* | |
* 3. You should receive an object with following values: | |
* { | |
* cpr: '1234567890', | |
* birthdate: '19920510', | |
* sex: 'M', | |
* statusCode: '01', | |
* statusDate: '000000000000', | |
* protectionStartDate: '000000000000', | |
* formattedName: 'Johnny Johnson', | |
* coName: '', | |
* locality: '', | |
* streetName: 'Købmagergade', | |
* city: '22', | |
* postalCode: '1200', | |
* houseNumber: '012', | |
* floor: '4', | |
* buildingNumber: '', | |
* sideNumber: '0001', | |
* firstName: 'Johnny', | |
* lastName: 'Johnson', | |
* statusCodeMessage: 'bopael_i_danmark' | |
* } | |
* | |
* CPR Direkete test CPR's: | |
* - 0709614029 - Status 80 | |
* - 3101530069 - Under guardianship (værgemål) | |
* - 0701614054 - Secret Adress | |
*/ | |
const dataSectionStartLength = 28; // start of DATA section of response | |
/** | |
* Make the actual TCP/IP request to CPRDirekte | |
* The stream closes immediately after having received the first response. | |
* Throw's an error | |
* @param {string} context | |
* @returns {string} | |
* @throws {Error} TCP/IP Socket connection error. Unauthenticated requests do not trigger an error. | |
*/ | |
async function makeRequest(context) { | |
const host = process.env.CPR_DIREKTE_URL; | |
const port = process.env.CPR_DIREKTE_PORT; | |
let socket = tls.connect(port, host); | |
socket.setEncoding("latin1"); // ISO-8859-1 encoding | |
socket.write(context); | |
let promise = await new Promise(function (resolve, reject) { | |
socket.on("data", function (response) { | |
socket.destroy(); | |
resolve(response); | |
}); | |
socket.on("error", function (err) { | |
displayConsoleError(err); | |
reject(err); | |
}); | |
}); | |
return promise; | |
} | |
/** | |
* Login to CPR Direkte to obtain authentication token for subsequent requests. | |
* @returns {string|null} Token or null for failed request | |
*/ | |
async function getToken() { | |
const loginContext = | |
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}90${process.env.CPR_DIREKTE_USERNAME}${process.env.CPR_DIREKTE_PASSWORD}`.padEnd( | |
35 | |
); | |
const response = await makeRequest(loginContext); | |
if ( | |
captureRequestErrorCode(response, "Unable to login. Check credentials.") | |
) { | |
return null; | |
} | |
const token = mockSubstr(response, 6, 8); | |
return token; | |
} | |
/** | |
* Lookup a CPR Number | |
* Only returns data if CPR status is 01, 03, 05, 07 | |
* | |
* @param {string} cprNumber | |
* @param {string} token | |
* @returns {object|null} Data or null for failed request. | |
*/ | |
async function lookupCPR(cprNumber, token) { | |
const lookupContext = | |
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}06${token}${process.env.CPR_DIREKTE_USERNAME}00${cprNumber}`.padEnd( | |
39 | |
); | |
const response = await makeRequest(lookupContext); | |
if (!token || cprNumber.length != 10) { | |
displayConsoleError( | |
"Invalid parameters. Check the token or CPR Number." | |
); | |
return null; | |
} | |
if (captureRequestErrorCode(response, "Failed looking up CPR Number.")) { | |
return null; | |
} | |
const records = getAvailableRecords(response); | |
// Current Data (Personal Data of the CPR Number holder) | |
const recordDataStart = records["001"]; | |
const lookupData = getCurrentData(recordDataStart, response); | |
lookupData.statusCodeMessage = getStatusCodeMessage(lookupData.statusCode); | |
// Contact Address | |
if (records.hasOwnProperty("003")) { | |
// check for KONTAKT_ADDRESS in list of found records | |
const recordContactStart = records["003"]; // get start position in the response of contact address records | |
const contactAddress = mockSubstr(response, recordContactStart, 195); | |
lookupData.contactAddress = contactAddress; // returns a long string of data - in our use case we're only interested in the existence | |
} | |
// Under guardianship (værgemål) | |
if (records.hasOwnProperty("005")) { | |
// check for KONTAKT_ADDRESS in list of found records | |
const recordGuardianStart = records["005"]; // get start position in the response of contact address records | |
const guardian = mockSubstr(response, recordGuardianStart, 243); | |
lookupData.guardian = guardian; // returns a long string of data - in our use case we're only interested in the existence | |
} | |
return lookupData; | |
} | |
/** | |
* Login to CPR Direkte to obtain authentication token for subsequent requests. | |
* | |
* CPR Direkte Criterias: | |
* - Password must be 8 characters long. | |
* - Minimum 1 lower-case alphabetical (a-z) | |
* - Minimum 1 upper-case alphabetical (A-Z) | |
* - Minimum 1 numerical (0-9) | |
* - Minimum 1 special character ~ ` ! @ # $ % ^ * ( ) _ - + = , . / \ { } [ ] ; : | |
* - The characters < > & ? ’ æ ø å cannot be used | |
* - Password can only be changed once every 24 hours | |
* - Reuse of paswords are not allowed. | |
* | |
* @returns {string|null} Token or null for failed request | |
*/ | |
async function updatePassword(newPassword) { | |
if (newPassword.length != 8) { | |
// False, password does not meet CPRDirekte criteria | |
displayConsoleError( | |
"New CPR Direkte password is not exactly 8 characters long" | |
); | |
return null; | |
} | |
const updatePasswordContext = | |
`${process.env.CPR_DIREKTE_TRANSACTION_CODE},${process.env.CPR_DIREKTE_CUSTOMER_NUMBER}90${process.env.CPR_DIREKTE_USERNAME}${process.env.CPR_DIREKTE_PASSWORD}${newPassword}`.padEnd( | |
35 | |
); | |
const response = await makeRequest(updatePasswordContext); | |
// If errorCode = 3, then the newPassword is invalid format | |
if ( | |
captureRequestErrorCode(response, "Failed password change attempt. Check new password format or credentials.") | |
) { | |
return null; | |
} | |
return true; | |
} | |
export const cprDirekte = { | |
getToken, | |
lookupCPR, | |
updatePassword, | |
generateCprDirektePassword | |
}; | |
/** | |
* ======================== | |
* HELPER METHODS | |
* ======================== | |
*/ | |
/** | |
* Generates a cryptographical password following CPR Direktes password criterias | |
* CPR Direkte Criterias: | |
* - Password must be 8 characters long. | |
* - Minimum 1 lower-case alphabetical (a-z) | |
* - Minimum 1 upper-case alphabetical (A-Z) | |
* - Minimum 1 numerical (0-9) | |
* - Minimum 1 special character ~ ` ! @ # $ % ^ * ( ) _ - + = , . / \ { } [ ] ; : | |
* - The characters < > & ? ’ æ ø å cannot be used | |
* - Password can only be changed once every 24 hours | |
* - Reuse of paswords are not allowed. | |
* | |
* @returns {string} 8 characters sring | |
*/ | |
function generateCprDirektePassword() { | |
// 1. Generate an array of 8 different unique numbers between 0 - 7 (array starts at 0) | |
// Source: https://stackoverflow.com/a/50652249 | |
const randomNumbersSet = new Set(); | |
while (randomNumbersSet.size !== 8) { | |
randomNumbersSet.add(Math.floor(Math.random() * 8) + 0); | |
} | |
const randomNumbersArray = [...randomNumbersSet]; // convert Set to Array - Result e.g.: [3,4,1,2,5,7,6] | |
// 2. Generate a cryptographical random token with 8 characters (4 bytes will be 8 hex characters which are 0-9, a-z) | |
const randomToken = crypto.randomBytes(4).toString("hex"); // 0-9, a-z | |
// 3. Specify CPR Direkte password required characters | |
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; | |
const lower = "abcdefghijklmnopqrstuvwxyz"; | |
const digit = "0123456789"; | |
const special = "~`!@#$%^*()_-+=,./{}[];:"; // removed the "\" as it would escape the following character | |
const requiredCharactersArray = [upper, lower, digit, special]; | |
// 4. Replace randomToken with at least one occurence of each required character set (from Step 3.) | |
let newPassword = randomToken; | |
requiredCharactersArray.forEach((requiredCharacters, index) => { | |
// Get random character from the requiredCharacters string set | |
const randomRequiredCharacter = requiredCharacters.charAt( | |
Math.random() * (requiredCharacters.length - 1 - 0) + 0 | |
); // E.g. returns "K" | |
// Get one of the unique random numbers | |
const replaceAtIndex = randomNumbersArray[index]; // E.g returns 3 | |
// Update the password to contain one of the randomly selected and randomly placed characters. | |
// E.g. Before "6djs8al2", with "K" at position 3 yields -> "6dKs8al2" | |
newPassword = | |
newPassword.substring(0, replaceAtIndex) + | |
randomRequiredCharacter + | |
newPassword.substring(replaceAtIndex + 1); | |
}); | |
return newPassword; | |
} | |
/** | |
* The record structures are specified below. For each data field, usage and format is explained in concise | |
* form. | |
* Method for getting the records sent from CPR system. Note that the records available | |
* to you are determined when you are setup as a customer with CPR. | |
* | |
* @param $response string The response to parse records from. | |
* @return array Returns array of records found, as well as the index in the response where the | |
* record begins. | |
*/ | |
function getAvailableRecords(response) { | |
let records = {}; | |
let start = dataSectionStartLength; | |
/* if we find the start of a record, save it's starting position in the response string | |
as a key in associative array so we can parse it later */ | |
while (start < response.length) { | |
let recordType = mockSubstr(response, start, 3); | |
if (recordType === "000") { | |
// START record | |
records["000"] = start; // mandatory in response | |
start += 35; // end of record | |
} else if (recordType === "001") { | |
// CURRENT_DATA record | |
records["001"] = start; // mandatory in response | |
start += 469; // end of record | |
} else if (recordType === "002") { | |
// FOREIGN_ADDRESS record | |
records["002"] = start; // NOTE: length is either 195/199 depending on record type 'A' or 'B' | |
start += 195; // end of record | |
} else if (recordType === "003") { | |
// KONTAKT_ADDRESS record | |
records["003"] = start; | |
start += 195; // end of record | |
} else if (recordType === "004") { | |
// MARRITAL_STATUS record | |
records["004"] = start; | |
start += 26; // end of record | |
} else if (recordType === "005") { | |
// GUARDIAN record | |
records["005"] = start; | |
start += 217; // end of record | |
} else if (recordType === "011") { | |
// CUSTOMERNUM_REF record | |
records["011"] = start; | |
start += 88; // end of record | |
} else if (recordType === "050") { | |
// CREDIT_WARNING record | |
// NB: Credit warning data (050 record) is first available in production from 1/1/2017 | |
records["050"] = start; | |
start += 29; // end of record | |
} else if (recordType === "999") { | |
// END record | |
records["999"] = start; // mandatory in response | |
start += 21; // end of record | |
} else { | |
// console.info("Unkown record code:", recordType); | |
start += ~-encodeURI(recordType).split(/%..|./).length; // so we don't loop infinitely | |
} | |
} | |
return records; | |
} | |
/** | |
* Get "current data" record type out of a CPR Direkte response | |
* @param {number} start | |
* @param {string} response | |
* @returns {object} | |
*/ | |
function getCurrentData(start, response) { | |
/** | |
* CPR Direkte Response Data Structure using positions from the interface Documentation. | |
* The following structure covers all CPR's having status: | |
* - 01: Aktiv, bopæl i dansk folkeregister | |
* - 03: Aktiv, speciel vejkode (9900-9999) i dansk folkeregister | |
* - 05: Aktiv, bopæl i grønlandsk folkeregister | |
* - 07: Aktiv, speciel vejkode (9900-9999) i grønlandsk folkeregister | |
* | |
* If you have to cover other status, examine the interface documentation as data positions may change. | |
* | |
* The "pos" value is one lower than in the documentation since our "mockSubstr" start position is not inclusive. | |
* @see https://cprservicedesk.atlassian.net/wiki/spaces/CPR/pages/11436180 | |
*/ | |
const dataStructure = { | |
cpr: { | |
pos: 3, | |
length: 10, | |
}, | |
birthdate: { | |
// YYYYMMDD | |
pos: 13, | |
length: 8, | |
}, | |
sex: { | |
pos: 21, | |
length: 1, | |
}, | |
statusCode: { | |
// active/inactive | |
pos: 22, | |
length: 2, | |
}, | |
statusDate: { | |
// YYYYMMDDTTMM | |
pos: 24, | |
length: 12, | |
}, | |
protectionStartDate: { | |
// Name/Address protection start date YYYYMMDDhhmmm (0 if no protection) | |
pos: 70, | |
length: 12, | |
}, | |
formattedName: { | |
// First/Lars or Last/First | |
pos: 116, | |
length: 34, | |
}, | |
coName: { | |
// C/O Name | |
pos: 150, | |
length: 34, | |
}, | |
locality: { | |
pos: 184, | |
length: 34, | |
}, | |
streetName: { | |
pos: 422, | |
length: 20, | |
}, | |
city: { | |
pos: 290, | |
length: 20, | |
}, | |
postalCode: { | |
pos: 286, | |
length: 4, | |
}, | |
houseNumber: { | |
pos: 318, | |
length: 4, | |
}, | |
floor: { | |
pos: 322, | |
length: 2, | |
}, | |
buildingNumber: { | |
pos: 328, | |
length: 4, | |
}, | |
sideNumber: { | |
// Side, or apartment Number | |
pos: 324, | |
length: 4, | |
}, | |
firstName: { | |
// first and middle name | |
pos: 332, | |
length: 50, | |
}, | |
lastName: { | |
pos: 382, | |
length: 40, | |
}, | |
}; | |
const currentData = {}; | |
for (const key in dataStructure) { | |
const keyValue = dataStructure[key]; | |
currentData[key] = mockSubstr( | |
response, | |
start + keyValue.pos, | |
keyValue.length | |
).trim(); | |
} | |
return currentData; | |
} | |
/** | |
* Return the meaning of a specific statuscode | |
* @param {string} statusCode | |
* @returns {string} | |
*/ | |
function getStatusCodeMessage(statusCode) { | |
const meanings = { | |
// Activ | |
"01": "bopael_i_danmark", | |
"03": "bopael_i_danmark_hoej_vejkode", | |
"05": "bopael_i_groenland", | |
"07": "bopael_i_groenland_hoej_vejkode", | |
// Inactive | |
20: "ej_bopael", | |
30: "annulleret", | |
50: "nedlagt_person", | |
60: "Ændret personnummer", | |
70: "forsvundet", | |
80: "udrejst", | |
90: "doed", | |
}; | |
return meanings[statusCode] || null; | |
} | |
/** | |
* Strip out an error code from a CPRDirekte request | |
* | |
* Error Codes: | |
* 00 = No errors | |
* 01 = USERID/PWD incorrect | |
* 02 = PWD expired, NEWPWD required | |
* 03 = NEWPWD format error | |
* 04 = No access to CPR | |
* 05 = PNR not found in CPR | |
* 06 = Unknown KUNDENR | |
* 07 = Timeout – new LOGON required | |
* 08 = 'DEAD-LOCK' while retrieving data from the CPR system | |
* 09 = Serious problem (e.g. failure to connect to the CPR system) please contact the CSC Service Center, tel (+45) 3614 6192 | |
* 10 = ’ABON_TYPE unknown | |
* 11 = ’ DATA_TYPE unknown’ | |
* 12 - 15 = (reserved error numbers) | |
* 16 = ’No access for your IP address’ | |
* 17 = ‘PNR’ not entered | |
* 99 = USERID has no access to the transaction | |
* | |
* @see https://cprservicedesk.atlassian.net/wiki/spaces/CPR/pages/11436180/Gr+nsefladebeskrivelse+-+CPR+Direkte+Match | |
* | |
* @param {string} request | |
* @param {string} message | |
* @returns {string|null} | |
*/ | |
function captureRequestErrorCode(request, message = null) { | |
const requestErrorCode = mockSubstr(request, 22, 2); | |
if (requestErrorCode != "00") { | |
if (message) { | |
// Display a console error | |
displayConsoleError( | |
`Returned with code: ${requestErrorCode}${ | |
message != null ? " - " + message : null | |
}` | |
); | |
} | |
return requestErrorCode; | |
} | |
return null; | |
} | |
function displayConsoleError(message) { | |
console.error("\n[ERROR CPRDirekte] ", message, "\n"); | |
} | |
/** | |
* Mock PHP's "substr" method using substring. | |
* | |
* All CPRDirekte examples use "substr" to retrieve the exact data positions. | |
* | |
* JavaScript's "substr" method is deprecated | |
* | |
* @param {string} string The string to apply "substr" to | |
* @param {number} start Start position - NOT inclusive | |
* @param {number} length Length - is inclusive | |
* @returns {string} The stripped string without touching the initial string | |
*/ | |
const mockSubstr = (string, start, length) => | |
string.substring(start, start + length); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
direkte-demo.cpr.dk
where you can use one of the many test CPRs that can be found on their documentation page: https://cprservicedesk.atlassian.net/