建構授權回呼處理常式

本文說明如何透過範例網路應用程式,使用 Java Servlet 實作 OAuth 2.0 授權回呼處理常式,該應用程式會使用 Google Tasks API 顯示使用者的工作。範例應用程式會先要求授權,存取使用者的 Google 工作,然後在預設工作清單中顯示使用者的工作。

觀眾

本文件適用於熟悉 Java 和 J2EE 網路應用程式架構的人員。建議您對 OAuth 2.0 授權流程有基本瞭解。

目錄

如要取得這類可完整運作的範例,必須完成幾個步驟:

在 web.xml 檔案中宣告 Servlet 對應

這個應用程式使用下列兩個 Servlet:

  • PrintTasksTitlesServlet (對應至 /):應用程式的進入點,負責處理使用者驗證,並顯示使用者的工作
  • OAuthCodeCallbackHandlerServlet (對應至 /oauth2callback):OAuth 2.0 回呼,用於處理 OAuth 授權端點的回應

下列 web.xml 檔案會將這 2 個 Servlet 對應至應用程式中的網址:

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

 <servlet>
   <servlet-name>PrintTasksTitles</servlet-name>
   <servlet-class>com.google.oauthsample.PrintTasksTitlesServlet</servlet-class>
 </servlet>

 <servlet-mapping>
   <servlet-name>PrintTasksTitles</servlet-name>
   <url-pattern>/</url-pattern>
 </servlet-mapping>

 <servlet>
   <servlet-name>OAuthCodeCallbackHandlerServlet</servlet-name>
   <servlet-class>com.google.oauthsample.OAuthCodeCallbackHandlerServlet</servlet-class>
 </servlet>

 <servlet-mapping>
   <servlet-name>OAuthCodeCallbackHandlerServlet</servlet-name>
   <url-pattern>/oauth2callback</url-pattern>
 </servlet-mapping>

</web-app>
/WEB-INF/web.xml 檔案

驗證使用者系統,並要求授權存取工作

使用者透過對應至 PrintTaskListsTitlesServlet servlet 的根「/」網址進入應用程式。該 Servlet 會執行下列工作:

  • 檢查使用者是否已在系統上通過驗證。
  • 如果使用者未通過驗證,系統會將使用者重新導向至驗證頁面。
  • 如果使用者已通過驗證,系統會檢查資料儲存空間中是否已有更新權杖,這項作業由下方的 OAuthTokenDao 處理。如果使用者的儲存空間中沒有權杖,表示使用者尚未授予應用程式存取工作項目的授權。接著,系統會將使用者重新導向至 Google 的 OAuth 2.0 授權端點。

以下說明如何實作這項功能:

package com.google.oauthsample;

import ...

/**
 * Simple sample Servlet which will display the tasks in the default task list of the user.
 */
@SuppressWarnings("serial")
public class PrintTasksTitlesServlet extends HttpServlet {

  /**
   * The OAuth Token DAO implementation, used to persist the OAuth refresh token.
   * Consider injecting it instead of using a static initialization. Additionally, a
   * simple memory implementation is used as a mock. Change the implementation to
   * using the user's own user/login implementation.
   */
  public static OAuthTokenDao oauthTokenDao = new OAuthTokenDaoMemoryImpl();

  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // Getting the current user
    // This is using App Engine's User Service but you should replace this to
    // your own user/login implementation
    UserService userService = UserServiceFactory.getUserService();
    User user = userService.getCurrentUser();

    // If the user is not logged-in it is redirected to the login service, then back to this page
    if (user == null) {
      resp.sendRedirect(userService.createLoginURL(getFullRequestUrl(req)));
      return;
    }

    // Checking if we already have tokens for this user in store
    AccessTokenResponse accessTokenResponse = oauthTokenDao.getKeys(user.getEmail());

