Africa\'s Talking
Africa's Talking Build a user account management system using USSD and...

Build a user account management system using USSD and SMS API in Go

-

- Advertisment -

Introduction

We will be learning how to use both the Africastalking SMS and USSD api by building an application where we can manage a user account for a simple storage/vault site.

Prerequisites

  • Basic knowledge of Go and Postgres
  • Working knowledge of docker
  • Africastalking account
  • Ngrok

How does it work?

A user dials the USSD code and is given options to :

  • Create an account.
  • Get items in the vault.
  • Add items to the vault.

For a new user they have to register with name and email via USSD and then they receive an SMS to complete registration on the web with link sent in the SMS message. On the web app, they are to complete registration by adding a password for the account. When registration is complete, not only can the user add items to the user’s vault, but also the user can get all items they have in their vault.

Let us set up the environment

First we will be using modules in Go. Let us initialize the module:

go mod init github.com/CalvoM/account-management-ussd

Replace the last part, i.e. after the github.com, with the your specific module path.

Next, we will use postgres and its docker image and use docker compose to run its container. Moreover, we will use redis, as well, for session and state management and we will come it.

version : "3.8"
services:
  pg_server:
    image: "postgres:latest"
    container_name: "at_acc_pg"
    ports:
      - "5431:5432"
    env_file:
      - .env
  redis_server:
    image: "redis:latest"
    container_name: "at_acc_redis"
    ports:
      - "6378:6379"

networks:
  default:
    external:
      name: at-acc-net

This will create a two services –

  • pg_server
  • redis_server

I have remapped the ports for each service because most times we have the default ports already used, though this is optional if your default ports are not already in use. Under pg_server service, we use the environment file .env which should define POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB. Concerning networks, create one network using docker so that our docker-compse service does not create a default one. Please note the name of network is at-acc-net, though you can use any other name.

docker network create at-acc-net

The advantage of having a predefined network to use is you have a confirmed IP to use as host. You can get the host IP for the network using the command:

docker network inspect at-acc-net

Get the Host IP by reading the value of Gateway. For me, it was 172.20.0.1, yours may differ. Start the services by running this command in the same directory as the docker-compose.yml file:

docker-compose up

You can test the postgress service by running:

psql -h <HOST> -U <POSTGRES_USER> -p 5431 -W <POSTGRES_DB>

You can test the redis service by running:

redis-cli -h <HOST> -p <PORT>

Make sure you have to postgres and redis installed on your machine to use the test commands above.

To finish up with database, let us create the tables for users and the vault in the postgres database.

create table if not exists reg_users(
	user_id serial primary key,
	username varchar(50) unique not null,
	password varchar(100) not null,
	email varchar(50) unique not null,
	registered boolean default false,
	activated boolean default false
	);

create table if not exists vault(
	vault_id serial primary key,
	user_id int,
	content varchar(100),
	constraint fk_reg_user
		foreign key(user_id)
			references reg_users(user_id)
			on delete cascade
);

You can automatically create the tables when connecting to the postgres server by saving the above statements in a file with a .sql extension, e.g. cmd.sql, and then running the command below. This saves you from the task of typing the statements in the postgres prompt.

psql -h <HOST> -U <POSTGRES_USER> -p 5431 -W <POSTGRES_DB> -f cmd.sql

Environment variables

We will be adding environment variables to the .env file as we proceed. We will user viper package to handle environment variables in our app. Add it to the module.

go get github.com/spf13/viper

Finally, make sure you have an africastalking account.

Handling USSD

For this section we will be using these docs as reference. Whenever the user interacts with the USSD application, we need to get the input via data sent to a callback we register with africastalking. The HTTP POST request payload is sent to the callback with the Content-Type of application/x-www-form-urlencoded. The payload contains the following parameters:

  • sessionId
  • phoneNumber
  • networkCode
  • serviceCode
  • text

In addition to this callback called during the sessions, there is another callback we could register to handle notifications at the end of the session. The parameters sent to this callback are well documented here. The ussd callback handlers should handle data as you deem fit.

Registration of the callbacks is done in the Africastalking account. Under the USSD menu, choose Service Codes, click the 3 dots under Actions and then click the callback option provided.

In our code we create handlers (USSDHandler and USSDNotificationHandler) for the endpoints for both callbacks. Furthermore, we create two structs (SessionDetails and EndSessionDetails) to handle the payloads sent to the two callback url endpoints.

