How to Build an Authentication Microservice in Golang from Scratch
Golang is one of the leading tools to build microservices. In a cloud-native application, a microservice is a loosely coupled application that provides important functionality. This article explores the steps you need to take to set up an authentication microservice for your application. Most of the functionality has been implemented from scratch to give you a better understanding of how Golang works.
There are many libraries — including jwt-go — available for the implementation of JSON Web Tokens (JWT). However, this guide explores implementation using your own logic to generate and validate web tokens. Additionally, the guide also covers how to handle user data, how to implement middleware the main server will use, and how to make handler functions. The guide also focuses on routing using the Gorilla MUX framework to ensure the application is accessible to the public using the REST framework.
Finally, the guide teaches you how to containerize the microservice using Docker. This will make sure you can deploy the microservice in a continuous integration/continuous deployment pipeline (CI/CD) to an orchestration service like Kubernetes.
At a high level, here are the parts of the project you’ll learn how to build by the end of this tutorial:
1. Handle User Data and Provide Query Functionality
2. Generate and Validate User Session Tokens
3. Authentication and Token Validation Middleware
4. Add MUX Routing Support
5. Containerize the Application With Docker
Handle User Data and Provide Query Functionality
To start designing the authentication microservice, you first need to think about what the user schema will look like and what schema functions should be exposed to other packages. You’ll want to make sure that unnecessary data isn’t exposed to other modules and that only the required data is available. So, you’ll need to define the private and public functions accordingly:
type user struct {
email string
username string
passwordhash string
fullname string
createDate string
role int
}
You’ll want to implement a multi-level authentication system, which is defined in the role field. If the role field is 0, the user is a standard user; if not, the user is an admin.
Based on the role, you can define the authentication levels and their priority. This idea can be extended further to develop more complex microservices. In addition to the role, a user needs to have a unique email and username, password hash, full name, and the date the account was created. The microservice expects the password to be already hashed from the client itself. So, the server will never know what the password of a user is. In case of a security breach, only the hashed values of the password will be revealed.
From the outset, users are stored in a list. However, this can be changed by attaching a database. To provide functionality for this data, you shouldn’t export the whole user list outside this package since you want to keep the module loosely coupled. Instead, you’ll use these methods: GetUserObject
, ValidatePasswordHash
, which is a method of struct that does a simple string comparison, and AddUserObject
, which verifies whether the username or email already exists in the database and, if not, adds the user.
func GetUserObject(email string) (user, bool) {
//needs to be replaces using Database
for _, user := range userList {
if user.email == email {
return user, true
}
}
return user{}, false
}
// checks if the password hash is valid
func (u *user) ValidatePasswordHash(pswdhash string) bool {
return u.passwordhash == pswdhash
}
// this simply adds the user to the list
func AddUserObject(email string, username string, passwordhash string, fullname string, role int) bool {
// declare the new user object
newUser := user{
email: email,
passwordhash: passwordhash,
username: username,
fullname: fullname,
role: role,
}
// check if a user already exists
for _, ele := range userList {
if ele.email == email || ele.username == username {
return false
}
}
userList = append(userList, newUser)
return true
}
Generate and Validate User Session Tokens
A web token is a dot-separated string consisting mainly of three parts: a header, a payload, and a signature. Here, the header and payload are simple base64 encoded strings that can be decoded easily. That being the case, it’s important to ensure you don’t write anything in them that might lead to the disclosure of sensitive information.
The signature is the main part used to validate the tokens. It’s encrypted using SHA256 or some other algorithm with a private key known only to the server.
The private key is the main breaking point of your authentication microservice. If this is compromised, anyone can replicate the token and manipulate the server. As such, you must pass it as an environment variable and ensure it’s handled with the utmost care. The key should be random and extremely complex. I have passed it as a string inside the code in this example, but we should pass it as an environment variable to a Docker container.
The server generates the web tokens and validates them. So, you should pass some parameters in the payload in the server, including an issuer (e.g., the domain name of your website), an audience (e.g., the client for whom the token is meant), and the expiration date. While the first two parameters can help you ensure the authentication is coming from a trusted source, the last parameter ensures tokens stay secure by regenerating them periodically.
To generate a token, use Base64
to encode the header and the payload, which in this case is of type map[string]string
. To generate the signature, you need to encrypt the header and payload together using a secret key. To validate the token, decode the header and the payload strings and then combine them to create a new signature. If this matches the signature present in the token, the token is valid. You can also set an expiration check here that indicates when the token will expire.
// Function for generating the tokens.
func GenerateToken(header string, payload map[string]string, secret string) (string, error) {
// create a new hash of type sha256. We pass the secret key to it
h := hmac.New(sha256.New, []byte(secret))
header64 := base64.StdEncoding.EncodeToString([]byte(header))
// We then Marshal the payload which is a map. This converts it to a string of JSON.
payloadstr, err := json.Marshal(payload)
if err != nil {
fmt.Println("Error generating Token")
return string(payloadstr), err
}
payload64 := base64.StdEncoding.EncodeToString(payloadstr)
// Now add the encoded string.
message := header64 + "." + payload64
// We have the unsigned message ready.
unsignedStr := header + string(payloadstr)
// We write this to the SHA256 to hash it.
h.Write([]byte(unsignedStr))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
//Finally we have the token
tokenStr := message + "." + signature
return tokenStr, nil
}
// This helps in validating the token
func ValidateToken(token string, secret string) (bool, error) {
// JWT has 3 parts separated by '.'
splitToken := strings.Split(token, ".")
// if length is not 3, we know that the token is corrupt
if len(splitToken) != 3 {
return false, nil
}
// decode the header and payload back to strings
header, err := base64.StdEncoding.DecodeString(splitToken[0])
if err != nil {
return false, err
}
payload, err := base64.StdEncoding.DecodeString(splitToken[1])
if err != nil {
return false, err
}
//again create the signature
unsignedStr := string(header) + string(payload)
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(unsignedStr))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
fmt.Println(signature)
// if both the signature don’t match, this means token is wrong
if signature != splitToken[2] {
return false, nil
}
// This means the token matches
return true, nil
}
Authentication and Token Validation Middleware
Let’s start with the simple SignUp
handler. A handler is a function that performs operations when a certain route is called by the router. If the router detects the request to a route and you’ve assigned a function to it, this handler — which takes a ResponseWriter as a parameter — is called.
The SignUp
handler takes the parameters out of a request and also checks if the request has all the necessary parameters. If even one of the parameters is missing, an error code will be written to the client. Next, it calls the AddUserObject
method from the data package and checks whether the user has been added. There’s only one case when a user won’t be added: when a duplicate email or username is provided.
// adds the user to the database of users
func SignupHandler(rw http.ResponseWriter, r *http.Request) {
// extra error handling should be done at server side to prevent malicious attacks
if _, ok := r.Header["Email"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Email Missing"))
return
}
if _, ok := r.Header["Username"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Username Missing"))
return
}
if _, ok := r.Header["Passwordhash"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Passwordhash Missing"))
return
}
if _, ok := r.Header["Fullname"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Fullname Missing"))
return
}
// validate and then add the user
check := data.AddUserObject(r.Header["Email"][0], r.Header["Username"][0], r.Header["Passwordhash"][0],
r.Header["Fullname"][0], 0)
// if false means username already exists
if !check {
rw.WriteHeader(http.StatusConflict)
rw.Write([]byte("Email or Username already exists"))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("User Created"))
}
Next, implement a SignIn
handler, which just needs two parameters: Email
and Passwordhash
. If the user exists, they will be validated using the ValidateHash
method of the user structure. After this, the token will be generated and sent back to the client, which will then send the token with every authentication request.
// we need this function to be private
func getSignedToken() (string, error) {
// we make a JWT Token here with signing method of ES256 and claims.
// claims are attributes.
// aud - audience
// iss - issuer
// exp - expiration of the Token
claimsMap := map[string]string{
"aud": "frontend.knowsearch.ml",
"iss": "knowsearch.ml",
"exp": fmt.Sprint(time.Now().Add(time.Minute * 1).Unix()),
}
// here we provide the shared secret. It should be very complex.
// Also, it should be passed as a System Environment variable
secret := "Secure_Random_String"
header := "HS256"
tokenString, err := jwt.GenerateToken(header, claimsMap, secret)
if err != nil {
return tokenString, err
}
return tokenString, nil
}
// searches the user in the database.
func validateUser(email string, passwordHash string) (bool, error) {
usr, exists := data.GetUserObject(email)
if !exists {
return false, errors.New("user does not exist")
}
passwordCheck := usr.ValidatePasswordHash(passwordHash)
if !passwordCheck {
return false, nil
}
return true, nil
}
func SigninHandler(rw http.ResponseWriter, r *http.Request) {
// validate the request first.
if _, ok := r.Header["Email"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Email Missing"))
return
}
if _, ok := r.Header["Passwordhash"]; !ok {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("Passwordhash Missing"))
return
}
// let’s see if the user exists
valid, err := validateUser(r.Header["Email"][0], r.Header["Passwordhash"][0])
if err != nil {
// this means either the user does not exist
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte("User Does not Exist"))
return
}
if !valid {
// this means the password is wrong
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte("Incorrect Password"))
return
}
tokenString, err := getSignedToken()
if err != nil {
fmt.Println(err)
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte("Internal Server Error"))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(tokenString))
}
Finally, you need to implement middleware, which returns a handler function. It’s called each time a request reaches a router. The main focus of authentication middleware is to ensure no request is fulfilled without validating the web token. If the token is invalid, the client will have to regenerate it, which protects against distributed denial of service (DDoS) attacks.
If the token is valid, send an “authorization confirmed” message with a 200 OK status code. If not, you’ll know that the incoming request is unauthorized and can send the response code accordingly.
// We want all our routes for REST to be authenticated. So, we validate the token
func tokenValidationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// check if token is present
if _, ok := r.Header["Token"]; !ok {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte("Token Missing"))
return
}
token := r.Header["Token"][0]
check, err := jwt.ValidateToken(token, "Secure_Random_String")
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
rw.Write([]byte("Token Validation Failed"))
return
}
if !check {
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte("Token Invalid"))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("Authorized Token"))
})
}
Add MUX Routing Support
At this point, you have your routing handlers and middleware ready. Now, you need to provide these to a router.
In this tutorial, Gorilla’s MUX framework is used for routing because it’s an easy and fast tool for this purpose (you can also use alternatives like gin). In this guide, the signup and signin field have been defined. This means they will be called by routes as SubRouter in the routing. /auth/signin also pass middlewares to different routes using and /auth/signup/. We can router.Use().
package main
import (
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/shadowshot-x/micro-product-go/authservice"
)
func main() {
mainRouter := mux.NewRouter()
authRouter := mainRouter.PathPrefix("/auth").Subrouter()
authRouter.HandleFunc("/signup", authservice.SignupHandler)
// The Signin will send the JWT back as we are making microservices.
// The JWT token will make sure that other services are protected.
// So, ultimately, we would need a middleware
authRouter.HandleFunc("/signin", authservice.SigninHandler)
// Add the middleware to different subrouter
// HTTP server
// Add time outs
server := &http.Server{
Addr: "127.0.0.1:9090",
Handler: mainRouter,
}
err := server.ListenAndServe()
if err != nil {
fmt.Println("Error Booting the Server")
}
}
Containerize the Application With Docker
Finally, it’s time to containerize the application you’ve been building using Docker.
Containers are easy to manage and can be deployed to highly scalable and available platforms like Kubernetes. This is an integral part of the continuous integration pipelines which often use hosted repositories like DockerHub to deploy the images of the application.
This is a very simple Dockerfile that’s also quite small because you’ve used very few external packages. Here, you can use an Alpine base image with Golang installed to be the base of the container — which you should be able to see as the current machine you’re working on. Now, it’s time to declare the working directory, which is inside the Alpine image. Moving forward, every operation will be in the server directory in the Alpine image. So, copy everything inside your current machine to the Alpine image.
Once that’s done, install the dependencies like gorilla/mux and then build the application’s binary, which will be run at the end of the Dockerfile. You’ll also need to expose a port, i.e., make the port available to be accessed from outside the container.
Now it’s time to run the created binary:
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine
WORKDIR /server
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY ./ ./
RUN go build -o /product-go-micro
EXPOSE 9090
CMD [ "/product-go-micro" ]
Build the image:
docker build . -t product-go-micro
Then run it:
docker run --network host -d <--your image id-->
Now, the server is up and running in a Docker container. In this guide, the network is set to be that of the host, which you can see in the Docker run. This means you can now reach the application using the localhost; check the application using the following curl command.
curl https://localhost:9090/auth/signin --header 'Email:[email protected]'
--header 'Passwordhash:hashedme1'
Continue Building
Hopefully this code-along provided you with some insights into how authentication with web tokens works. Building applications with microservices provides an understanding of how routing works and the power of REST APIs. To further integrate the CI/CD process, you can use other tools — like TravisCI and CircleCI.
All of the code found in this blog post can be found on GitHub, or you can download it directly here.
The content of this blog post is published under a CC BY-NC-SA 4.0 license.