Developing an authentication system in Java+Tarantool

tarantool

tarantool

Posted on February 21, 2022

Developing an authentication system in Java+Tarantool

Author: Alexander Goryakin

Image description

My name is Alexander, I am a software engineer in the architecture and pre-sale department at VK. In this article, I'm going to show you how to build an authentication system based on Tarantool and Java. In pre-sales, we often have to implement such systems. There are plenty of authentication methods: by password, biometric data, SMS, etc. To make it simple, I'll show you how to implement password authentication.

This article should be useful for those who want to understand the construction of authentication systems. I will use a simple example to demonstrate all the main parts of such an architecture, how they relate to each other and how they work as a whole.

The authentication system verifies the authenticity of the data entered by the user. We encounter these systems everywhere, from operating systems to various services. There are many types of authentication: by login and password pair, with electronic signature, biometric data, etc. I chose the login-password pair as an example, as it's the most common and quite simple. And it also allows showing the basic features of Cartridge and Cartridge Java, with a fairly small amount of code. But first things first.

Fundamentals of authentication systems

In any authentication system, you can usually identify several elements as follows:

  • subject that will undergo the procedure;
  • characteristic of the subject, its distinguishing feature;
  • host of the authentication system, who is responsible for it and controls its operation;
  • authentication mechanism, that is, the operating principles of the system;
  • access control mechanism, which grants certain access rights to a subject.

The authentication mechanism can be provided by the software that verifies the authenticity of the subject characteristics: a web service, an operating system module, etc. Most often, the subject characteristics must be stored somewhere, which means there must be a database, MySQL or PostgreSQL, for example.

If there is no existing software that allows you to implement an authentication mechanism according to certain rules, you have to write it by yourself. Among these cases, I can list authentication by several characteristics, with complicated verification algorithms, etc.

What are Tarantool Cartridge and Cartridge Java?

Tarantool Cartridge is a framework for scaling and managing a cluster of multiple Tarantool instances. Besides creating a cluster, it also allows you to manage that cluster quite effectively, such as expanding it, automatically resharding it and implementing any role-based business logic.

To work with the cluster from an application, you need to use connectors”drivers for interaction with the database and the cluster using the iproto binary protocol. Tarantool currently has connectors for programming languages such as Go, Java, Python, to name a few. Some of them can only work with one instance of Tarantool, while others can work with entire clusters. One of those connectors is Cartridge Java. It allows you to interact with a cluster from a Java application. This brings up a reasonable question: why this particular language?

Why Java?

I work in the architecture and pre-sales department, which means that we make pilot projects for customers from different areas of business. By a pilot project, I mean a prototype of a system, that will later be finalized and handed over to the customer. That is why our customers are mostly people who use programming languages that allow them to create full enterprise solutions. One of those is Java, so we chose Cartridge Java connector for this example.

Why authentication process?

The next question that arises is the choice of a service on which we will demonstrate our technology. So why did we take authentication and not some other service? The answer is quite simple: this is the most common problem that people try to solve not only with Tarantool but with other databases as well.

Users encounter authentication in almost all more or less major applications. Most commonly, databases such as MySQL or PostgreSQL are used to store user profiles. However, using Tarantool here is most appropriate since it can handle tens of thousands of queries per second due to the fact that all data is stored in RAM. And if an instance crashes, it can recover rather quickly via snapshots and write-ahead logs.

Now let's get to the structure of our sample service. It will consist of two parts:

  • Tarantool Cartridge application, serving as a database;
  • Java application, providing an API for performing basic operations.

Let's start by looking at the first part of our service.

Tarantool Cartridge application

This application will provide a small cluster of one router, two sets of storage replicas, and one stateboard.

Router is an instance with the router role. It is responsible for routing requests to storage. We're going to expand its functionality a little bit. I'll explain how to do it further below.

Replica set (storage replica set) refers to a group of N instances with the storage role, of which one is the master, and the rest are its replicas. In our case, these are pairs of instances that act as profile storage.

Stateboard is responsible for configuring the failover mechanism of the cluster in case of failure of individual instances.

Creating and configuring an application

Let's create an application by executing

$ cartridge create --name authentication
Enter fullscreen mode Exit fullscreen mode

This will create "authentication" directory, containing everything you need to create a cluster. Let's define a list of instances in the instances.yml file:

