This tutorial teaches you on developing a secure REST API server application in Sling programming language using the Sympathy framework.

Developing a secure REST API server application seem to be a daunting task especially if you haven't ever tried developing one. In this tutorial, we will be developing one and we will be discussing fundamental things to consider and good practices.

At the end of this tutorial you will learn how to develop a secure REST API server application in Sling programming language using the Sympathy framework.

Development Tools

In this tutorial, we will be using the following development tools and technologies:

Eqela Runtime

The QX Scripting Language

The Sling Programming Language

The Jkop Framework

Sympathy web server and application platform

Creating a Sympathy Web Server project

Let's begin by creating our project directory and name it 'srapiy' which stands for 'Secure REST API Sympathy'.

Under this directory, create the following files with the following contents:

App.sling

class is WebServerWithDatabase:

import sympathy
import sympathy.dbapp

func main(args as array<string>) static as int #main:
	return new this().executeMain(args)

func initializeServer(server as HTTPServer) override as bool
{
	assert base.initializeServer(server):
		Log.error(ctx, "Failed to initialize HTTP server.")
	return true
}

this.pling

title = "Secure REST API"
preFilters += "@cape"
preFilters += "@sympathy"

Now we have a working Sympathy web server. The next step is create our QX build automation script. Create a build script file in the same directory as the srapiy directory (not inside the srapiy directory) and name it build.qx with the following contents:

use eqela:slingbuild:r19
use eqela:jsh:r4
use eqela:dotnet:2.1.301
use eqela:symlib:5.3.0
set version v1.0.0

build : src {
	requireValue src
	set id ${=getIdNameForPath(${src})}
	eqela:jsh delete build/${id}
	eqela:slingbuild buildNetCoreStatic src=${src} -libdir=${eqela:symlib}/src
}

run : id {
	requireValue id
	eqela:dotnet build/${id}/netcore/${id}.dll
}

NOTE: You can look at this short tutorial to learn more about the QX build automation script.

Now let's build our project by executing the "eqela" command like this:

eqela build.qx build src=srapiy

Then run it by executing the same command like this:

eqela build.qx run id=srapiy

Then open your browser and open the link http://localhost:8080. If everything is working, you will see exactly this on the page:

Not found

This is because we haven't implemented yet our handlers and by default, the server responds with a 'Not found' response. But before that, let's work on the resource handling first.

SQL database integration (SQLite)

Database is simply an organized data storage. This is where we will store our resource persons and other data that we will be using. In this tutorial we will be using one of the most popular databases SQLite.

Configuring the database

The database can be configured by indicating a commandline parameter when running your project or by using a config file (.config) with the parameter key 'db'. Please see below for its format:

via commandline parameter:
-Odb=<database-type>:<database-connection-string-or-path-to-database-file>

via config file:
db: <database-type>:<database-connection-string-or-path-to-database-file>

For instance:

via commandline parameter:
-Odb=sqlite:srapiydb.sqlite

via config file:
db: sqlite:srapiydb.sqlite

Now let's update our run script by adding additional parameter for our database configuration:

run : id db {
	requireValue id
	requireValue db
	eqela:dotnet build/${id}/netcore/${id}.dll -Odb=sqlite:${db}
}

Then run it by executing the "eqela" command like this:

eqela build.qx run id=srapiy db=srapiy.sqlite

NOTE: For advanced users, you can use MySQL database server. Below is its connection string format:

via commandline parameter:
-Odb=mysql:<address>:<username>:<password>:<database-name>

via config file:
db: mysql:<address>:<username>:<password>:<database-name>

For instance:

via commandline parameter:
-Odb=mysql:localhost:root:root:srapiydb

via config file:
db: mysql:localhost:root:root:srapiydb

Initializing the database

To initialize our database, we need the database connection string (which differs from what database we will be using). The good thing is that the database connection string is generated from the parameter 'db' that we supplied when configuring our database. Now we can create an instance of our database with the SQLDatabaseProvider class like this:

var db = SQLDatabaseProvider.getDatabase(ctx, dbstring)

