DEV Community

loading...
Cover image for How to add authentication to  your Go Chat application (Part 4)

How to add authentication to your Go Chat application (Part 4)

jeroendk profile image Jeroen de Kok Originally published at whichdev.com on ・10 min read

In the last part of this tutorial, we will allow our users to use the chat application with a user account. To do this we will authenticate the users with the use of JWT (JSON web token). After authenticating, the user will use the chat as an authenticated user.

Posts overview

Preconditions

To follow along you should have completed part 1, part 2 and part3 or grab the source from here.

Step 1: Storing credentials

First, we will create some utility functions for encoding and comparing passwords. For encoding the password we will use Argon2, get the Go library with:

go get golang.org/x/crypto/argon2
Enter fullscreen mode Exit fullscreen mode

Then create the utility class with the code below.

// auth/encoder.go

package auth

import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "fmt"
    "strings"

    "golang.org/x/crypto/argon2"
)

type PasswordConfig struct {
    time uint32
    memory uint32
    threads uint8
    keyLen uint32
}

// GeneratePassword is used to generate a new password hash for storing and
// comparing at a later date.
func GeneratePassword(password string) (string, error) {

    c := &PasswordConfig{
        time: 1,
        memory: 64 * 1024,
        threads: 4,
        keyLen: 32,
    }
    // Generate a Salt
    salt := make([]byte, 16)
    if _, err := rand.Read(salt); err != nil {
        return "", err
    }

    hash := argon2.IDKey([]byte(password), salt, c.time, c.memory, c.threads, c.keyLen)

    // Base64 encode the salt and hashed password.
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    format := "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s"
    full := fmt.Sprintf(format, argon2.Version, c.memory, c.time, c.threads, b64Salt, b64Hash)
    return full, nil
}

// ComparePassword is used to compare a user-inputted password to a hash to see
// if the password matches or not.
func ComparePassword(password, hash string) (bool, error) {

    parts := strings.Split(hash, "$")

    c := &PasswordConfig{}
    _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &c.memory, &c.time, &c.threads)
    if err != nil {
        return false, err
    }

    salt, err := base64.RawStdEncoding.DecodeString(parts[4])
    if err != nil {
        return false, err
    }

    decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
    if err != nil {
        return false, err
    }
    c.keyLen = uint32(len(decodedHash))

    comparisonHash := argon2.IDKey([]byte(password), salt, c.time, c.memory, c.threads, c.keyLen)

    return (subtle.ConstantTimeCompare(decodedHash, comparisonHash) == 1), nil
}
Enter fullscreen mode Exit fullscreen mode

Database scheme

With the password encoder in place, we can update/create our database schema. Delete the existing User table and create a new one, or rewrite the query below to update the table instead.

// config/database.go

...
sqlStmt = ` 
    CREATE TABLE IF NOT EXISTS user (
        id VARCHAR(255) NOT NULL PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        username VARCHAR(255) NULL,
        password VARCHARR(255) NULL
    );
    `
_, err = db.Exec(sqlStmt)
if err != nil {
  log.Fatal("%q: %s\n", err, sqlStmt)
}

password, _ := auth.GeneratePassword("password")

sqlStmt = `INSERT into user (id, name, username, password) VALUES
                    ('` + uuid.New().String() + `', 'john', 'john','` + password + `')`

_, err = db.Exec(sqlStmt)
if err != nil {
  log.Fatal("%q: %s\n", err, sqlStmt)
}

return db
Enter fullscreen mode Exit fullscreen mode

The code above also includes an insert query to create a user, you can remove it after executing it once.

Step 2: Authenticating the user

The next step includes an API endpoint to let the user authenticate himself. After authenticating, the user is provided with a JWT. Then this JWT can be used later on to use the Chat application as an authenticated user.

First create a Handler function thats handles the http request and returns a response.

// api.go
package main

import (
    "encoding/json"
    "net/http"

    "github.com/jeroendk/chatApplication/auth"
    "github.com/jeroendk/chatApplication/repository"
)

type LoginUser struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

type API struct {
    UserRepository *repository.UserRepository
}