---
authentication.router:
  advertise_uri: localhost:3301
  http_port: 8081

authentication.s1-master:
  advertise_uri: localhost:3302
  http_port: 8082

authentication.s1-replica:
  advertise_uri: localhost:3303
  http_port: 8083

authentication.s2-master:
  advertise_uri: localhost:3304
  http_port: 8084

authentication.s2-replica:
  advertise_uri: localhost:3305
  http_port: 8085

authentication-stateboard:
  listen: localhost:4401
  password: passwd
Enter fullscreen mode Exit fullscreen mode

Now we have to configure the roles.

Configuring roles

For our application to work with the Cartridge Java connector, we need to create and configure new roles. You can do this by copying the custom.lua file and renaming the copies into storage.lua and router.lua, placing them into the app/roles directory, and then changing the settings in them. First, change the name of the role”the value in the role_name field”in the return statement. In router.lua the role will be router and in storage.lua it will be storage. Second, specify the corresponding role names in init.lua in the roles section of the cartridge.cfg file.

In order to work with Cartridge Java, we need to install the ddl module by adding 'ddl == 1.3.0-1' to the dependencies section of the file with the .rockspec extension. And add the get_schema function to router.lua after that:

function get_schema()
    for _, instance_uri in pairs(cartridge_rpc.get_candidates('app.roles.storage', { leader_only = true })) do
        local conn = cartridge_pool.connect(instance_uri)
        return conn:call('ddl.get_schema', {})
    end
end
Enter fullscreen mode Exit fullscreen mode

Add the following to the init function:

rawset(_G, 'ddl', { get_schema = get_schema })
Enter fullscreen mode Exit fullscreen mode

In addition, add the following condition to the init function in storage.lua:

 if opts.is_master then
        rawset(_G, 'ddl', { get_schema = require('ddl').get_schema })
 end
Enter fullscreen mode Exit fullscreen mode

It means that we have to execute the rawset function on those storages which are masters. Now let's move on to defining the cluster topology.

Defining a cluster topology and launching the cluster

Let's specify the cluster topology in the replicasets.yml file:

router:
  instances:
  - router
  roles:
  - failover-coordinator
  - router
  all_rw: false
s-1:
  instances:
  - s1-master
  - s1-replica
  roles:
  - storage
  weight: 1
  all_rw: false
  vshard_group: default
s-2:
  instances:
  - s2-master
  - s2-replica
  roles:
  - storage
  weight: 1
  all_rw: false
  vshard_group: default
Enter fullscreen mode Exit fullscreen mode

After establishing the instance configuration and topology, execute the commands to build and run our cluster:

$ cartridge build
$ cartridge start -d
Enter fullscreen mode Exit fullscreen mode

The instances that we defined in instances.yml will be created and launched. Now we can access http://localhost:8081 in a browser to manage our cluster via GUI. All the created instances will be listed there. However, they are not configured or combined into replica sets as we described in replicasets.yml just yet. To avoid configuring instances manually, run the following:

$ cartridge replicasets setup -bootstrap-vshard
Enter fullscreen mode Exit fullscreen mode

If we check the list of our instances now, we'll see that the topology is now set up, that is, the instances have the appropriate roles assigned to them, and they are combined into replica sets:

Image description

Image description

Furthermore, the initial bootstrapping of the cluster was performed, which resulted in a working sharding. And now we can use our cluster!

Building a data model

Well, actually we can't make use of it just yet, since we don't have a proper data model to describe the user. Let's see, what do we need to describe the user? What kind of information about the user do we want to store? Since our example is quite simple, let's use the following fields as general information about the user:

  • uuid, user's unique identifier;
  • login, user's login;
  • password, the hash sum of the user's password.

These are the main fields that the data model will contain. They are sufficient for most cases when there are few users and the load is pretty low. But what happens when the number of users becomes immense? We would probably want to implement sharding, so we can distribute users to different storages, and those in turn to different servers or even different data centers. Then what field should we use to shard the users? There are two options, UUID and login. In this example, we're going to shard the users by login.

Most often, the sharding key is chosen so that a storage will contain records with the same sharding key, even if they belong to different spaces. But since there is only one space in our case, we can choose any field we like. After that, we have to decide which algorithm to use for sharding. Fortunately, this choice is not necessary because Tarantool Cartridge already has the vshard library, which uses a virtual sharding algorithm. To use this library, we need to add one more field to the data model, bucket_id. This field's value will be calculated based on the login field's value. And now we can describe our space in full:

local user_info = box.schema.create_space('user_info', {
            format = {
                { name = 'bucket_id', type = 'unsigned' },
                { name = 'uuid', type = 'string' },
                { name = 'login', type = 'string' },
                { name = 'password', type = 'string' },
            },
            if_not_exists = true,
        })
Enter fullscreen mode Exit fullscreen mode

To start using the space, we have to create at least one index. Let's create a primary index primary based on the login field:

user_info:create_index('primary', {
            parts = { 'login' },
            if_not_exists = true,
        })
Enter fullscreen mode Exit fullscreen mode

Since we are using vshard, we also need to create a secondary index based on the bucket_id field:

user_info:create_index('bucket_id', {
            parts = { 'bucket_id' },
            if_not_exists = true,
            unique = false
        })
Enter fullscreen mode Exit fullscreen mode

Now let's add a sharding key based on the login field:

utils.register_sharding_key('user_info', {'login'})
Enter fullscreen mode Exit fullscreen mode

Performing migrations

We'll use the migrations module to work with spaces. To do this, add this line to the dependencies section of the file with the .rockspec extension:

'migrations == 0.4.0-1'
Enter fullscreen mode Exit fullscreen mode

To use this module, create a migrations directory in the application's root directory and put a 0001_initial.lua file with the following contents there:

local utils = require('migrator.utils')

return {
    up = function()
        local user_info = box.schema.create_space('user_info', {
            format = {
                { name = 'bucket_id', type = 'unsigned' },
                { name = 'uuid', type = 'string' },
                { name = 'login', type = 'string' },
                { name = 'password', type = 'string' },
            },
            if_not_exists = true,
        })

        user_info:create_index('primary', {
            parts = { 'login' },
            if_not_exists = true,
        })

        user_info:create_index('bucket_id', {
            parts = { 'bucket_id' },
            if_not_exists = true,
            unique = false
        })

        utils.register_sharding_key('user_info', {'login'})

        return true
    end
}
Enter fullscreen mode Exit fullscreen mode

To create our space, we have to send a POST request to http://localhost:8081/migrations/up, such as this:

$ curl X POST http://localhost:8081/migrations/up
Enter fullscreen mode Exit fullscreen mode

By doing so, we perform the migration. To create new migrations, add new files with names beginning with 0002-…, to the migrations directory and run the same command.

Creating stored procedures

After constructing the data model and building the space for it, we need to create functions through which our Java application will interact with the cluster. Such functions are referred to as stored procedures. They are called on routers and they process the data by invoking certain space methods.

What kind of operations with user profiles do we want to perform? Since we want to use our cluster primarily as profile storage, it's obvious that we should have a function to create profiles. In addition, since this application is an example of authentication, we should be able to get information about the user by their login. And finally, we should have a function to update a user's information, in case a user forgets their password, for instance, and a function to delete a user if they want to delete their account.

Now that we have defined which basic stored procedures we want, it's time to implement them. The entire code for them will be stored in the app/roles/router.lua file. Let's start by implementing the user creation, but first we'll set up some auxiliary constants:

local USER_BUCKET_ID_FIELD = 1
local USER_UUID_FIELD = 2
local USER_LOGIN_FIELD = 3
local USER_PASSWORD_FIELD = 4
Enter fullscreen mode Exit fullscreen mode

As you can see from their names, these constants define the numbers of the corresponding fields in the space. These constants will allow us to use meaningful names when indexing the fields of the tuple in our stored procedures. Now let's move on to creating the first stored procedure. It will be named create_user and will receive UUID, username, and password hash as parameters.

function create_user(uuid, login, password_hash)
    local bucket_id = vshard.router.bucket_id_mpcrc32(login)

    local _, err = vshard.router.callrw(bucket_id, 'box.space.user_info:insert', {
        {bucket_id, uuid, login, password_hash }
    })

    if err ~= nil then
        log.error(err)
        return nil
    end

    return login
