Using NGINX for authentication and authorisation to Node-RED

And now we will take the plunge into something much more interesting. Using "sub-request" authorisation. This required the auth_basic NGINX module, that is standard in most builds.

The way this works is that every web request to /authrequest/* is intercepted and sent first to /auth. The process on the /auth path returns status code 200 if the user is authorised and 401 if they are not. When NGINX gets a 401 it redirects the client to a logon page with a parameter telling that page where to return to once the logon is successfully completed.

This snippet assumes that you have configured your server and TLS. It also asumes (for simplicity) that you are proxying the whole web server to Node-RED. We will just protect everything under a single URL path /authrequest/, you will need to set up at least a default web page at that path.

red.conf

# ---------------------------------------------------
# NGINX secure config for Node-RED
#
# NOTE: The actual proxy_pass URL's may be different
#   for you depending on how you have configured 
#   Node-RED.
#
# For this example, settings.js:
#   requireHttps:  true,     // Not really needed when NR is on same server as NGINX
#   httpAdminRoot: '/red',
#   httpNodeRoot:  '',       // Use this if you want ALL NR routes proxied under something other than /
# ---------------------------------------------------

# If the user is not logged in, redirect them to an appropriate login URL
# Pass the originating request location so that a successful login can be redirected back to the app
location @logon {
  # Vars created with auth_request_set in /auth are available here

  # Here we still use a proxied route
  # We can't pass custom headers because this has to be a client redirect
  return 302 /logon?url=$scheme://$host$request_uri;
}

# Deal with all other Node-RED user endpoints - e.g. uibuilder
# Takes over the whole root url which means you can't use it for anything else
# Better to set httpNodeRoot to something (e.g. 'nr') and then just proxy that (e.g. '/nr/')
location / {
  # A full set of headers have to be redone for every context that defines another header
  include /etc/nginx/conf.d/includes/common_security_headers.conf;

  # Reverse Proxy
  proxy_pass https://localhost:1880/; # <== CHANGE TO MATCH Node-RED's base URL

  # Common headers MUST be re-included whenever setting another header in a route
  include /etc/nginx/conf.d/includes/common_proxy_headers.conf;
  # Add a header that helps identify what location we went through, remove for production
  add_header X-JK-Proxy "ROOT";
  
  # Sub-request authentication using Node-RED http-in/-out only
  # NOTE that the /auth and @logon locations could be uibuilder instance API's or uibuilder middleware API's or http-in/-out API's
  location /authrequest/ {
    # See https://developer.okta.com/blog/2018/08/28/nginx-auth-request
    #     https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-subrequest-authentication/
    
    satisfy all; # Only actually needed if also limiting by IP address, etc

    # This will be called for every request to this route - if it returns 200, NGINX will run the proxy, else the error.
    auth_request /auth;

    # Setting auth requests here doesn't appear to work though they are in the docs
    #auth_request_set $auth_status $upstream_status;

    # Adding headers of any kind doesn't appear to work here! Set in /auth

    proxy_pass https://localhost:1880/authrequest/;

    # If the /auth route returns a 401, the client is redirected to a logon page
    error_page 401 = @logon;
  }
  # Proxy the /auth request to node-red - make sure it returns either 401 or 200 status code
  location /auth {
    internal; # Prevent external access to this route

    proxy_pass_request_body off;
    proxy_set_header Content-Length "";

    # You might not need/want all of these
    include /etc/nginx/conf.d/includes/common_proxy_headers.conf;

    proxy_set_header Authorization $http_authorization;
    proxy_pass_header Authorization;

    proxy_set_header X-JK-Proxy  "Auth";
    proxy_set_header X-JK-Username $cookie_username;  # Returns the logged in username

    # If you set auth req vars here, they are available to the @logon call
    # auth_request_set $auth_resp_jwt $upstream_http_x_vouch_jwt;
    # auth_request_set $auth_resp_err $upstream_http_x_vouch_err;
    # auth_request_set $auth_resp_failcount $upstream_http_x_vouch_failcount;

    proxy_pass https://localhost:1880/auth;
  }
 
}

And here is a partial solution to doing the /auth and /logon routes in Node-RED with no additional contrib nodes required. Obviously, this is a VERY simplistic approach that is nowhere near secure but it should hopefully give you sufficient knowledge.

[{"id":"05ee944a2d0ee1a3","type":"debug","z":"ff1a7711.244f48","name":"debug 2","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1275,"y":2140,"wires":[],"l":false},{"id":"0e8563a7ccc91d37","type":"http response","z":"ff1a7711.244f48","name":"","statusCode":"","headers":{},"x":1275,"y":2180,"wires":[],"l":false},{"id":"b96a461f0c9eb657","type":"http in","z":"ff1a7711.244f48","name":"","url":"/auth","method":"get","upload":false,"swaggerDoc":"","x":800,"y":2140,"wires":[["747bd227a1b6d992"]]},{"id":"40a4805036a294a1","type":"http in","z":"ff1a7711.244f48","name":"","url":"/logon","method":"post","upload":false,"swaggerDoc":"","x":810,"y":2280,"wires":[["ac2a2a6033d00091"]]},{"id":"ccdbd6e0a1f7bb6e","type":"debug","z":"ff1a7711.244f48","name":"debug 3","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1275,"y":2240,"wires":[],"l":false},{"id":"bb22f8d9b0d14c56","type":"http response","z":"ff1a7711.244f48","name":"","statusCode":"","headers":{},"x":1275,"y":2280,"wires":[],"l":false},{"id":"c3eacc1c01c74c51","type":"http in","z":"ff1a7711.244f48","name":"","url":"/logon","method":"get","upload":false,"swaggerDoc":"","x":800,"y":2240,"wires":[["ac2a2a6033d00091"]]},{"id":"52b06581d22a473a","type":"template","z":"ff1a7711.244f48","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<style>\n  @import url(https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300,700|Open+Sans:400,300,600);\n\n*{box-sizing:border-box;}\n\nbody {\n  font-family: 'open sans', helvetica, arial, sans;\nbackground:url(http://farm8.staticflickr.com/7064/6858179818_5d652f531c_h.jpg) no-repeat center center fixed; \n  -webkit-background-size: cover;\n  -moz-background-size: cover;\n  -o-background-size: cover;\n  background-size: cover;\n}\n\n@grey:#2a2a2a;\n@blue:#1fb5bf;\n.log-form {\n  width: 40%;\n  min-width: 320px;\n  max-width: 475px;\n  background: #fff;\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  -webkit-transform: translate(-50%,-50%);\n-moz-transform: translate(-50%,-50%);\n-o-transform: translate(-50%,-50%);\n-ms-transform: translate(-50%,-50%);\ntransform: translate(-50%,-50%);\n  \n  box-shadow: 0px 2px 5px rgba(0,0,0,.25);\n  \n  @media(max-width: 40em){\n    width: 95%;\n    position: relative;\n    margin: 2.5% auto 0 auto;\n    left: 0%;\n  -webkit-transform: translate(0%,0%);\n-moz-transform: translate(0%,0%);\n-o-transform: translate(0%,0%);\n-ms-transform: translate(0%,0%);\ntransform: translate(0%,0%);\n  }\n  \n  form {\n    display: block;\n    width: 100%;\n    padding: 2em;\n  }\n  \n  h2 {\n    width: 100%;\n    color: lighten(@grey, 20%);\n    font-family: 'open sans condensed';\n    font-size: 1.35em;\n    display: block;\n    background:@grey;\n    width: 100%;\n    text-transform: uppercase;\n    padding: .75em 1em .75em 1.5em;\n    box-shadow:inset 0px 1px 1px fadeout(white, 95%);\n    border: 1px solid darken(@grey, 5%);\n    //text-shadow: 0px 1px 1px darken(@grey, 5%);\n    margin: 0;\n    font-weight: 200;\n  }\n  \n  input {\n    display: block;\n    margin: auto auto;\n    width: 100%;\n    margin-bottom: 2em;\n    padding: .5em 0;\n    border: none;\n    border-bottom: 1px solid #eaeaea;\n    padding-bottom: 1.25em;\n    color: #757575;\n    &:focus {\n      outline: none;\n    }\n  }\n  \n  .btn {\n    display: inline-block;\n    background: @blue;\n    border: 1px solid darken(@blue, 5%);\n    padding: .5em 2em;\n    color: white;\n    margin-right: .5em;\n    box-shadow: inset 0px 1px 0px fadeout(white, 80%); \n    &:hover {\n      background: lighten(@blue, 5%);\n    }\n    &:active {\n      background: @blue; \n      box-shadow: inset 0px 1px 1px fadeout(black, 90%); \n    }\n    &:focus {\n      outline: none;\n    }\n  }\n  \n  .forgot {\n    color: lighten(@blue, 10%);\n    line-height: .5em;\n    position: relative;\n    top: 2.5em;\n    text-decoration: none;\n    font-size: .75em;\n    margin:0;\n    padding: 0;\n    float: right;\n    \n    &:hover {\n      color:darken(@blue, 5%);\n    }\n    &:active{ \n    }\n  }\n  \n}\n\n</style>\n\n<div class=\"log-form\">\n  <h2>Login to your account</h2>\n  <form method=\"post\" id=\"logon\">\n    <input name=\"username\" type=\"text\" title=\"username\" placeholder=\"username\" />\n    <input name=\"password\" type=\"password\" title=\"username\" placeholder=\"password\" />\n    <button type=\"submit\" class=\"btn\">Login</button>\n    <a class=\"forgot\" href=\"#\">Forgot Username?</a>\n  </form>\n  {{#parms}}\n  <div>\n    Origin URL: {{url}}\n  </div>\n  {{/parms}}\n</div><!--end log form -->","output":"str","x":1105,"y":2240,"wires":[["2630323d557163c8"]],"l":false},{"id":"c7e647264a18d001","type":"function","z":"ff1a7711.244f48","name":"function 2","func":"// Authorisation process for an NGINX proxied route with sub-query authorisation\n// Try to keep this tight and fast since it will be called EVERY TIME a request\n// is made to your protected route.\n\nmsg.payload = 'auth' // not really needed\nmsg.reqHeaders = msg.req.headers // for debugging only\n\n// Just in case we want to set response headers.\n// In NGINX config, response headers are available as $sent_http_<varname>\n//   varnames with `-` in them are consumed as `_` in the config file.\n//   e.g. a header named `X-JK-Somthing` here would be `sent_http_x_jk_something` in the NGINX config\nmsg.headers = {}\n\n// Return HTTP status of 401 (Not authorised) by default\nmsg.statusCode = 401\n\n// Should not be doing auth if not using TLS (HTTPS)\nif ( msg.req.client.encrypted !== true ) {\n    node.error('AUTH: Do not try to authenticate without using HTTPS! Login data is now compromised, change passwords.')\n    return msg\n}\n\n// Cookie session data should be ENCRYPTED!\n// It should also be more dynamic and tied to a specific user (see smg.req.cookies.username)\nif ( msg.req.cookies.sessionid === '1237') {\n    // Return 200 if authorised\n    msg.statusCode = 200\n}\n\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":995,"y":2140,"wires":[["efd781b9fbd3fa0a"]],"l":false},{"id":"2388fa72ce80a19f","type":"function","z":"ff1a7711.244f48","name":"function 3","func":"const cookieTimeout = 60000 // 60000=1min, 3600000=1hr, 900000=15min\n\n// msg.parms = msg.req.query\n// msg.reqHeaders = msg.req.headers\n\nmsg.statusCode = 200\nmsg.headers = {}\n\nif (msg.req.body) {\n    const form = msg.req.body\n    // Obviously do something more sensible here!\n    if (form.username !== '' && form.password !== '') {\n        msg.payload = 'auth'\n        msg.statusCode = 301\n        msg.headers = {\n            'Location': msg.req.query.url,\n        }\n        // WARNING: these need to be encrypted! This is only for testing\n        //   Also need to be locked to a path and to https only\n        msg.cookies = {\n            sessionid: {\n                // This should be a random number or UUID stored\n                // against the username\n                value: '1237',\n                maxAge: cookieTimeout,\n            },\n            username: {\n                value: form.username,\n                maxAge: cookieTimeout,\n            },\n        }\n    }\n}\n\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":995,"y":2240,"wires":[["52b06581d22a473a"]],"l":false},{"id":"747bd227a1b6d992","type":"junction","z":"ff1a7711.244f48","x":940,"y":2140,"wires":[["c7e647264a18d001"]]},{"id":"ac2a2a6033d00091","type":"junction","z":"ff1a7711.244f48","x":940,"y":2240,"wires":[["2388fa72ce80a19f"]]},{"id":"2630323d557163c8","type":"junction","z":"ff1a7711.244f48","x":1180,"y":2240,"wires":[["ccdbd6e0a1f7bb6e","bb22f8d9b0d14c56"]]},{"id":"efd781b9fbd3fa0a","type":"junction","z":"ff1a7711.244f48","x":1180,"y":2140,"wires":[["05ee944a2d0ee1a3","0e8563a7ccc91d37"]]}]

You will want a logout route too and maybe one for password hints or resets and possibly one for self sign-up.

You will also want to protect the sessionid by encrypting it and will need to create something to record unique, random session id's for any logged in user (could be as simple as a flow variable).


Of course, if you are using uibuilder, you could move the /logon page to a uibuilder page such as /authrequest/logon/index.html and then use uibuilder to manage the logon processing - noting that you could use websocket comms to exchange the logon details rather than the user having a reloaded page on logon as has to happen when using http-in/-out. That lets you have a much richer and nicer user interaction. For the /auth route, you could use a uibuilder instance API.

If you wanted a single logon and authorisation process, you could use a logon page in the common folder along with a uibuilder middleware function.

9 Likes