本文說明如何透過範例網頁應用程式,使用 Java Servlet 實作 OAuth 2.0 授權回呼處理常式,該應用程式會使用 Google Tasks API 顯示使用者的任務。範例應用程式會先要求存取使用者的 Google Tasks 授權,然後在預設工作清單中顯示使用者的工作。
觀眾
本文件適用於熟悉 Java 和 J2EE 網路應用程式架構的使用者。建議您具備一些 OAuth 2.0 授權流程的相關知識。
目錄
為了讓這個範例能正常運作,您需要執行以下步驟:
在 web.xml 檔案中宣告 Servlet 對應
我們將在應用程式中使用 2 個 servlet:
- PrintTasksTitlesServlet (對應至 /):應用程式的進入點,會處理使用者驗證,並顯示使用者的任務
- OAuthCodeCallbackHandlerServlet (對應至 /oauth2callback):處理 OAuth 授權端點回應的 OAuth 2.0 回呼
以下是 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>
驗證系統上的使用者,並要求存取其工作項的授權
使用者會透過根目錄「/」網址進入應用程式,該網址會對應至 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. 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 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; } } /** * 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); }
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); } }
此外,應用程式的 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 of the right format (does not contains 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 { } }
以下是 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 2.0 用戶端 ID 和用戶端秘密值可識別您的應用程式,並讓 Tasks API 套用為應用程式定義的篩選器和配額規則。您可以在 Google API 控制台中找到用戶端 ID 和密鑰。進入控制台後,您必須:
- 建立或選取所需專案。
- 在服務清單中將 Tasks API 狀態切換為「ON」,即可啟用 Tasks API。
- 在「API 存取權」下方,建立 OAuth 2.0 用戶端 ID (如果尚未建立)。
- 請確認專案的 OAuth 2.0 程式碼回呼處理常式網址已在「Redirect URIs」中註冊/加入許可清單。舉例來說,如果您的網頁應用程式是從 https://www.example.com 網域提供服務,則必須在這個範例專案中註冊 https://www.example.com/oauth2callback。

從 Google 授權端點監聽授權碼
如果使用者尚未授權應用程式存取其工作,因此已重新導向至 Google 的 OAuth 2.0 授權端點,系統會向使用者顯示 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 會交換 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";/** 處理回呼後,系統會將使用者重新導向至這個網址。如果您有多個可將使用者重新導向的網址,建議先將此資訊儲存在 Cookie 中,再將使用者重新導向至 Google 授權網址。*/ public static final String REDIRECT_URL = "/"; /** OAuth 權杖 DAO 實作。建議您使用注入方式,而非使用靜態初始化。我們也使用簡單的記憶體實作項目做為模擬項目。將實作項目改為使用資料庫系統。*/ public static OAuthTokenDao oauthTokenDao = new OAuthTokenDaoMemoryImpl();// 建構傳入要求網址 String requestUrl = getOAuthCodeCallbackHandlerUrl(req); // 將代碼換成 OAuth 權杖 AccessTokenResponse accessTokenResponse = exchangeCodeForAccessAndRefreshTokens(code[0], requestUrl); // 取得目前使用者 // 這會使用 App Engine 的 User Service,但您應將其替換為 // 自己的使用者/登入實作項目 UserService userService = UserServiceFactory.getUserService(); String email = userService.getCurrentUser().getEmail(); // 儲存權杖 oauthTokenDao.saveKeys(accessTokenResponse, email); resp.sendRedirect(REDIRECT_URL); } 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; }/** * 將指定代碼換成交換和重新整理權杖。* * @param code 從授權服務取得的代碼 * @param currentUrl 回呼的網址 * @param oauthProperties 包含 OAuth 設定的物件 * @return 包含存取權和重新整理權杖的物件 * @throws IOException */ public AccessTokenResponse exchangeCodeForAccessAndRefreshTokens(String code, String currentUrl) throws IOException { HttpTransport httpTransport = new NetHttpTransport(); JacksonFactory jsonFactory = new JacksonFactory(); // 載入 oauth 設定檔 OAuthProperties oauthProperties = new OAuthProperties(); return new GoogleAuthorizationCodeGrant(httpTransport, jsonFactory, oauthProperties .getClientId(), oauthProperties.getClientSecret(), code, currentUrl).execute(); } } /** * 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 檔案注意:上述實作方式使用了一些 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. 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 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; }// 在回應中列印使用者的待辦事項清單標題 resp.setContentType("text/plain"); resp.getWriter().append("Task Lists titles for user " + user.getEmail() + ":\n\n"); printTasksTitles(accessTokenResponse, resp.getWriter());/** * 使用 Google Tasks API 擷取預設工作清單中的使用者工作清單。 * * @param accessTokenResponse OAuth 2.0 AccessTokenResponse 物件,包含存取權杖和更新權杖。* @param output 輸出串流寫入器,用於寫入工作清單標題 * @return 預設工作清單中使用者的工作標題清單。 * @throws IOException */ public void printTasksTitles(AccessTokenResponse accessTokenResponse, Writer output) throws IOException { // 初始化 Tasks 服務 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); // 使用已初始化的 Tasks API 服務查詢工作清單清單 com.google.api.services.tasks.model.Tasks tasks = service.tasks.list("@default").execute(); for (Task task : tasks.items) { output.append(task.title + "\n"); } } } } /** * 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; }PrintTasksTitlesServlet.java 檔案系統會向使用者顯示其工作:
使用者的工作應用程式範例
您可以在這裡下載此應用程式範例的程式碼。歡迎查看。