HTTP 与 Web

使用 OAuth2/OpenID Connect 保护 Web 应用程序

您将学习如何使用 Vert.x、OAuth2 和 OpenID Connect 构建并保护一个简单的 Web 应用程序。

您将构建什么

在本操作指南的第一部分,您将构建一个安全的 Web 应用程序,该程序将使用 GitHub 对任何应用程序用户进行身份验证。然后,我们将继续探索 API 并使用 OpenID Connect 自动发现应用程序的安全相关配置。

您需要什么

  • 文本编辑器或 IDE

  • Java 11 或更高版本

  • 一个 GitHub 账户

创建项目

访问 start.vertx.io 并创建一个项目,包含以下依赖项

  • Vert.x Web

  • OAuth2

  • Handlebars 模板引擎

  • Vert.x Web 客户端

project

身份验证基础

在本节中,我们将重点介绍身份验证的基础知识。具体来说,我们将创建一个 Java 服务器,实现 GitHub 的Web 应用程序流程

注册您的应用程序

首先,您需要注册您的应用程序。每个已注册的 OAuth2 应用程序都会被分配一个唯一的 客户端 ID客户端密钥。客户端密钥不得共享!这包括将其字符串提交到您的存储库中。

您可以随意填写所有信息,除了授权回调 URL。这无疑是设置应用程序最重要的一部分。这是 GitHub 在成功身份验证后将用户重定向到的回调 URL。

由于我们正在运行一个普通的 Vert.x Web 服务器,本地实例的位置设置为 https://:8080。让我们将回调 URL 填写为 https://:8080/callback

接受用户授权

现在,我们开始填充我们的简单服务器。打开类 howto.oauth_oidc.MainVerticle 并粘贴以下内容

package howto.oauth_oidc;

import io.vertx.core.Future;
import io.vertx.core.VerticleBase;
import io.vertx.ext.auth.oauth2.OAuth2Auth;
import io.vertx.ext.auth.oauth2.providers.GithubAuth;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.OAuth2AuthHandler;
import io.vertx.ext.web.templ.handlebars.HandlebarsTemplateEngine;

public class MainVerticle extends VerticleBase {

  private static final String CLIENT_ID =
    System.getenv("GITHUB_CLIENT_ID");
  private static final String CLIENT_SECRET =
    System.getenv("GITHUB_CLIENT_SECRET");  (1)

  @Override
  public Future<?> start() {

    HandlebarsTemplateEngine engine =
      HandlebarsTemplateEngine.create(vertx);     (2)

    Router router = Router.router(vertx);         (3)

    router.get("/")                               (4)
      .handler(ctx -> {
        // we pass the client id to the template
        ctx.put("client_id", CLIENT_ID);
        // and now delegate to the engine to render it.
        engine.render(ctx.data(), "views/index.hbs")
          .onSuccess(buffer -> {
            ctx.response()
              .putHeader("Content-Type", "text/html")
              .end(buffer);
          })
          .onFailure(ctx::fail);
      });

    OAuth2Auth authProvider = GithubAuth.create(vertx, CLIENT_ID, CLIENT_SECRET);

    router.get("/protected")                      (5)
      .handler(
        OAuth2AuthHandler.create(vertx, authProvider, "https://:8080/callback")   (6)
          .setupCallback(router.route("/callback"))
          .withScope("user:email"))               (7)
      .handler(ctx -> {
        ctx.response()
          .end("Hello protected!");
      });

    return vertx.createHttpServer()                      (8)
      .requestHandler(router)
      .listen(Integer.getInteger("port", 8080))
      .onSuccess(server -> System.out.println("HTTP server started on port: " + server.actualPort()));
  }
}
1 我们将把密钥作为环境变量读取
2 为了使用 Handlebars,我们首先需要创建一个引擎
3 为了简化 Web 组件的开发,我们使用 Router 来路由所有 HTTP 请求,以便以可重用的方式组织代码。
4 应用程序的入口点,这将渲染一个自定义模板。
5 受保护的资源(尚未真正受保护)
6 现在我们配置 OAuth2 处理程序,它将设置回调处理程序(如 GitHub 应用程序面板中定义的)
7 对于此资源,我们要求用户拥有检索用户电子邮件的权限
8 启动服务器

您的客户端 ID 和客户端密钥来自您应用程序的配置页面。您绝不应该将这些值存储在您的 Git 存储库或任何其他公共场所。我们建议将它们存储为环境变量——这正是我们在这里所做的。

请注意,受保护的资源使用范围 user:email 来定义应用程序请求的范围。对于我们的应用程序,我们请求 user:email 范围,以便在本操作指南后续部分读取私人电子邮件地址。

接下来,在项目 resources 中创建模板 views/index.hbs 并粘贴以下内容

<html lang="en">
<body>
<p>
  Well, hello there!
