向 Java 应用对用户进行身份验证

Java Bookshelf 教程在本部分中将介绍如何为用户创建登录流程,以及如何使用个人资料信息为用户提供个性化功能。

通过使用 Google Identity Platform,您可以轻松访问用户的相关信息,同时确保用户的登录凭据由 Google 妥善管理。 OAuth 2.0 可为所有用户提供登录流程,并为您的应用提供相应权限,使其能够访问经过身份验证的用户的基本个人资料信息。

本教程由多个页面组成,本页面是其中之一。要从头开始阅读并查看设置说明,请参阅 Java Bookshelf 应用

创建 Web 应用客户端 ID

通过 Web 应用客户端 ID,您的应用可以向用户授权并访问 Google API。

  1. 在 Google Cloud Platform Console 中,转到凭据页面。

    转到“凭据”页面

  2. 点击 OAuth 同意屏幕

  3. 对于产品名称,输入 Java Bookshelf App

  4. 对于已获授权的网域,按 [YOUR_PROJECT_ID].appspot.com 格式添加 App Engine 应用名称。

    [YOUR_PROJECT_ID] 替换为您的 GCP 项目 ID。

  5. 填写其他任何相关的可选字段,然后点击保存

  6. 点击创建凭据 > OAuth 客户端 ID

  7. 应用类型下拉列表中,点击网络应用

  8. 名称字段中,输入 Java Bookshelf Client

  9. 已获授权的重定向 URI 字段中,输入以下网址(一次一个)。

    http://localhost:8080/oauth2callback
    http://[YOUR_PROJECT_ID].appspot.com/oauth2callback
    https://[YOUR_PROJECT_ID].appspot.com/oauth2callback

  10. 点击创建

  11. 复制客户端 ID客户端密钥并保存以备后用。

配置设置

1. 在 getting-started-java/bookshelf/4-auth 目录中,打开 pom.xml 进行修改。

  1. <properties> 部分,将 callback.host 设置为 [YOUR_PROJECT_ID].appspot.com

  2. bookshelf.clientID 设置为您之前创建的客户端 ID。

  3. bookshelf.clientSecret 设置为您之前创建的客户端密钥。

  4. 保存并关闭 pom.xml

在本地机器上运行应用

要在本地运行应用,请执行以下操作:

  1. getting-started-java/bookshelf/4-auth 目录中,输入以下命令以启动本地网络服务器。将 [YOUR_PROJECT_ID] 替换为您的 GCP 项目 ID:

    mvn -Plocal clean jetty:run-exploded -DprojectID=[YOUR-PROJECT-ID]
  2. 在网络浏览器中,转到 http://localhost:8080

现在,您可以浏览应用的网页、使用您的 Google 帐号登录、添加图书,并通过顶部导航栏中的我的图书链接查看已添加的图书。

将应用部署到 App Engine 柔性环境

  1. 部署应用。

    mvn appengine:deploy -DprojectID=YOUR-PROJECT-ID
    
  2. 在网络浏览器中输入以下地址。将 [YOUR_PROJECT_ID] 替换为您的项目 ID。

    https://[YOUR_PROJECT_ID].appspot-preview.com
    

在更新应用时,您可以使用首次部署应用时所用的命令来部署更新后的版本。新部署会创建应用的新版本并将其提升为默认版本。应用的旧版本仍会保留,与其关联的虚拟机实例也是一样。所有这些应用版本和虚拟机实例都属于可计费资源。

您可以通过删除应用的非默认版本来减少费用。如需全面了解如何清理可计费资源,请参阅本教程最后一步中的清理部分。

应用结构

下图展示了应用的各个组成部分以及它们如何彼此关联。

Auth 示例结构

了解代码

此部分逐步介绍了应用代码及其工作原理。

当用户点击登录时,LoginServlet 会执行以下操作:

  1. 保存某个随机的 state,以帮助防止请求伪造

  2. 保存用户登录后的着陆位置。

  3. 使用 Google API 客户端库(即 GoogleAuthorizationCodeFlow)生成向 Google 发送的 callback 请求,以处理登录 Google 帐号的操作。此应用指定 emailprofile 的范围,因此可以在每个页面上显示用户的电子邮件地址和图像:

@WebServlet(name = "login", value = "/login")
@SuppressWarnings("serial")
public class LoginServlet extends HttpServlet {

  private static final Collection<String> SCOPES = Arrays.asList("email", "profile");
  private static final JsonFactory JSON_FACTORY = new JacksonFactory();
  private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();

