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

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

  1. 从HTTP转移到HTTPS,以及

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

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

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

  1.  
    keytool -genkey
  2.  
    -alias test
  3.  
    -keyalg RSA
  4.  
    -keystore server-keystore.jks
  5.  
    -keysize 2048
  6.  
    -validity 360
  7.  
    -dname CN=localhost
  8.  
    -keypass secret
  9.  
    -storepass secret

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

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

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

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

  1.  
    webClient = WebClient.create(vertx, new WebClientOptions()
  2.  
    .setDefaultHost("localhost")
  3.  
    .setDefaultPort(8080)
  4.  
    .setSsl(true) (1)
  5.  
    .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依赖关系列表中:

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

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

  1.  
    user.root=w00t,admin
  2.  
    user.foo=bar,editor,writer
  3.  
    user.bar=baz,writer
  4.  
    user.baz=baz
  5.  
     
  6.  
    role.admin=*
  7.  
    role.editor=create,delete,update
  8.  
    role.writer=update

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

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

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

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

  1.  
    Router router = Router.router(vertx);
  2.  
     
  3.  
    router.route().handler(CookieHandler.create());
  4.  
    router.route().handler(BodyHandler.create());
  5.  
    router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)));
  6.  
    router.route().handler(UserSessionHandler.create(auth)); (1)
  7.  
     
  8.  
    AuthHandler authHandler = RedirectAuthHandler.create(auth, "/login"); (2)
  9.  
    router.route("/").handler(authHandler); (3)
  10.  
    router.route("/wiki/*").handler(authHandler);
  11.  
    router.route("/action/*").handler(authHandler);
  12.  
     
  13.  
    router.get("/").handler(this::indexHandler);
  14.  
    router.get("/wiki/:page").handler(this::pageRenderingHandler);
  15.  
    router.post("/action/save").handler(this::pageUpdateHandler);
  16.  
    router.post("/action/create").handler(this::pageCreateHandler);
  17.  
    router.get("/action/backup").handler(this::backupHandler);
  18.  
    router.post("/action/delete").handler(this::pageDeletionHandler);
  1. 我们为所有路由安装用户会话处理程序。

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

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

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

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

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

