Access control and authentication

Vert.x provides a wide range of options for doing authentication and authorization. The officially supported modules cover JDBC, MongoDB, Apache Shiro, htdigest files, OAuth2 with well-known providers and JWT (JSON web tokens).

While the next section will cover JWT, this one focuses on using JDBC-based authentication, reusing the wiki database.

The goal is to require users to authenticate for using the wiki, and have role-based permissions:

  • having no role only allows reading pages,

  • having a writer role allows editing pages,

  • having an editor role allows creating, editing and deleting pages,

  • having an admin role is equivalent to having all possible roles.

Refactoring the JDBC configuration

In the previous steps, only the database verticle and service where aware of the JDBC configuration. Now the HTTP service vertice also needs to share the same JDBC access.

To do that we extract configuration parameters and default values to an interface:

  1. package io.vertx.guides.wiki;
  2. public interface DatabaseConstants {
  3. String CONFIG_WIKIDB_JDBC_URL = "wikidb.jdbc.url";
  4. String CONFIG_WIKIDB_JDBC_DRIVER_CLASS = "wikidb.jdbc.driver_class";
  5. String CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE = "wikidb.jdbc.max_pool_size";
  6. String DEFAULT_WIKIDB_JDBC_URL = "jdbc:hsqldb:file:db/wiki";
  7. String DEFAULT_WIKIDB_JDBC_DRIVER_CLASS = "org.hsqldb.jdbcDriver";
  8. int DEFAULT_JDBC_MAX_POOL_SIZE = 30;
  9. }

Now in WikiDatabaseVerticle we use these constants as follows:

  1. JDBCClient dbClient = JDBCClient.createShared(vertx, new JsonObject()
  2. .put("url", config().getString(CONFIG_WIKIDB_JDBC_URL, DEFAULT_WIKIDB_JDBC_URL))
  3. .put("driver_class", config().getString(CONFIG_WIKIDB_JDBC_DRIVER_CLASS, DEFAULT_WIKIDB_JDBC_DRIVER_CLASS))
  4. .put("max_pool_size", config().getInteger(CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, DEFAULT_JDBC_MAX_POOL_SIZE)));

Adding JDBC authentication to routes

The first step is to add the vertx-auth-jdbc module to the Maven dependencies list:

  1. <dependency>
  2. <groupId>io.vertx</groupId>
  3. <artifactId>vertx-auth-jdbc</artifactId>
  4. </dependency>

Back to the HttpServerVerticle class code, we create an authentication provider:

  1. JDBCClient dbClient = JDBCClient.createShared(vertx, new JsonObject()
  2. .put("url", config().getString(CONFIG_WIKIDB_JDBC_URL, DEFAULT_WIKIDB_JDBC_URL))
  3. .put("driver_class", config().getString(CONFIG_WIKIDB_JDBC_DRIVER_CLASS, DEFAULT_WIKIDB_JDBC_DRIVER_CLASS))
  4. .put("max_pool_size", config().getInteger(CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, DEFAULT_JDBC_MAX_POOL_SIZE)));
  5. JDBCAuth auth = JDBCAuth.create(vertx, dbClient);

The JDBCAuth object instance is then used to deal with server-side user sessions:

  1. Router router = Router.router(vertx);
  2. router.route().handler(CookieHandler.create());
  3. router.route().handler(BodyHandler.create());
  4. router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));
  5. router.route().handler(UserSessionHandler.create(auth)); (1)
  6. AuthHandler authHandler = RedirectAuthHandler.create(auth, "/login"); (2)
  7. router.route("/").handler(authHandler); (3)
  8. router.route("/wiki/*").handler(authHandler);
  9. router.route("/action/*").handler(authHandler);
  10. router.get("/").handler(this::indexHandler);
  11. router.get("/wiki/:page").handler(this::pageRenderingHandler);
  12. router.post("/action/save").handler(this::pageUpdateHandler);
  13. router.post("/action/create").handler(this::pageCreateHandler);
  14. router.get("/action/backup").handler(this::backupHandler);
  15. router.post("/action/delete").handler(this::pageDeletionHandler);
  1. We install a user session handler for all routes.

  2. This automatically redirects requests to /login when there is no user session for the request.

  3. We install authHandler for all routes where authentication is required.

Finally, we need to create 3 routes for displaying a login form, handling login form submissions and logging out users:

  1. router.get("/login").handler(this::loginHandler);
  2. router.post("/login-auth").handler(FormLoginHandler.create(auth)); (1)
  3. router.get("/logout").handler(context -> {
  4. context.clearUser(); (2)
  5. context.response()
  6. .setStatusCode(302)
  7. .putHeader("Location", "/")
  8. .end();
  9. });
  1. FormLoginHandler is a helper for processing login submission requests. By default it expects the HTTP POST request to have: username as the login, password as the password, and return_url as the URL to redirect to upon success.

  2. Logging out a user is a simple as clearing it from the current RoutingContext.

