Author: Alexander Goryakin
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
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
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
Add the following to the init
function:
rawset(_G, 'ddl', { get_schema = get_schema })
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
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
After establishing the instance configuration and topology, execute the commands to build and run our cluster:
$ cartridge build
$ cartridge start -d
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
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:
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,
})
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,
})
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
})
Now let's add a sharding key based on the login
field:
utils.register_sharding_key('user_info', {'login'})
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'
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
}
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
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
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
- First, we use
vshard.router.bucket_id_mpcrc32
to calculate thebucket_id
parameter, which will be used to shard our entries. - Then we call the
insert
function from the space on the bucket with the calculatedbucket_id
, and pass a tuple consisting ofbucket_id
,uuid
,login
andpassword_hash
fields to this space. This call is performed using thevshard.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). - 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:
- Calculate the
bucket_id
by login. - Call the
get
function for the calculated bucket via thevshard.router.callbro
function. - 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
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
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
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>
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")
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
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)
We will use username
and password
fields to create credentials for authentication:
SimpleTarantoolCredentials credentials = new SimpleTarantoolCredentials(username, password);
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();
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;
}
};
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());
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());
}
}
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>
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;
}
}
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);
}
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;
}
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;
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();
}
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;
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;
}
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;
}
}
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);
}
}
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;
}
}
}
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);
}
}
}
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);
}
}
}
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
After the application is built, you can run it with:
$ java -jar ./target/authentication-example-1.0-SNAPSHOT.jar
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:
The result is:
Now let's check the authentication:
Check the user's data:
Trying to update the user's password:
Checking if the password was updated:
Deleting the user:
Checking the user's data again:
All requests are executed correctly, we receive the expected results.
Conclusion
As an example, we implemented an authentication system consisting of two applications:
- A Tarantool Cartridge application that implements the business logic for handling user information and data storage.
- 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.
Top comments (1)
Awesome :)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.