  private GoogleAuthorizationCodeFlow flow;

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws IOException, ServletException {

    String state = new BigInteger(130, new SecureRandom()).toString(32);  // prevent request forgery
    req.getSession().setAttribute("state", state);

    if (req.getAttribute("loginDestination") != null) {
      req
          .getSession()
          .setAttribute("loginDestination", (String) req.getAttribute("loginDestination"));
    } else {
      req.getSession().setAttribute("loginDestination", "/books");
    }

    flow = new GoogleAuthorizationCodeFlow.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY,
        getServletContext().getInitParameter("bookshelf.clientID"),
        getServletContext().getInitParameter("bookshelf.clientSecret"),
        SCOPES)
        .build();

    // Callback url should be the one registered in Google Developers Console
    String url =
        flow.newAuthorizationUrl()
            .setRedirectUri(getServletContext().getInitParameter("bookshelf.callback"))
            .setState(state)            // Prevent request forgery
            .build();
    resp.sendRedirect(url);
  }
}

Google 将用户重定向至 /oauth2callback 网址。用户成功登录后,Oauth2CallbackServlet doGet 方法会执行以下操作:

  1. 通过比较我们的 state 与保存的会话 state,确认请求不是伪造的。

  2. 删除保存的会话 state

  3. 获取响应 tokenResponse

  4. 使用 tokenResponse 获取 Credential

  5. 使用 credential 创建 requestFactory

  6. 使用 request 获取 jsonIdentity

  7. 提取电子邮件地址、图片和 ID。

  8. 从上一步重定向至已保存的 loginDestination

@WebServlet(name = "oauth2callback", value = "/oauth2callback")
@SuppressWarnings("serial")
public class Oauth2CallbackServlet extends HttpServlet {

  private static final Collection<String> SCOPES = Arrays.asList("email", "profile");
  private static final String USERINFO_ENDPOINT
      = "https://www.googleapis.com/plus/v1/people/me/openIdConnect";
  private static final JsonFactory JSON_FACTORY = new JacksonFactory();
  private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();

  private GoogleAuthorizationCodeFlow flow;

  @Override
  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException,
      ServletException {

    // Ensure that this is no request forgery going on, and that the user
    // sending us this connect request is the user that was supposed to.
    if (req.getSession().getAttribute("state") == null
        || !req.getParameter("state").equals((String) req.getSession().getAttribute("state"))) {
      resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      resp.sendRedirect("/books");
      return;
    }

    req.getSession().removeAttribute("state");     // Remove one-time use state.

    flow = new GoogleAuthorizationCodeFlow.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY,
        getServletContext().getInitParameter("bookshelf.clientID"),
        getServletContext().getInitParameter("bookshelf.clientSecret"),
        SCOPES).build();

    final TokenResponse tokenResponse =
        flow.newTokenRequest(req.getParameter("code"))
            .setRedirectUri(getServletContext().getInitParameter("bookshelf.callback"))
            .execute();

    req.getSession().setAttribute("token", tokenResponse.toString()); // Keep track of the token.
    final Credential credential = flow.createAndStoreCredential(tokenResponse, null);
    final HttpRequestFactory requestFactory = HTTP_TRANSPORT.createRequestFactory(credential);

    final GenericUrl url = new GenericUrl(USERINFO_ENDPOINT);      // Make an authenticated request.
    final HttpRequest request = requestFactory.buildGetRequest(url);
    request.getHeaders().setContentType("application/json");

    final String jsonIdentity = request.execute().parseAsString();
    @SuppressWarnings("unchecked")
    HashMap<String, String> userIdResult =
        new ObjectMapper().readValue(jsonIdentity, HashMap.class);
    // From this map, extract the relevant profile info and store it in the session.
    req.getSession().setAttribute("userEmail", userIdResult.get("email"));
    req.getSession().setAttribute("userId", userIdResult.get("sub"));
    req.getSession().setAttribute("userImageUrl", userIdResult.get("picture"));
    resp.sendRedirect((String) req.getSession().getAttribute("loginDestination"));
  }
}

LogoutServlet 会删除该 session 并创建一个新会话:

@WebServlet(name = "logout", value = "/logout")
@SuppressWarnings("serial")
public class LogoutServlet extends HttpServlet {

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws IOException, ServletException {
    // you can also make an authenticated request to logout, but here we choose to
    // simply delete the session variables for simplicity
    HttpSession session =  req.getSession(false);
    if (session != null) {
      session.invalidate();
    }
    // rebuild session
    req.getSession();
  }
}