保护和控制访问与Vert.x很容易。在本节中,我们将:
从HTTP转移到HTTPS,以及
使用基于组的权限将用户身份验证添加到Web应用程序,以及
使用JSON Web令牌(JWT)控制对Web API的访问。
证书可以存储在Java KeyStore文件中。您可能需要用于测试目的的自签名证书,以下是如何在server-keystore.jks
KeyStore中创建一个密码为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 /,但由于证书是自签名的,所以任何优秀的浏览器都会正确地产生安全警告:
最后但并非最不重要的是,我们需要更新测试用例,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")));
确保SSL。
由于证书是自签名的,我们需要明确信任它,否则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依赖关系列表中:
- <dependency>
- <groupId>io.vertx</groupId>
- <artifactId>vertx-auth-shiro</artifactId>
- </dependency>
我们使用的属性文件定义如下,位于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
,并且writer
有update
权限。
回到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);
我们为所有路由安装用户会话处理程序。
这会自动将请求重定向到
/login
没有用户会话的请求时。我们安装
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();
- });
FormLoginHandler
是处理登录提交请求的助手。默认情况下,它希望HTTP POST请求具有:username
作为登录名,password
密码以及return_url
成功时重定向到的URL。注销用户很简单,就像从当前清除它一样
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">
- <div class="row">
- <div class="col-md-12 mt-1">
- <form action="/login-auth" method="POST">
- <div class="form-group">
- <input type="text" name="username" placeholder="login">
- <input type="password" name="password" placeholder="password">
- <input type="hidden" name="return_url" value="/">
- <button type="submit" class="btn btn-primary">Login</button>
- </div>
- </form>
- </div>
- </div>
- <#include "footer.ftl">
登录页面如下所示:
支持基于角色的功能
只有当用户拥有足够的权限时才能激活功能。在以下屏幕截图中,管理员可以创建一个页面并执行备份:
相比之下,没有角色的用户不能执行这些操作:
为此,我们可以访问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());
- }
- });
- });
- }
这是如何进行权限查询的。请注意,这是一个异步操作。
我们使用结果...
...在HTML模板中利用它。
我们也可以访问用户登录。
模板代码已被修改为仅基于以下值来呈现特定片段canCreatePage
:
- <#include "header.ftl">
- <div class="row">
- <div class="col-md-12 mt-1">
- <#if context.canCreatePage>
- <div class="float-xs-right">
- <form class="form-inline" action="/action/create" method="post">
- <div class="form-group">
- <input type="text" class="form-control" id="name" name="name" placeholder="New page name">
- </div>
- <button type="submit" class="btn btn-primary">Create</button>
- </form>
- </div>
- </#if>
- <h1 class="display-4">${context.title}</h1>
- <div class="float-xs-right">
- <a class="btn btn-outline-danger" href="/logout" role="button" aria-pressed="true">Logout (${context.username})</a>
- </div>
- </div>
- <div class="col-md-12 mt-1">
- <#list context.pages>
- <h2>Pages:</h2>
- <ul>
- <#items as page>
- <li><a href="/wiki/${page}">${page}</a></li>
- </#items>
- </ul>
- <#else>
- <p>The wiki is currently empty!</p>
- </#list>
- <#if context.canCreatePage>
- <#if context.backup_gist_url?has_content>
- <div class="alert alert-success" role="alert">
- Successfully created a backup:
- <a href="${context.backup_gist_url}" class="alert-link">${context.backup_gist_url}</a>
- </div>
- <#else>
- <p>
- <a class="btn btn-outline-secondary btn-sm" href="/action/backup" role="button" aria-pressed="true">Backup</a>
- </p>
- </#if>
- </#if>
- </div>
- </div>
- <#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 Tokens(RFC 7519)是发布包含声明的 JSON编码标记的标准,通常标识用户和权限,但声明可以是任何事情。
令牌由服务器发出,并使用服务器密钥进行签名。客户端可以将令牌发送回以及随后的请求:客户端和服务器都可以检查令牌是否真实且未改变。
警告 | JWT令牌签名时,其内容未加密。它必须通过安全通道(例如HTTPS)进行传输,并且它不应该有敏感数据作为声明(例如,密码,私人API密钥等)。 |
添加JWT支持
我们首先将vertx-auth-jwt
模块添加到Maven依赖关系中:
- <dependency>
- <groupId>io.vertx</groupId>
- <artifactId>vertx-auth-jwt</artifactId>
- </dependency>
我们将有一个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);
- }
- });
- });
我们预计登录名和密码信息已通过HTTP请求标头传递,我们使用上一节的Apache Shiro身份验证提供程序进行身份验证。
一旦成功,我们可以查询角色。
我们生成令牌
username
,canCreate
,canDelete
和canUpdate
索赔。
每个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令牌与请求一起使用Authorization
HTTP请求头,其值必须是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
:
- @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());
- }
- });
- // (...)
凭证作为标题传递。
响应有效载荷是
text/plain
类型的,因此我们将其用于身体解码编解码器。一旦成功,我们
tokenRequest
用令牌值完成未来。
现在使用JWT令牌将其作为头传递给HTTP请求:
- Future<JsonObject> 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<JsonObject> postResponse = ar.result();
- postRequest.complete(postResponse.body());
- } else {
- context.fail(ar.cause());
- }
- });
- }, postRequest);
- Future<JsonObject> getRequest = Future.future();
- postRequest.compose(h -> {
- webClient.get("/api/pages")
- .putHeader("Authorization", jwtTokenHeaderValue)
- .as(BodyCodec.jsonObject())
- .send(ar -> {
- if (ar.succeeded()) {
- HttpResponse<JsonObject> getResponse = ar.result();
- getRequest.complete(getResponse.body());
- } else {
- context.fail(ar.cause());
- }
- });
- }, getRequest);
- // (...)
我们将带有
Bearer
前缀的令牌存储在下一个请求的字段中。我们将令牌作为头部传递。