Luke Evers

SSH As Authentication For A Web Application

Posted on 1 May 2016

Back in December of 2015, I dabbled with the idea of using SSH as the primary source of authentication for web applications. I’ve been very busy at work, and I haven’t spent any time blogging or testing this idea out until a few weeks ago. The repository I created back then doesn’t look like it’s currently working, and locally I have a lot of changes that are uncommitted that will most likely stay uncommitted forever.

I didn’t have any web applications that currently have this integrated, so I’ve implemented it alongside my blog and embedded it in this blog post. I’m using websockets to communicate with the server, but you could easily use a RESTful API or whatever you prefer for your communications.

I’m using golang.org/x/crypto/ssh to handle the SSH requests, which accepts the following key algorithms:

const (
    KeyAlgoRSA      = "ssh-rsa"
    KeyAlgoDSA      = "ssh-dss"
    KeyAlgoECDSA256 = "ecdsa-sha2-nistp256"
    KeyAlgoECDSA384 = "ecdsa-sha2-nistp384"
    KeyAlgoECDSA521 = "ecdsa-sha2-nistp521"
)

Please enter your public key below and hit submit:

And then enter this in your terminal:

ssh  -p 5000 -l 

I’m using the -l flag in the SSH command along with the random id generated for the session of the user on my blog because I don’t have users. If I was writing an application with users, I would want my users to be able to login with their username instead of some random string of characters. It would look more like this:

ssh  -p 5000 -l lukevers

Tutorial

If you just wanted to play with this, feel free to stop reading.

You’re probably aware of this by now, but I haven’t came out and said it. This is all written in Go. I have some other posts that are also about Go if you want to check them out.

There are a few parts to this tutorial. The first part is the SSH server, and the second part is integrating it with a web application.

SSH Server

The bulk of this entire tutorial is the SSH server. As a preface, I recommend using some sort of environment variables instead of hardcoding strings, but for ease of the tutorial, I’m going to be hardcoding strings as parameters for functions.

Before we start writing any code, we need to generate a SSH key to use with the server. This should be fairly standard for you, and if it’s not, GitHub has a nice guide.

ssh-keygen -t rsa -b 4096

When it asks you where you want to save the public/private key pair, choose a non-public location, but be careful not to overwrite your existing keys (I’m sure someone has done that while generating a second key). For the article’s sake, let’s say we’re keeping our keys here:

/var/www/app/.ssh/id_rsa
/var/www/app/.ssh/id_rsa.pub

Now that we have keys, we can start working on the server.

package main

import (
    "golang.org/x/crypto/ssh"
)

var config *ssh.ServerConfig

func main() {
    config = &ssh.ServerConfig{}
}

As you can see, this doesn’t do anything yet. Also, our ServerConfig is empty. Let’s change that. The first thing we’ll want to add to our ServerConfig is the ServerVersion. RFC 4253 Section 4.2 requires that the server version begins with SSH-2.0-, but after that anything is fair game. I like to reference kittens in things I write (if you haven’t already noticed).

config = &ssh.ServerConfig{
    ServerVersion: "SSH-2.0-BASKET-OF-KITTENS",
}

This is nice, but it still doesn’t get us anywhere. If you looked at the GoDocs for ServerConfig that I posted earlier, you’d notice that there are a few callback functions that handle authentication.

PasswordCallback
PublicKeyCallback
KeyboardInteractiveCallback
AuthLogCallback

We’re going to be using the PublicKeyCallback function. If you want to write any other type of SSH authentication, feel free to try the others out. We’ll come back to the body of the PublicKeyCallback later, but let’s set up what we have so far.

package main

import (
    "golang.org/x/crypto/ssh"
)

var config *ssh.ServerConfig

func main() {
    config = &ssh.ServerConfig{
        ServerVersion:     "SSH-2.0-BASKET-OF-KITTENS",
        PublicKeyCallback: publicKeyCallback,
    }
}

func publicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
    // TODO
    return nil, nil
}

If you run what we currently have, it’ll compile and run, but it will also stop immediately. Before we can start the server, we need to add the private key we generated to the ServerConfig.

We’ll need to import another package to read the key:

import "io/ioutil"