    // If tokens are not available for this user
    if (accessTokenResponse == null) {
      OAuthProperties oauthProperties = new OAuthProperties();
      // Redirects to the Google OAuth 2.0 authorization endpoint
      resp.sendRedirect(new GoogleAuthorizationRequestUrl(oauthProperties.getClientId(),
          OAuthCodeCallbackHandlerServlet.getOAuthCodeCallbackHandlerUrl(req), oauthProperties
              .getScopesAsString()).build());
      return;
    }
  }

  /**
   * Construct the request's URL without the parameter part.
   *
   * @param req the HttpRequest object
   * @return The constructed request's URL
   */
  public static String getFullRequestUrl(HttpServletRequest req) {
    String scheme = req.getScheme() + "://";
    String serverName = req.getServerName();
    String serverPort = (req.getServerPort() == 80) ? "" : ":" + req.getServerPort();
    String contextPath = req.getContextPath();
    String servletPath = req.getServletPath();
    String pathInfo = (req.getPathInfo() == null) ? "" : req.getPathInfo();
    String queryString = (req.getQueryString() == null) ? "" : "?" + req.getQueryString();
    return scheme + serverName + serverPort + contextPath + servletPath + pathInfo + queryString;
  }
}

注意:上述實作方式會使用部分 App Engine 程式庫。這些是為了簡化而使用的。如果您是為其他平台開發應用程式,請重新實作處理使用者驗證的 UserService 介面。

應用程式會使用 DAO 持續存取使用者的授權權杖。 以下範例顯示本範例中使用的 OAuthTokenDao 介面和模擬 (記憶體內) 實作項目 - OAuthTokenDaoMemoryImpl -:

package com.google.oauthsample;

import com.google.api.client.auth.oauth2.draft10.AccessTokenResponse;

/**
 * Allows easy storage and access of authorization tokens.
 */
public interface OAuthTokenDao {

  /**
   * Stores the given AccessTokenResponse using the {@code username}, the OAuth
   * {@code clientID} and the tokens scopes as keys.
   *
   * @param tokens The AccessTokenResponse to store
   * @param userName The userName associated wit the token
   */
  public void saveKeys(AccessTokenResponse tokens, String userName);

  /**
   * Returns the AccessTokenResponse stored for the given username, clientId and
   * scopes. Returns {@code null} if there is no AccessTokenResponse for this
   * user and scopes.
   *
   * @param userName The username of which to get the stored AccessTokenResponse
   * @return The AccessTokenResponse of the given username
   */
  public AccessTokenResponse getKeys(String userName);
}
OAuthTokenDao.java 檔案
package com.google.oauthsample;

import com.google.api.client.auth.oauth2.draft10.AccessTokenResponse;
...

/**
 * Quick and Dirty memory implementation of {@link OAuthTokenDao} based on
 * HashMaps.
 */
public class OAuthTokenDaoMemoryImpl implements OAuthTokenDao {

  /** Object where all the Tokens will be stored */
  private static Map<String, AccessTokenResponse> tokenPersistance = new HashMap<String, AccessTokenResponse>();

  public void saveKeys(AccessTokenResponse tokens, String userName) {
    tokenPersistance.put(userName, tokens);
  }

  public AccessTokenResponse getKeys(String userName) {
    return tokenPersistance.get(userName);
  }
}
OAuthTokenDaoMemoryImpl.java 檔案

應用程式的 OAuth 2.0 憑證會儲存在屬性檔案中。 或者,您也可以將這些值儲存在其中一個 Java 類別中的某處,做為常數。 以下是範例中使用的 OAuthProperties 類別和 oauth.properties 檔案:

package com.google.oauthsample;

import ...

/**
 * Object representation of an OAuth properties file.
 */
public class OAuthProperties {

  public static final String DEFAULT_OAUTH_PROPERTIES_FILE_NAME = "oauth.properties";

  /** The OAuth 2.0 Client ID */
  private String clientId;

  /** The OAuth 2.0 Client Secret */
  private String clientSecret;

