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
- Basic chat application with Go + Vue.JS
- Multi-room & 1 one 1 chats.
- Using Redis Pub/Sub for scalability
- Adding authentication and allow users to log-in (This page)
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
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
}
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
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\"}"))
}
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
}
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
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
}
}
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)
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"
}'
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"))
}
})
}
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)
}))
...
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
}
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
}
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>
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;
}
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.
Top comments (0)