第八章 安全和访问控制
版权声明:本文为博主自主翻译,转载请标明出处。 https://blog.csdn.net/elinespace/article/details/80663688
相应代码位于本指南仓库的step-7目录下
使用Vert.x,安全和访问控制非常容易实现。在本章中,我们将介绍:
- 迁移HTTP到HTTPS
- 添加用户认证以及基于分组的权限到Web应用中
- 使用JWT对Web API进行访问控制
8.1 在Vert.x中支持HTTPS
Vert.x为SSL加密网络链接提供了支持。在生产环境中暴露HTTP服务器比较常见的方式是通过前端HTTP服务器或代理如Nginx,前端服务器或者代理对于输入请求采用HTTPS。Vert.x还可以自己暴露HTTPS,以便提供端到端的加密。
证书可以存储在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) ①
.setTrustOptions(new JksOptions().setPath("server-keystore.jks").setPassword("secret"))); ②
① 启用SSL
② 由于证书是自包含的,我们需要显式信任它,否则Web客户端链接将失败,正如一个Web浏览器一样。
8.2 访问控制与认证
Vert.x为认证和授权提供了各种选项。官方支持模块包括JDBC、MongoDB、Apache Shiro、知名提供商的OAuth2授权以及JWT(JSON Web 令牌)。
下一节涵盖JWT,本节集中使用Apache Shiro,当认证必须由LDAP或者活动目录服务器支持时,它非常有趣。在我们的例子中,我们将凭证简单的存储在一个properties文件中,以便确保尽可能简单,但是依靠一个LDAP服务器的API用法仍然相同。
本节目标是要求用户使用Wiki时进行认证,并且还有基于角色的许可:
- 没有角色只允许读页面
- 拥有writer角色允许编辑页面
- 拥有editor角色允许创建、编辑和删除页面
- 拥有admin角色相当于拥有所有可能的角色
由于Apache Shiro的内部工作机制,Vert.x Shiro集成有一些问题。它的某些部分是阻塞的,可能妨碍性能,并且某些数据使用本地线程状态存储。你不应该试图滥用公开的内部状态API。
8.2.1 添加Apache Shiro认证到路由
第一步是添加vertx-auth-shiro模块到Maven依赖列表:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-shiro</artifactId>
</dependency>
我们使用的properties文件定义如下,位于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)); ①
AuthHandler authHandler = RedirectAuthHandler.create(auth, "/login"); ②
router.route("/").handler(authHandler); ③
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)); ①
router.get("/logout").handler(context -> { context.clearUser(); ②
context.response().setStatusCode(302).putHeader("Location", "/").end();
});
① FormLoginHander是一个登录提交请求处理的助手。默认情况下,它期望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">
登录页面看起来如下:
8.2.2 基于角色的特征支持
只有当用户具有足够的权限时,才可以激活这些特征。在下面的截图中,一个管理员可以创建一个页面并执行一个备份:
相比之下,没有角色的用户不能执行这些动作:
为了实现该功能,我们可以访问RoutingContext中的用户引用,查询许可。以下是indexHandler处理器方法的实现:
private void indexHandler(RoutingContext context) {
context.user().isAuthorised("create", res -> { ①
boolean canCreatePage = res.succeeded() && res.result(); ②
dbService.fetchAllPages(reply -> {
if (reply.succeeded()) {
context.put("title", "Wiki home");
context.put("pages", reply.result().getList());
context.put("canCreatePage", canCreatePage); ③
context.put("username", context.user().principal().getString("username")); ④
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请求处理器中检查许可而不止当渲染页面时检查是非常重要的。实际上,恶意攻击者仍然可以在不进行身份验证的情况下处理请求并执行操作。以下通过包装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();
}
});
}
8.3 使用JWT进行Web API认证
JSON Web令牌(RFC 7519)是一项用户发出基于JSON编码令牌(包含claims)的标准,通常用于识别用户和许可,虽然claims可以是任何东西。
令牌由服务器发出并且使用服务器密钥签名。客户端可以与随后的请求一起发回一个令牌:客户端和服务器均可以检查令牌是可信的且未被改变。
当JWT令牌签名时,它的内容未被加密。它必须通过一个安全的通道传输(如HTTPS),并且永远不要将敏感信息作为claim(如密码、私有API密钥等)。
8.3.1 添加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 -> { ①
if (authResult.succeeded()) {
User user = authResult.result();
user.isAuthorised("create", canCreate -> { ②
user.isAuthorised("delete", canDelete -> {
user.isAuthorised("update", canUpdate -> {
String token = jwtAuth.generateToken( ③
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 claim。
每个API处理器方法现在可以查询当前用户信息以及claim。以下是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);
}
}
8.3.2 使用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.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRl Ijp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.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
通过使用Authorization HTTP请求头,与请求一起发送一个JWT令牌,它的值必须是Bearer <令牌值>。以下是如何修正上面的API请求,通过传递已经颁发给我们的JWT令牌:
http --verbose --verify no GET https://localhost:8080/api/pages 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRl Ijp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.RmtJb81QKVUFreXL- ajZ8ktLGasoKEqG8GSQncRWrN8='
GET /api/pages HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InJvb3QiLCJjYW5DcmVhdGUiOnRydWUsImNhbkRlbGV0ZSI6dHJ1ZSwiY2FuVXBkYXRl Ijp0cnVlLCJpYXQiOjE0ODk0NDE1OTAsImlzcyI6IlZlcnQueCIsInN1YiI6Ildpa2kgQVBJIn0=.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
}
8.3.3 适配API测试装备
ApiTest类需要更新以支持JWT令牌。
我们添加一个属性用于获取测试场景使用的令牌值:
private String jwtTokenHeaderValue;
我们添加第一步来为用户foo获取一个JWT令牌认证:
@Test
public void play_with_api(TestContext context) {
Async async = context.async();
Future<String> tokenRequest = Future.future();
webClient.get("/api/token")
.putHeader("login", "foo") ①
.putHeader("password", "bar")
.as(BodyCodec.string()) ②
.send(ar -> {
if (ar.succeeded()) {
tokenRequest.complete(ar.result().body()); ③
}else{
context.fail(ar.cause());
}
});
// (...)
① 凭证作为请求头传递。
② 响应载荷是text/plain类型,因此我们使用BodyCodec进行响应体解码。
③ 成功时,我们使用token值完成tokenRequest的future。
现在,使用JWT令牌只需要将它作为报头传递给HTTP请求:
Future<JsonObject> postRequest = Future.future();
tokenRequest.compose(token -> {
jwtTokenHeaderValue = "Bearer " + token; ①
webClient.post("/api/pages")
.putHeader("Authorization", jwtTokenHeaderValue) ②
.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前缀到这个属性,以便用于下次请求。
② 我们传递令牌作为头信息。