  /** The Google APIs scopes to access */
  private String scopes;

  /**
   * Instantiates a new OauthProperties object reading its values from the
   * {@code OAUTH_PROPERTIES_FILE_NAME} properties file.
   *
   * @throws IOException IF there is an issue reading the {@code propertiesFile}
   * @throws OauthPropertiesFormatException If the given {@code propertiesFile}
   *           is not of the right format (does not contains the keys {@code
   *           clientId}, {@code clientSecret} and {@code scopes})
   */
  public OAuthProperties() throws IOException {
    this(OAuthProperties.class.getResourceAsStream(DEFAULT_OAUTH_PROPERTIES_FILE_NAME));
  }

  /**
   * Instantiates a new OAuthProperties object, reading its values from the given
   * properties file.
   *
   * @param propertiesFile the InputStream to read an OAuth Properties file. The
   *          file should contain the keys {@code clientId}, {@code
   *          clientSecret} and {@code scopes}
   * @throws IOException if there is an issue reading the {@code propertiesFile}
   * @throws OAuthPropertiesFormatException If the given {@code propertiesFile}
   *           is not in the correct format (does not contain the keys {@code
   *           clientId}, {@code clientSecret} and {@code scopes})
   */
  public OAuthProperties(InputStream propertiesFile) throws IOException {
    Properties oauthProperties = new Properties();
    oauthProperties.load(propertiesFile);
    clientId = oauthProperties.getProperty("clientId");
    clientSecret = oauthProperties.getProperty("clientSecret");
    scopes = oauthProperties.getProperty("scopes");
    if ((clientId == null) || (clientSecret == null) || (scopes == null)) {
      throw new OAuthPropertiesFormatException();
    }
  }

  /**
   * @return the clientId
   */
  public String getClientId() {
    return clientId;
  }

  /**
   * @return the clientSecret
   */
  public String getClientSecret() {
    return clientSecret;
  }

  /**
   * @return the scopes
   */
  public String getScopesAsString() {
    return scopes;
  }

  /**
   * Thrown when the OAuth properties file was not at the right format, i.e not
   * having the right properties names.
   */
  @SuppressWarnings("serial")
  public class OAuthPropertiesFormatException extends RuntimeException {
  }
}
OAuthProperties.java 檔案

oauth.properties 檔案包含應用程式的 OAuth 2.0 憑證,如下列範例所示。您需要變更這個檔案中的值。

# Client ID and secret. They can be found in the APIs console.
clientId=1234567890.apps.googleusercontent.com
clientSecret=aBcDeFgHiJkLmNoPqRsTuVwXyZ
# API scopes. Space separated.
scopes=https://www.googleapis.com/auth/tasks
oauth.properties 檔案

OAuth 2.0 用戶端 ID 和用戶端密鑰可識別應用程式,並允許 Tasks API 套用為應用程式定義的篩選器和配額規則。您可以在 Google API 控制台中找到用戶端 ID 和密鑰。進入控制台後,使用者必須:

  • 建立或選取所需專案。
  • 在服務清單中將 Tasks API 狀態設為「開啟」,即可啟用 Tasks API。
  • 在「API Access」(API 存取權) 下方,建立 OAuth 2.0 用戶端 ID (如果尚未建立)。
  • 請確認專案的 OAuth 2.0 程式碼回呼處理常式網址已在「重新導向 URI」中註冊/加入允許清單。舉例來說,在這個範例專案中,如果網頁應用程式是從 https://www.example.com 網域提供服務,使用者就必須註冊 https://www.example.com/oauth2callback

API 控制台中的重新導向 URI
API 控制台中的重新導向 URI

處理 Google 授權端點的授權碼

如果使用者尚未授權應用程式存取工作,因此系統將使用者重新導向至 Google 的 OAuth 2.0 授權端點,Google 會顯示授權對話方塊,提示使用者授予應用程式工作存取權:

Google 的授權對話方塊
Google 的授權對話方塊