The getDatabase() method will create and initialize an instance of SQLDatabase class from the given database connection string and if everything is good, it will return a reference of that instance. Since we are inheriting from the base class WebServerWithDatabase, it will automatically initialize the database for us, all we need to do is configure the database and call the getDb() method to get a reference to the database instance like this:

var db = getDb()

Data models

Data models are fundamental entities. Simply think that a data model is a database table that represents a resource which has its properties and how it relates to other data models if there are any. Since we will be dealing with the resource persons, create the data model class and name it Person.sling:

Person.sling

class #dataModel:

prop personId as string #dataField #primary
prop name as string #dataField
prop gender as string #dataField
prop age as int #dataField

Note the #dataModel custom modifier. It tells the compiler that the class is a data model and will cause the compiler to generate additional code for handling this data in an SQL database. The data model Person will have the following properties: personId, name, gender and age. Note the #dataField modifier, which will tell the compiler that it is a data model property and it will become a database column upon database initialization. The #primary modifier tells the compiler that this data field is the primary key.

Database manager

The database manager will be the one that does the database related transactions such as insert, update, delete and select. Create a new class and name it DatabaseManager.sling with the following initial code:

DatabaseManager.sling

class:

import capex.data

const PERSONS_TABLE = "persons"

func forSQLDatabase(db as SQLDatabase) static as this
{
	assert db
	var v = new this()
	v.setDb(db)
	assert v.initialize()
	return v
}

prop db as SQLDatabase

func initialize as bool
{
	assert db
	assert db.ensureTableExists(Person.SQL.getTableInfo(PERSONS_TABLE))
	return true
}

As you can see, the forSQLDatabase() static method which accepts an instance of the SQLDatabase class will create an instance of itself, set the database reference and call its initialize() method and returns the instance created if everything is good. This is called for easy instantiation. The initialize() method as you can see will check if the SQLDatabase property isn't null and calls the ensureTableExists() method to make sure the persons database table exists for our resource persons and creates it otherwise.

As you have noticed, the Person class, since it's a data model, it will automatically have the static inner class SQL and would have several methods like the getTableInfo() static method which will return an instance of the SQLTableInfo class which defines the properties and structure of the person data model which is needed for creating its database table.

Storing data

To store a new record of a person in our database, we will implement the addPerson() method which will accept an instance of DynamicMap class which would contain the data we need and a callback function parameter which will be called after successfully storing the new record or when an error occured.

func addPerson(data as DynamicMap, callback as function<void, Error>)
{
	assert data:
		callback(Error.instance("no_data", "No data"))
	Person.forValidDynamicMap(data, func(person as Person, error as Error) {
		assert not error:
			callback(error)
		assert person:
			callback(Error.instance("internal_error", "Internal error occured."))
		person.setPersonId(generateId())
		Person.SQL.insert(db, PERSONS_TABLE, person, func(success as bool) {
			assert success:
				callback(Error.instance("internal_error", "Internal error occured"))
			callback(null)
		})
	})
}

As you have noticed, the Person data model class has the generated forValidDynamicMap() static method. This method will call the validate() method and if everything is good, it will call the supplied callback function parameter supplying an instance of itself with the properties being supplied from the DynamicMap instance, otherwise, it will return an instance of the Error class if something went wrong. For the validate() method to work, we should implement it first, so let's add the implementation below in our data model class Person.sling:

func validate(callback as function<void, Error>)
{
	name = String.strip(name)
	assert String.isNotEmpty(name):
		callback(Error.instance("no_name", "Please specify name."))
	assert String.equals("male", gender) || String.equals("female", gender):
		callback(Error.instance("invalid_gender", "Please specify gender. Must be between 'male' or 'female'."))
	assert age >= 0:
		callback(Error.instance("invalid_age", "Please specify age. Must be above or equal to zero."))
	callback(null)
}

We also need to implement the generateId() method which is called to create a unique id for personId property before storing the new record in our database. Creating a unique string requires generating random characters so we need to import and prepare the following in our DatabaseManager class:

var random private as Random

ctor
{
	random = new Random()
}