</p>
<p>
  We're going to the protected resource, if there is no
  user in the session we will talk to the GitHub API. Ready?
  <a href="/protected">Click here</a> to begin!
</p>
<p>
  <b>If that link doesn't work</b>, remember to provide
  your own <a href="https://github.com/settings/applications/new">
  Client ID</a>!
</p>
</body>
</html>

(如果您不熟悉 Handlebars 的工作原理,我们建议阅读 Handlebars 指南。)

在浏览器中访问 https://:8080。点击链接后,您应该会被带到 GitHub,并看到一个类似的对话框

authorize

应用程序成功身份验证后,GitHub 会提供一个临时代码值。此代码随后被回传到 GitHub,以换取一个 access_token,该 access_token 继而会在您的 Vert.x 应用程序中转换为一个 User 实例。所有这些都由处理程序为您完成。

检查已授予的范围

User 对象传递给您之前,如果您的处理程序配置了 authorities,它们将首先被检查。如果它们不存在,则整个过程将中止并返回 Authorization (403) 错误。

但是,您可能希望断言其他已授予的权限,在这种情况下,您可以添加一个中间处理程序,例如

AuthorizationHandler.create(
  PermissionBasedAuthorization      (1)
    .create("user:email"))          (2)
    .addAuthorizationProvider(ScopeAuthorization.create(" ")))  (3)
1 创建一种授权,在这种情况下它是一个权限(Permission)
2 我们想要断言的权限
3 provider 对象将从用户中提取正确的数据并执行断言

如果断言失败,路由器将停止执行并返回 Forbidden 错误。

发出已认证的请求

此时,您的应用程序已经安全,您可以执行处理程序,并且知道用户是真实的 GitHub 用户。您现在可以代表用户执行 API 调用。例如,我们可以更新受保护的资源,以打印出用户注册的电子邮件地址,以及来自 userInfo 端点的一些基本配置文件信息。

package howto.oauth_oidc;

import io.vertx.core.Handler;
import io.vertx.ext.auth.authentication.TokenCredentials;
import io.vertx.ext.auth.oauth2.OAuth2Auth;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.codec.BodyCodec;
import io.vertx.ext.web.templ.handlebars.HandlebarsTemplateEngine;

class ProtectedProfileHandler implements Handler<RoutingContext> {

  private final OAuth2Auth authProvider;
  private final HandlebarsTemplateEngine engine;

  ProtectedProfileHandler(OAuth2Auth authProvider, HandlebarsTemplateEngine engine) {
    this.authProvider = authProvider;
    this.engine = engine;
  }

  @Override
  public void handle(RoutingContext ctx) {
    authProvider
      .userInfo(ctx.user().get())       (1)
      .onFailure(err -> {
        ctx.session().destroy();
        ctx.fail(err);
      })
      .onSuccess(userInfo -> {
        // fetch the user emails from the github API
        WebClient.create(ctx.vertx())
          .getAbs("https://api.github.com/user/emails")
          .authentication(new TokenCredentials(ctx.user().get().<String>get("access_token"))) (2)
          .as(BodyCodec.jsonArray())
          .send()
          .onFailure(err -> {
            ctx.session().destroy();
            ctx.fail(err);
          })
          .onSuccess(res -> {
            userInfo.put("private_emails", res.body());
            // we pass the client info to the template
            ctx.put("userInfo", userInfo);
            // and now delegate to the engine to render it.
            engine.render(ctx.data(), "views/protected.hbs")
              .onSuccess(buffer -> {
                ctx.response()
                  .putHeader("Content-Type", "text/html")
                  .end(buffer);
              })
              .onFailure(ctx::fail);
          });
      });
  }
}
1 从 OAuth2 userInfo 端点获取用户信息
2 代表用户发起 API 调用(使用其访问令牌)

我们可以对结果进行任何操作。在这种情况下,我们只将它们直接输出到 protected.hbs

