本文档介绍了如何通过一个示例 Web 应用(该应用将使用 Google Tasks API 显示用户的任务)使用 Java servlet 实现 OAuth 2.0 授权回调处理程序。示例应用将先请求授权以访问用户的 Google Tasks,然后在默认任务列表中显示用户的任务。
受众群体
本文档专门面向熟悉 Java 和 J2EE Web 应用架构的人员。建议您对 OAuth 2.0 授权流程有所了解。
目录
为了获得这样一个完全可用的示例,您需要完成以下几个步骤:
- 在 web.xml 文件中声明 servlet 映射
- 对系统中的用户进行身份验证,并请求访问其 Tasks 的授权
- 监听 Google 授权端点中的授权代码
- 使用授权代码兑换刷新令牌和访问令牌
- 读取用户的任务并显示这些任务
在 web.xml 文件中声明 servlet 映射
我们将在应用中使用 2 个 servlet:
- PrintTasksTitlesServlet(映射到 /):应用的入口点,用于处理用户身份验证,并显示用户的任务
- OAuthCodeCallbackHandlerServlet(映射到 /oauth2callback):用于处理 OAuth 授权端点响应的 OAuth 2.0 回调
下面是将这 2 个 servlet 映射到应用中的网址的 web.xml 文件:
<?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 状态切换为启用,以启用 Tasks API。
- 在 API 访问权限下,创建 OAuth 2.0 客户端 ID(如果尚未创建)。
- 确保项目的 OAuth 2.0 代码回调处理程序网址已在重定向 URI 中注册/列入了许可名单。例如,在此示例项目中,如果您的 Web 应用是从 https://www.example.com 网域提供的,则您必须注册 https://www.example.com/oauth2callback。

监听 Google 授权端点中的授权代码
如果用户尚未授权应用访问其任务,因此被重定向到 Google 的 OAuth 2.0 授权端点,系统会向用户显示 Google 的授权对话框,要求用户向您的应用授予对其任务的访问权限:

授予或拒绝访问权限后,系统会将用户重定向回在构建 Google 授权网址时指定为重定向/回调的 OAuth 2.0 代码回调处理程序:
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 网址:
下面文件中添加的代码会突出显示语法,现有代码会灰显。
/** The 网址 to redirect the user to after handling the callback. 如果您有多个可能的重定向网址,请考虑在将用户重定向到 Google 授权网址之前将此值保存在 Cookie 中。*/ public static final String REDIRECT_网址 = "/"; /** The OAuth Token DAO implementation. 请考虑注入它,而不是使用静态初始化。此外,我们还使用简单的内存实现作为模拟对象。将实现更改为使用您的数据库系统。*/ public static OAuthTokenDao oauthTokenDao = new OAuthTokenDaoMemoryImpl(); 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";// 构建传入请求网址 String requestUrl = getOAuthCodeCallbackHandlerUrl(req); // 使用代码换取 OAuth 令牌 AccessTokenResponse accessTokenResponse = exchangeCodeForAccessAndRefreshTokens(code[0], requestUrl); // 获取当前用户 // 这使用的是 App Engine 的 User 服务,但您应将其替换为自己的用户/登录实现 UserService userService = UserServiceFactory.getUserService(); String email = userService.getCurrentUser().getEmail(); // 保存令牌 oauthTokenDao.saveKeys(accessTokenResponse, email); resp.sendRedirect(REDIRECT_网址); } 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; }/** * Exchanges the given code for an exchange and a refresh token. * * @param code The code gotten back from the authorization service * @param currentUrl The 网址 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(); } } /** * 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 现在可以使用这些令牌访问用户的任务并将其显示:
下面文件中添加的代码会突出显示语法,现有代码会灰显。
// Printing the user's task lists titles in the response resp.setContentType("text/plain"); resp.getWriter().append("Task Lists titles for user " + user.getEmail() + ":\n\n"); printTasksTitles(accessTokenResponse, resp.getWriter()); 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; }/** * Uses the Google Tasks API to retrieve a list of the users' tasks in the default * tasks list. * * @param accessTokenResponse 包含访问令牌和刷新令牌的 OAuth 2.0 AccessTokenResponse 对象。 * @param output the output stream writer where to rite the tasks lists titles * @return A list of the users's tasks 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"); } } } } /** * 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 文件系统会向用户显示其任务:
用户的任务示例应用
您可以点击此处下载此示例应用的代码。欢迎随时查看。