func (api *API) HandleLogin(w http.ResponseWriter, r *http.Request) {

    var user LoginUser

    // Try to decode the JSON request to a LoginUser
    err := json.NewDecoder(r.Body).Decode(&user)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // Find the user in the database by username
    dbUser := api.UserRepository.FindUserByUsername(user.Username)
    if dbUser == nil {
        returnErrorResponse(w)
        return
    }

    // Check if the passwords match
    ok, err := auth.ComparePassword(user.Password, dbUser.Password)

    if !ok || err != nil {
        returnErrorResponse(w)
        return
    }

    // Create a JWT
    token, err := auth.CreateJWTToken(dbUser)

    if err != nil {
        returnErrorResponse(w)
        return
    }

    w.Write([]byte(token))

}

func returnErrorResponse(w http.ResponseWriter) {

    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte("{\"status\": \"error\"}"))
}

Enter fullscreen mode Exit fullscreen mode

If the requested user is found and if the provided password is correct, the function above provides a JWT token that the user can use in further requests.

Now lets implement the functions the code above relies on.

// repository/userRepository.go
...
type User struct {
    Id string `json:"id"`
    Name string `json:"name"`
    Username string `json:"username"`
    Password string `json:"password"`
}

...

func (repo *UserRepository) FindUserByUsername(username string) *User {

    row := repo.Db.QueryRow("SELECT id, name, username, password FROM user where username = ? LIMIT 1", username)

    var user User

    if err := row.Scan(&user.Id, &user.Name, &user.Username, &user.Password); err != nil {
        if err == sql.ErrNoRows {
            return nil
        }
        panic(err)
    }

    return &user
}
Enter fullscreen mode Exit fullscreen mode

Our repository now includes a method to retrieve a user by his Username.

The next file lets us create a JWT and decode the created JWT. To do this we need an external library, install it with:

go get github.com/dgrijalva/jwt-go
Enter fullscreen mode Exit fullscreen mode

Then add this file:

// auth/jwt.go
package auth

import (
    "fmt"
    "time"

    "github.com/jeroendk/chatApplication/models"

    "github.com/dgrijalva/jwt-go"
)

const hmacSecret = "SecretValueReplaceThis"
const defaulExpireTime = 604800 // 1 week

type Claims struct {
    ID string `json:"id"`
    Name string `json:"name"`
    jwt.StandardClaims
}

func (c *Claims) GetId() string {
    return c.ID
}

func (c *Claims) GetName() string {
    return c.Name
}

// CreateJWTToken generates a JWT signed token for for the given user
func CreateJWTToken(user models.User) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "Id": user.GetId(),
        "Name": user.GetName(),
        "ExpiresAt": time.Now().Unix() + defaulExpireTime,
    })
    tokenString, err := token.SignedString([]byte(hmacSecret))

    return tokenString, err
}

func ValidateToken(tokenString string) (models.User, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        // Don't forget to validate the alg is what you expect:
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
        }

        // hmacSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
        return []byte(hmacSecret), nil
    })

    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    } else {
        return nil, err
    }
}
Enter fullscreen mode Exit fullscreen mode

Now wire up the API endpoint by adding a route for it in the main.go

// main.go

import (
    ...
    "github.com/jeroendk/chatApplication/auth"
    ...
)

...

func main() {
    ...
    // Define the userRepo here, to use it in bothe the wsServer & the API
    userRepository := &repository.UserRepository{Db: db}

    wsServer := NewWebsocketServer(&repository.RoomRepository{Db: db}, userRepository)
    go wsServer.Run()

    api := &API{UserRepository: userRepository}

    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        ServeWs(wsServer, w, r)
    })

    // Add the login route
    http.HandleFunc("/api/login", api.HandleLogin) 
Enter fullscreen mode Exit fullscreen mode

With this in place you should be able to retrieve a JWT with a tool Like Postman or by performing the following cURL request:

curl --location --request GET 'localhost:8080/api/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "john",
    "Password":"password"
}'
Enter fullscreen mode Exit fullscreen mode

Step 3: Securing the WebSocket connection

Now it’s time to identify the user when the WebSocket connection is established. To make this happen we will create a Middleware function that we can add to the WebSocket HTTP endpoint.

In this example, we will allow known users, identified with a JWT and anonymous users identified by a name. You can easily change this to only allow known users.