end
Enter fullscreen mode Exit fullscreen mode
  1. First, we use vshard.router.bucket_id_mpcrc32 to calculate the bucket_id parameter, which will be used to shard our entries.
  2. Then we call the insert function from the space on the bucket with the calculated bucket_id, and pass a tuple consisting of bucket_id, uuid, login and password_hash fields to this space. This call is performed using the vshard.router.callrw call of the vshard library, which allows write operations to the space and returns the result of the function being called (and an error if it fails).
  3. Finally, we check if our function has been executed successfully. If yes — the data was inserted into the space — we return the user's login. Otherwise, we return nil.

Now let's create the next stored procedure, the one for getting information about the user by their login. This one will be named get_user_by_login. We will apply the following algorithm to it:

  1. Calculate the bucket_id by login.
  2. Call the get function for the calculated bucket via the vshard.router.callbro function.
  3. If a user with the specified login exists, then we return the tuple with information about the user, otherwise return nil.

Implementation:

function get_user_by_login(login)

    local bucket_id = vshard.router.bucket_id_mpcrc32(login)

    local user = vshard.router.callbro(bucket_id, 'box.space.user_info:get', {login})
    return user
end
Enter fullscreen mode Exit fullscreen mode

Besides authentication, it will also be helpful in updating and deleting user information.

Let's consider the case where the user decided to update their information, for example, their password. We're going to write a function named update_user_by_login that will accept the user's login and the new password's hash. Which algorithm should we use for that task? Let's start by trying to get the user's information via the get_user_by_login function we have implemented. If the user doesn't exist, we'll return nil. Otherwise, we'll calculate bucket_id by the user's login and call the update function for our space on the bucket with the calculated id. We'll pass the user's login and the tuple containing information about the field we need to update — the new password hash — to this function. If an error occurred during the update, then we will log it and return nil, otherwise we will return the tuple with the user's information. In Lua, this function will look like this:

function update_user_by_login(login, new_password_hash)
    local user = get_user_by_login(login)

    if user ~= nil then
        local bucket_id = vshard.router.bucket_id_mpcrc32(user[USER_LOGIN_FIELD])

        local user, err = vshard.router.callrw(bucket_id, 'box.space.user_info:update', { user[USER_LOGIN_FIELD], {
            {'=', USER_PASSWORD_FIELD, new_password_hash }}
        })

        if err ~= nil then
            log.error(err)
            return nil
        end

        return user
    end

    return nil
end
Enter fullscreen mode Exit fullscreen mode

And lastly, let's implement the function for deleting a user. It will be named delete_user_by_login. The algorithm will be somewhat similar to the update function, the only difference being that if a user exists in the space, the delete function will be called and the information about the deleted user will be returned, otherwise the function will return nil. This stored procedure's implementation goes as follows:

function delete_user_by_login(login)

    local user = get_user_by_login(login)

    if user ~= nil then

        local bucket_id = vshard.router.bucket_id_mpcrc32(user[USER_LOGIN_FIELD])

        local _, _ = vshard.router.callrw(bucket_id, 'box.space.user_info:delete', {
            {user[USER_LOGIN_FIELD]}
        })

        return user
    end

    return nil

end
Enter fullscreen mode Exit fullscreen mode

What was done

  • We built an application.
  • Configured roles for it.
  • Set up a cluster topology.
  • Launched the cluster.
  • Described a data model and created migration logic.
  • Implemented stored procedures.

Now we can restart the cluster and start filling it with data. In the meantime, we'll move on to developing the Java application.

Java application

The Java application will serve as an API and will provide the business logic for user authentication. Since it's an enterprise application, we will create it using the Spring framework. We are going to use the Apache Maven framework to build it.

Setting up the connector

To set the connector, add the following dependency in the dependencies section of the pom.xml file:

<dependency>
     <groupId>io.tarantool</groupId>
     <artifactId>cartridge-driver</artifactId>
     <version>0.4.2</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

After that, we must update the dependencies. You can find the latest connector's version here. After installing the connector, we need to import the necessary classes from io.tarantool.driver package.

Connecting to the cluster

After setting up the connector, we need to create a class that will be responsible for its configuration and will connect the application to the Tarantool Cartridge cluster. Let's call this class TarantoolConfig. We will specify that it is a configuration class and that its parameters are defined in the application-tarantool.properties file:

@Configuration
@PropertySource(value="classpath:application-tarantool.properties", encoding = "UTF-8")
Enter fullscreen mode Exit fullscreen mode