And then we can read the key. Remember, I’m hardcoding the path that we decided upon earlier, but you should be using some sort of environment variables or configuration files.

pbytes, err := ioutil.ReadFile("/var/www/app/.ssh/id_rsa")
if err != nil {
    // Handle the error
}

After we read the key in and handle the error (you can handle it however you generally handle errors), now we can parse the key and add it to the ServerConfig.

pkey, err := ssh.ParsePrivateKey(pbytes)
if err != nil {
    // Handle the error
}

// Add the private key to the ServerConfig
config.AddHostKey(pkey)

Now we can start listening for connections! We’ll need to import the net package.

import "net"

And then we can create a Listener to listen on the tcp host/port of your choice. I’ve chosen to listen on [::] on port 5000 to support both IPv4 and IPv6 connections.

listener, err := net.Listen("tcp", "[::]:5000")
if err != nil {
    // Handle the error
}

Another thing we’ll want to do is defer the closing of the listener until we’re completely done with it.

defer listener.Close()

Once we setup a connection, we can begin to accept requests! We’re also going to create a new function called handle to handle the incoming connections.

for {
    conn, err := listener.Accept()
    if err != nil {
        // Handle the error
    }

    go func(c net.Conn) {
        _, channels, requests, err := ssh.NewServerConn(c, config)
        if err != nil {
            // Handle the error
        }

        // Discard all requests
        go ssh.DiscardRequests(requests)

        // Handle the connections
        handle(channels)

        // Close the connection
        conn.Close()
    }(conn)
}

Now we want to setup our handle function. There’s a lot of things we could do here, but all we want to do is accept the connection, tell them that they’ve been authenticated, and then close the connection. If the user does not pass the authentication in our PublicKeyCallback (that we haven’t setup yet). The parameter channels will be nil if the user is not authorized, so the for loop will never run if the user is not authorized.

func handle(channels <-chan ssh.NewChannel) {
    for ch := range channels {
        // Reject all connections of a different type than "session"
        if ch.ChannelType() != "session" {
            ch.Reject(ssh.UnknownChannelType, "Unknown channel type")
            continue
        }

        // Accept the channel
        channel, _, err := ch.Accept()
        if err != nil {
            // Handle the error
        }

        // Let the user know that they've been authorized in the shell.
        channel.Write([]byte("Authorized! "))
        channel.Close()
    }
}

Since we haven’t added anything in our PublicKeyCallback function, and we’re currently returning nil, nil, any connection that comes through will receive the message Authorized! in their terminal.

But let’s come back to that later. In order to verify that the user is who they say they are from their terminal, we have to have some sort of infrastructure in place.

Setting Up A Web Server

If you’re reading this article, you really should already have a grasp on how to do this already. If you don’t, maybe this shouldn’t be the article you read to write your first web server.

First let’s re-arrange our file structure. Since both the SSH server and the web server create listeners that are blocking, we’re going to use a WaitGroup and run each function in goroutines.

package main

import (
    "sync"
)

var wg sync.WaitGroup

func main() {
    wg.Add(2)
    go startSshServer()
    go startWebServer()
    wg.Wait()
}

If you haven’t used a WaitGroup before, they’re very simple. The functions that a WaitGroup has are Add(int), Done(), and Wait(). Inside of both of our new functions startSshServer and startWebServer at the bottom of them we’ll add wg.Done() to signal that the goroutine for that specific function is done.

Yes, we could just do it this way too:

package main

func main() {
    go startSshServer()
    startWebServer()
}

But let’s utilize WaitGroups instead.

I like to organize files by their use when it comes to writing in Go. Here’s the layout of my new ssh.go file:

package main

import (
    // ...
)

var config *ssh.ServerConfig

func startSshServer() {
    // ...
    wg.Done()
}

func publicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
    // ...
}

func handle(channels <-chan ssh.NewChannel) {
    // ...
}

And now we’ll start to setup our http.go file.

package main

func startWebServer() {
    wg.Done()
}

There’s not much in there right now, so let’s add a few things into our startWebServer function. First we want to import the net/http package to handle all of the basic webserver needs that we have.

import "net/http"