The code for the loginHandler method is:

  1. private void loginHandler(RoutingContext context) {
  2. context.put("title", "Login");
  3. templateEngine.render(context.data(), "templates/login.ftl", ar -> {
  4. if (ar.succeeded()) {
  5. context.response().putHeader("Content-Type", "text/html");
  6. context.response().end(ar.result());
  7. } else {
  8. context.fail(ar.cause());
  9. }
  10. });
  11. }

The HTML template is placed in src/main/resources/templates/login.ftl:

  1. <#include "header.ftl">
  2. <div class="row">
  3. <div class="col-md-12 mt-1">
  4. <form action="/login-auth" method="POST">
  5. <div class="form-group">
  6. <input type="text" name="username" placeholder="login">
  7. <input type="password" name="password" placeholder="password">
  8. <input type="hidden" name="return_url" value="/">
  9. <button type="submit" class="btn btn-primary">Login</button>
  10. </div>
  11. </form>
  12. </div>
  13. </div>
  14. <#include "footer.ftl">

The login page looks as follows:

login form

Supporting features based on roles

Features need to be activated only if the user has sufficient permissions. In the following screenshot an administrator can create a page and perform a backup:

as root

By contrast a user with no role cannot perform these actions:

as baz

To do that, we can access the RoutingContext user reference, and query for permissions. Here is how this is implemented for the indexHandler handler method:

  1. private void indexHandler(RoutingContext context) {
  2. context.user().isAuthorized("create", res -> { (1)
  3. boolean canCreatePage = res.succeeded() && res.result(); (2)
  4. dbService.fetchAllPages(reply -> {
  5. if (reply.succeeded()) {
  6. context.put("title", "Wiki home");
  7. context.put("pages", reply.result().getList());
  8. context.put("canCreatePage", canCreatePage); (3)
  9. context.put("username", context.user().principal().getString("username")); (4)
  10. templateEngine.render(context.data(), "templates/index.ftl", ar -> {
  11. if (ar.succeeded()) {
  12. context.response().putHeader("Content-Type", "text/html");
  13. context.response().end(ar.result());
  14. } else {
  15. context.fail(ar.cause());
  16. }
  17. });
  18. } else {
  19. context.fail(reply.cause());
  20. }
  21. });
  22. });
  23. }
  1. This is how a permission query is made. Note that this is an asynchronous operation.

  2. We use the result to…​

  3. …​leverage it in the HTML template.

  4. We also have access to the user login.

The template code has been modified to only render certain fragments based on the value of canCreatePage:

  1. <#include "header.ftl">
  2. <div class="row">
  3. <div class="col-md-12 mt-1">
  4. <#if canCreatePage>
  5. <div class="float-right">
  6. <form class="form-inline" action="/action/create" method="post">
  7. <div class="form-group">
  8. <input type="text" class="form-control" id="name" name="name" placeholder="New page name">
  9. </div>
  10. <button type="submit" class="btn btn-primary">Create</button>
  11. </form>
  12. </div>
  13. </#if>
  14. <h1 class="display-4">${title}</h1>
  15. <div class="float-right">
  16. <a class="btn btn-outline-danger" href="/logout" role="button" aria-pressed="true">Logout (${username})</a>
  17. </div>
  18. </div>
  19. <div class="col-md-12 mt-1">
  20. <#list pages>
  21. <h2>Pages:</h2>
  22. <ul>
  23. <#items as page>
  24. <li><a href="/wiki/${page}">${page}</a></li>
  25. </#items>
  26. </ul>
  27. <#else>
  28. <p>The wiki is currently empty!</p>
  29. </#list>
  30. <#if canCreatePage>
  31. <#if backup_gist_url?has_content>
  32. <div class="alert alert-success" role="alert">
  33. Successfully created a backup:
  34. <a href="${backup_gist_url}" class="alert-link">${backup_gist_url}</a>
  35. </div>
  36. <#else>
  37. <p>
  38. <a class="btn btn-outline-secondary btn-sm" href="/action/backup" role="button" aria-pressed="true">Backup</a>
  39. </p>
  40. </#if>
  41. </#if>
  42. </div>
  43. </div>
  44. <#include "footer.ftl">

The code is similar for ensuring that updating or deleting a page is restricted to certain roles and is available from the guide Git repository.

