vertx 异步编程指南 step7-保护和控制访问

保护和控制访问与Vert.x很容易。在本节中,我们将:

  1. 从HTTP转移到HTTPS,以及

  2. 使用基于组的权限将用户身份验证添加到Web应用程序,以及

  3. 使用JSON Web令牌(JWT)控制对Web API的访问


证书可以存储在Java KeyStore文件中。您可能需要用于测试目的自签名证书,以下是如何在server-keystore.jksKeyStore中创建一个密码为secret

keytool -genkey \
  -alias test \
  -keyalg RSA \
  -keystore server-keystore.jks \
  -keysize 2048 \
  -validity 360 \
  -dname CN=localhost \
  -keypass secret \
  -storepass secret

然后,我们可以更改HTTP服务器创建,以传递一个HttpServerOptions对象来指定我们需要SSL,并指向我们的KeyStore文件:

HttpServer server = vertx.createHttpServer(new HttpServerOptions()
  .setSsl(true)
  .setKeyStoreOptions(new JksOptions()
    .setPath("server-keystore.jks")
    .setPassword("secret")));

我们可以将浏览器指向https:// localhost:8080 /,但由于证书是自签名的,所以任何优秀的浏览器都会正确地产生安全警告:

vertx 异步编程指南 step7-保护和控制访问_第1张图片

最后但并非最不重要的是,我们需要更新测试用例,ApiTest因为原始代码是用于通过Web客户端发出HTTP请求的:

webClient = WebClient.create(vertx, new WebClientOptions()
  .setDefaultHost("localhost")
  .setDefaultPort(8080)
  .setSsl(true) (1)
  .setTrustOptions(new JksOptions().setPath("server-keystore.jks").setPassword("secret"))); 
  1. 确保SSL。

  2. 由于证书是自签名的,我们需要明确信任它,否则Web客户端连接将失败,就像Web浏览器一样。

访问控制和认证

Vert.x为执行身份验证和授权提供了广泛的选项。官方支持的模块涵盖了JDBC,MongoDB,Apache Shiro,OAuth2以及众所周知的提供者和JWT(JSON Web令牌)。

虽然下一部分将介绍JWT,但本部分重点介绍如何使用Apache Shiro,这在验证必须由LDAP或Active Directory服务器支持时特别有用。在我们的例子中,我们只是将凭据存储在属性文件中以保持简单,但对LDAP服务器的API使用保持不变。

目标是要求用户使用wiki进行身份验证,并拥有基于角色的权限:

  • 没有角色只允许阅读页面,

  • 具有作家角色允许编辑页面,

  • 具有编辑角色允许创建,编辑和删除页面,

  • 具有管理角色相当于具有所有可能的角色。

警告
由于Apache Shiro的内部运作,Vert.x Shiro集成有一些问题。有些部分阻塞会影响性能,有些数据是使用线程本地状态存储的。您不应该尝试滥用暴露的内部状态API。

将Apache Shiro身份验证添加到路由

第一步是将vertx-auth-shiro模块添加到Maven依赖关系列表中:


  io.vertx
  vertx-auth-shiro

我们使用的属性文件定义如下,位于src/main/resources/wiki-users.properties

user.root=w00t,admin
user.foo=bar,editor,writer
user.bar=baz,writer
user.baz=baz

role.admin=*
role.editor=create,delete,update
role.writer=update

user前缀的条目是一个用户帐户,其中第一个值条目是密码可能跟随的角色。在这个例子中,用户bar有密码baz,是一个writer,并且writerupdate权限。

回到HttpServerVerticle课程代码,我们使用Apache Shiro创建认证提供者:

AuthProvider auth = ShiroAuth.create(vertx, new ShiroAuthOptions()
  .setType(ShiroAuthRealmType.PROPERTIES)
  .setConfig(new JsonObject()
    .put("properties_path", "classpath:wiki-users.properties")));

ShiroAuth对象实例然后用于处理服务器端用户会话:

Router router = Router.router(vertx);

router.route().handler(CookieHandler.create());
router.route().handler(BodyHandler.create());
router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));
router.route().handler(UserSessionHandler.create(auth));  (1)

AuthHandler authHandler = RedirectAuthHandler.create(auth, "/login"); (2)
router.route("/").handler(authHandler);  (3)
router.route("/wiki/*").handler(authHandler);
router.route("/action/*").handler(authHandler);