<html lang="en">
<body>
<p>Well, well, well, {{userInfo.login}}!</p>
<p>
  {{#if userInfo.email}} It looks like your public email
    address is {{userInfo.email}}.
  {{else}} It looks like you don't have a public email.
    That's cool.
  {{/if}}
</p>
<p>
  {{#if userInfo.private_emails}}
    With your permission, we were also able to dig up your
    private email addresses:
    {{#each userInfo.private_emails}}
      {{email}}{{#unless @last}},{{/unless}}
    {{/each}}
  {{else}}
    Also, you're a bit secretive about your private email
    addresses.
  {{/if}}
</p>
</body>
</html>

您应该会看到一个类似的简单屏幕

emails

实现“持久”身份验证

如果我们要求用户每次访问网页时都登录应用程序,那将是一个非常糟糕的模型。例如,尝试直接导航到 https://:8080/protected。您将一次又一次地收到身份验证请求。

如果我们能够绕过整个“点击此处”的过程,并且只要用户已登录 GitHub,就记住他们应该能够访问此应用程序,那该怎么办?请系好安全带,因为这正是我们要做的

我们上面介绍的服务器相当简单。为了嵌入一些智能身份验证,我们将切换到使用会话来存储令牌。这将使身份验证对用户透明。

这可以通过现成的处理程序实现,因此我们的服务器文件将是

  @Override
  public Future<?> start() {

    HandlebarsTemplateEngine engine =
      HandlebarsTemplateEngine.create(vertx);

    Router router = Router.router(vertx);

    router.route()
      .handler(SessionHandler
        .create(LocalSessionStore.create(vertx)));  (1)

    router.get("/")
      .handler(ctx -> {
        // we pass the client id to the template
        ctx.put("client_id", CLIENT_ID);
        // and now delegate to the engine to render it.
        engine.render(ctx.data(), "views/index.hbs")
          .onSuccess(buffer -> {
            ctx.response()
              .putHeader("Content-Type", "text/html")
              .end(buffer);
          })
          .onFailure(ctx::fail);
      });

    // ...
1 使用内存存储的会话处理程序现在将能够跟踪活跃用户,并且您无需在每次请求时重新登录。

为什么持久性很重要?

虽然在服务器上不保留任何状态可能听起来更好,但持久性相对于无状态有一些优势。当会话可用时,您的应用程序将更安全。原因是 OAuth2 在调用期间使用 nonce/state 值,只有在会话存在时才能正确验证这些值。通过会话,我们确保 nonce 值是唯一的且不可重复使用,从而保护您的应用程序免受重放攻击。

第二层可选的保护是使用用于代码交换的证明密钥 (PKCE)。PKCE 在您的应用程序和 OAuth2 服务器之间的交换中增加了另一层安全性,要启用它,您只需将处理程序配置为

OAuth2AuthHandler.create(vertx, authProvider)
  .setupCallback(router.route("/callback"))
  .withScope("user:email")
  .pkceVerifierLength(64);  (1)
1 通过指定 64 到 128 之间的长度,PKCE 将被启用

OpenID Connect

到目前为止,我们一直在介绍纯粹的 OAuth2。Vert.x 也允许您使用 OpenID Connect

简而言之,OpenID Connect 是构建在 OAuth 2.0 协议之上的一个简单身份层。主要区别在于令牌不是不透明的字符串,而是以 JSON Web Token 格式编码。这使得应用程序能够对权限/角色进行更精细的控制,并减少与 IdP 服务器的往返次数。这也意味着您在启动应用程序时需要预先了解更多信息。例如,一些额外的 HTTP 端点、安全密钥等等。

尽管这看起来更复杂,但 OpenID 定义了一个发现 API,它将设置简化为仅几行代码。您无需了解所有属性,只需(例如)在使用 Keycloak 时发现配置即可。

package howto.oauth_oidc;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.ext.auth.oauth2.OAuth2Options;
import io.vertx.ext.auth.oauth2.providers.KeycloakAuth;

public class KeycloakDiscoverVerticle extends AbstractVerticle {

  private static final String CLIENT_ID =
    System.getenv("KEYCLOAK_CLIENT_ID");
  private static final String CLIENT_SECRET =
    System.getenv("KEYCLOAK_CLIENT_SECRET");

  @Override
  public void start(Promise<Void> startPromise) {
    OAuth2Options options = new OAuth2Options()
      .setClientId(CLIENT_ID)
      .setClientSecret(CLIENT_SECRET)
      .setTenant("vertx-test")          (1)
      .setSite("https://your.keycloak.instance/auth/realms/{tenant}"); (2)

    KeycloakAuth
      .discover(vertx, options)
      .onFailure(startPromise::fail)
      .onSuccess(authProvider -> {
        // use the authProvider like before to
        // protect your application
      });
  }
}
1 Keycloak 可以托管多个应用程序,因此我们可以指定租户名称
2 Keycloak 服务器 URL

发现过程将执行所有已知 HTTP 端点的配置,并加载用于验证令牌的安全密钥。准备就绪后,将像之前一样返回一个 OAuth2Auth 实例。这里重要的是,您无需手动加载和配置所有这些。

发现 (Discovery) 是一项标准,因此您可以将其与其他支持它的服务一起使用,例如(不分先后)

  • Microsoft Azure

  • Google Cloud

  • Salesforce

  • Amazon Incognito

  • 等等

总结

在本操作指南中,我们涵盖了

  1. 创建 Web 项目

  2. 使用 OAuth2 保护 Web 应用程序

  3. 使用 WebClient 和 OAuth2 调用安全 API

  4. 持久化用户会话数据

  5. 使用 OpenID Connect

希望您现在能够在您的下一个项目中应用 OAuth2!