The random of type Random class will be used to generate a random string. Now let's implement the generateId() method.

func generateId(prefix as string = null, length as int = 64, allCaps as bool = true) private as string
{
	var sb = StringBuilder.forString(prefix)
	var l = length
	if l < 8:
		l = 8
	for(var i = 0; i < l; i++) {
		if random.nextInt(0, 2) == 0 {
			sb.append(random.nextInt(48, 58) as! char)
		}
		else {
			if allCaps {
				sb.append(Character.toUppercase(random.nextInt(97, 123) as! char))
			}
			else {
				if random.nextInt(0, 2) == 0 {
					sb.append(Character.toUppercase(random.nextInt(97, 123) as! char))
				}
				else {
					sb.append(random.nextInt(97, 123) as! char)
				}
			}
		}
	}
	return sb.toString()
}

Retrieving data

To retrieve all the data, we will implement the method getPersons() which accepts a callback function parameter that supplies an instance of DynamicVector which will contain all the records or nothing if there's no data stored.

func getPersons(callback as function<void, DynamicVector>)
{
	Person.SQL.queryAll(db, PERSONS_TABLE, [ SQLOrderingRule.forAscending("name") ], func(itr as Iterator<Person>) {
		assert itr:
			callback(null)
		var persons = new DynamicVector()
		loop {
			var person = itr.next()
			if not person:
				break
			persons.append(person.toDynamicMap())
		}
		callback(persons)
	})
}

To retrieve a specific record, we will implement the method getPerson() which accepts a parameter personId of type string and a callback function parameter that supplies an instance of Person which will contain the data. If something went wrong, it will supply an Error instance instead.

func getPerson(personId as string, callback as function<void, Person, Error>)
{
	assert String.isNotEmpty(personId):
		callback(null, Error.instance("no_person_id", "Please specify person id."))
	Person.SQL.queryByPersonId(db, PERSONS_TABLE, personId, func(person as Person) {
		assert person:
			callback(null, Error.instance("invalid_person_id", "The person id you specified is invalid. Please check your spelling and try again."))
		callback(person, null)
	})
}

Updating data

To update a specific record, we will implement the method updatePerson() which accepts a parameter data of type DynamicMap which will contain the new data and a callback function parameter that supplies an Error instance if something went wrong, otherwise the update was successful.

func updatePerson(data as DynamicMap, callback as function<void, Error>)
{
	assert data:
		callback(Error.instance("no_data", "No data"))
	Person.forValidDynamicMap(data, func(person as Person, error as Error) {
		assert not error:
			callback(error)
		assert person:
			callback(Error.instance("internal_error", "Internal error occured."))
		Person.SQL.queryByPersonId(db, PERSONS_TABLE, person.getPersonId(), func(currentPerson as Person) {
			assert currentPerson:
				callback(Error.instance("invalid_person_id", "The person id you specified is invalid. Please check your spelling and try again."))
			Person.SQL.update(db, PERSONS_TABLE, person, func(success as bool) {
				assert success:
					callback(Error.instance("internal_error", "Internal error occured"))
				callback(null)
			})
		})
	})
}

Deleting data

To delete a specific record, we will implement the method deletePerson() which accepts a parameter personId of type string which will be used to specify which record we are going to delete from the database. It will also have a callback function parameter which will be called after the delete transaction. The Error instance will be supplied if something went wrong.

func deletePerson(personId as string, callback as function<void, Error>)
{
	assert String.isNotEmpty(personId):
		callback(Error.instance("no_person_id", "Please specify person id."))
	Person.SQL.queryByPersonId(db, PERSONS_TABLE, personId, func(person as Person) {
		assert person:
			callback(Error.instance("invalid_person_id", "The person id you specified is invalid. Please check your spelling and try again."))
		Person.SQL.deleteFromTable(db, PERSONS_TABLE, person, func(success as bool) {
			assert success:
				callback(Error.instance("internal_error", "Internal error occured."))
			callback(null)
		})
	})
}

Preparing the database manager