授權或拒絕存取權後,系統會將使用者重新導向至 OAuth 2.0 程式碼回呼處理常式,該處理常式是在建構 Google 授權網址時指定為重新導向/回呼:

new GoogleAuthorizationRequestUrl(oauthProperties.getClientId(),
      OAuthCodeCallbackHandlerServlet.getOAuthCodeCallbackHandlerUrl(req), oauthProperties
          .getScopesAsString()).build()

OAuth 2.0 程式碼回呼處理常式 - OAuthCodeCallbackHandlerServlet - 會處理從 Google OAuth 2.0 端點的重新導向。需要處理的情況有 2 種:

  • 使用者已授予存取權:系統會剖析要求,從網址參數取得 OAuth 2.0 授權碼。
  • 使用者拒絕存取:系統會向使用者顯示訊息。
package com.google.oauthsample;

import ...

/**
 * Servlet handling the OAuth callback from the authentication service. We are
 * retrieving the OAuth code, then exchanging it for a refresh and an access
 * token and saving it.
 */
@SuppressWarnings("serial")
public class OAuthCodeCallbackHandlerServlet extends HttpServlet {

  /** The name of the Oauth code URL parameter */
  public static final String CODE_URL_PARAM_NAME = "code";

  /** The name of the OAuth error URL parameter */
  public static final String ERROR_URL_PARAM_NAME = "error";

  /** The URL suffix of the servlet */
  public static final String URL_MAPPING = "/oauth2callback";

  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // Getting the "error" URL parameter
    String[] error = req.getParameterValues(ERROR_URL_PARAM_NAME);

    // Checking if there was an error such as the user denied access
    if (error != null && error.length > 0) {
      resp.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, "There was an error: \""+error[0]+"\".");
      return;
    }
    // Getting the "code" URL parameter
    String[] code = req.getParameterValues(CODE_URL_PARAM_NAME);

    // Checking conditions on the "code" URL parameter
    if (code == null || code.length == 0) {
      resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "The \"code\" URL parameter is missing");
      return;
    }
  }

  /**
   * Construct the OAuth code callback handler URL.
   *
   * @param req the HttpRequest object
   * @return The constructed request's URL
   */
  public static String getOAuthCodeCallbackHandlerUrl(HttpServletRequest req) {
    String scheme = req.getScheme() + "://";
    String serverName = req.getServerName();
    String serverPort = (req.getServerPort() == 80) ? "" : ":" + req.getServerPort();
    String contextPath = req.getContextPath();
    String servletPath = URL_MAPPING;
    String pathInfo = (req.getPathInfo() == null) ? "" : req.getPathInfo();
    return scheme + serverName + serverPort + contextPath + servletPath + pathInfo;
  }
}
OAuthCodeCallbackHandlerServlet.java 檔案

使用授權碼交換更新和存取權杖

接著,OAuthCodeCallbackHandlerServlet 會將 Auth 2.0 程式碼換成更新和存取權杖,並將其保留在資料存放區中,然後將使用者重新導向至 PrintTaskListsTitlesServlet 網址:

新增至檔案的程式碼會以醒目方式顯示。

package com.google.oauthsample;

import ...

/**
 * Servlet handling the OAuth callback from the authentication service. We are
 * retrieving the OAuth code, then exchanging it for a refresh and an access
 * token and saving it.
 */
@SuppressWarnings("serial")
public class OAuthCodeCallbackHandlerServlet extends HttpServlet {

  /** The name of the Oauth code URL parameter */
  public static final String CODE_URL_PARAM_NAME = "code";

  /** The name of the OAuth error URL parameter */
  public static final String ERROR_URL_PARAM_NAME = "error";

  /** The URL suffix of the servlet */
  public static final String URL_MAPPING = "/oauth2callback";
  /** The URL to redirect the user to after handling the callback. Consider
   * saving this in a cookie before redirecting users to the Google
   * authorization URL if you have multiple possible URL to redirect people to. */
  public static final String REDIRECT_URL = "/";