The application-tarantool.properties file contains the following lines:

tarantool.nodes=localhost:3301 # node list
tarantool.username=admin # user name
tarantool.password=authentication-cluster-cookie # password
Enter fullscreen mode Exit fullscreen mode

They specify the values of the fields required to connect to the cluster. This is why the constructor of our class takes these parameters as input:

public TarantoolClient tarantoolClient(
            @Value("${tarantool.nodes}") String nodes,
            @Value("${tarantool.username}") String username,
            @Value("${tarantool.password}") String password)
Enter fullscreen mode Exit fullscreen mode

We will use username and password fields to create credentials for authentication:

SimpleTarantoolCredentials credentials = new SimpleTarantoolCredentials(username, password);
Enter fullscreen mode Exit fullscreen mode

Let's create a custom configuration for connecting to the cluster, namely specify the authentication parameters and the request timeout:

TarantoolClientConfig config = new TarantoolClientConfig.Builder()
                .withCredentials(credentials)
                .withRequestTimeout(1000*60)
                .build();
Enter fullscreen mode Exit fullscreen mode

Then we have to pass the list of nodes to the AddressProvider which converts a string into a list of addresses and returns this list:

TarantoolClusterAddressProvider provider = new TarantoolClusterAddressProvider() {
            @Override
            public Collection<TarantoolServerAddress> getAddresses() {
                ArrayList<TarantoolServerAddress> addresses = new ArrayList<>();

                for (String node: nodes.split(",")) {
                    String[] address = node.split(":");
                    addresses.add(new TarantoolServerAddress(address[0], Integer.parseInt(address[1])));
                }

                return addresses;
            }
        };
Enter fullscreen mode Exit fullscreen mode

Finally, let's create a client that will connect to the cluster. We wrap it into a proxy-client and return the result wrapped into a retrying-client, which, if the connection fails, tries to reconnect until it reaches the specified number of attempts:

ClusterTarantoolTupleClient clusterClient = new ClusterTarantoolTupleClient(config, provider);
        ProxyTarantoolTupleClient proxyClient = new ProxyTarantoolTupleClient(clusterClient);

        return new RetryingTarantoolTupleClient(
                proxyClient,
                TarantoolRequestRetryPolicies.byNumberOfAttempts(
                        10, e -> e.getMessage().contains("Unsuccessful attempt")
                ).build());
Enter fullscreen mode Exit fullscreen mode

Full code of the class:

@Configuration
@PropertySource(value="classpath:application-tarantool.properties", encoding = "UTF-8")
public class TarantoolConfig {

    @Bean
    public TarantoolClient tarantoolClient(
            @Value("${tarantool.nodes}") String nodes,
            @Value("${tarantool.username}") String username,
            @Value("${tarantool.password}") String password) {

        SimpleTarantoolCredentials credentials = new SimpleTarantoolCredentials(username, password);

        TarantoolClientConfig config = new TarantoolClientConfig.Builder()
                .withCredentials(credentials)
                .withRequestTimeout(1000*60)
                .build();

        TarantoolClusterAddressProvider provider = new TarantoolClusterAddressProvider() {
            @Override
            public Collection<TarantoolServerAddress> getAddresses() {
                ArrayList<TarantoolServerAddress> addresses = new ArrayList<>();

                for (String node: nodes.split(",")) {
                    String[] address = node.split(":");
                    addresses.add(new TarantoolServerAddress(address[0], Integer.parseInt(address[1])));
                }

                return addresses;
            }
        };

        ClusterTarantoolTupleClient clusterClient = new ClusterTarantoolTupleClient(config, provider);
        ProxyTarantoolTupleClient proxyClient = new ProxyTarantoolTupleClient(clusterClient);

        return new RetryingTarantoolTupleClient(
                proxyClient,
                TarantoolRequestRetryPolicies.byNumberOfAttempts(
                        10, e -> e.getMessage().contains("Unsuccessful attempt")
                ).build());
    }
}
Enter fullscreen mode Exit fullscreen mode

The application will connect to the cluster after the first request was sent to Tarantool on the application's launching. Now let's move on to creating an API and a user data model for our application.

Creating an API and a user data model