To prepare our database manager, let's update our App.sling and add the member as class member variable of type DatabaseManager and set it to private then call the instantiation method forSQLDatabase() passing the SQLDatabase instance by calling the getDb() method. Our code should now look like this:

class is WebServerWithDatabase:

import sympathy
import sympathy.dbapp

func main(args as array<string>) static as int #main:
	return new this().executeMain(args)

var manager private as DatabaseManager

func initializeServer(server as HTTPServer) override as bool
{
	assert base.initializeServer(server):
		Log.error(ctx, "Failed to initialize HTTP server.")
	assert manager = DatabaseManager.forSQLDatabase(getDb()):
		Log.error(ctx, "Failed to initialize database manager.")
	return true
}

As you might have noticed, we haven't wrote any SQL statements. Although you can still opt to write and execute your own SQL statements, it's one of the good things the data models offer, you don't need to do it and this saves you development time and from writing erroneous and complicated statements leading to potential problems. Although there are some cases that you need to write SQL statements, but as much as possible, just don't do it.

Creating the API handler

Now let's create a new source file under our project directory and name it 'WebAPIHandler.sling' with the following contents:

WebAPIHandler.sling

class #webapi:

import sympathy
import symlib.webapi

prop manager as DatabaseManager

GET ""
{
	req.sendTextString("GET requested for: \"" .. req.getURLPath() .. "\"")
}

POST ""
{
	req.sendTextString("POST received data size: " .. req.getHeader("content-length"))
}

With the #webapi, you can specify your method handlers starting with the HTTP method (GET, POST, PUT, DELETE, etc.) and the resource like this:

GET "resource"
{
	// handle request here.
}

Now let's update our 'App.sling' source file to use the 'WebAPIHandler.sling' we have created by instantiating the WebAPIHandler class. Our code should now look like this:

App.sling

class is WebServerWithDatabase:

import sympathy
import sympathy.dbapp

func main(args as array<string>) static as int #main:
	return new this().executeMain(args)

var manager private as DatabaseManager

func initializeServer(server as HTTPServer) override as bool
{
	assert base.initializeServer(server):
		Log.error(ctx, "Failed to initialize HTTP server.")
	assert manager = DatabaseManager.forSQLDatabase(getDb()):
		Log.error(ctx, "Failed to initialize database manager.")
	var handler = new WebAPIHandler()
		.setManager(manager)
	server.pushRequestHandler(handler)
	return true
}

Now rebuild and run your project then open the same link (http://localhost:8080) and you will see this response:

GET requested for: "/"

Since this is a REST API, our response should be in JSON format. So instead of using the sendTextString() method, we should instead use the sendJSONObject() method like this:

req.sendJSONObject(json)

Where the 'json' is of valid JSON data type (string, number, JSON object (map), array, boolean, or null value). For a better JSON response format, we will be using the JSONResponse class like this:

req.sendJSONObject(JSONResponse.forOk())

Updating the API handler

To complete our database integration above, we will now update our API handler. Remove the two method handlers GET and POST. Since this is a REST API, proper use of HTTP methods should be followed, and with this, the GET method is for retrieving data, POST method is for creating or adding a data, PUT is for updating a specific data, and the DELETE is for removing data. Since we will be dealing with the resource persons, it is also appropriate that our URL starts with '/persons'.

GET method

To retrieve all the data, we will use the getPersons() method of DatabaseManager which will return an instance of DynamicVector which we will be our response.

GET "persons"
{
	manager.getPersons(func(persons as DynamicVector) {
		req.sendJSONObject(JSONResponse.forOk(persons))
	})
}

To retrieve a specific data, we will use the getPerson() method of DatabaseManager passing a person id parameter of type string to specify which record we are going to retrieve and an instance of Person will be returned. This is not a valid JSON data type so we will call its method toDynamicMap() to get a valid JSON object.

GET "persons/*"
{
	manager.getPerson(req.popResource(), func(person as Person, error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk(person.toDynamicMap()))
	})
}

Now the person id will be from the next node of the URL after the 'persons' which can be retrieved by calling the popResource() method of the HTTPServerRequest req. Note the "persons/*", this means that this method handler expects a node after the /persons.

