io.vertx
vertx-auth-shiro
保护和控制访问与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。 |
第一步是将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
,并且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">
class="row">
class="col-md-12 mt-1">
<#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">
class="row">
class="col-md-12 mt-1">
<#if context.canCreatePage>
class="float-xs-right">
#if>
class="display-4">${context.title}
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();
}
});
}
JSON Web Tokens(RFC 7519)是发布包含声明的 JSON编码标记的标准,通常标识用户和权限,但声明可以是任何事情。
令牌由服务器发出,并使用服务器密钥进行签名。客户端可以将令牌发送回以及随后的请求:客户端和服务器都可以检查令牌是否真实且未改变。
警告
|
JWT令牌签名时,其内容未加密。它必须通过安全通道(例如HTTPS)进行传输,并且它不应该有敏感数据作为声明(例如,密码,私人API密钥等)。 |
我们首先将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);
}
});
});
我们预计登录名和密码信息已通过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令牌,让我们为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
。以下是如何通过传递已发布给我们的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 }
本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 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);
// (...)
我们将带有Bearer
前缀的令牌存储在下一个请求的字段中。
我们将令牌作为头部传递。