In my previous post, I said I would build a simple app that shows you which service was selected. While working on it, I wanted to hide it behind a login screen. Rather than build an entire login process, I decided to leverage the options Traefik provided me and start with BasicAuth. In this example, whoami is substituting for the blue-green status app.

docker-compose.yml:

proxy:
  image: traefik:v2.4
  command: --api.insecure=true --providers.docker
  ports:
    - "80:80"
    - "8080:8080"
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock
  labels:
    - "traefik.http.middlewares.auth.basicauth.users=test:$$apr1$$H6uskkkW$$IgXLP6ewTrSuBkTrqE8wj/,test2:$$apr1$$d9hr9HBB$$4HxwgUir3HP4EsggP/QNo0"
    - "traefik.http.middlewares.auth.basicauth.realm=Frustrated.blog"

  whoami:
    image: traefik/whoami
    labels:
      - "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)"
      - "traefik.http.routers.whoami.middlewares=auth@docker"

Spin up the services: docker-compose up, and you can visit the Traefik dasbhboard and the protected service, whoami.docker.localhost. When you do, you’ll see something like this (thuse username and password are both test):

Chrome Sign in Dialog

Great start! But I wanted soemething less intrusive, and more flexable. A little further down in the docs, I found ForwardAuth.

I’ve been playing with Svlete, and Svelte Kit recently entered public beta, so I figured this would be a great time to build something!

Starting in the same directory as the docker-compose.yml file, I ran npm init svelte@next. I chose template, and then ran npm init.

Run npm run dev and visit the site, and you should see something like:

Welcome to SvelteKit

Note: If you are running in WSL, like me, you may need to run npm run dev -- --host.

Update docker-compose.yml and add the following service:

auth:
  image: node:14
  ports:
    - 3000:3000
    - 24678:24678
  command: npm run dev -- --host
  working_dir: /usr/src/app
  volumes:
    - ./:/usr/src/app
  labels:
    - "traefik.http.routers.auth.rule=Host(`auth.docker.localhost`)"

You can now visit the Svelte tmplate at auth.docker.localhost. Port 24678 allows live reload, so your changes are reflected instantly to the site!

According the the docs for ForwardAuth, the middleware does a get to our endpoint, and expects either a 2xx for success, or anything else for failure. Adding an endpoint to SvelteKit is easy. Just add a file, and implement a get method.

src/routes/auth.js:

export async function get() {
  return {
    status: 200,
    body: 'OK'
  };
}

Update docker-compose.yml, removing the labels from proxy, adding this label to auth:

- "traefik.http.middlewares.auth.forwardauth.address=http://auth:3000/auth"

Now, when you open whoami.docker.localhost, the page should show, and any logging you added to auth.js should show as well. (docker-compose logs -f auth)

If we make a small change, and return status: 401 instead, we will be denied access. Awesome!

Alright, to turn this into something useful, we need a couple things. We need a Login page, and a way to record the login status. SveletKit passes a context as part of the parameters object to the get method. The context is created inside a getContext method in the hooks file. We are going to use that method to look for a session id cookie, and retrieve or create the context. We will then use the companion handle method to write the cookie to the browser.

src/hooks.js:

import * as cookie from 'cookie';

const SESSION_COOKIE = 'sid';
const contexts = {};

export async function getContext({ headers }) {
  const cookies = cookie.parse(headers.cookie || '');

  let sid = cookies[SESSION_COOKIE];
  let context = contexts[sid];

  if (!context) {
    const sid = Date.now(); // Don't do this in a real site!!
    context = contexts[sid] = { sid };
  }

  return context;
}

export async function handle( { request: { context, ...request }, render }) {
  const { headers, ...response } = await render({ ...request, context });

  if (context.sid) {
    headers['set-cookie'] = [cookie.serialize(SESSION_COOKIE, context.sid, {
      httpOnly: true, domain: 'docker.localhost', path: '/'
    })];
  }

  return { ...response, headers };
}

Now, in a real site, we would need to worry about expiring the session, generating a more secure session id, checking ip addresses, etc, but this will do for now.