// auth/middleware.go

package auth

import (
    "context"
    "net/http"

    "github.com/google/uuid"
)

type contextKey string

const UserContextKey = contextKey("user")

type AnonUser struct {
    Id string `json:"id"`
    Name string `json:"name"`
}

func (user *AnonUser) GetId() string {
    return user.Id
}

func (user *AnonUser) GetName() string {
    return user.Name
}

func AuthMiddleware(f http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token, tok := r.URL.Query()["bearer"]
        name, nok := r.URL.Query()["name"]

        if tok && len(token) == 1 {            
            user, err := ValidateToken(token[0])
            if err != nil {
                http.Error(w, "Forbidden", http.StatusForbidden)

            } else {
                ctx := context.WithValue(r.Context(), UserContextKey, user)
                f(w, r.WithContext(ctx))
            }

        } else if nok && len(name) == 1 {
            // Continue with new Anon. user
            user := AnonUser{Id: uuid.New().String(), Name: name[0]}
            ctx := context.WithValue(r.Context(), UserContextKey, &user)
            f(w, r.WithContext(ctx))

        } else {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte("Please login or provide name"))
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Then add the Middleware to the WebSocket HTTP endpoint

// main.go
...
http.HandleFunc("/ws", auth.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
  ServeWs(wsServer, w, r)
}))
...
Enter fullscreen mode Exit fullscreen mode

From now on, when connecting to the websocket server, the User will be set in de context by the middleware function. So let’s make the proper adjustments in the ServeWs function in client.go

//client.go

func newClient(conn *websocket.Conn, wsServer *WsServer, name string, ID string) *Client {
    client := &Client{
        Name: name,
        conn: conn,
        wsServer: wsServer,
        send: make(chan []byte, 256),
        rooms: make(map[*Room]bool),
    }

    // Use existing User ID
    client.ID, _ = uuid.Parse(ID)
    return client
}

func ServeWs(wsServer *WsServer, w http.ResponseWriter, r *http.Request) {

    // Remove these lines
    - name, ok := r.URL.Query()["name"]

    - if !ok || len(name[0]) < 1 {
    -   log.Println("Url Param 'name' is missing")
    -   return
    - }

    // Instead get the User from the context
    userCtxValue := r.Context().Value(auth.UserContextKey)
    if userCtxValue == nil {
        log.Println("Not authenticated")
        return
    }

    user := userCtxValue.(models.User)

    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }

    // Now user the context user when creating a new Client.
    client := newClient(conn, wsServer, user.GetName(), user.GetId())

    go client.writePump()
    go client.readPump()

    wsServer.register <- client
}
Enter fullscreen mode Exit fullscreen mode

Because a user can log-in with multiple clients, the ChatServer needs some changes as well. Update the methods listed below:

// chatServer.go
...
func (server *WsServer) registerClient(client *Client) {
    // First check if the user does not exist yet.
    if user := server.findUserByID(client.ID.String()); user == nil {
        // Add user to the repo
        server.userRepository.AddUser(client)
    }

    // Publish user in PubSub
    server.publishClientJoined(client)

    server.listOnlineClients(client)
    server.clients[client] = true
}

...

func (server *WsServer) unregisterClient(client *Client) {
    if _, ok := server.clients[client]; ok {
        delete(server.clients, client)

        // Remove this line, We don't want to delete user accounts.
        - server.userRepository.RemoveUser(client)

        // Publish user left in PubSub
        server.publishClientLeft(client)
    }
}

...

func (server *WsServer) handleUserLeft(message Message) {
    for i, user := range server.users {
        if user.GetId() == message.Sender.GetId() {
            server.users[i] = server.users[len(server.users)-1]
            server.users = server.users[:len(server.users)-1]
            break // added this break to only remove the first occurrence
        }
    }

    server.broadcastToClients(message.encode())
}

func (server *WsServer) handleUserJoinPrivate(message Message) {
    // Find client for given user, if found add the user to the room.
    // Expect multiple clients for one user now.
    targetClients := server.findClientsByID(message.Message)
    for _, targetClient := range targetClients {
        targetClient.joinRoom(message.Target.GetName(), message.Sender)
    }
}