router.get("/").handler(this::indexHandler);
router.get("/wiki/:page").handler(this::pageRenderingHandler);
router.post("/action/save").handler(this::pageUpdateHandler);
router.post("/action/create").handler(this::pageCreateHandler);
router.get("/action/backup").handler(this::backupHandler);
router.post("/action/delete").handler(this::pageDeletionHandler);
  1. 我们为所有路由安装用户会话处理程序。

  2. 这会自动将请求重定向到/login没有用户会话的请求时。

  3. 我们安装authHandler了所有需要身份验证的路由。

最后,我们需要创建3条路线来显示登录表单,处理登录表单提交和注销用户:

router.get("/login").handler(this::loginHandler);
router.post("/login-auth").handler(FormLoginHandler.create(auth));  (1)

router.get("/logout").handler(context -> {
  context.clearUser();  (2)
  context.response()
    .setStatusCode(302)
    .putHeader("Location", "/")
    .end();
});
  1. FormLoginHandler是处理登录提交请求的助手。默认情况下,它希望HTTP POST请求具有:username作为登录名,password密码以及return_url成功时重定向到的URL。

  2. 注销用户很简单,就像从当前清除它一样RoutingContext

loginHandler方法的代码是:

private void loginHandler(RoutingContext context) {
  context.put("title", "Login");
  templateEngine.render(context, "templates", "/login.ftl", ar -> {
    if (ar.succeeded()) {
      context.response().putHeader("Content-Type", "text/html");
      context.response().end(ar.result());
    } else {
      context.fail(ar.cause());
    }
  });
}

HTML模板位于src/main/resources/templates/login.ftl

<#include "header.ftl">

 class="row">

   class="col-md-12 mt-1">
     action="/login-auth" method="POST">
       class="form-group">
         type="text" name="username" placeholder="login">
         type="password" name="password" placeholder="password">
         type="hidden" name="return_url" value="/">
         type="submit" class="btn btn-primary">Login
      
<#include "footer.ftl">
登录页面如下所示:

vertx 异步编程指南 step7-保护和控制访问_第2张图片

支持基于角色的功能

只有当用户拥有足够的权限时才能激活功能。在以下屏幕截图中,管理员可以创建一个页面并执行备份:

vertx 异步编程指南 step7-保护和控制访问_第3张图片

相比之下,没有角色的用户不能执行这些操作:

vertx 异步编程指南 step7-保护和控制访问_第4张图片

为此,我们可以访问RoutingContext用户参考,并查询权限。以下是indexHandler处理器方法的实现方式:

private void indexHandler(RoutingContext context) {
  context.user().isAuthorised("create", res -> {  (1)
    boolean canCreatePage = res.succeeded() && res.result();  (2)
    dbService.fetchAllPages(reply -> {
      if (reply.succeeded()) {
        context.put("title", "Wiki home");
        context.put("pages", reply.result().getList());
        context.put("canCreatePage", canCreatePage);  (3)
        context.put("username", context.user().principal().getString("username"));  (4)
        templateEngine.render(context, "templates", "/index.ftl", ar -> {
          if (ar.succeeded()) {
            context.response().putHeader("Content-Type", "text/html");
            context.response().end(ar.result());
          } else {
            context.fail(ar.cause());
          }
        });
      } else {
        context.fail(reply.cause());
      }
    });
  });
}
  1. 这是如何进行权限查询的。请注意,这是一个异步操作。

  2. 我们使用结果...

  3. ...在HTML模板中利用它。

  4. 我们也可以访问用户登录。

模板代码已被修改为仅基于以下值来呈现特定片段canCreatePage

<#include "header.ftl">

 class="row">

   class="col-md-12 mt-1">
  <#if context.canCreatePage>
     class="float-xs-right">
       class="form-inline" action="/action/create" method="post">
         class="form-group">
           type="text" class="form-control" id="name" name="name" placeholder="New page name">
        
type="submit" class="btn btn-primary">Create
#if> class="display-4">${context.title} class="float-xs-right"> class="btn btn-outline-danger" href="/logout" role="button" aria-pressed="true">Logout (${context.username})
class="col-md-12 mt-1"> <#list context.pages>

Pages:

    <#items as page>
  • href="/wiki/${page}">${page}
  • #items>
<#else>

The wiki is currently empty!

#list> <#if context.canCreatePage> <#if context.backup_gist_url?has_content> class="alert alert-success" role="alert"> Successfully created a backup: href="${context.backup_gist_url}" class="alert-link">${context.backup_gist_url}
<#else>

