登录用户

这是 Google 课堂插件演示系列中的第二个演示。

在本演示中,您将向 Web 应用添加 Google 登录功能。这是 Google 课堂插件的必需行为。今后对此 API 的所有调用都使用此授权流程中的凭据。

在本演示过程中,您将完成以下操作:

  • 配置 Web 应用以在 iframe 中维护会话数据。
  • 实现 Google OAuth 2.0 服务器到服务器登录流程。
  • 发出对 OAuth 2.0 API 的调用。
  • 创建其他路由,以支持 API 调用授权、退出帐号和测试。

完成后,您可以在您的 Web 应用中向用户完全授权,并发出对 Google API 的调用。

了解授权流程

Google API 使用 OAuth 2.0 协议进行身份验证和授权。有关 Google OAuth 实现的完整说明,请参阅 Google Identity OAuth 指南

应用的凭据在 Google Cloud 中进行管理。创建完成后,请实现一个四步流程以对用户进行授权和身份验证:

  1. 请求授权。为此请求提供回调网址。 完成后,您会收到一个授权网址
  2. 将用户重定向到授权网址。随即出现的页面会告知用户您的应用所需的权限,并提示他们授予相应访问权限。完成后,用户将转到回调网址。
  3. 在回拨路由中收到授权代码。用授权代码交换访问令牌刷新令牌
  4. 使用令牌调用 Google API。

获取 OAuth 2.0 凭据

确保您已按照“概览”页中的说明创建并下载了 OAuth 凭据。您的项目必须使用这些凭据让用户登录。

实现授权流程

向我们的 Web 应用添加逻辑和路由,以实现上述流程,其中包括以下功能:

  • 到达着陆页时启动授权流程。
  • 请求授权并处理授权服务器响应。
  • 清除存储的凭据。
  • 撤消应用的权限。
  • 测试 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。如需了解 client_secret.json 文件的放置位置,请参阅源代码中 README.md 的“项目设置”部分。

@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 路线。

请求授权

如需请求授权,请构造用户并将其重定向到身份验证网址。此网址包含一些信息,例如请求的范围、授权后的目标路由以及 Web 应用的客户端 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)

使用 flow 对象构建 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 Discovery 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);
    }
}

撤消应用的权限

用户可以通过向 https://oauth2.googleapis.com/revoke 发送 POST 请求来撤消应用的权限。请求应包含用户的访问令牌。

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 课堂。导航至课业标签页,然后创建新的作业。点击文本区域下方的插件按钮,然后选择您的插件。iframe 会打开,并且该插件会加载您在 GWM SDK 的应用配置页面中指定的附件设置 URI

恭喜!您可以继续执行下一步:处理对插件的重复访问