It is important to ensure that checks are also being done on HTTP POST request handlers and not just when rendering HTML pages. Indeed, malicious attackers could still craft requests and perform actions while not being authenticated. Here is how to protect page deletions by wrapping the pageDeletionHandler code inside a topmost permission check:

  1. private void pageDeletionHandler(RoutingContext context) {
  2. context.user().isAuthorized("delete", res -> {
  3. if (res.succeeded() && res.result()) {
  4. // Original code:
  5. dbService.deletePage(Integer.valueOf(context.request().getParam("id")), reply -> {
  6. if (reply.succeeded()) {
  7. context.response().setStatusCode(303);
  8. context.response().putHeader("Location", "/");
  9. context.response().end();
  10. } else {
  11. context.fail(reply.cause());
  12. }
  13. });
  14. } else {
  15. context.response().setStatusCode(403).end();
  16. }
  17. });
  18. }

Populating the database with user and role data

A last step is required for assembling all pieces of our authentication puzzle. We leave adding user registration and management as an exercice left to the reader, and instead we add some code to ensure that the database is being populated with some roles and accounts:

LoginPasswordRoles

root

admin

admin (create, delete, update)

foo

bar

editor (create, delete, update), writer (update)

bar

baz

writer (update)

baz

baz

/

The vertx-auth-jdbc prescribes a default database schema with 3 tables: 1 for users (with salted passwords), 1 for role permissions, and 1 to map users to roles. That schema can be changed and configured, but in many cases sticking to the default is a very good option.

To do that, we are going to deploy a verticle whose sole role is performing the initialisation work:

  1. package io.vertx.guides.wiki.http;
  2. import io.vertx.core.AbstractVerticle;
  3. import io.vertx.core.AsyncResult;
  4. import io.vertx.core.Handler;
  5. import io.vertx.core.json.JsonObject;
  6. import io.vertx.ext.jdbc.JDBCClient;
  7. import io.vertx.ext.sql.ResultSet;
  8. import io.vertx.ext.sql.SQLConnection;
  9. import org.slf4j.Logger;
  10. import org.slf4j.LoggerFactory;
  11. import java.util.Arrays;
  12. import java.util.List;
  13. import static io.vertx.guides.wiki.DatabaseConstants.*;
  14. public class AuthInitializerVerticle extends AbstractVerticle {
  15. private final Logger logger = LoggerFactory.getLogger(AuthInitializerVerticle.class);
  16. @Override
  17. public void start() throws Exception {
  18. List<String> schemaCreation = Arrays.asList(
  19. "create table if not exists user (username varchar(255), password varchar(255), password_salt varchar(255));",
  20. "create table if not exists user_roles (username varchar(255), role varchar(255));",
  21. "create table if not exists roles_perms (role varchar(255), perm varchar(255));"
  22. );
  23. /*
  24. * Passwords:
  25. * root / admin
  26. * foo / bar
  27. * bar / baz
  28. * baz / baz
  29. */
  30. List<String> dataInit = Arrays.asList( (1)
  31. "insert into user values ('root', 'C705F9EAD3406D0C17DDA3668A365D8991E6D1050C9A41329D9C67FC39E53437A39E83A9586E18C49AD10E41CBB71F0C06626741758E16F9B6C2BA4BEE74017E', '017DC3D7F89CD5E873B16E6CCE9A2307C8E3D9C5758741EEE49A899FFBC379D8');",
  32. "insert into user values ('foo', 'C3F0D72C1C3C8A11525B4563BAFF0E0F169114DE36796A595B78A373C522C0FF81BC2A683E2CB882A077847E8FD4DA09F1993072A4E9D7671313E4E5DB898F4E', '017DC3D7F89CD5E873B16E6CCE9A2307C8E3D9C5758741EEE49A899FFBC379D8');",
  33. "insert into user values ('bar', 'AEDD3E9FFCB847596A0596306A4303CC61C43D9904A0184951057D07D2FE2F36FA855C58EBCA9F3AEC9C65C46656F393E3D0F8711881F250D0D860F143A7A281', '017DC3D7F89CD5E873B16E6CCE9A2307C8E3D9C5758741EEE49A899FFBC379D8');",
  34. "insert into user values ('baz', 'AEDD3E9FFCB847596A0596306A4303CC61C43D9904A0184951057D07D2FE2F36FA855C58EBCA9F3AEC9C65C46656F393E3D0F8711881F250D0D860F143A7A281', '017DC3D7F89CD5E873B16E6CCE9A2307C8E3D9C5758741EEE49A899FFBC379D8');",
  35. "insert into roles_perms values ('editor', 'create');",
  36. "insert into roles_perms values ('editor', 'delete');",
  37. "insert into roles_perms values ('editor', 'update');",
  38. "insert into roles_perms values ('writer', 'update');",
  39. "insert into roles_perms values ('admin', 'create');",
  40. "insert into roles_perms values ('admin', 'delete');",
  41. "insert into roles_perms values ('admin', 'update');",
  42. "insert into user_roles values ('root', 'admin');",
  43. "insert into user_roles values ('foo', 'editor');",
  44. "insert into user_roles values ('foo', 'writer');",
  45. "insert into user_roles values ('bar', 'writer');"
  46. );
  47. JDBCClient dbClient = JDBCClient.createShared(vertx, new JsonObject()
  48. .put("url", config().getString(CONFIG_WIKIDB_JDBC_URL, DEFAULT_WIKIDB_JDBC_URL))
  49. .put("driver_class", config().getString(CONFIG_WIKIDB_JDBC_DRIVER_CLASS, DEFAULT_WIKIDB_JDBC_DRIVER_CLASS))
  50. .put("max_pool_size", config().getInteger(CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, DEFAULT_JDBC_MAX_POOL_SIZE)));
  51. dbClient.getConnection(car -> {
  52. if (car.succeeded()) {
  53. SQLConnection connection = car.result();
  54. connection.batch(schemaCreation, ar -> schemaCreationHandler(dataInit, connection, ar));
  55. } else {
  56. logger.error("Cannot obtain a database connection", car.cause());
  57. }
  58. });
  59. }
  60. private void schemaCreationHandler(List<String> dataInit, SQLConnection connection, AsyncResult<List<Integer>> ar) {
  61. if (ar.succeeded()) {
  62. connection.query("select count(*) from user;", testQueryHandler(dataInit, connection));
  63. } else {
  64. connection.close();
  65. logger.error("Schema creation failed", ar.cause());
  66. }
  67. }
  68. private Handler<AsyncResult<ResultSet>> testQueryHandler(List<String> dataInit, SQLConnection connection) {
  69. return ar -> {
  70. if (ar.succeeded()) {
  71. if (ar.result().getResults().get(0).getInteger(0) == 0) {
  72. logger.info("Need to insert data");
  73. connection.batch(dataInit, batchInsertHandler(connection));
  74. } else {
  75. logger.info("No need to insert data");
  76. connection.close();
  77. }
  78. } else {
  79. connection.close();
  80. logger.error("Could not check the number of users in the database", ar.cause());
  81. }
  82. };
  83. }
  84. private Handler<AsyncResult<List<Integer>>> batchInsertHandler(SQLConnection connection) {
  85. return ar -> {
  86. if (ar.succeeded()) {
  87. logger.info("Successfully inserted data");
  88. } else {
  89. logger.error("Could not insert data", ar.cause());
  90. }
  91. connection.close();
  92. };
  93. }
  94. }
  1. The hashed values can be generated using auth.computeHash(password, salt) where auth is a JDBCAuth instance. Salt values can also be generated using auth.generateSalt().