func (server *WsServer) listOnlineClients(client *Client) {
    // Find unique users instead of returning all users.
    var uniqueUsers = make(map[string]bool)
    for _, user := range server.users {
        if ok := uniqueUsers[user.GetId()]; !ok {
            message := &Message{
                Action: UserJoinedAction,
                Sender: user,
            }
            uniqueUsers[user.GetId()] = true
            client.send <- message.encode()
        }
    }
}

...

func (server *WsServer) findClientsByID(ID string) []*Client {
    // Find all clients for given user ID.
    var foundClients []*Client
    for client := range server.clients {
        if client.GetId() == ID {
            foundClients = append(foundClients, client)
        }
    }

    return foundClients
}
Enter fullscreen mode Exit fullscreen mode

Front-end

The last thing we need to do is to add a log-in form and use the JWT to connect to the WebSocket.

Let’s update the HTML first:

...
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
...

<div class="col-12 form" v-if="!ws">
  <h2>Anonymous log-in</h2>
  <div class="input-group">
    <input v-model="user.name" class="form-control name" placeholder="Please fill in your (nick)name"
           @keyup.enter.exact="connect"></input>
    <div class="input-group-append">
      <span class="input-group-text send_btn" @click="connect">
        >
      </span>
    </div>
  </div>
  <h2>Registered users</h2>

  <div class="input-group">
      <input v-model="user.username" class="form-control username" placeholder="username"></input>
      <input v-model="user.password" type="password" class="form-control password" placeholder="password"></input>
      <div class="input-group-append">
          <span class="input-group-text send_btn" @click="login">
          >
          </span>
      </div>
  </div>

  <div class="alert alert-danger" role="alert" v-show="loginError">
    {{loginError}}
  </div>

</div>
Enter fullscreen mode Exit fullscreen mode

Then at last update the Javascript file:

// public/assets/app.js
  ...
  data: {
    ws: null,
    serverUrl: "ws://" + location.host + "/ws",
    roomInput: null,
    rooms: [],
    user: {
      name: "",
      // Add the new user properties
      username: "",
      password: "",
      token: ""
    },
    users: [],
    initialReconnectDelay: 1000,
    currentReconnectDelay: 0,
    maxReconnectDelay: 16000,
    loginError: "" // Login error message string
  },

  ...
  // The login function
  // on success save the token and connect to the WebSocket.
  async login() {
    try {
      const result = await axios.post("http://" + location.host + '/api/login', this.user);
      if (result.data.status !== "undefined" && result.data.status == "error") {
        this.loginError = "Login failed";
      } else {
        this.user.token = result.data;
        this.connectToWebsocket();
      }
    } catch (e) {
      this.loginError = "Login failed";
      console.log(e);
    }
  },
  connectToWebsocket() {
    // Use the token if available, else connect with a name. 
    if (this.user.token != "") {
      this.ws = new WebSocket(this.serverUrl + "?bearer=" + this.user.token);
    } else {
      this.ws = new WebSocket(this.serverUrl + "?name=" + this.user.name);
    }
    this.ws.addEventListener('open', (event) => { this.onWebsocketOpen(event) });
    this.ws.addEventListener('message', (event) => { this.handleNewMessage(event) });
    this.ws.addEventListener('close', (event) => { this.onWebsocketClose(event) });
  },

  ...
  // Make sure only one client of the user counts
  handleUserJoined(msg) {
    if(!this.userExists(msg.sender)) {
      this.users.push(msg.sender);
    }
  },
  userExists(user) {
    for (let i = 0; i < this.users.length; i++) {
      if (this.users[i].id == user.id) {
        return true;
      }
    }
    return false;
  }
Enter fullscreen mode Exit fullscreen mode

Result

You should now be able to log-in with a user account and use the chat as that user on different clients simultaneously.

This should be a good starting point for your application or at least give you some inspiration for the next time you need to implement a chat. From here on you can add functions such as a registration endpoint, saving sent messages, keeping track of online users, and many other things.

Thanks for following along and feel free to leave a comment when you have suggestions or questions!

The final source code of this part van be found here:

https://github.com/jeroendk/go-vuejs-chat/tree/v4.0

The post How to add authentication to your Go Chat application (Part 4) appeared first on Which Dev.

Discussion

pic
Editor guide