  /** The OAuth Token DAO implementation. Consider injecting it instead of using
   * a static initialization. Also we are using a simple memory implementation
   * as a mock. Change the implementation to using your database system. */
  public static OAuthTokenDao oauthTokenDao = new OAuthTokenDaoMemoryImpl();

  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // Getting the "error" URL parameter
    String[] error = req.getParameterValues(ERROR_URL_PARAM_NAME);

    // Checking if there was an error such as the user denied access
    if (error != null && error.length > 0) {
      resp.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, "There was an error: \""+error[0]+"\".");
      return;
    }

    // Getting the "code" URL parameter
    String[] code = req.getParameterValues(CODE_URL_PARAM_NAME);

    // Checking conditions on the "code" URL parameter
    if (code == null || code.length == 0) {
      resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "The \"code\" URL parameter is missing");
      return;
    }
    // Construct incoming request URL
    String requestUrl = getOAuthCodeCallbackHandlerUrl(req);

    // Exchange the code for OAuth tokens
    AccessTokenResponse accessTokenResponse = exchangeCodeForAccessAndRefreshTokens(code[0],
        requestUrl);

    // Getting the current user
    // This is using App Engine's User Service, but the user should replace this
    // with their own user/login implementation
    UserService userService = UserServiceFactory.getUserService();
    String email = userService.getCurrentUser().getEmail();

    // Save the tokens
    oauthTokenDao.saveKeys(accessTokenResponse, email);

    resp.sendRedirect(REDIRECT_URL);
  }

  /**
   * Construct the OAuth code callback handler URL.
   *
   * @param req the HttpRequest object
   * @return The constructed request's URL
   */
  public static String getOAuthCodeCallbackHandlerUrl(HttpServletRequest req) {
    String scheme = req.getScheme() + "://";
    String serverName = req.getServerName();
    String serverPort = (req.getServerPort() == 80) ? "" : ":" + req.getServerPort();
    String contextPath = req.getContextPath();
    String servletPath = URL_MAPPING;
    String pathInfo = (req.getPathInfo() == null) ? "" : req.getPathInfo();
    return scheme + serverName + serverPort + contextPath + servletPath + pathInfo;
  }
  /**
   * Exchanges the given code for an exchange and a refresh token.
   *
   * @param code The code gotten back from the authorization service
   * @param currentUrl The URL of the callback
   * @param oauthProperties The object containing the OAuth configuration
   * @return The object containing both an access and refresh token
   * @throws IOException
   */
  public AccessTokenResponse exchangeCodeForAccessAndRefreshTokens(String code, String currentUrl)
      throws IOException {

    HttpTransport httpTransport = new NetHttpTransport();
    JacksonFactory jsonFactory = new JacksonFactory();

    // Loading the oauth config file
    OAuthProperties oauthProperties = new OAuthProperties();

    return new GoogleAuthorizationCodeGrant(httpTransport, jsonFactory, oauthProperties
        .getClientId(), oauthProperties.getClientSecret(), code, currentUrl).execute();
  }
}
OAuthCodeCallbackHandlerServlet.java 檔案

注意:上述實作項目使用了一些 App Engine 程式庫,目的是簡化作業。如果您是為其他平台開發應用程式,請重新實作處理使用者驗證的 UserService 介面。

讀取並顯示使用者的工作

使用者已授予應用程式存取工作項目的權限。應用程式的重新整理權杖儲存在資料儲存庫中,可透過 OAuthTokenDao 存取。現在,PrintTaskListsTitlesServlet servlet 可以使用這些權杖存取及顯示使用者的工作:

新增至檔案的程式碼會以醒目方式顯示。

package com.google.oauthsample;

import ...

/**
 * Simple sample Servlet which will display the tasks in the default task list of the user.
 */
@SuppressWarnings("serial")
public class PrintTasksTitlesServlet extends HttpServlet {