//SessionDetails store session details sent to callback
type SessionDetails struct {
	SessionID   string
	PhoneNumber string
	NetworkCode string
	ServiceCode string
	Text        string
}

//EndSessionDetails store end of session details
type EndSessionDetails struct {
	SessionID    string
	ServiceCode  string
	NetworkCode  string
	PhoneNumber  string
	Status       string
	Input        string
	ErrorMessage string
}
//USSDHandler handle details of ussd sessions
func USSDHandler(w http.ResponseWriter, r *http.Request) {
	contentType := r.Header.Get("Content-Type")
	if strings.Compare(contentType, "application/x-www-form-urlencoded") == 0 {
		r.ParseForm()
		sessionDet := SessionDetails{}
		sessionDet.SessionID = r.PostForm.Get("sessionId")
		sessionDet.PhoneNumber = r.PostForm.Get("phoneNumber")
		sessionDet.NetworkCode = r.PostForm.Get("networkCode")
		sessionDet.ServiceCode = r.PostForm.Get("serviceCode")
		sessionDet.Text = r.PostForm.Get("text")
		val, err := redisClient.Exists(sessionDet.SessionID).Result()
		if err != nil || val == 0 {
			log.Warn(sessionDet.SessionID, " Not found")
			state := Begin
			err = updateRedisSession(state, sessionDet.SessionID)
			if err != nil {
				log.Error(err)
			}
		}
		fmt.Fprintf(w, "%s", generateUSSDResponse(getUserChoice(sessionDet.Text), sessionDet))
	}

}

//USSDEndNotificationHandler gets details of just ended session
func USSDEndNotificationHandler(w http.ResponseWriter, r *http.Request) {
	contentType := r.Header.Get("Content-Type")
	if strings.Compare(contentType, "application/x-www-form-urlencoded") == 0 {
		r.ParseForm()
		sessionDet := EndSessionDetails{}
		sessionDet.SessionID = r.PostForm.Get("sessionId")
		sessionDet.ServiceCode = r.PostForm.Get("serviceCode")
		sessionDet.NetworkCode = r.PostForm.Get("networkCode")
		sessionDet.PhoneNumber = r.PostForm.Get("phoneNumber")
		sessionDet.Input = r.PostForm.Get("input")
		sessionDet.Status = r.PostForm.Get("status")
		if strings.Compare(sessionDet.Status, "Failed") == 0 {
			sessionDet.ErrorMessage = r.PostForm.Get("errorMessage")
		}
		log.Info(sessionDet)
	}
}

We will discuss about the generateUSSDResponse() and getUserChoice() functions later.

Session and state management

USSD is session driven and each request sent contains an session ID as you saw from the parameters sent to the callbacks. The session ID comes in handy when differentiating users since each will have a single unique session ID generated until the USSD session ends. Consequently, if more than one person is using the USSD application, you can keep track of where the user is in the app. Keeping track of where of the user is while using the USSD app is achieved by developing a finite state machine.

For this app, the session is managed using Redis. We will be using the docker container we included in the docker-compose.yml . We will use the go-redis package to handle Redis operations in the application. Add it to the module.

go get github.com/go-redis/redis

We will have the following states in the USSD app to help us know how to handle various sessions.

type USSDState int

const (
	Begin USSDState = iota
	GetOption
	RegGetEmail //Registration Option states
	RegGetusername
	RegDoneOK
	VGGetEmail //Get from vault Option states
	VGGetPassword
	VGGetContent
	VGDoneOK
	VSGetEmail //Add to vault Option state
	VSGetPassword
	VSSetContent
	VSDoneOK
	Error
)

You will notice that the states are pretty much the same but only prefixed with either Reg, VG and VS. Reg is “Registration”, VG is “Vault Get” and VS is “Vault Set”. Just extra information for those wondering about the weird names.

From the USSDHandler function shown above, the response to the USSD requests are delegated to the generateUSSDResponse function. In the generateUSSDResponse function, we have the state machine responding to the requests sent to the USSD endpoint, according to the session and the state the session is in.