Feel free to use a framework if you prefer that. Personally, I like Gin and Gorilla Mux. We’re only going to add two handlers because I don’t actually want to implement full-authentication with middleware right now. The first handler we’re going to add is for /ws to handle our websocket connection, and the second handler we’re going to add is / to just catch everything else. Ideally you should be doing some route matching and serve 404s for pages that don’t exist, but that’s for another blog post.

http.HandleFunc("/ws", handleWebsockets)
http.HandleFunc("/", handleRoot)

And we’ll create the bones of the two functions that we want to create:

func handleRoot(w http.ResponseWriter, r *http.Request) {
    // TODO
}

func handleWebsockets(w http.ResponseWriter, r *http.Request) {
    // TODO
}

Lastly in our startWebServer function we just want to add an HTTP listener. This, like everything else, is nicely built into the net/http package. Like in the SSH server, I’m going to listen on [::] but on port 5001. Most people (see: me) seem to bind their HTTP servers written in Go on localhost and reverse proxy it behind Nginx, Caddy, HAProxy or similar.

http.ListenAndServe("[::]:5001", nil)

So, this is great. If you compile and run your binary, it will run–but that’s about it. Now we’re getting to the exciting part! In our http.go file we’re going to create an HTML template file and include html/template to parse it.

import "html/template"

And in our handleRoot function we’re going to have it parse the template and execute it. I’m having a file called index.html live at the root of my directory, but ideally you’d have a directory filled with templates instead. Also, in production you shouldn’t parse the files on every single request–you should do it up front.

tmpl, err := template.ParseFiles("index.html")
if err != nil {
    // If there was a problem, stop
    return
}

// `w` is the http.ResponseWriter from the function parameter, `"index"` is the
// name of the template that we haven't defined yet, and `nil` is the data that
// we are passing to the template. As of right now we are passing no data.
tmpl.ExecuteTemplate(w, "index", nil)

And now we’ll create a basic HTML file with everything we need. We’re defining the template and calling it “index” which we reference in our handleRoot function.

{{ define "index" -}}
<!DOCTYPE html>
<html>
    <head>
        <title>SSH As Authentication For Web Applications</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <p>Please enter your public key below and hit submit:</p>
        <textarea id="key" maxlength="1000"></textarea>
        <input type="submit" onclick="submit();" value="Save Public Key">
        <p>And then enter this in your terminal:</p>
        <code>ssh <span id="ssh-host"></span> -p 5000 -l <span id="ssh-id">ID</span></code>

        <script>
            // #ssh-host TODO

            // Submit
            function submit() {
                // TODO
            }
        </script>
    </body>
</html>
{{- end }}

Nice! It doesn’t do anything yet, but we now have a really basic input with a submit button for our public key. Hitting the submit button doesn’t authenticate the user yet. Once the user submits their public key, they then have to type the code into their terminal which you gave them. As you can see right now, it’s displaying as ssh -p 5000 -l ID which isn’t going to help at all. You’re not sending the host for them to try to connect to, and you’re also not giving them a username–this is where the template data comes in.

Since we don’t have usernames for the users, we’re going to randomly generate ids for sessions.

To generate the random ids we’re going to need to import some packages:

import (
    "crypto/rand"
    "encoding/base64"
)

And then we’re going to use both in our handleRoot function.

func handleRoot(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("index.html")
    if err != nil {
        // If there was a problem, stop
        return
    }

    // Generate random id for users
    bytes := make([]byte, 32)
    _, err = rand.Read(bytes)
    if err != nil {
        return
    }

    tmpl.ExecuteTemplate(w, "index", struct {
        Id string
    }{
        Id: base64.URLEncoding.EncodeToString(bytes),
    })
}

Now that we’re generating a random id, we can change our template to use that id.

<code>ssh <span id="ssh-host"></span> -p 5000 -l <span id="ssh-id">{{ .Id }}</span></code>

Now the only part of that line that we need to fix is the #ssh-host part. Since I don’t know what the host is going to be that you’re using for this, let’s just do it with JavaScript. If you definitely know, just go ahead and hard code it.

// Set hostname
document.getElementById('ssh-host').innerText = window.location.hostname

Great! Now all that’s left are the websockets. Let’s setup the backend before doing the JavaScript. We’ll need to import one more package to handle the websocket connections.

import "github.com/gorilla/websocket"

