登入使用者帳戶

這是 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 中管理。建立完成後,請執行四個步驟程序,以便授權及驗證使用者:

  1. 提出授權要求。請在這項要求中提供回呼網址。完成後,您會收到授權網址
  2. 將使用者重新導向至授權網址。產生的頁面會通知使用者應用程式所需的權限,並提示使用者允許存取。完成後,系統會將使用者轉送至回呼網址。
  3. 系統會在回呼路徑上接收授權碼。交換存取權杖更新權杖的授權碼。
  4. 使用權杖呼叫 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)。新增邏輯,在工作階段「不」包含「憑證」金鑰時轉譯登入頁面。

@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 的「專案設定」一節,進一步瞭解放置 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)

設定 flowredirect_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_urlstate。將 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 方法來完成這項操作。此方法預期 codeauthorization_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 探索 API 的說明文件,瞭解如何使用這個 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。前往「課堂作業」分頁並建立新的「作業」。按一下文字區域下方的「Add-ons」按鈕,然後選取外掛程式。該 iframe 會隨即開啟,外掛程式會載入您在 GWM SDK「應用程式設定」頁面中指定的連結設定 URI

恭喜!您可以開始進行下一個步驟:處理外掛程式的重複造訪