class="btn btn-outline-secondary btn-sm" href="/action/backup" role="button" aria-pressed="true">Backup

#if> #if>
<#include "footer.ftl">

该代码类似于确保更新或删除页面仅限于某些角色,并可从指南Git存储库中获得。

确保检查也是在HTTP POST请求处理程序上完成,而不仅仅是在呈现HTML页面时进行。事实上,恶意攻击者仍然可以制作请求并执行操作,而无需进行身份验证。以下是如何通过将pageDeletionHandler代码包装在最上面的权限检查中来保护页面删除

private void pageDeletionHandler(RoutingContext context) {
  context.user().isAuthorised("delete", res -> {
    if (res.succeeded() && res.result()) {

      // Original code:
      dbService.deletePage(Integer.valueOf(context.request().getParam("id")), reply -> {
        if (reply.succeeded()) {
          context.response().setStatusCode(303);
          context.response().putHeader("Location", "/");
          context.response().end();
        } else {
          context.fail(reply.cause());
        }
      });

    } else {
      context.response().setStatusCode(403).end();
    }
  });
}

使用JWT验证Web API请求

JSON Web TokensRFC 7519)是发布包含声明的 JSON编码标记的标准,通常标识用户和权限,但声明可以是任何事情。

令牌由服务器发出,并使用服务器密钥进行签名。客户端可以将令牌发送回以及随后的请求:客户端和服务器都可以检查令牌是否真实且未改变。

警告
JWT令牌签名时,其内容未加密。它必须通过安全通道(例如HTTPS)进行传输,并且它不应该有敏感数据作为声明(例如,密码,私人API密钥等)。

添加JWT支持

我们首先将vertx-auth-jwt模块添加到Maven依赖关系中:


  io.vertx
  vertx-auth-jwt

我们将有一个JCEKS密钥库来保存我们测试的密钥。以下是如何keystore.jceks使用各种长度的适当键生成一个文件

keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA256 -keysize 2048 -alias HS256 -keypass secret
keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA384 -keysize 2048 -alias HS384 -keypass secret
keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA512 -keysize 2048 -alias HS512 -keypass secret
keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS256 -keypass secret -sigalg SHA256withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS384 -keypass secret -sigalg SHA384withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
keytool -genkey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg RSA -keysize 2048 -alias RS512 -keypass secret -sigalg SHA512withRSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES256 -keypass secret -sigalg SHA256withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES384 -keypass secret -sigalg SHA384withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360
keytool -genkeypair -keystore keystore.jceks -storetype jceks -storepass secret -keyalg EC -keysize 256 -alias ES512 -keypass secret -sigalg SHA512withECDSA -dname "CN=,OU=,O=,L=,ST=,C=" -validity 360

我们需要在API路由上安装JWT令牌处理程序:

Router apiRouter = Router.router(vertx);

JWTAuth jwtAuth = JWTAuth.create(vertx, new JsonObject()
  .put("keyStore", new JsonObject()
    .put("path", "keystore.jceks")
    .put("type", "jceks")
    .put("password", "secret")));

apiRouter.route().handler(JWTAuthHandler.create(jwtAuth, "/api/token"));

我们通过/api/token作为JWTAuthHandler对象创建的参数来指定该URL将被忽略。的确,这个URL被用来生成新的JWT令牌:

apiRouter.get("/token").handler(context -> {

  JsonObject creds = new JsonObject()
    .put("username", context.request().getHeader("login"))
    .put("password", context.request().getHeader("password"));
  auth.authenticate(creds, authResult -> {  (1)

    if (authResult.succeeded()) {
      User user = authResult.result();
      user.isAuthorised("create", canCreate -> {  (2)
        user.isAuthorised("delete", canDelete -> {
          user.isAuthorised("update", canUpdate -> {

            String token = jwtAuth.generateToken( (3)
              new JsonObject()
                .put("username", context.request().getHeader("login"))
                .put("canCreate", canCreate.succeeded() && canCreate.result())
                .put("canDelete", canDelete.succeeded() && canDelete.result())
                .put("canUpdate", canUpdate.succeeded() && canUpdate.result()),
              new JWTOptions()
                .setSubject("Wiki API")
                .setIssuer("Vert.x"));
            context.response().putHeader("Content-Type", "text/plain").end(token);
          });
        });
      });
    } else {
      context.fail(401);
    }
  });
});
  1. 我们预计登录名和密码信息已通过HTTP请求标头传递,我们使用上一节的Apache Shiro身份验证提供程序进行身份验证。

  2. 一旦成功,我们可以查询角色。

  3. 我们生成令牌usernamecanCreatecanDeletecanUpdate索赔。