We also want to define a websocket upgrader. The websocket upgrader “upgrades” the connection from a basic HTTP[S] connection to a websocket connection. Feel free to learn more about websockets if you need to know how they work and somehow don’t, even though you’re reading a blog post about using them.

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

Also, we want to define a map of websocket connections and keys so we can keep track of public keys and connections!

var (
    connections map[string]*websocket.Conn = make(map[string]*websocket.Conn)
    keys        map[string]string          = make(map[string]string)
)

Lastly, we want to define a struct for our Message type that we’ll be parsing JSON with.

type Message struct {
    Type    string `json:"type"`
    Id      string `json:"id"`
    Message string `json:"message"`
}

And now we can start handling the websocket connections in our handleWebsockets function.

func handleWebsockets(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }

    // Save the id to help with deleting the connection later
    var id string

    for {
        var message Message
        err := conn.ReadJSON(&message)
        if err != nil {
            // Only delete the connection and keys if we ever saved it with the id
            if id != "" {
                delete(connections, id)
                delete(keys, id)
            }
            break
        }

        // Save connection and id if they're not already set
        if id == "" {
            id = message.Id
            connections[id] = conn
        }

        switch message.Type {
        case "SAVE-KEY":
            keys[id] = message.Message
        }
    }
}

Now we get to the even more fun part! Making sure the public key matches the private key. Let’s go back to our publicKeyCallback function. We’ll have to first import a few more packages we need:

import (
    "encoding/base64"
    "strings"
)
func publicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
    // Make sure the user has an active connection
    if _, ok := connections[conn.User()]; !ok {
        return nil, errors.New("User does not exist")
    }

    // Now make sure the key exists
    k, ok := keys[conn.User()]
    if !ok {
        return nil, errors.New("Key does not exist for user")
    }

    // Split the key up into parts to make sure it's the correct format.
    parts := strings.Split(k, " ")
    if len(parts) < 2 {
        return nil, errors.New("Invalid public key format")
    }

    // Encode public key to base64 for parsing
    encoded, err := base64.StdEncoding.DecodeString(parts[1])
    if err != nil {
        return nil, errors.New("Could not decode key")
    }

    // Parse public key
    pk, err := ssh.ParsePublicKey([]byte(encoded))
    if err != nil {
        return nil, err
    }

    // Make sure the key types match
    if key.Type() != pk.Type() {
        return nil, errors.New("Key types do not match")
    }

    kbytes := key.Marshal()
    pbytes := pk.Marshal()

    // Make sure the key lengths match
    if len(kbytes) != len(pbytes) {
        return nil, errors.New("Keys do not match")
    }

    // Make sure every byte of the key matches up
    for i, b := range kbytes {
        if b != pbytes[i] {
            return nil, errors.New("Keys do not match")
        }
    }

    // If we got this far, no issues were found!
    connections[conn.User()].WriteJSON(&Message{
        Type:    "ALERT",
        Id:      conn.User(),
        Message: "Your request has been authorized",
    })

    // If this were a real application we'd want to actually do some authentication
    // like setting it in a session and everything else. I'll just put this here
    // for you to do yourself: TODO

    return nil, nil
}

And now back on the JavaScript side, we’re going to want to setup the websockets for both sending and receiving messages.

// Setup Websockets
var ws = new WebSocket('ws://' + window.location.hostname + ':5001/ws');
ws.onmessage = function(data) {
    data = JSON.parse(data.data);
    if (data.type === 'ALERT') {
        alert(data.message);
    }
}

// Submit
function submit() {
    var msg = document.getElementById('key').value;
    if (msg.length > 0) {
        ws.send(JSON.stringify({
            type: 'SAVE-KEY',
            id: document.getElementById('ssh-id').innerText,
            message: msg,
        }));
    } else {
        alert('Please enter a key first.');
    }
}

And now it all works! Obviously there are a lot of improvements that could go on here like using better validation both on the frontend and backend, and actual authentication with sessions. This is more of a POC that could be easily taken and twisted a bit into a functioning feature of a web application. It could even be used for not primary authentication, but for doing certain tasks, or adding it as a part of MFA.

You can view the entire code in a repository on GitHub that I created just for this here that contains all of the code that you see here in this article.

comments powered by Disqus