POST method

To add a new record, we will use the addPerson() method of DatabaseManager passing the request body in a JSON map as its data then the response will be ok or an error if something is not right.

POST "persons"
{
	var data = assert req.getBodyJSONMap():
		req.sendJSONObject(JSONResponse.forInvalidRequest())
	manager.addPerson(data, func(error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk())
	})
}

PUT method

To update a specific record, we will use the updatePerson() method of DatabaseManager passing the request body in a JSON map as its data with the person id from the next resource node then the response will be ok or an error if something went wrong.

PUT "persons/*"
{
	var data = assert req.getBodyJSONMap():
		req.sendJSONObject(JSONResponse.forInvalidRequest())
	data.set("personId", req.popResource())
	manager.updatePerson(data, func(error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk())
	})
}

DELETE method

To delete a specific record, we will use the deletePerson() method of DatabaseManager passing the person id from the next resource node then the response will be ok or an error if something went wrong.

DELETE "persons/*"
{
	manager.deletePerson(req.popResource(), func(error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk())
	})
}

Authentication

Now that our REST API is ready, we need to secure it so only authorized users can access it by implementing an authentication. Authentication is a process by which a client provides credentials to an authentication server, this can be username and password, which will be compared to those stored on its database of authorized users. If the credentials match, the client is then granted authorization in a form of a session ID or access token which the client will use and embed it to its every transaction with the server.

What is a session?

A session is a somewhat loose concept that connects multiple requests from a single client to the same site. A session is conventionally represented by a session ID (which is sometimes also called "access token" which can often be thought of to mean roughly the same thing), which is a string that is usually composed of alphanumeric characters with a specific length (eg. 64 characters) to represent a unique identifier for the session. For example:

68e656b251e67e8358bef8483ab0d51c6619f3e7a1a9f0e75838d41ff368f728

Aside from being a unique code, sessions are usually associated with other information that identifies the client such as its username, full name, location, IP address, etc. They also have TTL (time to live) for expiration purpose.

Session handling

In handling sessions, basically we will inspect each client request for its session ID and check for its validity then respond appropriately. Let's implement a small handler to do this in the initializeServer() method of our App.sling, our code will now look like this:

func initializeServer(server as HTTPServer) override as bool
{
	assert base.initializeServer(server):
		Log.error(ctx, "Failed to initialize HTTP server.")
	assert manager = DatabaseManager.forSQLDatabase(getDb()):
		Log.error(ctx, "Failed to initialize database manager.")
	var handler = new WebAPIHandler()
		.setManager(manager)
	server.pushRequestHandler(func(req as HTTPServerRequest, next as function) {
		var sessionId = req.getHeader("x-session")
		
		// Check session ID here and respond with a 'not authenticated' error
		// if there is no session ID provided or if it is invalid, otherwise call the next
		// callback function to forward the request to its assigned handler.
	})
	server.pushRequestHandler(handler)
	return true
}

This is not yet complete because there's no actual checking taking place. Let's proceed.

Data model for users

Let's create a data model for our authorized users of the server. Let's just make it simple by adding two properties: username and password. Create a data model class and name it as User.sling with the following contents:

class #dataModel:

import capex.data

prop username as string #dataField #primary
prop password as string #dataField

Adding users

When adding users, we wanna make sure that the credentials provided by our registering client are valid and composed of safe characters to avoid from malicious users from composing compromising usernames. Usernames should only be composed of alphanumeric characters (no whitespace characters) and with a length ranging from 8 to 15 characters. For passwords to make it strong, it should be atleast 8 characters long and should be a combination of uppercase and lowercase characters, numeric characters, and special characters (also no whitespace characters). Acceptable ones are:

`~!@#$%^&*()_-+={[}]|\:;"'<,>.?/

To do this, let's implement the validate() method of our User data model.

func validate(callback as function<void, Error>)
{
	assert String.isNotEmpty(username):
		callback(Error.instance("no_username", "Please specify your username."))
	var length = String.getLength(username)
	assert length >= 8 && length <= 15:
		callback(Error.instance("invalid_username_length", "Please specify your username with a length from 8 to 15 characters long."))
	var itr = assert String.iterate(username):
		callback(Error.instance("internal_error", "Internal error occured."))
	loop {
		var c = itr.getNextChar()
		if c < 1:
			break
		if((c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57)):
			continue
		callback(Error.instance("invalid_username", "Please specify your username with alphanumeric characters only (a-z, A-Z, 0-9)."))
		return
	}
	assert String.isNotEmpty(password):
		callback(Error.instance("no_password", "Please specify your password."))
	length = String.getLength(password)
	assert length >= 8 && length <= 15:
		callback(Error.instance("invalid_password_length", "Please specify your password with atleast 8 characters long."))
	var hasLowerAlpha = false
	var hasUpperAlpha = false
	var hasNumeric = false
	var hasSpecial = false
	assert itr = String.iterate(password):
		callback(Error.instance("internal_error", "Internal error occured."))
	loop {
		var c = itr.getNextChar()
		if c < 1:
			break
		if c > 32 && c < 127 {
			if c >= 65 && c <= 90 {
				hasUpperAlpha = true
			}
			else if c >= 97 && c <= 122 {
				hasLowerAlpha = true
			}
			else if c >= 48 && c <= 57 {
				hasNumeric = true
			}
			else {
				hasSpecial = true
			}
			continue
		}
		callback(Error.instance("invalid_password", "Please specify your password with a combination of uppercase and lowercase characters, numeric characters, and special characters (but not whitespace characters)."))
		return
	}
	assert hasLowerAlpha && hasUpperAlpha && hasNumeric && hasSpecial:
		callback(Error.instance("invalid_password", "Please specify your password with a combination of uppercase and lowercase characters, numeric characters, and special characters (but not whitespace characters)."))
	callback(null)
}

Securing passwords

When securing passwords, your goal is to make sure that no one (including you as the developer and/or administrator of the system) should ever be able to know or find out the user's password. SO PLEASE NEVER STORE PLAINTEXT PASSWORDS IN THE DATABASE!

The stored password values should never give any hints about the password, not even its length. Even two passwords that only differ with one character like password1 and password2 should not give any hints about it. So if you're thinking about encrypting the password, it's not an acceptable solution. And here's why:

Encryption turns data into a set of unreadable characters that aren't of a fixed length. And the encrypted value can be reversed back into its original decrypted form if you have the right key. Just imagine if your database gets stolen by the "bad guys", don't even expect they won't be able to determine all the users' passwords.

Password hashing

"Cryptographic hashing" is a kind of "one-way" encryption resulting to a fixed length. This means it cannot be decrypted back to its original form. During the hashing process, the original contents are completely lost. Hashing algorithms are designed so that it's impossible to recover the original form. It won't give any hints including the length of the actual password.

On of the most secure hashing algorithms is the "bcrypt". In this tutorial, we will be using the bcrypt hashing algorithm. In the DatabaseManager.sling, include the following import statement:

import capex.crypto

Then add the private class member bcrypt of type BCryptEncoder and its instantiation in the constructor:

var bcrypt private as BCryptEncoder
var random private as Random

ctor
{
	bcrypt = BCryptEncoder.create()
	random = new Random()
}

Then add this line for the number of bcrypt iteration to do when hashing:

const BCRYPT_ITERATION_COUNT = 12

Now let's implement the register() method.

func register(data as DynamicMap, callback as function<void, Error>)
{
	assert data:
		callback(Error.instance("no_data", "No data"))
	User.forValidDynamicMap(data, func(user as User, error as Error) {
		assert not error:
			callback(error)
		assert user:
			callback(Error.instance("internal_error", "Internal error occured."))
		User.SQL.queryByUsername(db, USERS_TABLE, user.getUsername(), func(existingUser as User) {
			assert not existingUser:
				callback(Error.instance("duplicate_username", "The username you specified already exists. Please specify another one and try again."))
			var passwordHash = bcrypt.hashPassword(user.getPassword(), bcrypt.generateSalt(BCRYPT_ITERATION_COUNT))
			assert String.isNotEmpty(passwordHash):
				callback(Error.instance("internal_error", "Internal error occured."))
			user.setPassword(passwordHash)
			User.SQL.insert(db, USERS_TABLE, user, func(success as bool) {
				assert success:
					callback(Error.instance("internal_error", "Internal error occured"))
				callback(null)
			})
		})
	})
}

Creating the registration handler

Now update the WebAPIHandler.sling and add the method handler for the registration:

POST "register"
{
	var data = assert req.getBodyJSONMap():
		req.sendJSONObject(JSONResponse.forInvalidRequest())
	manager.register(data, func(error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk())
	})
}

Take note that we should not check the register request for a session ID, so in the initializeServer() method in our App.sling, let's insert a checking that if the request is addressed to the register handler, it will be forwarded right away:

server.pushRequestHandler(func(req as HTTPServerRequest, next as function) {
	var path = req.peekResource()
	if String.equals("register", path) {
		next()
		return
	}
	var sessionId = req.getHeader("x-session")

	// Check session ID here and respond with a 'not authenticated' error
	// if there is no session ID provided or if it is invalid, otherwise call the next
	// callback function to forward the request to its assigned handler.
})

Completing the session handler

The next part is to complete our session handling.

Data model for sessions

Let's create a data model for our sessions with three properties: sessionId, username, and timeStamp. Create the data model class and name it Session.sling with the following contents:

class #dataModel:

prop sessionId as string #dataField #primary
prop username as string #dataField #index
prop timeStamp as long #dataField #index

Creating sessions

To create unique session IDs, we will use the generateCode() method to generate a random code and hash the result using the SHA hashing algorithm. To do this, add the sha private class member of type SHAEncoder and its instantiation in the constructor:

var bcrypt private as BCryptEncoder
var sha private as SHAEncoder
var random private as Random

ctor
{
	bcrypt = BCryptEncoder.create()
	sha = SHAEncoder.create()
	random = new Random()
}

Then add the following methods that we will use for creating session IDs.

func generateCode(prefix as string = null) private as string:
	return hashText(generateId(prefix))

func hashText(rawText as string) private as string:
	return sha.encodeAsString(String.toUTF8Buffer(rawText), SHAEncoder.SHA256)

Then let's implement the login() method.

func login(data as DynamicMap, callback as function<void, Session, Error>)
{
	assert data:
		callback(null, Error.instance("no_data", "No data"))
	var credentials = assert User.forDynamicMap(data):
		callback(null, Error.instance("internal_error", "Internal error occured."))
	assert String.isNotEmpty(credentials.getUsername()):
		callback(null, Error.instance("no_username", "Please specify your username."))
	assert String.isNotEmpty(credentials.getPassword()):
		callback(null, Error.instance("no_password", "Please specify your password."))
	User.SQL.queryByUsername(db, USERS_TABLE, credentials.getUsername(), func(user as User) {
		assert user:
			callback(null, Error.instance("invalid_username", "The username you specified is invalid. Please check your spelling and try again."))
		var password = credentials.getPassword()
		assert bcrypt.checkPassword(password, user.getPassword()):
			callback(null, Error.instance("incorrect_password", "You have entered an incorrect password. Please try again."))
		var session = new Session()
		session.setSessionId(generateCode())
		session.setUsername(user.getUsername())
		session.setTimeStamp(SystemClock.asUTCSeconds())
		Session.SQL.insert(db, SESSIONS_TABLE, session, func(success as bool) {
			assert success:
				callback(null, Error.instance("internal_error", "Internal error occured."))
			callback(session, null)
		})
	})
}

Now let's add the login method handler in our WebAPIHandler.sling which will use the login() method we did above.

POST "login"
{
	var data = assert req.getBodyJSONMap():
		req.sendJSONObject(JSONResponse.forInvalidRequest())
	manager.login(data, func(session as Session, error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		assert session:
			req.sendJSONObject(JSONResponse.forInternalError())
		req.sendJSONObject(JSONResponse.forOk(session.toDynamicMap()))
	})
}

Since we don't need to check for session ID when logging in, add the login path to the checking we did in the initializeServer() method in our App.sling:

server.pushRequestHandler(func(req as HTTPServerRequest, next as function) {
	var path = req.peekResource()
	if String.equals("register", path) || String.equals("login", path) {
		next()
		return
	}
	var sessionId = req.getHeader("x-session")

	// Check session ID here and respond with a 'not authenticated' error
	// if there is no session ID provided or if it is invalid, otherwise call the next
	// callback function to forward the request to its assigned handler.
})

Session maintenance

Let's implement the session maintenance which will be executed periodically to check for expired sessions and delete them. In this tutorial, let's set the session's TTL (time to live) to 24 hours. Add the property below:

prop sessionTTL as long = 60 * 60 * 24

Then add the onMaintenance() method:

func onMaintenance(ctx as LoggingContext)
{
	var now = SystemClock.asUTCSeconds()
	var expired = now - sessionTTL
	db.execute(db.prepare("DELETE FROM " .. SESSIONS_TABLE .. " WHERE timeStamp < ?;").addParamLongInteger(expired), func(success as bool) {
		assert success:
			Log.error(ctx, "Session Maintenance: Failed to delete sessions older than: '" .. String.forInteger(expired) .. "' now: '" .. String.forInteger(now) .. "'")
		Log.debug(ctx, "Session Maintenance: Deleted sessions older than: '" .. String.forInteger(expired) .. "' now: '" .. String.forInteger(now) .. "'")
	})
}

Then update the App.sling and implement the maintenance handler to call the method above.

func onMaintenance override:
	manager.onMaintenance(ctx)

Deleting sessions

Aside from the expired sessions, deleting sessions also takes place when the user logs-out from the system. Add the logout() method below:

func logout(session as Session, callback as function<void, Error>)
{
	Session.SQL.deleteFromTable(db, SESSIONS_TABLE, session, func(success as bool) {
		assert success:
			callback(Error.instance("internal_error", "Internal error occured."))
		callback(null)
	})
}

To complete this part, add the logout method handler in our WebAPIHandler.sling which will use the logout() method we did above.

DELETE "logout"
{
	manager.logout(req.getSession() as Session, func(error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		req.sendJSONObject(JSONResponse.forOk())
	})
}

Updating sessions

Updating the session will update its timestamp to the current Unix time which will extend its lifetime. This happens everytime the server inspects a request for a session ID and check its validity. Let's implement the checkSession() method that would do this.

func checkSession(sessionId as string, callback as function<void, Session, Error>)
{
	assert String.isNotEmpty(sessionId):
		callback(null, Error.instance("not_authenticated", "Not authenticated"))
	Session.SQL.queryBySessionId(db, SESSIONS_TABLE, sessionId, func(session as Session) {
		assert session:
			callback(null, Error.instance("not_authenticated", "Your session is invalid or has expired. Please login."))
		session.setTimeStamp(SystemClock.asUTCSeconds())
		Session.SQL.update(db, SESSIONS_TABLE, session, func(success as bool) {
			assert success:
				callback(null, Error.instance("internal_error", "Internal error occured."))
			callback(session, null)
		})
	})
}

Then let's update the initializeServer() method of our App.sling to call this method for session checking. Our code should now look like this:

server.pushRequestHandler(func(req as HTTPServerRequest, next as function) {
	var path = req.peekResource()
	if String.equals("register", path) || String.equals("login", path) {
		next()
		return
	}
	var sessionId = req.getHeader("x-session")
	manager.checkSession(sessionId, func(session as Session, error as Error) {
		assert not error:
			req.sendJSONObject(JSONResponse.forError(error))
		assert session:
			req.sendJSONObject(JSONResponse.forInternalError())
		req.setSession(session)
		next()
	})
})

Secure REST API Server is now complete

Our secure REST API server is now complete and ready. You can find the complete sample project source code as part of the Eqela Samples on GitHub (under subdirectory "src", look for the subdirectory "sling/srapiy").

What next?

Sling Tutorials: Learn how to develop in Sling


Twitter Facebook LinkedIn Youtube Slideshare Github