//generateUSSDResponse respond to USSD
func generateUSSDResponse(text string, session SessionDetails) (resp string) {
	data, err := redisClient.HGet(session.SessionID, "state").Result()
	if err != nil { //Session Handling err
		return "END Error detected."
	}
	i, err := strconv.Atoi(data)
	if err != nil { //Session Handling err
		return "END Error detected."
	}
	ussdState := USSDState(i)
	if text == "" && ussdState != Begin { //If user does not supply input and it is not start
		resp += "END Error detected.\nPlease provide an input."
		ussdState = Begin
		if err := updateRedisSession(ussdState, session.SessionID); err != nil {
			return
		}
		return
	}

	switch {
	case ussdState == Begin:
		resp = "CON Welcome to the Bazenga Vault.\r\n"
		resp += "What would you like to do?\r\n"
		resp += "1. Register as a new user.\r\n"
		resp += "2. Add to the vault.\r\n"
		resp += "3. Get all items in the vault."
		ussdState = GetOption
		if err := updateRedisSession(ussdState, session.SessionID); err != nil {
			return "END Error detected."
		}

	case ussdState == GetOption:
		if text == "1" {
			ussdState = RegGetEmail
			if err := updateRedisSession(ussdState, session.SessionID); err != nil {
				return "END Error detected."
			}
			resp = handleRegistration(session, ussdState)
		} else if text == "2" {
			ussdState = VSGetEmail
			if err := updateRedisSession(ussdState, session.SessionID); err != nil {
				return "END Error detected."
			}
			resp = handleUpdateVault(session, ussdState)
		} else if text == "3" {
			ussdState = VGGetEmail
			if err := updateRedisSession(ussdState, session.SessionID); err != nil {
				return "END Error detected."
			}
			resp = handleGetVaultItems(session, ussdState)
		} else {
			resp = "END Not chosen"
		}
	case ussdState >= RegGetEmail && ussdState <= RegDoneOK:
		resp = handleRegistration(session, ussdState)

	case ussdState >= VSGetEmail && ussdState <= VSDoneOK:
		resp = handleUpdateVault(session, ussdState)
	case ussdState >= VGGetEmail && ussdState <= VGDoneOK:
		resp = handleGetVaultItems(session, ussdState)
	}

	return
}

The functions handleRegistration, handleUpdateVault and handleGetVaultItems all contain state machines to handle the states during the registration, vault update and vault retrieval process respectively. Looking at the handleRegistration code below, we see a switch statement to handle the states. Furthermore, after each step we update the state for the session in redis.

func handleRegistration(session SessionDetails, state USSDState) (resp string) {
	switch state {
	case RegGetEmail:
		resp = "CON Please enter your email"
		ussdState := RegGetusername
		if err := updateRedisSession(ussdState, session.SessionID); err != nil {
			return "END Error detected."
		}

	case RegGetusername:
		email := getUserChoice(session.Text)
		u := models.User{Email: email}
		ok, _ := models.IsUserEmailInDb(&u)
		if ok { // Email is unique
			resp = "END Email already registered"
		} else {
			if err := updateSessionDetails("email", email, session.SessionID); err != nil {
				resp = "END Error detected."
			}
			resp = "CON Please enter your user name"
			ussdState := RegDoneOK
			if err := updateRedisSession(ussdState, session.SessionID); err != nil {
				return "END Error detected."
			}
		}
	case RegDoneOK:
		username := getUserChoice(session.Text)
		email, err := redisClient.HGet(session.SessionID, "email").Result()
		if err != nil {
			return "END Error detected."
		}
		if err = updateSessionDetails("name", username, session.SessionID); err != nil {
			return "END Error detected."
		}
		user := models.User{
			Name:     username,
			Email:    email,
			Password: "0",
		}
		id, err := user.AddUser()
		if err != nil {
			return "END Error detected."
		}
		resp = "END You will receive an SMS to complete registration.."
		token, err := CreateToken(id)
		if err != nil {
			return "END Error detected."
		}
		server := "http://946f79474c31.ngrok.io"
		endpoint := "/?token=" + token + "&uid=" + strconv.Itoa(int(id))
		url := server + endpoint
		NotifyByATSMS(session, "Registration Complete.\n\rPlease activate at "+url)

	}
	return
}

We noted before that for each session, whenever the user interacted with the USSD app, the app received the text the user entered. However, what is sent to the callback url is the text concatenated with asterisks(*), i.e. all the input the user has entered in the app at each stage, thus far, is joined with asterisks.