每个API处理程序方法现在可以查询当前的用户主体和声明。这是如何apiDeletePage做到的:

private void apiDeletePage(RoutingContext context) {
  if (context.user().principal().getBoolean("canDelete", false)) {
    int id = Integer.valueOf(context.request().getParam("id"));
    dbService.deletePage(id, reply -> {
      handleSimpleDbReply(context, reply);
    });
  } else {
    context.fail(401);
  }
}

使用JWT令牌

为了说明如何使用JWT令牌,让我们为root用户创建一个新的令牌

$ http --verbose --verify no GET https://localhost:8080/api/token login:root password:w00t
GET /api/token HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8080
User-Agent: HTTPie/0.9.8
login: root
password: w00t



HTTP/1.1 200 OK
Content-Length: 242
Content-Type: text/plain
Set-Cookie: vertx-web.session=8cbb38ac4ce96737bfe31cc0ceaae2b9; Path=/

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8=

响应文本是令牌值并应保留。

我们可以检查执行不带令牌的API请求会导致拒绝访问:

$ http --verbose --verify no GET https://localhost:8080/api/pages
GET /api/pages HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8080
User-Agent: HTTPie/0.9.8



HTTP/1.1 401 Unauthorized
Content-Length: 12

Unauthorized

发送JWT令牌与请求一起使用AuthorizationHTTP请求头,其值必须是Bearer 以下是如何通过传递已发布给我们的JWT令牌来修复上面的API请求:

$ http --verbose --verify no GET https://localhost:8080/api/pages 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8='
GET /api/pages HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRlIjp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL-ajZ8ktLGasoKEqG8GSQncRWrN8=
Connection: keep-alive
Host: localhost:8080
User-Agent: HTTPie/0.9.8



HTTP/1.1 200 OK
Content-Length: 99
Content-Type: application/json
Set-Cookie: vertx-web.session=0598697483371c7f3cb434fbe35f15e4; Path=/

{
    "pages": [
        {
            "id": 0,
            "name": "Hello"
        },
        {
            "id": 1,
            "name": "Apple"
        },
        {
            "id": 2,
            "name": "Vert.x"
        }
    ],
    "success": true
}

调整API测试夹具

ApiTest类需要进行更新,以支持JWT令牌。

我们添加一个新字段来检索要在测试用例中使用的令牌值:

private String jwtTokenHeaderValue;

我们添加第一步来检索经过身份验证的JTW令牌foo

@Test
public void play_with_api(TestContext context) {
  Async async = context.async();

  Future<String> tokenRequest = Future.future();
  webClient.get("/api/token")
    .putHeader("login", "foo")  (1)
    .putHeader("password", "bar")
    .as(BodyCodec.string()) (2)
    .send(ar -> {
      if (ar.succeeded()) {
        tokenRequest.complete(ar.result().body());  (3)
      } else {
        context.fail(ar.cause());
      }
    });
    // (...)
  1. 凭证作为标题传递。

  2. 响应有效载荷是text/plain类型的,因此我们将其用于身体解码编解码器。

  3. 一旦成功,我们tokenRequest用令牌值完成未来。

现在使用JWT令牌将其作为头传递给HTTP请求:

Future postRequest = Future.future();
tokenRequest.compose(token -> {
  jwtTokenHeaderValue = "Bearer " + token;  (1)
  webClient.post("/api/pages")
    .putHeader("Authorization", jwtTokenHeaderValue)  (2)
    .as(BodyCodec.jsonObject())
    .sendJsonObject(page, ar -> {
      if (ar.succeeded()) {
        HttpResponse postResponse = ar.result();
        postRequest.complete(postResponse.body());
      } else {
        context.fail(ar.cause());
      }
    });
}, postRequest);

Future getRequest = Future.future();
postRequest.compose(h -> {
  webClient.get("/api/pages")
    .putHeader("Authorization", jwtTokenHeaderValue)
    .as(BodyCodec.jsonObject())
    .send(ar -> {
      if (ar.succeeded()) {
        HttpResponse getResponse = ar.result();
        getRequest.complete(getResponse.body());
      } else {
        context.fail(ar.cause());
      }
    });
}, getRequest);
// (...)
  1. 我们将带有Bearer前缀的令牌存储在下一个请求的字段中。

  2. 我们将令牌作为头部传递。











你可能感兴趣的:(vertx)