במסמך הזה מוסבר איך להטמיע handler של קריאה חוזרת (callback) להרשאה מסוג OAuth 2.0 באמצעות שרתי Java servlets דרך אפליקציית אינטרנט לדוגמה, שתציג את המשימות של המשתמש באמצעות Google Tasks API. האפליקציה לדוגמה תבקש קודם הרשאה לגשת ל-Google Tasks של המשתמש, ולאחר מכן תציג את המשימות של המשתמש ברשימת המשימות המוגדרת כברירת מחדל.
קהל
המסמך הזה מותאם לאנשים שמכירים ארכיטקטורת אפליקציות אינטרנט של Java ו-J2EE. מומלץ ידע מסוים לגבי תהליך ההרשאה של OAuth 2.0.
תוכן עניינים
כדי לבצע דגימה תקינה באופן מלא, נדרשים מספר שלבים:
- להצהיר על מיפויי servlet בקובץ web.xml
- אימות המשתמשים במערכת ולבקש הרשאה לגשת ל-Tasks
- האזנה לקוד ההרשאה מנקודת הקצה של Google Authorization
- החלפת קוד ההרשאה באסימון של רענון וגישה
- קריאת המשימות של המשתמש והצגתן
יש להצהיר על מיפויים של מאגרים בקובץ web.xml
נשתמש ב-2 מאגרים באפליקציה שלנו:
- PrintTasksTitlesServlet (ממופה ל-/): נקודת הכניסה לאפליקציה שתטפל באימות המשתמש, ותציג את המשימות של המשתמש
- OAuthCodeCallbackHandlerServlet (ממופה ל-/oauth2callback): קריאה חוזרת ב-OAuth 2.0 שמטפלת בתגובה מנקודת הקצה של הרשאת OAuth
בהמשך מופיע הקובץ web.xml שממפה את שני השרתים האלה לכתובות URL באפליקציה שלנו:
<?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>
אימות המשתמשים במערכת ולבקש הרשאה לגשת למשימות שלה
המשתמש נכנס לאפליקציה דרך השורש '/' כתובת ה-URL שממופה אל ה-servlet PrintTaskListsTitlesServlet. ב-servlet הזה, מבצעים את המשימות הבאות:
- בדיקה אם המשתמש מאומת במערכת
- אם המשתמש לא מאומת, הוא יופנה לדף האימות
- אם המשתמש מאומת, אנחנו בודקים אם כבר יש לנו אסימון רענון באחסון הנתונים שלנו, שמטופל על ידי OAuthTokenDao שלמטה. אם אין אסימון רענון למשתמש, פירוש הדבר הוא שהמשתמש עדיין לא העניק לאפליקציה הרשאה לגשת למשימות שלו. במקרה כזה, המשתמש מופנה לנקודת הקצה של הרשאת OAuth 2.0 של Google.
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 MaptokenPersistance = new HashMap (); 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 מזהים את האפליקציה שלכם ומאפשרים ל-Tasks API להחיל מסננים וכללי מכסות שהוגדרו לאפליקציה. תוכלו למצוא את מזהה הלקוח והסוד במסוף Google APIs. לאחר הכניסה למסוף, תצטרכו:
- יוצרים או בוחרים פרויקט.
- כדי להפעיל את Tasks API, מחליפים את הסטטוס של Tasks API למצב מופעל ברשימת השירותים.
- בקטע API Access, אם עדיין לא נוצר מזהה לקוח ב-OAuth 2.0, צריך ליצור מזהה לקוח.
- מוודאים שכתובת ה-URL של ה-handler של קריאה חוזרת לקוד OAuth 2.0 של הפרויקט רשומה/מופיעה ב-URIs להפניה אוטומטית. לדוגמה, בפרויקט לדוגמה הזה תצטרכו לרשום את הכתובת https://www.example.com/oauth2callback אם אפליקציית האינטרנט שלכם מוצגת מהדומיין https://www.example.com.
האזנה לקוד ההרשאה מנקודת הקצה של ההרשאה של Google
במקרה שבו המשתמש עדיין לא אישר לאפליקציה לגשת למשימות שלה ולכן הופנה מחדש לנקודת הקצה של Google להרשאה OAuth 2.0, תוצג למשתמש תיבת דו-שיח להרשאה מ-Google, שתבקש מהמשתמש להעניק לאפליקציה גישה למשימות שלה:
לאחר הענקת גישה או דחיית הגישה, המשתמש יופנה חזרה ל-handler של קריאה חוזרת (callback) בקוד OAuth 2.0, שהוגדר כהפניה אוטומטית/קריאה חוזרת (callback) במהלך בניית כתובת ה-URL להרשאה של Google:
new GoogleAuthorizationRequestUrl(oauthProperties.getClientId(), OAuthCodeCallbackHandlerServlet.getOAuthCodeCallbackHandlerUrl(req), oauthProperties .getScopesAsString()).build()
ה-handler של קריאה חוזרת (callback) של קוד 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 באסימוני רענון וגישה, שומר אותו במאגר הנתונים ומפנה את המשתמש חזרה אל כתובת ה-URL של 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";/** כתובת ה-URL שאליה המשתמשים יופנו אחרי טיפול בקריאה חוזרת (callback). נקודות שכדאי להעלות * שמירת הפריט בקובץ cookie לפני הפניית משתמשים אל Google * כתובת URL להרשאה אם יש לכם כמה כתובות URL שניתן להפנות אליהן אנשים. */ Public static Final String REDIRECT_URL = "/"; /** הטמעת DAO של אסימון OAuth. כדאי להזריק אותו במקום להשתמש בו * אתחול סטטי. אנחנו גם משתמשים ביישום פשוט של זיכרון * בתור הדמיה. לשנות את היישום לשימוש במערכת מסד הנתונים. */ ציבורי סטטי OAuthTokenDao oauthTokenDao = חדש 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; }// בנייה של כתובת האתר של הבקשה הנכנסת String requestUrl = getOAuthCodeCallbackHandlerUrl(req); // החלפת הקוד באסימוני OAuth AccessTokenResponse accessTokenResponse = exchangeCodeForAccessAndRefreshTokens(code[0], requestUrl); // Getting the current user // This is using App Engine's User Service, אבל צריך להחליף את זה ב- // הטמעת המשתמש/ההתחברות שלך UserService userService = UserServiceStore.getUserService(); String email = userService.getCurrentUser().getEmail(); // שומרים את האסימונים 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; }/** * מחליפה את הקוד הנתון ב-Exchange ובאסימון רענון. * * @param code הקוד שהתקבל משירות ההרשאה * @param currentUrl כתובת ה-URL של הקריאה החוזרת (callback) * @param oauthProperties האובייקט שמכיל את תצורת OAuth * @return האובייקט שמכיל גם אסימון גישה וגם אסימון רענון * @throws IONOTE */ Public AccessTokenResponse ExchangeCodeForAccessAndrefreshTokens(קוד מחרוזת, מחרוזת currentUrl) throws IOException { HttpTransport httpTransport = new NetHttpTransport(); Jacksonworks jsonforums = new Jackson המרכזית(); // טעינת קובץ התצורה oauth OAuthProperties oauthProperties = new OAuthProperties(); החזרת GoogleAuthorizationCodeGrant(httpTransport, jsonforums, oauthProperties) חדש .getClientId(), oauthProperties.getClientSecret(), code, currentUrl).execute(); } }קובץ OAuthCodeCallbackHandlerServlet.javaהערה: ההטמעה שלמעלה מתבססת על ספריות מסוימות של App Engine. ספריות אלה משמשות כדי לפשט את התהליך. אם את/ה מפתח/ת לפלטפורמה אחרת, ניתן להטמיע מחדש את ממשק UserService שמטפל באימות המשתמשים.
קריאת המשימות של המשתמש והצגתן
המשתמש העניק לאפליקציה גישה למשימות שלה. לאפליקציה יש אסימון רענון שנשמר במאגר הנתונים, שאפשר לגשת אליו דרך OAuthTokenDao. המאגר PrintTaskListsTitlesServlet יכול עכשיו להשתמש באסימונים האלה כדי לגשת למשימות של המשתמש ולהציג אותן:
הקוד שנוסף לקובץ שלמטה מודגש בתחביר, הקוד שכבר קיים מופיע באפור.
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; }// Printing the user's tasks lists in the response resp.setContentType("text/plain"); resp.getWriter().append("Task List 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; }/** * משתמש ב-Google Tasks API כדי לאחזר רשימת משימות של המשתמשים כברירת מחדל * רשימת משימות. * * @param accessTokenResponse אובייקט Access 2.0 AccessTokenResponse * שמכיל את אסימון הגישה ואסימון רענון. * @param פלט את הכותב של זרם הפלט היכן קורים את הכותרות של רשימות המשימות * @return רשימה של כותרות המשימות של המשתמשים ברשימת המשימות המוגדרת כברירת מחדל. * @throws IONOTE */ public void printTasksTitles(AccessTokenResponse accessTokenResponse, Writer output) throws IOException { // אתחול שירות Tasks HttpTransport = new NetHttpTransport(); Jsonworks jsonפק = new Jackson ונהנים מ-(); OAuthProperties oauthProperties = new OAuthProperties(); GoogleAccessProtectedResource accessProtectedResource = new GoogleAccessProtectedResource( accessTokenResponse.accessToken, תעבורה, jsonforums, oauthProperties.getClientId(), oauthProperties.getClientSecret(), accessTokenResponse.refreshToken); Tasks service = new Tasks(transport, accessProtectedResource, jsonטען); // שימוש בשירות Tasks API האתחול כדי לבצע שאילתה על רשימת המשימות com.google.api.services.tasks.model.Tasks tasks = service.tasks.list("@default").execute(); for (Task task : tasks.items) { export.append(task.title + "\n"); } } }קובץ PrintTasksTitlesServlet.javaלמשתמש יוצגו המשימות שלו:
המשימות של המשתמשאפליקציה לדוגמה
כאן ניתן להוריד את הקוד של האפליקציה לדוגמה. אתם מוזמנים לעיין בו.