For example the callback url could received 544*1*2*3*4, this means that the user entered 544 at the first stage, then 1 at the next, then 2 , then 3 and so forth, However, 4 is the text he entered for the last stage. So we developed a function getUserChoice() to get the last entry.

//getUserChoice splits the text sent to callback since all user text is joined by *
func getUserChoice(text string) string {
	vals := strings.Split(text, "*")
	return vals[len(vals)-1]
}

Notification through SMS

The app notifies the user via SMS when the following the events happen:

  • The user registration is partially complete via USSD, sending url to complete on the web.
  • The user requests to get items saved in the vault, i.e. the items are sent via SMS.

Get the Africastalking Golang SDK to use its SMS functionality

go get github.com/AndroidStudyOpenSource/africastalking-go

We define a function NotifyByATSMS, to send whatever information we wish to the user. Please note that you need to have your api key, username and enviroment variables. We get the three variables from the Africastalking account.

The app name and username are what we check for username and environment variables.

For the api key, we find it under the settings menu then clicking api key option. Enter your password and voila! Your api key.

//NotifyByATSMS send notification using AT's SMS service
func NotifyByATSMS(session SessionDetails, message string) {
	viper.SetConfigFile("./.env")
	err := viper.ReadInConfig()
	if err != nil {
		panic(err)
	}
	userName := viper.GetString("AT_USERNAME")
	apiKey := viper.GetString("AT_APIKEY")
	env := viper.GetString("AT_ENV")
	smsService := sms.NewService(userName, apiKey, env)
	smsResponse, err := smsService.Send("", session.PhoneNumber, message)
	if err != nil {
		log.Error(err)
	}
	log.Info(smsResponse)
}

From the SMS docs, we see that there is a callback we can register to handle the request sent when an SMS event has occurred for your account. So you can register the callback in the africastlking account; under the SMS menu -> SMS Callback URLs -> Delivery Reports.

This callback helps you to know if your SMS sending operation was successful or not and the reason for the failure, if any. Furthermore, it would not be bad to have all logs for your application, they can provide insight into the operations, you can save them to the database if you want.

Updating the password and JWT

During the registration process using USSD, we capture only the user email and username, but not the user password. The user password is important since we would not want somebody else to see the contents of our vault or add anything to it.

Upon registering, the user receives an SMS with link to complete the registration i.e. secure the user account with a password, on a web page. We use a jwt token to confirm the user, since the user id is in the jwt’s payload.

When the user accesses the page, we check the validity of the jwt token by checking if it is expired and if the uid matches the one in the url. This are just some safeguards though the final safeguard for validity is found in the secret key used to sign the jwt token.

func serveUi(w http.ResponseWriter, r *http.Request) {
	basePath := filepath.Join("ui", "templates", "base.html")
	urlPath := r.URL.Path
	token := r.URL.Query().Get("token")
	uid := r.URL.Query().Get("uid")
	if token != "" && uid != "" { //Both token and uid should be present
		_uid, err := ValidateToken(token)
		if err != nil {
			http.Error(w, "Auth Error", http.StatusBadRequest)
			return
		}
		i_uid, err := strconv.Atoi(uid)
		if err != nil {
			http.Error(w, "Server Error", http.StatusInternalServerError)
			return
		}
		if int64(i_uid) != _uid {
			http.Error(w, "Auth Error", http.StatusBadRequest)
			return
		}
		parts := strings.Split(urlPath, "/?")
		urlPath = parts[0] + "login.html"
		reqPath := filepath.Join("ui", "templates", filepath.Clean(urlPath))
		info, err := os.Stat(reqPath)
		if err != nil {
			log.Error("Error=>", err)
			if os.IsNotExist(err) {
				http.NotFound(w, r)
				return
			}
		}
		if info.IsDir() {
			http.NotFound(w, r)
			return
		}
		tmpl, err := template.ParseFiles(basePath, reqPath)
		if err != nil {
			log.Fatal(err.Error())
		}
		err = tmpl.ExecuteTemplate(w, "base", token)
		if err != nil {
			log.Fatal(err.Error())
		}

	} else {
		fmt.Fprint(w, "Welcome to the this APP")
	}
}

If the token is valid, the user is taken to the web page to complete the registration process. The token is sent as data to the web page so that it can be used to identify the user when they submit the their password.