loginHandler方法的代码是:

  1.  
    private void loginHandler(RoutingContext context) {
  2.  
    context.put("title", "Login");
  3.  
    templateEngine.render(context, "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.  
    }

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

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

登录页面如下所示:

支持基于角色的功能

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

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

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

  1.  
    private void indexHandler(RoutingContext context) {
  2.  
    context.user().isAuthorised("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, "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. 这是如何进行权限查询的。请注意,这是一个异步操作。

  2. 我们使用结果...

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

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

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

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

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

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

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

使用JWT验证Web API请求

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

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

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

添加JWT支持

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

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

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

  1.  
    keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA256 -keysize 2048 -alias HS256 -keypass secret
  2.  
    keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA384 -keysize 2048 -alias HS384 -keypass secret
  3.  
    keytool -genseckey -keystore keystore.jceks -storetype jceks -storepass secret -keyalg HMacSHA512 -keysize 2048 -alias HS512 -keypass secret
  4.  
    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
  5.  
    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
  6.  
    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
  7.  
    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
  8.  
    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
  9.  
    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令牌处理程序:

  1.  
    Router apiRouter = Router.router(vertx);
  2.  
     
  3.  
    JWTAuth jwtAuth = JWTAuth.create(vertx, new JsonObject()
  4.  
    .put("keyStore", new JsonObject()
  5.  
    .put("path", "keystore.jceks")
  6.  
    .put("type", "jceks")
  7.  
    .put("password", "secret")));
  8.  
     
  9.  
    apiRouter.route().handler(JWTAuthHandler.create(jwtAuth, "/api/token"));

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

  1.  
    apiRouter.get("/token").handler(context -> {
  2.  
     
  3.  
    JsonObject creds = new JsonObject()
  4.  
    .put("username", context.request().getHeader("login"))
  5.  
    .put("password", context.request().getHeader("password"));
  6.  
    auth.authenticate(creds, authResult -> { (1)
  7.  
     
  8.  
    if (authResult.succeeded()) {
  9.  
    User user = authResult.result();
  10.  
    user.isAuthorised("create", canCreate -> { (2)
  11.  
    user.isAuthorised("delete", canDelete -> {
  12.  
    user.isAuthorised("update", canUpdate -> {
  13.  
     
  14.  
    String token = jwtAuth.generateToken( (3)
  15.  
    new JsonObject()
  16.  
    .put("username", context.request().getHeader("login"))
  17.  
    .put("canCreate", canCreate.succeeded() && canCreate.result())
  18.  
    .put("canDelete", canDelete.succeeded() && canDelete.result())
  19.  
    .put("canUpdate", canUpdate.succeeded() && canUpdate.result()),
  20.  
    new JWTOptions()
  21.  
    .setSubject("Wiki API")
  22.  
    .setIssuer("Vert.x"));
  23.  
    context.response().putHeader("Content-Type", "text/plain").end(token);
  24.  
    });
  25.  
    });
  26.  
    });
  27.  
    } else {
  28.  
    context.fail(401);
  29.  
    }
  30.  
    });
  31.  
    });
  1. 我们预计登录名和密码信息已通过HTTP请求标头传递,我们使用上一节的Apache Shiro身份验证提供程序进行身份验证。

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

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

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

  1.  
    private void apiDeletePage(RoutingContext context) {
  2.  
    if (context.user().principal().getBoolean("canDelete", false)) {
  3.  
    int id = Integer.valueOf(context.request().getParam("id"));
  4.  
    dbService.deletePage(id, reply -> {
  5.  
    handleSimpleDbReply(context, reply);
  6.  
    });
  7.  
    } else {
  8.  
    context.fail(401);
  9.  
    }
  10.  
    }

使用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 <token value>。以下是如何通过传递已发布给我们的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

  1.  
    @Test
  2.  
    public void play_with_api(TestContext context) {
  3.  
    Async async = context.async();
  4.  
     
  5.  
    Future<String> tokenRequest = Future.future();
  6.  
    webClient.get("/api/token")
  7.  
    .putHeader("login", "foo") (1)
  8.  
    .putHeader("password", "bar")
  9.  
    .as(BodyCodec.string()) (2)
  10.  
    .send(ar -> {
  11.  
    if (ar.succeeded()) {
  12.  
    tokenRequest.complete(ar.result().body()); (3)
  13.  
    } else {
  14.  
    context.fail(ar.cause());
  15.  
    }
  16.  
    });
  17.  
    // (...)
  1. 凭证作为标题传递。

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

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

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

  1.  
    Future<JsonObject> postRequest = Future.future();
  2.  
    tokenRequest.compose(token -> {
  3.  
    jwtTokenHeaderValue = "Bearer " + token; (1)
  4.  
    webClient.post("/api/pages")
  5.  
    .putHeader("Authorization", jwtTokenHeaderValue) (2)
  6.  
    .as(BodyCodec.jsonObject())
  7.  
    .sendJsonObject(page, ar -> {
  8.  
    if (ar.succeeded()) {
  9.  
    HttpResponse<JsonObject> postResponse = ar.result();
  10.  
    postRequest.complete(postResponse.body());
  11.  
    } else {
  12.  
    context.fail(ar.cause());
  13.  
    }
  14.  
    });
  15.  
    }, postRequest);
  16.  
     
  17.  
    Future<JsonObject> getRequest = Future.future();
  18.  
    postRequest.compose(h -> {
  19.  
    webClient.get("/api/pages")
  20.  
    .putHeader("Authorization", jwtTokenHeaderValue)
  21.  
    .as(BodyCodec.jsonObject())
  22.  
    .send(ar -> {
  23.  
    if (ar.succeeded()) {
  24.  
    HttpResponse<JsonObject> getResponse = ar.result();
  25.  
    getRequest.complete(getResponse.body());
  26.  
    } else {
  27.  
    context.fail(ar.cause());
  28.  
    }
  29.  
    });
  30.  
    }, getRequest);
  31.  
    // (...)
  1. 我们将带有Bearer前缀的令牌存储在下一个请求的字段中。

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

原文地址:https://www.cnblogs.com/endv/p/11956473.html