Cookie-based Authorization
This flow provides "Cookie-based Authorization" for HTTP endpoints which are intended for certain users only.
This flow is part of a set of node-red-authorization-examples but also published here for easier lookup
Prerequisites
This example requires the following Node-RED extension:
- node-red-contrib-reusable-flows
"Reusable Flows" allow multiply needed flows to be defined once and then invoked from multiple places
Additionally, it expects the global flow context to contain an object called UserRegistry
which has the same format as described in "node-red-within-express":
- the object's property names are the ids of registered users
user ids are strings with no specific format, they may be user names, email addresses or any other data you are free to choose - with two important exceptions: user ids must neither contain any slashes ("/") nor any colons (":") or the authentication mechanisms described below (and the user management described in node-red-user-management-example) will fail. Additionally, upper and lower case in user ids is not distinguished - the object's property values are JavaScript objects with the following properties, at least (additional properties may be added at will):
- Roles
is either missing or contains a list of strings with the user's roles. There is no specific format for role names except that a role name must not contain any white space - Salt
is a string containing a random "salt" value which is used during PBKDF2 password hash calculation - Hash
is a string containing the actual PBKDF2 hash of the user's password
- Roles
When used outside "node-red-within-express", the following flows allow such a registry to be loaded from an external JSON file called registeredUsers.json
(or to be created if no such file exists or an existing file can not be loaded) and written back after changes:
These flows are already part of this example but may be removed (or customized) if not needed.
For testing and debugging purposes, the following flow may also be imported, which dumps the current contents of the user registry onto Node-RED's debug console when clicked:
This flow is also already part of this example.
Postman Collection
For testing purposes, the GitHub repository for this example additionally contains a Postman collection with a few predefined requests.
Cookie-based Authorization
A popular approach is to let users log-in and generate access tokens which are then used as "cookies" for the communication between browser and server. Browsers automatically attach such cookies to every request, and the contained tokens may be designed to "expire" or to be deleted upon a "logout".
The token in this example consists of a user id and an expiration time. While it is stored in plain text (and, thus, may be inspected by the client), its value is secured with a "message digest" - as a consequence, any attempt to change the token will inevitably be recognized and lead to authorization loss. On the other hand, any successful token validation automatically refreshes that token - tokens therefore effectively expire after a certain time of inactivity only.
The key used to generate message digests is randomly chosen at server startup - a server restart will therefore automatically invalidate any active tokens.
Token lifetime may be configured - by default, it is set to 2 minutes.
In order to "login", POST a form containing the variables UserId
and Password
to the proper endpoint (/cookie-auth
in this example).
Nota bene: current law often requires users to be informed about cookie usage. The cookie used here counts as a "technically required cookie" which cannot be forbidden if the visited site is expected to work as foreseen.
The upper outputs are used for successful authentications and logins, the lower ones for failures.
If you require the authenticating user to have a specific role, you may set msg.requiredRole
to that role before invoking cookie auth
or cookie login
- otherwise, user roles will not be checked.
Upon successful authentication, msg.authenticatedUser
contains the id of the authenticated user and msg.authorizedRoles
contains a (possibly empty) list with the roles of that user.
Try yourself
The following example illustrates how to integrate Cookie-based authentication into Node-RED flows:
- send a POST request to the shown entry point in order to log-in and then
- send a GET request to the same entry point to validate that log-in and access the protected resource
(The Postman collection mentioned below facilitates these steps.)
Sending GET requests without prior login (or after token expiration) should fail with status code 401 (Unauthorized)
The login request should either contain
- a body of type "application/json" with the JSON serialization of an object containing the properties
UserId
andPassword
, at least, or - a body of type "application/x-www-form-urlencoded" with the form variables
UserId
andPassword
, at least
Additional object properties or form variables will be ignored by the authentication itself, but passed on to any following nodes.
Successful login, token validation and token refresh always add the related cookie to the cookies
property of the msg
object which, thus, automatically becomes part of the flow's response.
Any login or token validation failure automatically deletes the token cookie, comparable to a logout.
Automated Tests
The GitHub repository for this example also contains some flows for automated tests.
[{"id":"a09b9281644a88fc","type":"comment","z":"d8fe099880fd703d","name":"Cookie-based authorization (w/ expiration)","info":"","x":180,"y":40,"wires":[]},{"id":"07b8b379eee00444","type":"function","z":"d8fe099880fd703d","name":"validate authorization","func":" let Cookie = ((msg.req.cookies || {}).authorization || '').trim()\n if (Cookie !== '') {\n let UserRegistry = global.get('UserRegistry') || Object.create(null)\n\n let [UserId,Expiration,Digest] = Cookie.split(':')\n UserId = UserId.toLowerCase()\n if (\n (UserId !== '') && (UserId in UserRegistry) && (UserRegistry[UserId] != null) &&\n /^\\d+$/.test(Expiration) && /^[0-9a-fA-F]+$/.test(Digest)\n ) {\n let TokenKey = global.get('TokenKey')\n const HMAC = crypto.createHmac('sha256',TokenKey)\n HMAC.update(UserId + ':' + Expiration)\n let expectedDigest = HMAC.digest('hex')\n\n if (\n (Digest === expectedDigest) &&\n (parseInt(Expiration,10) >= Date.now())\n ) {\n let UserRoles = UserRegistry[UserId].Roles || []\n if (\n (msg.requiredRole == null) ||\n (UserRoles.indexOf(msg.requiredRole) >= 0)\n ) {\n msg.authenticatedUser = UserId\n msg.authorizedRoles = UserRegistry[UserId].Roles || []\n\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = Cookie\n return [msg,null] // authorized\n } else {\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = null\n\n msg.payload = 'Unauthorized'\n msg.statusCode = 401\n return [null,msg] // not authorized\n }\n }\n }\n }\n\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = null\n\n msg.payload = 'Unauthorized'\n msg.statusCode = 401\n return [null,msg] // not authorized\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":160,"y":300,"wires":[["9c83d20d034a8217"],["4a4efb4deeea3a24"]]},{"id":"6f16c88dad72878f","type":"function","z":"d8fe099880fd703d","name":"validate credentials","func":" let UserId = (msg.payload.UserId || '').toLowerCase()\n let Password = msg.payload.Password || ''\n\n let UserRegistry = global.get('UserRegistry') || Object.create(null)\n if ((UserId in UserRegistry) && (UserRegistry[UserId] != null)) {\n let UserSpecs = UserRegistry[UserId]\n if (UserSpecs.Password === Password) { // internal optimization\n return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n }\n\n let PBKDF2Iterations = global.get('PBKDF2Iterations') || 100000\n crypto.pbkdf2(\n Password, Buffer.from(UserSpecs.Salt,'hex'), PBKDF2Iterations, 64, 'sha512',\n function (Error, computedHash) {\n if ((Error == null) && (computedHash.toString('hex') === UserSpecs.Hash)) {\n UserSpecs.Password = Password // speeds up future auth. requests\n return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n } else {\n return withoutAuthorization()\n }\n }\n )\n } else {\n return withoutAuthorization()\n }\n\n function withAuthorizationOf (UserName, UserRoles) {\n if ((msg.requiredRole == null) || (UserRoles.indexOf(msg.requiredRole) >= 0)) {\n msg.authenticatedUser = UserId\n msg.authorizedRoles = UserRoles\n\n node.send([msg,null])\n node.done()\n } else {\n return withoutAuthorization()\n }\n }\n\n function withoutAuthorization () {\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = { value:null }\n\n msg.payload = 'Unauthorized'\n msg.statusCode = 401\n\n node.send([null,msg])\n node.done()\n }\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":150,"y":420,"wires":[["8586b1e3f59f5e67"],["0a60fc2822814b75"]]},{"id":"e5bd2fb29dfdf1f1","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":160,"wires":[["0b5dde3db674cb58"]]},{"id":"0b5dde3db674cb58","type":"function","z":"d8fe099880fd703d","name":"generate Token Key","func":" let TokenKey = global.get('TokenKey')\n if (TokenKey == null) { // do not change TokenKey upon Node-RED deployment\n global.set('TokenKey',crypto.randomBytes(16).toString('hex'))\n }\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":320,"y":160,"wires":[["7c029d497f0dbc7d"]]},{"id":"7c029d497f0dbc7d","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'token key generated'","statusType":"jsonata","x":530,"y":160,"wires":[]},{"id":"8586b1e3f59f5e67","type":"function","z":"d8fe099880fd703d","name":"create token","func":" let Expiration = Date.now() + global.get('TokenLifetime')\n let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n let TokenKey = global.get('TokenKey')\n const HMAC = crypto.createHmac('sha256',TokenKey)\n HMAC.update(TokenContent)\n let Digest = HMAC.digest('hex')\n\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = { value:TokenContent + ':' + Digest }\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":350,"y":360,"wires":[["b67fe1f3071cca07"]]},{"id":"9c83d20d034a8217","type":"function","z":"d8fe099880fd703d","name":"refresh token","func":" let Expiration = Date.now() + global.get('TokenLifetime')\n let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n let TokenKey = global.get('TokenKey')\n const HMAC = crypto.createHmac('sha256',TokenKey)\n HMAC.update(TokenContent)\n let Digest = HMAC.digest('hex')\n\n msg.cookies = msg.cookies || {}\n msg.cookies.authorization = { value:TokenContent + ':' + Digest }\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":330,"y":240,"wires":[["8f16cbb0cc28ee30"]]},{"id":"80106edae73330cd","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":100,"wires":[["e1e821402ba2abe1"]]},{"id":"e1e821402ba2abe1","type":"change","z":"d8fe099880fd703d","name":"set token lifetime","rules":[{"t":"set","p":"TokenLifetime","pt":"global","to":"2*60*1000","tot":"jsonata"},{"t":"set","p":"payload","pt":"msg","to":"TokenLifetime","tot":"global"}],"action":"","property":"","from":"","to":"","reg":false,"x":310,"y":100,"wires":[["11640b19fd562bfb"]]},{"id":"11640b19fd562bfb","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"msg","x":530,"y":100,"wires":[]},{"id":"93bc823a9594e786","type":"reusable-in","z":"d8fe099880fd703d","name":"cookie auth","info":"describe your reusable flow here","scope":"global","x":90,"y":240,"wires":[["07b8b379eee00444"]]},{"id":"a2c8904560f5d8e1","type":"reusable-in","z":"d8fe099880fd703d","name":"cookie login","info":"describe your reusable flow here","scope":"global","x":90,"y":360,"wires":[["6f16c88dad72878f"]]},{"id":"8f16cbb0cc28ee30","type":"reusable-out","z":"d8fe099880fd703d","name":"authorized","position":1,"x":510,"y":240,"wires":[]},{"id":"4a4efb4deeea3a24","type":"reusable-out","z":"d8fe099880fd703d","name":"unauthorized","position":"2","x":510,"y":280,"wires":[]},{"id":"b67fe1f3071cca07","type":"reusable-out","z":"d8fe099880fd703d","name":"success","position":1,"x":520,"y":360,"wires":[]},{"id":"0a60fc2822814b75","type":"reusable-out","z":"d8fe099880fd703d","name":"failure","position":"2","x":530,"y":400,"wires":[]},{"id":"e7e4d431ae21b31c","type":"comment","z":"d8fe099880fd703d","name":"try yourself","info":"","x":80,"y":500,"wires":[]},{"id":"bcce3b9c21368743","type":"http in","z":"d8fe099880fd703d","name":"","url":"cookie-auth","method":"get","upload":false,"swaggerDoc":"","x":100,"y":560,"wires":[["a1d6fa210264c774"]]},{"id":"79102e3ebd685bba","type":"http response","z":"d8fe099880fd703d","name":"","statusCode":"","headers":{},"x":530,"y":620,"wires":[]},{"id":"b146b96c1e6e4802","type":"change","z":"d8fe099880fd703d","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":620,"wires":[["79102e3ebd685bba"]]},{"id":"bca7975301895f4d","type":"http in","z":"d8fe099880fd703d","name":"","url":"cookie-auth","method":"post","upload":false,"swaggerDoc":"","x":110,"y":680,"wires":[["6ad54df9077948bd"]]},{"id":"5aa149195cb2bafd","type":"change","z":"d8fe099880fd703d","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":740,"wires":[["9603dfde62c5f08b"]]},{"id":"9603dfde62c5f08b","type":"http response","z":"d8fe099880fd703d","name":"","statusCode":"","headers":{},"x":530,"y":740,"wires":[]},{"id":"a1d6fa210264c774","type":"reusable","z":"d8fe099880fd703d","name":"","target":"cookie auth","outputs":2,"x":290,"y":560,"wires":[["b146b96c1e6e4802"],["79102e3ebd685bba"]]},{"id":"6ad54df9077948bd","type":"reusable","z":"d8fe099880fd703d","name":"","target":"cookie login","outputs":2,"x":310,"y":680,"wires":[["5aa149195cb2bafd"],["9603dfde62c5f08b"]]},{"id":"824216bdbb591cea","type":"comment","z":"d8fe099880fd703d","name":"if used without \"node-red-within-express\"","info":"","x":180,"y":820,"wires":[]},{"id":"a58452a677c7be13","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":100,"y":880,"wires":[["620a731991335960"]]},{"id":"dc02f08e694bc9ae","type":"file in","z":"d8fe099880fd703d","name":"","filename":"./registeredUsers.json","format":"utf8","chunk":false,"sendError":false,"encoding":"utf8","allProps":false,"x":160,"y":1000,"wires":[["14f00e0115539df9"]]},{"id":"4690b38a2f0f4c4c","type":"catch","z":"d8fe099880fd703d","name":"","scope":["dc02f08e694bc9ae","14f00e0115539df9"],"uncaught":false,"x":110,"y":1060,"wires":[["751a4b5f6d827646"]]},{"id":"f47143ada81d0161","type":"debug","z":"d8fe099880fd703d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not load user registry'","statusType":"jsonata","x":510,"y":1060,"wires":[]},{"id":"4a700dfcceee780a","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'user registry available'","statusType":"jsonata","x":530,"y":880,"wires":[]},{"id":"751a4b5f6d827646","type":"function","z":"d8fe099880fd703d","name":"create in global context","func":" let UserRegistry = Object.create(null)\n UserRegistry['node-red'] = {\n Roles: ['node-red'],\n Salt: '4486e8d35b8275020b1301226cc77963',\n Hash: 'ab2b740ea9148aa4f320af3f3ba60ee2e33bb8039c57eea2b29579ff3f3b16bec2401f19e3c6ed8ad36de432b80b6f973a12c41af5d50738e4bb902d0117df53'\n }\n global.set('UserRegistry',UserRegistry)\n\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":1060,"wires":[["f47143ada81d0161","8318c57d9522aa33"]]},{"id":"8494471c9c395698","type":"file","z":"d8fe099880fd703d","name":"","filename":"./registeredUsers.json","appendNewline":false,"createDir":false,"overwriteFile":"true","encoding":"utf8","x":420,"y":1180,"wires":[["5432a3f68d4f0c24"]]},{"id":"6c62fbae4d2acb44","type":"catch","z":"d8fe099880fd703d","name":"","scope":["8494471c9c395698"],"uncaught":false,"x":110,"y":1300,"wires":[["165a1ce2e9945426","dd9bbfd781f6ea50"]]},{"id":"165a1ce2e9945426","type":"debug","z":"d8fe099880fd703d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not write user registry'","statusType":"jsonata","x":250,"y":1340,"wires":[]},{"id":"5432a3f68d4f0c24","type":"change","z":"d8fe099880fd703d","name":"restore payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"_payload","tot":"msg"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":180,"y":1240,"wires":[["6b8835c3f66d4f0b"]]},{"id":"dd9bbfd781f6ea50","type":"change","z":"d8fe099880fd703d","name":"report in payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"'Internal Server Error'","tot":"jsonata"},{"t":"set","p":"statusCode","pt":"msg","to":"500","tot":"str"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":290,"y":1300,"wires":[["6b8835c3f66d4f0b"]]},{"id":"14f00e0115539df9","type":"function","z":"d8fe099880fd703d","name":"write to global context","func":" let UserSet = JSON.parse(msg.payload) // may fail!\n\n let UserRegistry = Object.create(null)\n for (let UserId in UserSet) {\n if (UserSet.hasOwnProperty(UserId)) {\n if ((UserId.indexOf('/') >= 0) || (UserId.indexOf(':') >= 0)) {\n throw 'Invalid character in UserId found'\n }\n \n UserRegistry[UserId.toLowerCase()] = UserSet[UserId]\n }\n }\n global.set('UserRegistry',UserRegistry)\n\n msg.payload = ''\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":400,"y":1000,"wires":[["8318c57d9522aa33"]]},{"id":"5cc4a021d6704942","type":"function","z":"d8fe099880fd703d","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":940,"wires":[["751a4b5f6d827646"]]},{"id":"94df8f09e02b2af2","type":"function","z":"d8fe099880fd703d","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":1120,"wires":[["dd9bbfd781f6ea50"]]},{"id":"ddd48700d0596225","type":"function","z":"d8fe099880fd703d","name":"read from global context","func":" let UserRegistry = global.get('UserRegistry')\n let UserSet = {}\n for (let UserId in UserRegistry) {\n if (UserRegistry[UserId] == null) {\n UserSet[UserId] = null\n } else {\n let UserEntry = Object.assign({},UserRegistry[UserId])\n delete UserEntry.Password // never write passwords in plain text!\n UserSet[UserId] = UserEntry\n }\n }\n\n msg.payload = JSON.stringify(UserSet)\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":170,"y":1180,"wires":[["8494471c9c395698"]]},{"id":"8318c57d9522aa33","type":"reusable-out","z":"d8fe099880fd703d","name":"return","position":1,"x":530,"y":940,"wires":[]},{"id":"6b8835c3f66d4f0b","type":"reusable-out","z":"d8fe099880fd703d","name":"return","position":1,"x":530,"y":1240,"wires":[]},{"id":"483c14063ef038dd","type":"reusable-in","z":"d8fe099880fd703d","name":"load or create UserRegistry","info":"describe your reusable flow here","scope":"global","x":140,"y":940,"wires":[["5cc4a021d6704942","dc02f08e694bc9ae"]]},{"id":"5e75962e8f9a13df","type":"reusable-in","z":"d8fe099880fd703d","name":"write UserRegistry","info":"describe your reusable flow here","scope":"global","x":110,"y":1120,"wires":[["94df8f09e02b2af2","ddd48700d0596225"]]},{"id":"620a731991335960","type":"reusable","z":"d8fe099880fd703d","name":"","target":"load or create userregistry","outputs":1,"x":320,"y":880,"wires":[["4a700dfcceee780a"]]},{"id":"ad7e203958af3b87","type":"inject","z":"d8fe099880fd703d","name":"show UserRegistry","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":130,"y":1480,"wires":[["4b46df7714a99db8"]]},{"id":"3005efbfdca160fb","type":"comment","z":"d8fe099880fd703d","name":"for testing purposes","info":"","x":110,"y":1420,"wires":[]},{"id":"4b46df7714a99db8","type":"function","z":"d8fe099880fd703d","name":"create output","func":"let UserRegistry = global.get('UserRegistry') || Object.create(null)\n let UserList = []\n for (let UserId in UserRegistry) {\n UserList.push(\n UserRegistry[UserId] == null ? '[' + UserId + ']' : UserId\n )\n }\nmsg.payload = (\n UserList.length === 0\n ? '(no user registered)'\n : 'registered users: \"' + UserList.join('\",\"') + '\"'\n)\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":1480,"wires":[["164c9be0f8434f61"]]},{"id":"164c9be0f8434f61","type":"debug","z":"d8fe099880fd703d","name":"show","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":510,"y":1480,"wires":[]}]