Header-based Authorization
This flow provides "Header-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.
Header-based Authorization
This example generates access tokens and stores them in an HTTP header instead of a cookie. This avoids having to follow any cookie-related laws but requires some JavaScript on the client side which always adds a proper authorization header to any outgoing request.
Access tokens consist of a user id and an expiration time. While they are stored in plain text (and, thus, may be inspected by the client), their value is secured with a "message digest" - as a consequence, any attempt to change a 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 sessions.
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 (/header-auth
in this example).
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 header auth
or header 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 Header-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
(The included Postman collection facilitates these steps as it also automatically copies the authorization header of any successful authentication into the GET request.)
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 required header to the headers
property of the msg
object which, thus, automatically becomes part of the response to the incoming request.
Any login or token validation failure automatically deletes the authorization header, comparable to a logout.
Automated Tests
The GitHub repository for this example also contains some flows for automated tests.
[{"id":"5f40353168982c37","type":"comment","z":"1ab76f62fe829c73","name":"Header-based authorization (w/ expiration)","info":"","x":180,"y":40,"wires":[]},{"id":"8d77be6450ce0f2a","type":"function","z":"1ab76f62fe829c73","name":"validate authorization","func":" let TokenHeader = msg.req.headers['authorization'] || ''\n if (TokenHeader.startsWith('Bearer')) {\n let UserRegistry = global.get('UserRegistry') || Object.create(null)\n\n let Token = TokenHeader.replace(/^Bearer\\s+/,'').trim()\n if (Token !== '') {\n let [UserId,Expiration,Digest] = Token.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 = UserRoles\n\n msg.headers = msg.headers || {}\n msg.headers['authorization'] = 'Bearer ' + Token\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\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":[["79785206c30e98c3"],["478efb8ea02f84dd"]]},{"id":"b21258d4f3b19a31","type":"function","z":"1ab76f62fe829c73","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.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":[["6e31b911deebaca2"],["d30b6243d1a61ddc"]]},{"id":"3ae11bc056bb4ea5","type":"inject","z":"1ab76f62fe829c73","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":[["02f5a2c430d381b9"]]},{"id":"02f5a2c430d381b9","type":"function","z":"1ab76f62fe829c73","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":[["08c6f38667b1f597"]]},{"id":"08c6f38667b1f597","type":"debug","z":"1ab76f62fe829c73","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":"3165fc5071ea3432","type":"inject","z":"1ab76f62fe829c73","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":[["4c8bc88e9138d602"]]},{"id":"4c8bc88e9138d602","type":"change","z":"1ab76f62fe829c73","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":[["59d1b07f62cd0149"]]},{"id":"59d1b07f62cd0149","type":"debug","z":"1ab76f62fe829c73","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"msg","x":530,"y":100,"wires":[]},{"id":"79785206c30e98c3","type":"function","z":"1ab76f62fe829c73","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.headers = msg.headers || {}\n msg.headers['authorization'] = 'Bearer ' + TokenContent + ':' + Digest\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":330,"y":240,"wires":[["2006e13c1c6ae4a2"]]},{"id":"6e31b911deebaca2","type":"function","z":"1ab76f62fe829c73","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.headers = msg.headers || {}\n msg.headers['authorization'] = 'Bearer ' + TokenContent + ':' + Digest\n return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":350,"y":360,"wires":[["71fe29a8a838e801"]]},{"id":"5a6436e0f351d31c","type":"reusable-in","z":"1ab76f62fe829c73","name":"header auth","info":"describe your reusable flow here","scope":"global","x":90,"y":240,"wires":[["8d77be6450ce0f2a"]]},{"id":"53a1975a92fb3b0d","type":"reusable-in","z":"1ab76f62fe829c73","name":"header login","info":"describe your reusable flow here","scope":"global","x":90,"y":360,"wires":[["b21258d4f3b19a31"]]},{"id":"2006e13c1c6ae4a2","type":"reusable-out","z":"1ab76f62fe829c73","name":"authorized","position":1,"x":510,"y":240,"wires":[]},{"id":"478efb8ea02f84dd","type":"reusable-out","z":"1ab76f62fe829c73","name":"unauthorized","position":"2","x":510,"y":280,"wires":[]},{"id":"71fe29a8a838e801","type":"reusable-out","z":"1ab76f62fe829c73","name":"success","position":1,"x":520,"y":360,"wires":[]},{"id":"d30b6243d1a61ddc","type":"reusable-out","z":"1ab76f62fe829c73","name":"failure","position":"2","x":530,"y":400,"wires":[]},{"id":"26648eb5bf6204e9","type":"comment","z":"1ab76f62fe829c73","name":"try yourself","info":"","x":80,"y":500,"wires":[]},{"id":"b658a6de0857907b","type":"http in","z":"1ab76f62fe829c73","name":"","url":"header-auth","method":"get","upload":false,"swaggerDoc":"","x":110,"y":560,"wires":[["dc105a4977025043"]]},{"id":"ecd50a9259a2cfde","type":"http response","z":"1ab76f62fe829c73","name":"","statusCode":"","headers":{},"x":530,"y":620,"wires":[]},{"id":"af1aef5eff24af57","type":"change","z":"1ab76f62fe829c73","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":[["ecd50a9259a2cfde"]]},{"id":"d44680247d011e9b","type":"http in","z":"1ab76f62fe829c73","name":"","url":"header-auth","method":"post","upload":false,"swaggerDoc":"","x":110,"y":680,"wires":[["2f655d899fbe9841"]]},{"id":"8c7ae8b543f3576a","type":"change","z":"1ab76f62fe829c73","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":[["d405c4344cb50dcb"]]},{"id":"d405c4344cb50dcb","type":"http response","z":"1ab76f62fe829c73","name":"","statusCode":"","headers":{},"x":530,"y":740,"wires":[]},{"id":"dc105a4977025043","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"header auth","outputs":2,"x":310,"y":560,"wires":[["af1aef5eff24af57"],["ecd50a9259a2cfde"]]},{"id":"2f655d899fbe9841","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"header login","outputs":2,"x":310,"y":680,"wires":[["8c7ae8b543f3576a"],["d405c4344cb50dcb"]]},{"id":"a437ea9e9983f2b9","type":"comment","z":"1ab76f62fe829c73","name":"if used without \"node-red-within-express\"","info":"","x":180,"y":820,"wires":[]},{"id":"ed50b5b2e94746c8","type":"inject","z":"1ab76f62fe829c73","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":[["4a4c700ae4ecd934"]]},{"id":"2d98c64a67e881e7","type":"file in","z":"1ab76f62fe829c73","name":"","filename":"./registeredUsers.json","format":"utf8","chunk":false,"sendError":false,"encoding":"utf8","allProps":false,"x":160,"y":1000,"wires":[["70c82a6e25251a70"]]},{"id":"470b387186d7852a","type":"catch","z":"1ab76f62fe829c73","name":"","scope":["2d98c64a67e881e7","70c82a6e25251a70"],"uncaught":false,"x":110,"y":1060,"wires":[["46809118b7d27252"]]},{"id":"a52bccab8bc183a8","type":"debug","z":"1ab76f62fe829c73","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":"3eb566d1907d9b3b","type":"debug","z":"1ab76f62fe829c73","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":"46809118b7d27252","type":"function","z":"1ab76f62fe829c73","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":[["a52bccab8bc183a8","e0b359a7b5f90189"]]},{"id":"a463788d4c5c13ab","type":"file","z":"1ab76f62fe829c73","name":"","filename":"./registeredUsers.json","appendNewline":false,"createDir":false,"overwriteFile":"true","encoding":"utf8","x":420,"y":1180,"wires":[["53948a7e0233c789"]]},{"id":"0892a7393f0e2863","type":"catch","z":"1ab76f62fe829c73","name":"","scope":["a463788d4c5c13ab"],"uncaught":false,"x":110,"y":1300,"wires":[["e50a7cafe871861b","d97e0ea28a9f3c81"]]},{"id":"e50a7cafe871861b","type":"debug","z":"1ab76f62fe829c73","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":"53948a7e0233c789","type":"change","z":"1ab76f62fe829c73","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":[["1ce07b54225e17dd"]]},{"id":"d97e0ea28a9f3c81","type":"change","z":"1ab76f62fe829c73","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":[["1ce07b54225e17dd"]]},{"id":"70c82a6e25251a70","type":"function","z":"1ab76f62fe829c73","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":[["e0b359a7b5f90189"]]},{"id":"4f110197c9f2a334","type":"function","z":"1ab76f62fe829c73","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":940,"wires":[["46809118b7d27252"]]},{"id":"687ebf9f0226b7dc","type":"function","z":"1ab76f62fe829c73","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":1120,"wires":[["d97e0ea28a9f3c81"]]},{"id":"848c6ddfb76643e4","type":"function","z":"1ab76f62fe829c73","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":[["a463788d4c5c13ab"]]},{"id":"e0b359a7b5f90189","type":"reusable-out","z":"1ab76f62fe829c73","name":"return","position":1,"x":530,"y":940,"wires":[]},{"id":"1ce07b54225e17dd","type":"reusable-out","z":"1ab76f62fe829c73","name":"return","position":1,"x":530,"y":1240,"wires":[]},{"id":"254d667c7cc38a4e","type":"reusable-in","z":"1ab76f62fe829c73","name":"load or create UserRegistry","info":"describe your reusable flow here","scope":"global","x":140,"y":940,"wires":[["4f110197c9f2a334","2d98c64a67e881e7"]]},{"id":"e50f6e5eb55f7d87","type":"reusable-in","z":"1ab76f62fe829c73","name":"write UserRegistry","info":"describe your reusable flow here","scope":"global","x":110,"y":1120,"wires":[["687ebf9f0226b7dc","848c6ddfb76643e4"]]},{"id":"4a4c700ae4ecd934","type":"reusable","z":"1ab76f62fe829c73","name":"","target":"load or create userregistry","outputs":1,"x":320,"y":880,"wires":[["3eb566d1907d9b3b"]]},{"id":"fe9d58b1f84fce63","type":"inject","z":"1ab76f62fe829c73","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":[["663a2575d83ff39f"]]},{"id":"80fab4a14a0c61a7","type":"comment","z":"1ab76f62fe829c73","name":"for testing purposes","info":"","x":110,"y":1420,"wires":[]},{"id":"663a2575d83ff39f","type":"function","z":"1ab76f62fe829c73","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":[["62b2658722dbd9e7"]]},{"id":"62b2658722dbd9e7","type":"debug","z":"1ab76f62fe829c73","name":"show","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":510,"y":1480,"wires":[]}]