Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Nginx authentication preflight request with Node.js backend in the Hetzner Cloud

profile picture
Author
Barnabas Bucsy
Published
2021-11-19
Time to read
16 minutes reading time

About the author- code monk(ey)

Introduction

This tutorial will walk you through the steps to securely authenticate users on an Nginx webserver with Node.js backend authentication API in the Hetzner Cloud infrastructure.

NOTE: All below commands assume you have root privileges, so you will have to either run the commands with the sudo prefix, or act in the name of root user with the command $ su root -.

Prerequisites

  • Hetzner account with access to Cloud and DNS Console
  • Secure Fedora cloud instance
  • Registered domain name with set up zone in DNS Console
  • Nginx webserver set up with wildcard subdomain SSL certificate
  • Basic understanding of Node.js backend services

NOTE: This tutorial builds on the setup we created in Setting Up a Secure Fedora Webserver in the Hetzner Cloud (or similar).

Step 1 - Nginx Configuration Refactor

Since we plan on serving multiple server blocks, to make configurations easier to maintain we will create some feature specific configuration files under /etc/nginx/shared.d, and site specific configurations under /etc/nginx/site.d/<your-project>.

$ mkdir -p /etc/nginx/shared.d/server
$ mkdir -p /etc/nginx/shared.d/location
$ mkdir -p /etc/nginx/site.d/<your-project>/server
$ mkdir -p /etc/nginx/site.d/<your-project>/location

Step 1.1 - Common HTTP Configuration

Create /etc/nginx/shared.d/server/http.conf with the following contents:

# Common HTTP directives
# Scope: server
# - redirect to HTTPS

listen 80;
return 301 https://$host$request_uri;

NOTE: Although in the previous tutorial it was optional, here we disallow HTTP communication by permanently redirecting all requests to HTTPS.

Step 1.2 - Common HTTPS Configuration

Create /etc/nginx/shared.d/server/https.conf with the following contents:

# Common HTTPS directives
# Scope: server
# - SSL
# - HTTP2
# - error handling

listen 443 ssl http2;
keepalive_timeout 70;
index index.html;
try_files $uri $uri/ =404;

ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_protocols TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS !RC4";

error_page 404 @error404;
error_page 500 502 503 504 @error50x;

Here we collect all server-wide HTTPS settings (that we will extract from our previous setup), including our shared pre-computed DH params._

NOTE: In the previous tutorial we already set up SSL certificate (and -renewal) for a specific wildcard domain, see the link for details.

Step 1.3 - Common Authentication Configuration

Create /etc/nginx/shared.d/server/auth.conf with the following contents:

# Common authentication directives
# Scope: server
# Note: $custom_validator_proxy variable must be set in site specific config
# - authentication proxy
# - authentication header forwarding
# - error handling

auth_request /validate-token;

auth_request_set $custom_header_user $upstream_http_x_custom_user;
auth_request_set $custom_header_role $upstream_http_x_custom_role;
add_header X-Custom-User $custom_header_user;
add_header X-Custom-Role $custom_header_role;

location = /validate-token {
    internal;
    proxy_pass $custom_validator_proxy;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";

    include /etc/nginx/shared.d/location/proxy.conf;
}

error_page 401 @error401;
error_page 403 @error403;

This file basically tells, that every request of the server block using it must be validated through the /validate-token internal location, which is a proxy to our yet-to-be-created authentication service (we will set the exact location in site specific configuration). It also decorates successful responses with two additional headers coming from the authentication service's response: X-Custom-User and X-Custom-Role.

Step 1.4 - Common CORS Configuration

Create /etc/nginx/shared.d/location/cors.conf with the following contents:

# Common CORS directives
# Scope: location
# Note: $custom_cors variable must be set in site specific config
# - CORS

if ($request_method = "OPTIONS") {
    set $custom_cors "${custom_cors}-options";
}

if ($custom_cors = "trusted-options") {
    add_header Access-Control-Allow-Origin $http_origin;
    add_header Access-Control-Allow-Credentials true;
    add_header Access-Control-Max-Age 1728000;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS, DELETE, PUT";
    add_header Access-Control-Allow-Headers "User-Agent, Keep-Alive, Content-Type";
    return 204;
}

if ($custom_cors = "trusted") {
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Credentials true always;
}