Ok, lets add a Login page. Here is one that I copied from CodeShack.

src/routes/login.svelte:

<style>
  .login-form {
    width: 300px;
    margin: 0 auto;
    font-family: Tahoma, Geneva, sans-serif;
  }
  .login-form h1 {
    text-align: center;
    color: #4d4d4d;
    font-size: 24px;
    padding: 20px 0 20px 0;
  }
  .login-form input[type="password"],
  .login-form input[type="text"] {
    width: 100%;
    padding: 15px;
    border: 1px solid #dddddd;
    margin-bottom: 15px;
    box-sizing:border-box;
  }
  .login-form input[type="submit"] {
    width: 100%;
    padding: 15px;
    background-color: #535b63;
    border: 0;
    box-sizing: border-box;
    cursor: pointer;
    font-weight: bold;
    color: #ffffff;
  }
</style>

<head>
  <meta charset="utf-8">
  <title>Login Form</title>
</head>

<div class="login-form">
  <h1>Login Form</h1>
  <form method="POST">
    <input type="text" name="username" placeholder="Username" required>
    <input type="password" name="password" placeholder="Password" required>
    <input type="submit">
  </form>
</div>

The form will POST to /login when you press enter, so:

src/routes/login.js:

export const post = async ({ body, context }) => {
  const username = body.get('username');
  const password = body.get('password');

  // High Security!!
  if (username === 'test' && password === 'test') {
    context.authenticated = true;

    return {
      status: 200,
      body: 'OK'
    };
  }

  return {
    status: 401,
    body: 'Invalid Username/Password'
  }
}

Upon successful login, we set the authenticated boolean in the context. Lets update the auth endpoint to check that. If it’s not set, redirect the user to the login page.

/src/routes/auth.js:

export async function get({ context }) {
  if (context.authenticated === true) {
    return {
      status: 200,
      body: 'OK'
    };
  }

  return {
    status: 302,
    headers: { location: 'http://auth.docker.localhost/login' },
    body: 'OK'
  };
}

Ok, open a browser window, and vist whoami.docker.localhost. You should get redirected to the login page. Enter the super secret username and password, and reload whoami.docker.localhost. This time it should work!

Pretty sweet, but we can make it just a little bit better! If we pass the original destination, either through the session, or as part of the URL, login can redirect the user back to the requested destination. This is passed to the auth endpoint as a set of headers. Here is the updated auth endpoint.

/src/routes/auth.js:

export async function get({ headers, context }) {
  const forwardedHost = headers['x-forwarded-host'];
  const forwardedUri = headers['x-forwarded-uri'];

  if (context.authenticated === true) {
    return {
      status: 200,
      body: 'OK'
    };
  }

  return {
    status: 302,
    headers: { location: `http://auth.docker.localhost/login?dest=${forwardedHost}${forwardedUri}` },
    body: 'OK'
  };
}

The login POST handler needs to be updated to get the destination, and redirect to it.

src/routes/login.js:

export const post = async ({ body, query, context }) => {
  const username = body.get('username');
  const password = body.get('password');

  const dest = query.get('dest');

  // High Security!!
  if (username === 'test' && password === 'test') {
    context.authenticated = true;

    return {
      status: 302,
      headers: {
        location: `http://${dest}`
      },
      body: 'OK'
    };
  }

  return {
    status: 401,
    body: 'Invalid Username/Password'
  }
}

Give it a try!

One final note, if you want to protect access to the Traefik Dashboard this way, you need to cover both the dashboard and api routes. Here are the labels you need:

labels:
  - "traefik.http.routers.dashboard.rule=Host(`traefik.docker.localhost`) && PathPrefix(`/`)"
  - "traefik.http.routers.dashboard.middlewares=auth@docker"
  - "traefik.http.routers.dashboard.service=dashboard@internal"
  - "traefik.http.routers.api.rule=Host(`traefik.docker.localhost`) && PathPrefix(`/api`)"
  - "traefik.http.routers.api.middlewares=auth@docker"
  - "traefik.http.routers.api.service=api@internal"

The final version of the code used here can be found at github: https://github.com/abendigo/forwardAuth