這是 Classroom 外掛程式操作說明系列的第二篇逐步操作說明。
在本逐步操作說明中,您將在網頁應用程式中加入 Google 登入功能。這是 Classroom 外掛程式的必要行為。日後所有 API 呼叫都會使用此授權流程中的憑證。
在本逐步操作說明的過程中,您將完成下列工作:
- 設定網頁應用程式,以便在 iframe 中維護工作階段資料。
- 實作 Google OAuth 2.0 伺服器對伺服器登入流程。
- 向 OAuth 2.0 API 發出呼叫。
- 建立其他路徑,以支援授權、登出和測試 API 呼叫。
完成後,您就可以在網頁應用程式中授予使用者完整權限,並向 Google API 發出呼叫。
瞭解授權流程
Google API 使用 OAuth 2.0 通訊協定進行驗證及授權。如需 Google OAuth 實作方式的完整說明,請參閱 Google Identity OAuth 指南。
您的應用程式憑證會在 Google Cloud 中管理。建立這些項目後,請實施四步驟程序來驗證及授權使用者:
- 要求授權。請在這個要求中提供回呼網址。完成後,您會收到授權網址。
- 將使用者重新導向至授權網址。結果頁面會向使用者說明應用程式所需的權限,並提示使用者允許存取權。完成後,系統會將使用者重新導向至回呼網址。
- 在回呼路徑中接收授權碼。將授權碼換成存取權杖和更新權杖。
- 使用權杖呼叫 Google API。
取得 OAuth 2.0 憑證
請確認您已按照總覽頁面中的說明建立及下載 OAuth 憑證。您的專案必須使用這些憑證登入使用者。
實作授權流程
在我們的網頁應用程式中加入邏輯和路徑,實現所述流程,包括以下功能:
- 使用者前往到達網頁時,啟動授權流程。
- 要求授權並處理授權伺服器回應。
- 清除儲存的憑證。
- 撤銷應用程式權限。
- 測試 API 呼叫。
啟動授權
視需要修改到達網頁,以便啟動授權流程。外掛程式可能處於兩種狀態:目前的工作階段中已儲存憑證,或是您需要從 OAuth 2.0 伺服器取得憑證。如果工作階段中含有符記,請執行測試 API 呼叫,否則請使用者登入。
Python
開啟 routes.py
檔案。首先,請根據 iframe 安全性建議設定幾個常數和 Cookie 設定。
# The file that contains the OAuth 2.0 client_id and client_secret.
CLIENT_SECRETS_FILE = "client_secret.json"
# The OAuth 2.0 access scopes to request.
# These scopes must match the scopes in your Google Cloud project's OAuth Consent
# Screen: https://console.cloud.google.com/apis/credentials/consent
SCOPES = [
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
]
# Flask cookie configurations.
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="None",
)
移至外掛程式到達路徑 (在範例檔案中為 /classroom-addon
)。如果工作階段「不」包含「credentials」鍵,請新增邏輯來轉譯登入頁面。
@app.route("/classroom-addon")
def classroom_addon():
if "credentials" not in flask.session:
return flask.render_template("authorization.html")
return flask.render_template(
"addon-discovery.html",
message="You've reached the addon discovery page.")
Java
您可以在 step_02_sign_in
模組中找到本逐步操作說明的程式碼。
開啟 application.properties
檔案,並新增符合iframe 安全性建議的工作階段設定。
# iFrame security recommendations call for cookies to have the HttpOnly and
# secure attribute set
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
# Ensures that the session is maintained across the iframe and sign-in pop-up.
server.servlet.session.cookie.same-site=none
建立服務類別 (step_02_sign_in
模組中的 AuthService.java
),以便處理控制器檔案中端點背後的邏輯,並設定外掛程式所需的重新導向 URI、用戶端密碼檔案位置和範圍。重新導向 URI 可在使用者授權應用程式後,將使用者重新導向至特定 URI。請參閱來源程式碼中 README.md
的「Project Setup」(專案設定) 部分,瞭解如何放置 client_secret.json
檔案。
@Service
public class AuthService {
private static final String REDIRECT_URI = "https://localhost:5000/callback";
private static final String CLIENT_SECRET_FILE = "client_secret.json";
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private static final String[] REQUIRED_SCOPES = {
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
};
/** Creates and returns a Collection object with all requested scopes.
* @return Collection of scopes requested by the application.
*/
public static Collection<String> getScopes() {
return new ArrayList<>(Arrays.asList(REQUIRED_SCOPES));
}
}
開啟控制器檔案 (step_02_sign_in
模組中的 AuthController.java
),並在登入階段「不」包含 credentials
鍵時,在登入路徑中加入邏輯,以便轉譯登入頁面。
@GetMapping(value = {"/start-auth-flow"})
public String startAuthFlow(Model model) {
try {
return "authorization";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
@GetMapping(value = {"/addon-discovery"})
public String addon_discovery(HttpSession session, Model model) {
try {
if (session == null || session.getAttribute("credentials") == null) {
return startAuthFlow(model);
}
return "addon-discovery";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
授權頁面應包含連結或按鈕,方便使用者「登入」。按一下這個按鈕後,應會將使用者重新導向至 authorize
路徑。
要求授權
如要要求授權,請建構並將使用者重新導向至驗證網址。這個網址包含多項資訊,例如要求的範圍、授權後的目的路徑,以及網頁應用程式的用戶端 ID。您可以在這個授權網址範例中看到這些參數。
Python
在 routes.py
檔案中新增下列匯入項目。
import google_auth_oauthlib.flow
建立新路線 /authorize
。建立 google_auth_oauthlib.flow.Flow
的例項;強烈建議您使用內附的 from_client_secrets_file
方法來執行此操作。
@app.route("/authorize")
def authorize():
# Create flow instance to manage the OAuth 2.0 Authorization Grant Flow
# steps.
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES)
設定 flow
的 redirect_uri
;這是您希望使用者在授權應用程式後返回的路徑。在以下範例中,這個路徑為 /callback
。
# The URI created here must exactly match one of the authorized redirect
# URIs for the OAuth 2.0 client, which you configured in the API Console. If
# this value doesn't match an authorized URI, you will get a
# "redirect_uri_mismatch" error.
flow.redirect_uri = flask.url_for("callback", _external=True)
使用資料流物件建構 authorization_url
和 state
。將 state
儲存在工作階段中,以便稍後驗證伺服器回應的真實性。最後,將使用者重新導向至 authorization_url
。
authorization_url, state = flow.authorization_url(
# Enable offline access so that you can refresh an access token without
# re-prompting the user for permission. Recommended for web server apps.
access_type="offline",
# Enable incremental authorization. Recommended as a best practice.
include_granted_scopes="true")
# Store the state so the callback can verify the auth server response.
flask.session["state"] = state
# Redirect the user to the OAuth authorization URL.
return flask.redirect(authorization_url)
Java
將下列方法新增至 AuthService.java
檔案,以便將流程物件例項化,然後使用該物件擷取授權網址:
getClientSecrets()
方法會讀取用戶端密鑰檔案,並建構GoogleClientSecrets
物件。getFlow()
方法會建立GoogleAuthorizationCodeFlow
的例項。authorize()
方法會使用GoogleAuthorizationCodeFlow
物件、state
參數和重新導向 URI 擷取授權網址。state
參數用於驗證授權伺服器回應的真實性。然後,該方法會傳回包含授權網址和state
參數的對應項目。
/** Reads the client secret file downloaded from Google Cloud.
* @return GoogleClientSecrets read in from client secret file. */
public GoogleClientSecrets getClientSecrets() throws Exception {
try {
InputStream in = SignInApplication.class.getClassLoader()
.getResourceAsStream(CLIENT_SECRET_FILE);
if (in == null) {
throw new FileNotFoundException("Client secret file not found: "
+ CLIENT_SECRET_FILE);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets
.load(JSON_FACTORY, new InputStreamReader(in));
return clientSecrets;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns authorization code flow.
* @return GoogleAuthorizationCodeFlow object used to retrieve an access
* token and refresh token for the application.
* @throws Exception if reading client secrets or building code flow object
* is unsuccessful.
*/
public GoogleAuthorizationCodeFlow getFlow() throws Exception {
try {
GoogleAuthorizationCodeFlow authorizationCodeFlow =
new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
getClientSecrets(),
getScopes())
.setAccessType("offline")
.build();
return authorizationCodeFlow;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns a map with the authorization URL, which allows the
* user to give the app permission to their account, and the state parameter,
* which is used to prevent cross site request forgery.
* @return map with authorization URL and state parameter.
* @throws Exception if building the authorization URL is unsuccessful.
*/
public HashMap authorize() throws Exception {
HashMap<String, String> authDataMap = new HashMap<>();
try {
String state = new BigInteger(130, new SecureRandom()).toString(32);
authDataMap.put("state", state);
GoogleAuthorizationCodeFlow flow = getFlow();
String authUrl = flow
.newAuthorizationUrl()
.setState(state)
.setRedirectUri(REDIRECT_URI)
.build();
String url = authUrl;
authDataMap.put("url", url);
return authDataMap;
} catch (Exception e) {
throw e;
}
}
使用建構函式插入作業,在控制器類別中建立服務類別的例項。
/** Declare AuthService to be used in the Controller class constructor. */
private final AuthService authService;
/** AuthController constructor. Uses constructor injection to instantiate
* the AuthService and UserRepository classes.
* @param authService the service class that handles the implementation logic
* of requests.
*/
public AuthController(AuthService authService) {
this.authService = authService;
}
將 /authorize
端點新增至控制器類別。這個端點會呼叫 AuthService authorize()
方法,擷取 state
參數和授權網址。接著,端點會在工作階段中儲存 state
參數,並將使用者重新導向至授權網址。
/** Redirects the sign-in pop-up to the authorization URL.
* @param response the current response to pass information to.
* @param session the current session.
* @throws Exception if redirection to the authorization URL is unsuccessful.
*/
@GetMapping(value = {"/authorize"})
public void authorize(HttpServletResponse response, HttpSession session)
throws Exception {
try {
HashMap authDataMap = authService.authorize();
String authUrl = authDataMap.get("url").toString();
String state = authDataMap.get("state").toString();
session.setAttribute("state", state);
response.sendRedirect(authUrl);
} catch (Exception e) {
throw e;
}
}
處理伺服器回應
授權後,使用者會從上一個步驟返回 redirect_uri
路徑。在上述範例中,這個路徑為 /callback
。
當使用者從授權頁面返回時,您會在回應中收到 code
。然後使用代碼換取存取和重新整理權杖:
Python
將下列匯入項目新增至 Flask 伺服器檔案。
import google.oauth2.credentials
import googleapiclient.discovery
新增路徑至伺服器。建構另一個 google_auth_oauthlib.flow.Flow
例項,但這次重複使用先前步驟中儲存的狀態。
@app.route("/callback")
def callback():
state = flask.session["state"]
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES, state=state)
flow.redirect_uri = flask.url_for("callback", _external=True)
接著,請要求存取和重新整理權杖。幸運的是,flow
物件也包含 fetch_token
方法,可用於完成這項操作。此方法預期 code
或 authorization_response
引數。請使用 authorization_response
,因為這是來自請求的完整網址。
authorization_response = flask.request.url
flow.fetch_token(authorization_response=authorization_response)
您現在已完成憑證設定!將這些值儲存在工作階段中,以便在其他方法或路徑中擷取,然後重新導向至外掛到達網頁。
credentials = flow.credentials
flask.session["credentials"] = {
"token": credentials.token,
"refresh_token": credentials.refresh_token,
"token_uri": credentials.token_uri,
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"scopes": credentials.scopes
}
# Close the pop-up by rendering an HTML page with a script that redirects
# the owner and closes itself. This can be done with a bit of JavaScript:
# <script>
# window.opener.location.href = "{{ url_for('classroom_addon') }}";
# window.close();
# </script>
return flask.render_template("close-me.html")
Java
在服務類別中新增方法,藉由傳入從授權網址執行的重新導向中擷取的授權碼,回傳 Credentials
物件。這個 Credentials
物件稍後會用於擷取存取權杖和重新整理權杖。
/** Returns the required credentials to access Google APIs.
* @param authorizationCode the authorization code provided by the
* authorization URL that's used to obtain credentials.
* @return the credentials that were retrieved from the authorization flow.
* @throws Exception if retrieving credentials is unsuccessful.
*/
public Credential getAndSaveCredentials(String authorizationCode) throws Exception {
try {
GoogleAuthorizationCodeFlow flow = getFlow();
GoogleClientSecrets googleClientSecrets = getClientSecrets();
TokenResponse tokenResponse = flow.newTokenRequest(authorizationCode)
.setClientAuthentication(new ClientParametersAuthentication(
googleClientSecrets.getWeb().getClientId(),
googleClientSecrets.getWeb().getClientSecret()))
.setRedirectUri(REDIRECT_URI)
.execute();
Credential credential = flow.createAndStoreCredential(tokenResponse, null);
return credential;
} catch (Exception e) {
throw e;
}
}
將重新導向 URI 的端點新增至控制器。從要求中擷取授權碼和 state
參數。將此 state
參數與儲存在工作階段中的 state
屬性進行比較。如果相符,請繼續進行授權流程。如果不相符,則傳回錯誤。
接著,請呼叫 AuthService
getAndSaveCredentials
方法,並將授權碼做為參數傳入。擷取 Credentials
物件後,請將其儲存在工作階段中。接著關閉對話方塊,並將使用者重新導向至外掛程式到達網頁。
/** Handles the redirect URL to grant the application access to the user's
* account.
* @param request the current request used to obtain the authorization code
* and state parameter from.
* @param session the current session.
* @param response the current response to pass information to.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the close-pop-up template if authorization is successful, or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/callback"})
public String callback(HttpServletRequest request, HttpSession session,
HttpServletResponse response, Model model) {
try {
String authCode = request.getParameter("code");
String requestState = request.getParameter("state");
String sessionState = session.getAttribute("state").toString();
if (!requestState.equals(sessionState)) {
response.setStatus(401);
return onError("Invalid state parameter.", model);
}
Credential credentials = authService.getAndSaveCredentials(authCode);
session.setAttribute("credentials", credentials);
return "close-pop-up";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
測試 API 呼叫
流程完成後,您現在可以向 Google API 發出呼叫!
舉例來說,請要求使用者的個人資料。您可以透過 OAuth 2.0 API 要求使用者資訊。
Python
請參閱 OAuth 2.0 discovery API 的說明文件,並使用該文件取得已填入資料的 UserInfo 物件。
# Retrieve the credentials from the session data and construct a
# Credentials instance.
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
# Construct the OAuth 2.0 v2 discovery API library.
user_info_service = googleapiclient.discovery.build(
serviceName="oauth2", version="v2", credentials=credentials)
# Request and store the username in the session.
# This allows it to be used in other methods or in an HTML template.
flask.session["username"] = (
user_info_service.userinfo().get().execute().get("name"))
Java
在服務類別中建立方法,以 Credentials
做為參數,建構 UserInfo
物件。
/** Obtains the Userinfo object by passing in the required credentials.
* @param credentials retrieved from the authorization flow.
* @return the Userinfo object for the currently signed-in user.
* @throws IOException if creating UserInfo service or obtaining the
* Userinfo object is unsuccessful.
*/
public Userinfo getUserInfo(Credential credentials) throws IOException {
try {
Oauth2 userInfoService = new Oauth2.Builder(
new NetHttpTransport(),
new GsonFactory(),
credentials).build();
Userinfo userinfo = userInfoService.userinfo().get().execute();
return userinfo;
} catch (Exception e) {
throw e;
}
}
將 /test
端點新增至顯示使用者電子郵件的控制器。
/** Returns the test request page with the user's email.
* @param session the current session.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the test page that displays the current user's email or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/test"})
public String test(HttpSession session, Model model) {
try {
Credential credentials = (Credential) session.getAttribute("credentials");
Userinfo userInfo = authService.getUserInfo(credentials);
String userInfoEmail = userInfo.getEmail();
if (userInfoEmail != null) {
model.addAttribute("userEmail", userInfoEmail);
} else {
return onError("Could not get user email.", model);
}
return "test";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
清除憑證
您可以從目前的工作階段中移除使用者的憑證,藉此「清除」使用者的憑證。這樣一來,您就能測試加購商品到達網頁上的路由。
建議您在將使用者重新導向至外掛程式到達網頁前,先顯示使用者已登出的訊息。您的應用程式應透過授權流程取得新的憑證,但不會提示使用者重新授權應用程式。
Python
@app.route("/clear")
def clear_credentials():
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
return flask.render_template("signed-out.html")
您也可以使用 flask.session.clear()
,但如果工作階段中儲存了其他值,這可能會產生非預期的效果。
Java
在控制器中新增 /clear
端點。
/** Clears the credentials in the session and returns the sign-out
* confirmation page.
* @param session the current session.
* @return the sign-out confirmation page.
*/
@GetMapping(value = {"/clear"})
public String clear(HttpSession session) {
try {
if (session != null && session.getAttribute("credentials") != null) {
session.removeAttribute("credentials");
}
return "sign-out";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
撤銷應用程式權限
使用者可以傳送 POST
要求給 https://oauth2.googleapis.com/revoke
,藉此撤銷應用程式的權限。要求應包含使用者的存取權存取權杖。
Python
import requests
@app.route("/revoke")
def revoke():
if "credentials" not in flask.session:
return flask.render_template("addon-discovery.html",
message="You need to authorize before " +
"attempting to revoke credentials.")
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
revoke = requests.post(
"https://oauth2.googleapis.com/revoke",
params={"token": credentials.token},
headers={"content-type": "application/x-www-form-urlencoded"})
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
status_code = getattr(revoke, "status_code")
if status_code == 200:
return flask.render_template("authorization.html")
else:
return flask.render_template(
"index.html", message="An error occurred during revocation!")
Java
在服務類別中新增方法,用於呼叫撤銷端點。
/** Revokes the app's permissions to the user's account.
* @param credentials retrieved from the authorization flow.
* @return response entity returned from the HTTP call to obtain response
* information.
* @throws RestClientException if the POST request to the revoke endpoint is
* unsuccessful.
*/
public ResponseEntity<String> revokeCredentials(Credential credentials) throws RestClientException {
try {
String accessToken = credentials.getAccessToken();
String url = "https://oauth2.googleapis.com/revoke?token=" + accessToken;
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
HttpEntity<Object> httpEntity = new HttpEntity<Object>(httpHeaders);
ResponseEntity<String> responseEntity = new RestTemplate().exchange(
url,
HttpMethod.POST,
httpEntity,
String.class);
return responseEntity;
} catch (RestClientException e) {
throw e;
}
}
將端點 /revoke
新增至控制器,該控制器會清除工作階段,並在撤銷成功時將使用者重新導向至授權頁面。
/** Revokes the app's permissions and returns the authorization page.
* @param session the current session.
* @return the authorization page.
* @throws Exception if revoking access is unsuccessful.
*/
@GetMapping(value = {"/revoke"})
public String revoke(HttpSession session) throws Exception {
try {
if (session != null && session.getAttribute("credentials") != null) {
Credential credentials = (Credential) session.getAttribute("credentials");
ResponseEntity responseEntity = authService.revokeCredentials(credentials);
Integer httpStatusCode = responseEntity.getStatusCodeValue();
if (httpStatusCode != 200) {
return onError("There was an issue revoking access: " +
responseEntity.getStatusCode(), model);
}
session.removeAttribute("credentials");
}
return startAuthFlow(model);
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
測試外掛程式
以老師測試使用者身分登入 Google Classroom。前往「課堂作業」分頁,然後建立新的「作業」。按一下文字區域下方的「外掛程式」按鈕,然後選取所需外掛程式。iframe 會開啟,外掛程式會載入您在 GWM SDK 的「App Configuration」頁面中指定的附件設定 URI。
恭喜!您可以繼續進行下一個步驟:處理加購商品的多次造訪。