We're going to use the OpenAPI specification of version 3.0.3. Let's create three endpoints, each of which will accept and process the corresponding types of requests:

  • /register
    • POST, creating a user.
  • /login
    • POST, user authentication.
  • /{login}
    • GET, obtaining user information;
    • PUT, updating user information;
    • DELETE, deleting a user.

We will also add descriptions for the methods that handle each request we send and each response the application returns:

  • authUserRequest
  • authUserResponse
  • createUserRequest
  • createUserResponse
  • getUserInfoResponse
  • updateUserRequest

The stored procedures we've implemented in Lua will be called by controllers when processing these methods.

Now we need to generate classes that correspond to the described methods and responses. We'll use the swagger-codegen plugin for that. Add the plugin description to the build section of the pom.xml file:

<plugin>
   <groupId>io.swagger.codegen.v3</groupId>
   <artifactId>swagger-codegen-maven-plugin</artifactId>
   <version>3.0.21</version>
   <executions>
      <execution>
         <id>api</id>
         <goals>
            <goal>generate</goal>
          </goals>
          <configuration>
             <inputSpec>${project.basedir}/src/main/resources/api.yaml</inputSpec>
             <language>java</language>
             <modelPackage>org.tarantool.models.rest</modelPackage>
             <output>${project.basedir}</output>
             <generateApis>false</generateApis>
             <generateSupportingFiles>false</generateSupportingFiles>
             <generateModelDocumentation>false</generateModelDocumentation>
             <generateModelTests>false</generateModelTests>
             <configOptions>
                <dateLibrary>java8</dateLibrary>
                <library>resttemplate</library>
                <useTags>true</useTags>
                <hideGenerationTimestamp>true</hideGenerationTimestamp>
             </configOptions>
         </configuration>
      </execution>
   </executions>
</plugin>
Enter fullscreen mode Exit fullscreen mode

In these lines, we specify the path to the api.yaml file that describes the API, and the path to the directory where the generated Java files are to be placed. After running the build, we will get the generated request and response classes, which we are going to use when creating controllers.

Let's move on to creating a user data model. The corresponding class will be called UserModel and we'll place it in the models directory. In the same directory, in its rest subdirectory, there are also the classes for requests and responses. The model will describe the user and will contain three private fields: uuid, login and password. It will also have getters and setters to access these fields. So, our data model's class goes as follows:

public class UserModel {

    String uuid;
    String login;
    String password;

    public String getUuid() {
        return uuid;
    }

    public void setUuid(String uuid) {
        this.uuid = uuid;
    }

    public String getLogin() {
        return login;
    }

    public void setLogin(String login) {
        this.login = login;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
Enter fullscreen mode Exit fullscreen mode

Creating services and controllers

In order to work with Tarantool when processing queries, we are going to use services that allow us to hide all the logic by calling methods of a certain class. We're going to use four basic methods:

  • getUserByLogin to get the user's information by their login;
  • createUser to create a new user;
  • updateUser to update the information of a user;
  • deleteUser to delete a user by their login.

To describe the basic service, let's create an interface that contains the signatures of these four methods, and then inherit the service that will contain our Tarantool logic from it. We'll call it StorageService:

public interface StorageService {

    UserModel getUserByLogin(String login);

    String createUser(CreateUserRequest request);

    boolean updateUser(String login, UpdateUserRequest request);

    boolean deleteUser(String login);
}
Enter fullscreen mode Exit fullscreen mode

Now, let's create the TarantoolStorageService class inherited from this interface. First, we must create a constructor for this class that will take TarantoolClient as input to be able to make queries to Tarantool. Let's save the client in a private variable and add the final modifier to it:

private final TarantoolClient tarantoolClient;

    public TarantoolStorageService(TarantoolClient tarantoolClient) {
        this.tarantoolClient = tarantoolClient;
    }
Enter fullscreen mode Exit fullscreen mode

Now let's override the method of getting the user by login. First, we create a variable userTuple of List<ObjРµct> type initialized by the null value:

List<Object> userTuple = null;
Enter fullscreen mode Exit fullscreen mode

After the initialization, we try to execute tarantoolClient's method call, which will result in Future. Since this method is asynchronous, we call the get method with 0 argument to get the result of its execution. If an exception is thrown during the call method execution, we should catch it and log it to the console.

try {
    userTuple = (List<Object>) tarantoolClient.call("get_user_by_login",login).get().get(0);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
}
Enter fullscreen mode Exit fullscreen mode

And if the method was executed successfully, we create an object of the UserModel class, fill all the fields and return it. Otherwise, we return null.

if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));

            return user;
        }

        return null;