  /**
   * The OAuth Token DAO implementation, used to persist the OAuth refresh token.
   * Consider injecting it instead of using a static initialization. Additionally, a
   * simple memory implementation is used as a mock. Change the implementation to
   * use your own database system.
   */
  public static OAuthTokenDao oauthTokenDao = new OAuthTokenDaoMemoryImpl();

  public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    // Getting the current user
    // This is using App Engine's User Service but you should replace this to
    // your own user/login implementation
    UserService userService = UserServiceFactory.getUserService();
    User user = userService.getCurrentUser();

    // If the user is not logged-in it is redirected to the login service, then back to this page
    if (user == null) {
      resp.sendRedirect(userService.createLoginURL(getFullRequestUrl(req)));
      return;
    }

    // Checking if we already have tokens for this user in store
    AccessTokenResponse accessTokenResponse = oauthTokenDao.getKeys(user.getEmail());

    // If we don't have tokens for this user
    if (accessTokenResponse == null) {
      OAuthProperties oauthProperties = new OAuthProperties();
      // Redirect to the Google OAuth 2.0 authorization endpoint
      resp.sendRedirect(new GoogleAuthorizationRequestUrl(oauthProperties.getClientId(),
          OAuthCodeCallbackHandlerServlet.getOAuthCodeCallbackHandlerUrl(req), oauthProperties
              .getScopesAsString()).build());
      return;
    }
// Prints the user's task list titles in the response
    resp.setContentType("text/plain");
    resp.getWriter().append("Task Lists titles for user " + user.getEmail() + ":\n\n");
    printTasksTitles(accessTokenResponse, resp.getWriter());
  }

  /**
   * Construct the request's URL without the parameter part.
   *
   * @param req the HttpRequest object
   * @return The constructed request's URL
   */
  public static String getFullRequestUrl(HttpServletRequest req) {
    String scheme = req.getScheme() + "://";
    String serverName = req.getServerName();
    String serverPort = (req.getServerPort() == 80) ? "" : ":" + req.getServerPort();
    String contextPath = req.getContextPath();
    String servletPath = req.getServletPath();
    String pathInfo = (req.getPathInfo() == null) ? "" : req.getPathInfo();
    String queryString = (req.getQueryString() == null) ? "" : "?" + req.getQueryString();
    return scheme + serverName + serverPort + contextPath + servletPath + pathInfo + queryString;
  }
  /**
   * Uses the Google Tasks API to retrieve a list of the user's tasks in the default
   * tasks list.
   *
   * @param accessTokenResponse The OAuth 2.0 AccessTokenResponse object
   *          containing the access token and a refresh token.
   * @param output The output stream writer to write the task list titles to.
   * @return A list of the user's task titles in the default task list.
   * @throws IOException
   */
  public void printTasksTitles(AccessTokenResponse accessTokenResponse, Writer output) throws IOException {

    // Initializing the Tasks service
    HttpTransport transport = new NetHttpTransport();
    JsonFactory jsonFactory = new JacksonFactory();
    OAuthProperties oauthProperties = new OAuthProperties();

    GoogleAccessProtectedResource accessProtectedResource = new GoogleAccessProtectedResource(
        accessTokenResponse.accessToken, transport, jsonFactory, oauthProperties.getClientId(),
        oauthProperties.getClientSecret(), accessTokenResponse.refreshToken);

    Tasks service = new Tasks(transport, accessProtectedResource, jsonFactory);

    // Using the initialized Tasks API service to query the list of tasks lists
    com.google.api.services.tasks.model.Tasks tasks = service.tasks.list("@default").execute();

    for (Task task : tasks.items) {
      output.append(task.title + "\n");
    }
  }
}
PrintTasksTitlesServlet.java 檔案

畫面會顯示使用者的工作:

使用者的工作清單
使用者的工作

應用程式範例

您可以下載這個範例應用程式的程式碼