This impacts the MainVerticle class, as we now deploy it first:

  1. public class MainVerticle extends AbstractVerticle {
  2. @Override
  3. public void start(Promise<Void> promise) {
  4. Promise<String> dbDeploymentPromise = Promise.promise();
  5. vertx.deployVerticle(new WikiDatabaseVerticle(), dbDeploymentPromise);
  6. Future<String> authDeploymentFuture = dbDeploymentPromise.future().compose(id -> {
  7. Promise<String> deployPromise = Promise.promise();
  8. vertx.deployVerticle(new AuthInitializerVerticle(), deployPromise);
  9. return deployPromise.future();
  10. });
  11. authDeploymentFuture.compose(id -> {
  12. Promise<String> deployPromise = Promise.promise();
  13. vertx.deployVerticle("io.vertx.guides.wiki.http.HttpServerVerticle", new DeploymentOptions().setInstances(2), deployPromise);
  14. return deployPromise.future();
  15. });
  16. authDeploymentFuture.setHandler(ar -> {
  17. if (ar.succeeded()) {
  18. promise.complete();
  19. } else {
  20. promise.fail(ar.cause());
  21. }
  22. });
  23. }
  24. }

Finally, we also need to update the ApiTest class to setup the in-memory database for both the authentication and wiki storage:

  1. JsonObject dbConf = new JsonObject()
  2. .put(WikiDatabaseVerticle.CONFIG_WIKIDB_JDBC_URL, "jdbc:hsqldb:mem:testdb;shutdown=true")
  3. .put(WikiDatabaseVerticle.CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, 4);
  4. vertx.deployVerticle(new AuthInitializerVerticle(),
  5. new DeploymentOptions().setConfig(dbConf), context.asyncAssertSuccess());
  6. vertx.deployVerticle(new WikiDatabaseVerticle(),
  7. new DeploymentOptions().setConfig(dbConf), context.asyncAssertSuccess());