<h1>Login</h1>
<form>
    <input id="email" type="text" name="" placeholder="Enter email" required>
    <input id="password" type="password" name="" placeholder="Enter password" required>
    <input id="conPas" type="password" name="" placeholder="Confirm password" required>
    <input type="submit" value="UPDATE PASSWORD">
</form>
<script>
    let form_ = document.querySelector("form");
    let email = document.querySelector("input#email");
    let password = document.querySelector("input#password");
    let confirmPassword = document.querySelector("input#conPas");
    let access_token = {{.}}

    form_.addEventListener("submit",function(event){
            event.preventDefault()
            if(password.value !== confirmPassword.value){
                    alert("Passwords not same.")
                    password.value = ""
                    confirmPassword.value = ""
                    return false
                }
            fetch("http://946f79474c31.ngrok.io/update_password/",
                { 
                method: "post",
                headers:{
                        "Authorization":"Bearer "+access_token,
                        "Content-Type": "application/json"
                    },
                        body: JSON.stringify({email:email.value,password:password.value,confirm:confirmPassword.value})}
            ).then(resp => {
            console.log(resp)
            })
            return false
    })
</script>

The jwt token is added to the header when sending the password to the server. Here is the handler for the route to update the password. Please note the token is validated again, as you know extra security never hurt anyone.

type userUpdate struct {
	Email          string `json:"email"`
	Password       string `json:"password"`
	ConfirmPasword string `json:"confirm"`
}

func UpdatePassword(w http.ResponseWriter, r *http.Request) {
	var u userUpdate
	token := r.Header.Get("Authorization")
	acc_token := strings.Split(token, " ")[1]
	uid, err := ValidateToken(acc_token)
	if err != nil {
		log.Error(err)
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}
	err = json.NewDecoder(r.Body).Decode(&u)
	if err != nil {
		log.Error(err)
		http.Error(w, err.Error(), http.StatusServiceUnavailable)
		return
	}
	user := models.User{
		Email: u.Email,
		ID:    uid,
	}
	err = user.UpdateUserPassword(u.Password)
	if err != nil {
		http.Error(w, err.Error(), http.StatusServiceUnavailable)
		return
	}
	fmt.Fprint(w, "Okay")
	return
}

To use jwts in the go code please add the following module in the go app:

 go get github.com/dgrijalva/jwt-go

Vault operations

When the user is done registering, thus becoming activated, and they can add items to the vault and get items stored in the vault. They will access the vault if they have not secured their accounts with a password. This is crucial since we need their email and password before allowing them to the vault.

Expose app to the world

Since the your app needs to get the data sent to the callback and respond accordingly, we use ngrok to expose the endpoints. You can replace the 8083 below with any port incase you change the app port in the main.go file. The url shown ngrok is what you use to fill in the callback inputs for sms and ussd.

ngrok http 8083

Conclusion

We have looked at what USSD is and how we can manage sessions and states. We have used this knowledge to build a simple account management app for a vault service.

This app can be modified to work with any account management service, so feel free to modify and add functionality. Possible items to add is a way for the user to get a url to activate their accounts incase the one they have expires before they activate.

The full code is found in this repo.

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Posts

Building Application using Node.js HTTPS Module and Africa’s Talking SMS API

Introduction For a long time, HTTP (Hypertext Transfer Protocol) was responsible for communication between a client application and a server...

Building an Exam Enrollment and Grade Notification System using Africa’s Talking SMS Shortcode API in Django

Introduction This article explores the application of SMS shortcodes to create transparency and improve the quality of education in learning...

Build a user account management system using USSD and SMS API in Go

Introduction We will be learning how to use both the Africastalking SMS and USSD api by building an application where...

Date & Time Analysis With R

One will learn how to analyse dates and time; reproducing date-related columns from a date and also format dates and time. In application, one will learn how to plot simple and multiple time series data using the R language.
- Advertisement -

Two Factor Authentication With PHP and Africa’s Talking SMS API

Two factor authentication is an additional layer of security used to ensure only authenticated users gain access to an online account. because Passwords are historically weak, and can be easily stolen, it can't alone be the way that users access their accounts.

Building a masked number system using Spring Boot, Android and Voice Apis from Africa’s Talking

Introduction In this walk through we shall be building a platform to enable a hypothetical company have their agents call...
- Advertisement -

You might also likeRELATED
Recommended to you