Enter fullscreen mode Exit fullscreen mode

Full code of the getUserByLogin method:

public UserModel getUserByLogin(String login) {

        List<Object> userTuple = null;

        try {
            userTuple = (List<Object>) tarantoolClient.call("get_user_by_login", login).get().get(0);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));

            return user;
        }

        return null;
    }
Enter fullscreen mode Exit fullscreen mode

We override other methods in the same way, but with some changes. Since the logic is quite similar to the one presented above, I'll just provide the full code of this class:

@Service
public class TarantoolStorageService implements StorageService{

    private final TarantoolClient tarantoolClient;

    public TarantoolStorageService(TarantoolClient tarantoolClient) {
        this.tarantoolClient = tarantoolClient;
    }

    @Override
    public UserModel getUserByLogin(String login) {

        List<Object> userTuple = null;

        try {
            userTuple = (List<Object>) tarantoolClient.call("get_user_by_login", login).get().get(0);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        if(userTuple != null) {
            UserModel user = new UserModel();
            user.setUuid((String)userTuple.get(1));
            user.setLogin((String)userTuple.get(2));
            user.setPassword((String)userTuple.get(3));

            return user;
        }

        return null;
    }

    @Override
    public String createUser(CreateUserRequest request) {

        String uuid = UUID.randomUUID().toString();
        List<Object> userTuple = null;

        try {
            userTuple = (List<Object>) tarantoolClient.call("create_user",
                    uuid,
                    request.getLogin(),
                    DigestUtils.md5DigestAsHex(request.getPassword().getBytes())
            ).get();
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        if(userTuple != null) {
            return (String) userTuple.get(0);
        }

        return null;
    }

    @Override
    public boolean updateUser(String login, UpdateUserRequest request) {

        List<Object> userTuple = null;

        try {
            userTuple = (List<Object>) tarantoolClient.call("update_user_by_login",
                    login, DigestUtils.md5DigestAsHex(request.getPassword().getBytes())
            ).get().get(0);
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        return userTuple != null;
    }

    @Override
    public boolean deleteUser(String login) {
        List<Object> userTuple = null;

        try {
            userTuple = (List<Object>) tarantoolClient.call("delete_user_by_login",
                    login
            ).get().get(0);
        } catch(InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        return userTuple != null;
    }
}
Enter fullscreen mode Exit fullscreen mode

After implementing this auxiliary service, we need to create services that contain user authentication and modification logic. The service for modifying and retrieving information about the user will be called UserService. It is quite straightforward in its implementation, as it's initialized by an object of the StorageService class and simply calls the methods defined in it. So I'll just provide the full code for this class, too:

@Service
public class UserService {
    private final StorageService storageService;

    public UserService(StorageService storageService) {
        this.storageService = storageService;
    }

    public String createUser(CreateUserRequest request) {
        return this.storageService.createUser(request);
    }

    public boolean deleteUser(String login) {
        return this.storageService.deleteUser(login);
    }

    public UserModel getUserByLogin(String login) {
        return this.storageService.getUserByLogin(login);
    }

    public boolean updateUser(String login, UpdateUserRequest request) {
        return this.storageService.updateUser(login, request);
    }
}
Enter fullscreen mode Exit fullscreen mode

The second service, which authenticates the user, we will call AuthenticationService. It will also be initialized with an object of the StorageService class and will contain only one method, authenticate, responsible for user authentication. How exactly is the authentication performed? This method calls the user's information from Tarantool by the user's login. Then it calculates the MD5 hash of the password and compares it with the one received from Tarantool. If the hashes match, the method returns a token, which for simplicity is just the user UUID, otherwise, it returns null. Full code of the AuthenticationService class:

@Service
public class AuthenticationService {

    private final StorageService storageService;

    public AuthenticationService(StorageService storageService) {
        this.storageService = storageService;
    }

    public AuthUserResponse authenticate(String login, String password) {
        UserModel user = storageService.getUserByLogin(login);

        if(user == null) {
            return null;
        }

        String passHash = DigestUtils.md5DigestAsHex(password.getBytes());

        if (user.getPassword().equals(passHash)) {

            AuthUserResponse response = new AuthUserResponse();
            response.setAuthToken(user.getUuid());
            return response;

        } else {
            return null;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's create two controllers responsible for authentication of the user and processing their information. The first one will be AuthenticationController, and the second one will be UserController.

Let's start with the AuthenticationController. Each controller is initialized with its own service, so we initialize the first one with an object of the AuthenticationService class. Our controller will also contain a mapping to the /login endpoint. It will parse the request, call the authenticate method of the service, and — based on the result of the call — return either UUID and code 200 or code 403 (Forbidden). Full code for this controller:

@RestController
public class AuthenticationController {
    private final AuthenticationService authenticationService;

    public AuthenticationController(AuthenticationService authenticationService) {
        this.authenticationService = authenticationService;
    }

    @PostMapping(value = "/login", produces={"application/json"})
    public ResponseEntity<AuthUserResponse> authenticate(@RequestBody AuthUserRequest request) {

        String login = request.getLogin();
        String password = request.getPassword();

        AuthUserResponse response = this.authenticationService.authenticate(login, password);

        if(response != null) {

            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.FORBIDDEN);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The second controller, UserController, will be initialized with an object of the UserService class. It will contain mappings to the /register and /{login} endpoints. This controller's full code:

@RestController
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping(value = "/register", produces={"application/json"})
    public ResponseEntity<CreateUserResponse> createUser(
            @RequestBody CreateUserRequest request) {
        String login = this.userService.createUser(request);

        if(login != null) {

            CreateUserResponse response = new CreateUserResponse();
            response.setLogin(login);

            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }

    @GetMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<GetUserInfoResponse> getUserInfo(
            @PathVariable("login") String login) {
        UserModel model = this.userService.getUserByLogin(login);
        if(model != null) {
            GetUserInfoResponse response = new GetUserInfoResponse();
            response.setUuid(model.getUuid());
            response.setLogin(model.getLogin());
            response.setPassword(model.getPassword());

            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .body(response);
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @PutMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<Void> updateUser(
            @PathVariable("login") String login,
            @RequestBody UpdateUserRequest request) {
        boolean updated = this.userService.updateUser(login, request);

        if(updated) {
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .build();
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @DeleteMapping(value = "/{login}", produces={"application/json"})
    public ResponseEntity<Void> deleteUser(
            @PathVariable("login") String login) {
        boolean deleted = this.userService.deleteUser(login);

        if(deleted) {
            return ResponseEntity.status(HttpStatus.OK)
                    .cacheControl(CacheControl.noCache())
                    .build();
        } else {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

This concludes the development of our Java application. All that's left to do now is build it. You can do that by running

$ mvn clean package
Enter fullscreen mode Exit fullscreen mode

After the application is built, you can run it with:

$ java -jar ./target/authentication-example-1.0-SNAPSHOT.jar
Enter fullscreen mode Exit fullscreen mode

Now we have finished developing our service! You can see its full code here.

What was done

  • Installed the Java connector.
  • Set up a connection to the cluster.
  • Developed an API.
  • Created controllers and services.
  • Built our application.

What's left to do is to test the service.

Checking if the service works

Let's check how correctly each of the requests is being processed. We'll use Postman for that task. We will use a test user with login1 as their username and password1 as their password.

We start by creating a new user. The request will look like this:

Image description

The result is:

Image description

Now let's check the authentication:
Image description

Check the user's data:

Image description

Trying to update the user's password:
Image description

Checking if the password was updated:
Image description

Deleting the user:

Image description

Trying to authenticate again:
Image description

Checking the user's data again:

Image description

All requests are executed correctly, we receive the expected results.

Conclusion

As an example, we implemented an authentication system consisting of two applications:

  1. A Tarantool Cartridge application that implements the business logic for handling user information and data storage.
  2. A Java application providing an API for authentication.

Tarantool Cartridge is a framework for scaling and managing a cluster of multiple Tarantool instances, and also for developing cluster applications.

We used the Cartridge Java Connector, which replaced the outdated Tarantool Java Connector, to communicate between the applications we wrote. It allows you to work not only with single instances of Tarantool, but also with entire clusters, which makes the connector more versatile and irreplaceable for developing enterprise applications.

Links

💖 💪 🙅 🚩
tarantool
tarantool

Posted on February 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related