Here we enable Cross Origin Resource Sharing (CORS) based on origin of the request (we will verify the request's origin in site specific configuration).

Step 1.5 - Common Proxy Configuration

Create /etc/nginx/shared.d/location/proxy.conf with the following contents:

# Common proxy directives
# Scope: location
# - headers

proxy_set_header Host $http_host:$server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Step 1.6 - Site Specific HTTPS Configuration

Create /etc/nginx/site.d/<your-project>/server/https.conf with the following contents:

# Site specific server configuration
# Scope: server
# - SSL certificate
# - CORS
# - error page redirects

ssl_certificate /etc/letsencrypt/live/<your-domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<your-domain>/privkey.pem;

location / {
    include /etc/nginx/site.d/<your-project>/location/cors.conf;
}

location @error404 {
    return 303 https://<your-domain>/not-found.html?url=$scheme://$http_host$request_uri;
}

location @error50x {
    return 303 https://<your-domain>/error.html?status=50x&url=$scheme://$http_host$request_uri;
}

Here we introduce error.html and not-found.html, you should create them with any basic content you like for testing purposes, but note that we do not address client authentication errors (401 & 403) yet - that we will do in the next step.

NOTE: If you followed the previous tutorial, error page handling here changed since, so if you created 40x.html and 50x.html in /var/www/<your-domain>/html, simply rename them to not-found.html and error.html - later on you might want to use the search parameter too that lead the visitors here.

Step 1.7 - Site Specific Authentication Configuration

Create /etc/nginx/site.d/<your-project>/server/auth.conf with the following contents:

# Site specific authentication configuration
# Scope: server
# - validator proxy
# - authentication exceptions

set $custom_validator_proxy "http://localhost:<api-port>/api/v1/validate";

location = / {
    auth_request off;
}

location = /index.html {
    auth_request off;
}

location = /favicon.ico {
    auth_request off;
}

location @error401 {
    return 303 https://private.<your-domain>/?status=401&url=$scheme://$http_host$request_uri;
}

location @error403 {
    return 303 https://private.<your-domain>/?status=403&url=$scheme://$http_host$request_uri;
}

The extra @error401 and @error403 locations redirect credential errors to our private.<your-domain> server block's root to later enforce login.

Step 1.8 - Site Specific CORS Configuration

Create /etc/nginx/site.d/<your-project>/location/cors.conf with the following contents:

# Site Specific CORS directives
# Scope: location
# - CORS

if ($http_origin ~ "^https://(.*\.)?<your-escaped-domain>(:[0-9]+)?$") {
    set $custom_cors "trusted";
}

include /etc/nginx/shared.d/location/cors.conf;

We will use this Regular Expression in our locations, where <your-escaped-domain> should be your RegExp escaped domain (eg. example.com -> example\.com)!

Let's break down the Regular Expression ^https://(.*\.)?<your-escaped-domain>(:[0-9]+)?$:

  • The origin starts (^) with the literal https://.
  • It can be followed by any character sequence following a dot optionally ((.*\.)? representing subdomains - note this is greedy by design).
  • <your-escaped-domain> mentioned above ensures that we operate only under your domain.
  • (:[0-9]+)? optionally allows calling ports on your domain.
  • $ ensures that the string ends, so nothing else is allowed.

Step 1.9 - Refactor existing site settings

If you followed the previous tutorial, your site's configuration should be located at /etc/nginx/sites-available/<your-domain>.conf. We will delete all lines present in our common HTTP or HTTPS configs, and instead will include the common and site specific configuration files. After the modifications it should look like this:

server {
    include /etc/nginx/shared.d/server/http.conf;

    server_name <your-domain> www.<your-domain>;
}

server {
    include /etc/nginx/site.d/<your-project>/server/https.conf;
    include /etc/nginx/shared.d/server/https.conf;

    server_name <your-domain> www.<your-domain>;
    root /var/www/<your-domain>/html;
}

Step 1.10 - Apply changes

Test your configuration, and if everything is ok restart the Nginx service:

$ nginx -t
$ service nginx restart

Step 2 - Create Restricted Subdomain

Next we will create a private.<your-domain> subdomain, these contents will be password protected. We will make some exceptions, will allow error pages and favicon to be served, also the root index.html, which will serve as a login page, and our whole API (which currently only serves as a skeleton for understanding concepts).

Step 2.1 - Create Server Block

Create a configuration under /etc/nginx/sites-available/ called private.<your-domain>.conf with the contents:

server {
    include /etc/nginx/shared.d/server/http.conf;

    server_name private.<your-domain>;
}

server {
    include /etc/nginx/site.d/<your-project>/server/https.conf;
    include /etc/nginx/shared.d/server/https.conf;
    include /etc/nginx/site.d/<your-project>/server/auth.conf;
    include /etc/nginx/shared.d/server/auth.conf;

    server_name private.<your-domain>;
    root /var/www/private.<your-domain>/html;

    location /api/ {
        include /etc/nginx/site.d/<your-project>/location/cors.conf;
        include /etc/nginx/shared.d/location/proxy.conf;

        auth_request off;
        proxy_pass http://localhost:<api-port>/api/;
    }
}

This file makes everything protected under the private.<your-domain> server block, but later we explicitly expose our favicon, the root (where we plan to have a login page), and our whole API. Also, we set up redirect rules to the main site when hitting server errors, or not finding content, but redirect credential errors to the private server block's root to enforce login.

Step 2.2 - Add Content

Create the directory for your contents, and allow SELinux to serve static content from it:

$ mkdir -p /var/www/private.<your-domain>/html
$ semanage fcontext -a -t httpd_sys_content_t "/var/www/private.<your-domain>/html(/.*)?"
$ restorecon -Rv /var/www/private.<your-domain>/html

Create an index.html inside it with the contents:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no, minimal-ui"
    />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta http-equiv="Content-Script-Type" content="text/javascript" />
    <meta http-equiv="Content-Style-Type" content="text/css" />
    <title>{{your-domain}} - Home</title>
    <meta name="author" content="{{author}}" />
  </head>
  <body>
    <section>
      <pre>{{your-domain}} - Login</pre>
      <form id="loginForm">
        <pre>Username: <input type="text" name="login" id="user" /></pre>
        <pre>Password: <input type="password" name="pass" id="pass" /></pre>
      </form>
      <button id="loginButton">Login</button>
      <button id="logoutButton">Logout</button>
      <pre id="loginStatus"></pre>
    </section>
  </body>
  <script>
    const form = document.getElementById("loginForm");
    const loginButton = document.getElementById("loginButton");
    const logoutButton = document.getElementById("logoutButton");
    const status = document.getElementById("loginStatus");
    const enableUi = (statusText = "") => {
      loginButton.disabled = false;
      logoutButton.disabled = false;
      status.innerText = statusText;
    };
    const disableUi = () => {
      loginButton.disabled = true;
      logoutButton.disabled = true;
      status.innerText = "Working...";
      const ret = { user: form.user.value, pass: form.pass.value };
      form.pass.value = "";
      return ret;
    };
    loginButton.addEventListener("click", async () => {
      const resp = await fetch("https://private.{{your-domain}}/api/v1/login", {
        method: "POST",
        credentials: "include",
        redirect: "error",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(disableUi())
      }).catch((err) => enableUi("Login failed.\n" + err));
      if (resp?.ok) {
        redirectUrl = new URLSearchParams(window.location.search).get("url");
        if (redirectUrl) {
          document.location = redirectUrl;
        } else {
          enableUi("Logged in.");
        }
      } else {
        enableUi("Login failed.");
      }
    });
    logoutButton.addEventListener("click", async () => {
      disableUi();
      const resp = await fetch("https://private.{{your-domain}}/api/v1/logout", {
        credentials: "include",
        redirect: "error"
      }).catch((err) => enableUi("Logout failed.\n" + err));
      if (resp?.ok) {
        enableUi("Logged out.");
      } else {
        enableUi("Logout failed.");
      }
    });
  </script>
</html>

NOTE: The placeholders to replace in this file are {{your-domain}} and {{author}}.

This is a really basic page, the file has two sections:

  • The <body> part creates a basic login form with input fields, buttons and status text.
  • The <script> part handles the login logic and communication with our publicly exposed API.
    • We use the credentials: "include" option for setting the cookie from all subdomains (since fetch will only set it on the same origin by default).

NOTE: Since we know that only errors will get redirected, we use the redirect: "error" option when fetching the API response, not to confuse a successfully redirected error page as false positive.

Also create a profile.html, this file is just for verifying access to a restricted page, you can add any content, eg.:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no, minimal-ui"
    />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta http-equiv="Content-Script-Type" content="text/javascript" />
    <meta http-equiv="Content-Style-Type" content="text/css" />
    <title>{{your-domain}} - Profile</title>
    <meta name="author" content="{{author}}" />
  </head>
  <body>
    <section>
      <pre>{{your-domain}} - Profile</pre>
      <button id="whoButton">Who am I?</button>
      <pre id="whoStatus"></pre>
    </section>
  </body>
  <script>
    const whoButton = document.getElementById("whoButton");
    const status = document.getElementById("whoStatus");
    whoButton.addEventListener("click", async () => {
      const resp = await fetch("https://private.{{your-domain}}/api/v1/validate", {
        credentials: "include",
        redirect: "error" // NOTE: only error pages redirect to static handlers
      }).catch((err) => console.log("Validation fetch failed.\n" + err));
      if (resp?.ok) {
        const data = await resp.json();
        status.innerText = `User: ${data.user}\nRole: ${data.role}`;
      } else {
        status.innerText = "Validation failed.";
      }
    });
  </script>
</html>

This is a simple page listing the user's username and assigned roles. We also use the credentials: "include" option for setting the cookie from all subdomains.

NOTE: The placeholders to replace in this file are {{your-domain}} and {{author}} too.

Step 2.3 - Apply changes

To enable the subdomain create a symlink in your sites-enabled directory:

$ ln -s /etc/nginx/sites-available/private.<your-domain>.conf /etc/nginx/sites-enabled/private.<your-domain>.conf

Test your configuration, and if everything is ok restart the Nginx service:

$ nginx -t
$ service nginx restart

Step 2.4 - Add DNS Zone Entry

Now we need to create a new DNS entry in the <your-domain> zone in Hetzner DNS Console:

Just add a new A record called private with previously acquired server IP address as value. After the zone updated you should be able to access your (not-yet-functional) login form under https://private.<your-domain>/.

Step 3 - Create Authentication Service

By this far our skeleton setup for private.<your-domain> server block uses 3 internal API endpoints:

  • http://localhost:9999/api/v1/validate called from any protected content as Nginx authentication preflight request (through internal /validate-token proxy)
  • http://localhost:9999/api/v1/login called from our login page (through private.<your-domain>/api/ proxy)
  • http://localhost:9999/api/v1/logout called from our login page (through private.<your-domain>/api/ proxy)

Next we need to create the actual backend service, that can handle these requests. We will be doing it in Node.js with the help of the Fastify web framework.

Step 3.1 - Install Node.js

To determine what version is available on your OS, run:

$ yum list nodejs

Check the Long Term Support (LTS) version on the Node.js website, and if it is greater than what your system provides (eg. on downstream Fedora distributions like Rocky, or CentOS), install the NodeSource distributions:

NOTE: Always examine shell scripts before running them on your system.

$ curl -sL https://rpm.nodesource.com/setup_lts.x | bash -

Now you can install the LTS version with the command:

$ yum install -y nodejs

NOTE: If you plan on using modules with native addons, you may need to also install make and gcc-c++.

Step 3.2 - Create Simple Server

Initialize a Node.js module under /usr/local/lib/<your-project>-auth/

$ mkdir -p /usr/local/lib/<your-project>-auth/
$ cd /usr/local/lib/<your-project>-auth/
$ npm init

Answer the questions, or go with the default values, then extend the newly created package.json with these lines under the "main": "index.js", entry:

  "bin": "index.js",
  "type": "module",

Install our dependencies:

$ npm install --save fastify fastify-cookie

Now create an executable index.js:

$ touch index.js
$ chmod +x index.js

Add the following contents to this file:

#!/usr/bin/env node

// imports
import fastifyFactory from "fastify";
import fastifyCookiePlugin from "fastify-cookie";

// constants
const serverPid = process.pid;
const serverId = process.env.SERVER_ID || "<your-project>-auth";
const serverPort = +process.env.SERVER_PORT || 9999;
const serverDomain = process.env.SERVER_DOMAIN || "<your-domain>";
const serverCookie = process.env.COOKIE_NAME || "@<your-project>-token";
const serverCookieTtl = +process.env.COOKIE_TTL || 7200; // sec
const serverHeaderUser = process.env.HEADER_USER || "X-Custom-User";
const serverHeaderRole = process.env.HEADER_ROLE || "X-Custom-Role";
const defaultCookieOptions = {
    domain: serverDomain,
    path: "/"
};
const defaultSendValues = {
    pid: serverPid,
    id: serverId
};
const defaultSendSuccess = { status: "success" };
const defaultSendFailure = { status: "failure" };

// utils
const extend = (base, ...extraParams) => {
    if (extraParams) {
        return Object.assign({}, base, ...extraParams);
    }
    return base;
};
const getCookieOptions = (...extraParams) => extend(defaultCookieOptions, ...extraParams);
const getSendValues = (...extraParams) => extend(defaultSendValues, ...extraParams);

// authentication
const getUserData = (body) => {
    // TODO: login logic
    if (body?.user === "admin" && body.pass === "admin") {
        return {
          user: "admin",
          role: "admin",
          token: "super-secret"
        }
    }
    return null;
}
const validateCloudToken = (token) => {
    // TODO: validation logic
    if (token === "super-secret") {
        return {
          user: "admin",
          role: "admin"
        }
    }
    return null;
}

// server: init
const server = fastifyFactory({
    disableRequestLogging: true
});
await server.register(fastifyCookiePlugin);

// server: routing
server.get("/api/v1", async (request, response) => {
    response.send(getSendValues(defaultSendSuccess));
});
server.post("/api/v1/login", async (request, response) => {
    const userData = getUserData(request.body);
    if (userData) {
        response
            .header(serverHeaderUser, userData.user)
            .header(serverHeaderRole, userData.role)
            .setCookie(serverCookie, userData.token, getCookieOptions({ maxAge: serverCookieTtl }))
            .send(getSendValues(defaultSendSuccess, {
                user: userData.user,
                role: userData.role
            }));
        return;
    }
    response
        .status(401)
        .header(serverHeaderUser, "")
        .header(serverHeaderRole, "")
        .clearCookie(serverCookie, getCookieOptions())
        .send(getSendValues(defaultSendFailure));
});
server.get("/api/v1/logout", async (request, response) => {
    response
        .header(serverHeaderUser, "")
        .header(serverHeaderRole, "")
        .clearCookie(serverCookie, getCookieOptions())
        .send(getSendValues(defaultSendSuccess));
});
server.get("/api/v1/validate", async (request, response) => {
    const userData = validateCloudToken(request?.cookies[serverCookie]);
    if (userData) {
        response
            .header(serverHeaderUser, userData.user)
            .header(serverHeaderRole, userData.role)
            .send(getSendValues(defaultSendSuccess, {
                user: userData.user,
                role: userData.role
            }));
        return;
    }
    response
        .status(401)
        .header(serverHeaderUser, "")
        .header(serverHeaderRole, "")
        .clearCookie(serverCookie, getCookieOptions())
        .send(getSendValues(defaultSendFailure));
});

// server: start
await server.listen(serverPort, "0.0.0.0");
console.log(`${serverId} listening on 0.0.0.0:${serverPort}`);

This script sets a token in the cookies for the <your-domain> root path when logging in successfully, and validates this token when called from the preflight authentication request by Nginx.

Check if running the script works as expected (you can exit with Ctrl + C):

$ ./index.js

NOTE: Creating a real-life authentication service is out of the scope of this tutorial, feel free to start building on this example, or create your own implementation. You can also check out my falkor-auth-server GitHub project (evolved from this setup).

Step 3.3 - Create Systemd Service

We will make our authentication service start with the OS, for this we will create a Systemd service in /etc/systemd/system/<your-project>-auth.service with the contents:

[Unit]
Description=<your-project> nginx auth-proxy
After=network-online.target

[Service]
Type=simple
Restart=on-failure
RestartSec=5
User=nobody
ExecStart=/usr/local/lib/<your-project>-auth/index.js

[Install]
WantedBy=multi-user.target

Next we enable and start up our new service, and test if it is responding on the /api/v1 heartbeat endpoint:

$ systemctl daemon-reload
$ systemctl enable --now <your-project>-auth.service
$ curl localhost:<port>/api/v1
$ curl --header "Content-Type: application/json" --request POST --data '{"user":"admin","pass":"admin"}' http://localhost:<port>/api/v1/login

If everything went well, you'll receive friendly JSON success messages.

NOTE: We run our service in the name of the built-in user nobody reserved for vulnerable services, thus assuming we won't have any disc activity - which is true for the current snippet. You can also run $ useradd -r -N -s /sbin/nologin <your-user> to create a system user without a group and home directory that is not allowed to login, and use User=<your-user> in the above service. If you plan to create a more sophisticated version of the service, you might end up needing to read configurations, or write custom log files, in that case you can look up the useradd documentation.

Step 4 - Finish and Test the Setup

The only thing left is SELinux to allow Nginx to connect to the network:

$ setsebool httpd_can_network_connect -P true

After this step you can try out your basic login form on the https://private.<yourdomain> location, and try to navigate to your https://private.<yourdomain>/profile.html either logged in, in which case you'll see your content, or logged out, in which case you will be redirected to the root login form (that will send you back to the profile.html page upon successful login). You can also check the custom headers (X-Custom-User and X-Custom-Role) we implemented in your network debug panel.

Conclusion

Now you have a private.<your-domain> subdomain, where you can authenticate, and serve restricted static content on any subdomain of <your-domain>.

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get 20€ free credit!

Valid until: 31 December 2024 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more