处理重复登录

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

在本演示中,您将通过自动检索用户之前授予的凭据来处理对插件的重复访问。然后,将用户路由到可以立即发出 API 请求的网页。这是 Google 课堂插件的必需行为。

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

  • 为用户凭据实现永久性存储空间。
  • 检索并评估以下插件查询参数:
    • login_hint:已登录用户的 Google ID 编号。
    • hd:已登录用户的网域。

请注意,系统只会发送其中一个值。如果用户尚未向您的应用授权,Classroom API 就会发送 hd 参数。否则,API 会发送 login_hint。如需查看查询参数的完整列表,请参阅 iframe 指南页面

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

了解 iframe 查询参数

打开插件时,Google 课堂会加载插件的附件设置 URI。Google 课堂会向 URI 附加几个 GET 查询参数;这些形参包含有用的上下文信息。例如,如果您的附件发现 URI 为 https://example.com/addon,Google 课堂会创建来源网址设为 https://example.com/addon?courseId=XXX&postId=YYY&addOnToken=ZZZ 的 iframe,其中 XXXYYYZZZ 是字符串 ID。如需详细了解这种情况,请参阅 iframe 指南

发现网址有五种可能的查询参数:

  • courseId:当前 Google 课堂课程的 ID。
  • postId:用户正在修改或创建的作业帖子的 ID。
  • addOnToken:用于授权某些 Google 课堂插件操作的令牌。
  • login_hint:当前用户的 Google ID。
  • hd:当前用户的主机网域,例如 example.com

本演示介绍了 hdlogin_hint。系统会根据提供的任何查询参数将用户路由到授权流程(如果为 hd)或插件发现页面(如果为 login_hint)。

访问查询参数

如上所述,查询参数通过 URI 字符串传递给 Web 应用。将这些值存储在会话中;它们会在授权流程中使用,并用于存储和检索用户的相关信息。这些查询参数仅在首次打开插件时传递。

Python

转到 Flask 路由的定义(如果您遵循我们提供的示例,则为 routes.py)。在插件着陆路线的顶部(在我们提供的示例中为 /classroom-addon),检索并存储 login_hinthd 查询参数:

# Retrieve the login_hint and hd query parameters.
login_hint = flask.request.args.get("login_hint")
hd = flask.request.args.get("hd")

确保 login_hinthd 存储在会话中。这个位置适合存储这些值;它们是临时的,当打开插件时,您会收到新值。

# It's possible that we might return to this route later, in which case the
# parameters will not be passed in. Instead, use the values cached in the
# session.

# If neither query parameter is available, use the values in the session.
if login_hint is None and hd is None:
    login_hint = flask.session.get("login_hint")
    hd = flask.session.get("hd")

# If there's no login_hint query parameter, then check for hd.
# Send the user to the sign in page.
elif hd is not None:
    flask.session["hd"] = hd
    return start_auth_flow()

# If the login_hint query parameter is available, we'll store it in the
# session.
else:
    flask.session["login_hint"] = login_hint

Java

前往控制器类中的附加着陆路线(在所提供的示例中,AuthController.java 中的 /addon-discovery)。在此路线的开头,检索并存储 login_hinthd 查询参数。

/** Retrieve the login_hint or hd query parameters from the request URL. */
String login_hint = request.getParameter("login_hint");
String hd = request.getParameter("hd");

确保 login_hinthd 存储在会话中。这个位置适合存储这些值;它们是临时的,当打开插件时,您会收到新值。

/** If neither query parameter is sent, use the values in the session. */
if (login_hint == null && hd == null) {
    login_hint = (String) session.getAttribute("login_hint");
    hd = (String) session.getAttribute("hd");
}

/** If the hd query parameter is provided, add hd to the session and route
*   the user to the authorization page. */
else if (hd != null) {
    session.setAttribute("hd", hd);
    return startAuthFlow(model);
}

/** If the login_hint query parameter is provided, add it to the session. */
else if (login_hint != null) {
    session.setAttribute("login_hint", login_hint);
}

将查询参数添加到授权流程

login_hinthd 参数也应传递给 Google 的身份验证服务器。这样可简化身份验证过程;如果您的应用知道哪个用户正在尝试进行身份验证,服务器便会使用该提示在登录表单中预填充电子邮件地址字段,从而简化登录流程。

Python

导航到 Flask 服务器文件中的授权路由(在我们提供的示例中为 /authorize)。将 login_hinthd 参数添加到对 flow.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",
    # The user will automatically be selected if we have the login_hint.
    login_hint=flask.session.get("login_hint"),
    # If we don't have login_hint, passing hd will reduce the list of
    # accounts in the account chooser to only those with the same domain.
    hd=flask.session.get("hd"))

Java

转到 AuthService.java 类中的 authorize() 方法。将 login_hinthd 作为参数添加到该方法,并将 login_hinthd 参数添加到授权网址构建工具。

String authUrl = flow
    .newAuthorizationUrl()
    .setState(state)
    .set("login_hint", login_hint)
    .set("hd", hd)
    .setRedirectUri(REDIRECT_URI)
    .build();

为用户凭据添加永久性存储空间

如果在加载插件时收到 login_hint 作为查询参数,则表示用户已完成应用的授权流程。您应该检索用户之前的凭据,而不是强制他们重新登录。

回想一下,您在授权流程完成后收到了一个刷新令牌。保存此令牌;可重复使用它来获取访问令牌,该令牌在短期内有效,在使用 Google API 时必须使用。您之前已在会话中保存了这些凭据,但您需要存储这些凭据来处理重复访问。

定义用户架构并设置数据库

User 设置数据库架构。

Python

定义用户架构

User 包含以下属性:

  • id:用户的 Google ID。这应与 login_hint 查询参数中提供的值一致。
  • display_name:用户的名字和姓氏,例如“Alex Smith”。
  • email:用户的电子邮件地址。
  • portrait_url:用户个人资料照片的网址。
  • refresh_token:先前获取的刷新令牌。

此示例使用 Python 原生支持的 SQLite 实现存储。它使用 flask_sqlalchemy 模块来协助我们的数据库管理。

设置数据库

首先,为数据库指定文件位置。进入您的服务器配置文件(在我们提供的示例中为 config.py),并添加以下内容。

import os

# Point to a database file in the project root.
DATABASE_FILE_NAME = os.path.join(
    os.path.abspath(os.path.dirname(__file__)), 'data.sqlite')

class Config(object):
    SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_FILE_NAME}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False

这会将 Flask 指向与 main.py 文件位于同一目录中的 data.sqlite 文件。

接下来,转到您的模块目录并创建新的 models.py 文件。如果您遵循我们提供的示例,则这是 webapp/models.py。将以下内容添加到新文件以定义 User 表,并将 webapp 替换为您的模块名称(如果不同)。

from webapp import db

# Database model to represent a user.
class User(db.Model):
    # The user's identifying information:
    id = db.Column(db.String(120), primary_key=True)
    display_name = db.Column(db.String(80))
    email = db.Column(db.String(120), unique=True)
    portrait_url = db.Column(db.Text())

    # The user's refresh token, which will be used to obtain an access token.
    # Note that refresh tokens will become invalid if:
    # - The refresh token has not been used for six months.
    # - The user revokes your app's access permissions.
    # - The user changes passwords.
    # - The user belongs to a Google Cloud organization
    #   that has session control policies in effect.
    refresh_token = db.Column(db.Text())

最后,在模块的 __init__.py 文件中,添加以下代码以导入新模型并创建数据库。

from webapp import models
from os import path
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy(app)

# Initialize the database file if not created.
if not path.exists(config.DATABASE_FILE_NAME):
    db.create_all()

Java

定义用户架构

User 包含以下属性:

  • id:用户的 Google ID。这应与 login_hint 查询参数中提供的值一致。
  • email:用户的电子邮件地址。

在模块的 resources 目录中创建一个 schema.sql 文件。Spring 会读取此文件,并相应地为数据库生成架构。 使用表名称 users 和表示 User 属性 idemail 的列定义表。

CREATE TABLE IF NOT EXISTS users (
    id VARCHAR(255) PRIMARY KEY, -- user's unique Google ID
    email VARCHAR(255), -- user's email address
);

创建一个 Java 类,以便为数据库定义 User 模型。在所提供的示例中为 User.java

添加 @Entity 注解,以指明这是一个可保存到数据库的 POJO。添加 @Table 注解,其中包含您在 schema.sql 中配置的相应表名称。

请注意,该代码示例包含这两个属性的构造函数和 setter。AuthController.java 中的构造函数和 setter 用来在数据库中创建或更新用户。您可以根据需要添加 getter 和 toString 方法,但在本演示中,这些方法并未用到,为简洁起见,本页面的代码示例中省略了这些方法。

/** An entity class that provides a model to store user information. */
@Entity
@Table(name = "users")
public class User {
    /** The user's unique Google ID. The @Id annotation specifies that this
     *   is the primary key. */
    @Id
    @Column
    private String id;

    /** The user's email address. */
    @Column
    private String email;

    /** Required User class no args constructor. */
    public User() {
    }

    /** The User class constructor that creates a User object with the
    *   specified parameters.
    *   @param id the user's unique Google ID
    *   @param email the user's email address
    */
    public User(String id, String email) {
        this.id = id;
        this.email = email;
    }

    public void setId(String id) { this.id = id; }

    public void setEmail(String email) { this.email = email; }
}

创建一个名为 UserRepository.java 的接口来处理对数据库的 CRUD 操作。此接口扩展了 CrudRepository 接口。

/** Provides CRUD operations for the User class by extending the
 *   CrudRepository interface. */
@Repository
public interface UserRepository extends CrudRepository<User, String> {
}

控制器类有助于客户端与代码库之间的通信。因此,请更新控制器类构造函数以注入 UserRepository 类。

/** Declare UserRepository to be used in the Controller class constructor. */
private final UserRepository userRepository;

/**
*   ...
*   @param userRepository the class that interacts with User objects stored in
*   persistent storage.
*/
public AuthController(AuthService authService, UserRepository userRepository) {
    this.authService = authService;
    this.userRepository = userRepository;
}

设置数据库

如需存储用户相关信息,请使用 Spring Boot 本身支持的 H2 数据库。此数据库还会在后续演示中用于存储其他与 Google 课堂相关的信息。设置 H2 数据库需要将以下配置添加到 application.properties

# Enable configuration for persistent storage using an H2 database
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:file:./h2/userdb
spring.datasource.username=<USERNAME>
spring.datasource.password=<PASSWORD>
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

spring.datasource.url 配置会创建一个名为 h2 的目录,其中包含文件 userdb。将 H2 数据库的路径添加到 .gitignore。您必须先更新 spring.datasource.usernamespring.datasource.password,然后才能运行应用,以使用您选择的用户名和密码来设置数据库。如需在运行应用后更新数据库的用户名和密码,请删除生成的 h2 目录,更新配置,然后重新运行应用。

spring.jpa.hibernate.ddl-auto 配置设置为 update 可确保在应用重启时保留存储在数据库中的数据。如需在每次重启应用时清除数据库,请将此配置设置为 create

spring.jpa.open-in-view 配置设置为 false。此配置默认处于启用状态,众所周知,此配置会导致难以在生产环境中诊断的性能问题。

如前所述,您必须能够检索回访用户的凭据。通过 GoogleAuthorizationCodeFlow 提供的内置凭据存储区支持,可以实现此目的。

AuthService.java 类中,定义存储凭据类的文件的路径。在此示例中,该文件在 /credentialStore 目录中创建。将凭据存储区的路径添加到 .gitignore。此目录会在用户开始授权流程后生成。

private static final File dataDirectory = new File("credentialStore");

接下来,在 AuthService.java 文件中创建一个用于创建并返回 FileDataStoreFactory 对象的方法。这是存储凭据的数据存储区。

/** Creates and returns FileDataStoreFactory object to store credentials.
 *   @return FileDataStoreFactory dataStore used to save and obtain users ids
 *   mapped to Credentials.
 *   @throws IOException if creating the dataStore is unsuccessful.
 */
public FileDataStoreFactory getCredentialDataStore() throws IOException {
    FileDataStoreFactory dataStore = new FileDataStoreFactory(dataDirectory);
    return dataStore;
}

更新 AuthService.java 中的 getFlow() 方法以将 setDataStoreFactory 添加到 GoogleAuthorizationCodeFlow Builder() 方法中,并调用 getCredentialDataStore() 来设置数据存储区。

GoogleAuthorizationCodeFlow authorizationCodeFlow =
    new GoogleAuthorizationCodeFlow.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY,
        getClientSecrets(),
        getScopes())
    .setAccessType("offline")
    .setDataStoreFactory(getCredentialDataStore())
    .build();

接下来,更新 getAndSaveCredentials(String authorizationCode) 方法。以前,此方法获取凭据时不会将其存储在任何位置。更新相应方法,以将凭据存储在按用户 ID 编入索引的数据存储区中。

您可以使用 id_tokenTokenResponse 对象中获取用户 ID,但必须先对其进行验证。否则,客户端应用可以通过向服务器发送修改后的用户 ID 来冒充用户。建议您使用 Google API 客户端库来验证 id_token。如需了解详情,请参阅 [有关验证 Google ID 令牌的 Google 身份页面]。

// Obtaining the id_token will help determine which user signed in to the application.
String idTokenString = tokenResponse.get("id_token").toString();

// Validate the id_token using the GoogleIdTokenVerifier object.
GoogleIdTokenVerifier googleIdTokenVerifier = new GoogleIdTokenVerifier.Builder(
        HTTP_TRANSPORT,
        JSON_FACTORY)
    .setAudience(Collections.singletonList(
        googleClientSecrets.getWeb().getClientId()))
    .build();

GoogleIdToken idToken = googleIdTokenVerifier.verify(idTokenString);

if (idToken == null) {
    throw new Exception("Invalid ID token.");
}

验证 id_token 后,获取要存储的 userId 以及获得的凭据。

// Obtain the user id from the id_token.
Payload payload = idToken.getPayload();
String userId = payload.getSubject();

更新对 flow.createAndStoreCredential 的调用以包含 userId

// Save the user id and credentials to the configured FileDataStoreFactory.
Credential credential = flow.createAndStoreCredential(tokenResponse, userId);

AuthService.java 类添加一个方法,用于返回特定用户的凭据(如果数据存储区中存在该方法)。

/** Find credentials in the datastore based on a specific user id.
*   @param userId key to find in the file datastore.
*   @return Credential object to be returned if a matching key is found in the datastore. Null if
*   the key doesn't exist.
*   @throws Exception if building flow object or checking for userId key is unsuccessful. */
public Credential loadFromCredentialDataStore(String userId) throws Exception {
    try {
        GoogleAuthorizationCodeFlow flow = getFlow();
        Credential credential = flow.loadCredential(userId);
        return credential;
    } catch (Exception e) {
        e.printStackTrace();
        throw e;
    }
}

检索凭据

定义用于获取 Users 的方法。您在 login_hint 查询参数中提供了一个 id,可用于检索特定的用户记录。

Python

def get_credentials_from_storage(id):
    """
    Retrieves credentials from the storage and returns them as a dictionary.
    """
    return User.query.get(id)

Java

AuthController.java 类中,定义一个方法,以根据用户 ID 从数据库中检索用户。

/** Retrieves stored credentials based on the user id.
*   @param id the id of the current user
*   @return User the database entry corresponding to the current user or null
*   if the user doesn't exist in the database.
*/
public User getUser(String id) {
    if (id != null) {
        Optional<User> user = userRepository.findById(id);
        if (user.isPresent()) {
            return user.get();
        }
    }
    return null;
}

存储凭据

存储凭据时有两种情况。如果用户的 id 已在数据库中,则使用任何新值更新现有记录。否则,请创建一个新的 User 记录并将其添加到数据库中。

Python

首先,定义一个实现存储或更新行为的实用程序方法。

def save_user_credentials(credentials=None, user_info=None):
    """
    Updates or adds a User to the database. A new user is added only if both
    credentials and user_info are provided.

    Args:
        credentials: An optional Credentials object.
        user_info: An optional dict containing user info returned by the
            OAuth 2.0 API.
    """

    existing_user = get_credentials_from_storage(
        flask.session.get("login_hint"))

    if existing_user:
        if user_info:
            existing_user.id = user_info.get("id")
            existing_user.display_name = user_info.get("name")
            existing_user.email = user_info.get("email")
            existing_user.portrait_url = user_info.get("picture")

        if credentials and credentials.refresh_token is not None:
            existing_user.refresh_token = credentials.refresh_token

    elif credentials and user_info:
        new_user = User(id=user_info.get("id"),
                        display_name=user_info.get("name"),
                        email=user_info.get("email"),
                        portrait_url=user_info.get("picture"),
                        refresh_token=credentials.refresh_token)

        db.session.add(new_user)

    db.session.commit()

在以下两种情况下,您可以将凭据保存到数据库:当用户在授权流程结束时返回您的应用时,以及发起 API 调用时。我们之前在这些代码中设置会话 credentials 键。

在“callback”路线的尽头调用 save_user_credentials。请保留 user_info 对象,而不是仅提取用户的姓名。

# The flow is complete! We'll use the credentials to fetch the user's info.
user_info_service = googleapiclient.discovery.build(
    serviceName="oauth2", version="v2", credentials=credentials)

user_info = user_info_service.userinfo().get().execute()

flask.session["username"] = user_info.get("name")

save_user_credentials(credentials, user_info)

您还应该在调用 API 后更新凭据。在这种情况下,您可以将更新后的凭据作为参数提供给 save_user_credentials 方法。

# Save credentials in case access token was refreshed.
flask.session["credentials"] = credentials_to_dict(credentials)
save_user_credentials(credentials)

Java

首先定义一个方法,用于将 User 对象存储在 H2 数据库中。

/** Adds or updates a user in the database.
*   @param credential the credentials object to save or update in the database.
*   @param userinfo the userinfo object to save or update in the database.
*   @param session the current session.
*/
public void saveUser(Credential credential, Userinfo userinfo, HttpSession session) {
    User storedUser = null;
    if (session != null && session.getAttribute("login_hint") != null) {
        storedUser = getUser(session.getAttribute("login_hint").toString());
    }

    if (storedUser != null) {
        if (userinfo != null) {
            storedUser.setId(userinfo.getId());
            storedUser.setEmail(userinfo.getEmail());
        }
        userRepository.save(storedUser);
    } else if (credential != null && userinfo != null) {
        User newUser = new User(
            userinfo.getId(),
            userinfo.getEmail(),
        );
        userRepository.save(newUser);
    }
}

在以下两种情况下,您可以将凭据保存到数据库:当用户在授权流程结束时返回您的应用时,以及发起 API 调用时。我们之前在这些代码中设置会话 credentials 键。

/callback 路线的尽头调用 saveUser。您应保留 user_info 对象,而不是仅提取用户的电子邮件地址。

/** This is the end of the auth flow. We should save user info to the database. */
Userinfo userinfo = authService.getUserInfo(credentials);
saveUser(credentials, userinfo, session);

您还应该在调用 API 后更新凭据。在这种情况下,您可以将更新后的凭据作为参数提供给 saveUser 方法。

/** Save credentials in case access token was refreshed. */
saveUser(credentials, null, session);

凭据过期

请注意,有多种原因可能会导致刷新令牌失效。这些细节包括:

  • 刷新令牌已有六个月没用过。
  • 用户撤消应用的访问权限。
  • 用户更改密码。
  • 用户属于已实施会话控制政策的 Google Cloud 组织。

如果用户的凭据恰巧失效,系统会再次提示用户通过授权流程来获取新令牌。

自动路由用户

修改附加的着陆路线,以检测用户之前是否已向我们的应用授权。如果是,请将他们路由到我们的插件主页面。否则,请提示他们登录。

Python

确保数据库文件已在应用启动时创建。将以下代码插入模块初始化程序(例如我们提供的示例中的 webapp/__init__.py)或启动服务器的主方法中。

# Initialize the database file if not created.
if not os.path.exists(DATABASE_FILE_NAME):
    db.create_all()

然后,您的方法应处理 login_hinthd 查询参数,如上文所述如果这是回访者,则加载商店凭据。如果您获得了 login_hint,就说明它是回访者。检索为此用户存储的任何凭据,并将其加载到会话中。

stored_credentials = get_credentials_from_storage(login_hint)

# If we have stored credentials, store them in the session.
if stored_credentials:
    # Load the client secrets file contents.
    client_secrets_dict = json.load(
        open(CLIENT_SECRETS_FILE)).get("web")

    # Update the credentials in the session.
    if not flask.session.get("credentials"):
        flask.session["credentials"] = {}

    flask.session["credentials"] = {
        "token": stored_credentials.access_token,
        "refresh_token": stored_credentials.refresh_token,
        "token_uri": client_secrets_dict["token_uri"],
        "client_id": client_secrets_dict["client_id"],
        "client_secret": client_secrets_dict["client_secret"],
        "scopes": SCOPES
    }

    # Set the username in the session.
    flask.session["username"] = stored_credentials.display_name

最后,如果我们没有用户凭据,将其路由到登录页面。如果我们这样做,请将他们路由到附加服务主页面。

if "credentials" not in flask.session or \
    flask.session["credentials"]["refresh_token"] is None:
    return flask.render_template("authorization.html")

return flask.render_template(
    "addon-discovery.html",
    message="You've reached the addon discovery page.")

Java

前往附加的着陆路线(所提供的示例中的 /addon-discovery)。如前所述,您可以在此处处理 login_hinthd 查询参数。

首先,检查会话中是否存在凭据。如果没有,请调用 startAuthFlow 方法通过身份验证流程路由用户。

/** Check if the credentials exist in the session. The session could have
 *   been cleared when the user clicked the Sign-Out button, and the expected
 *   behavior after sign-out would be to display the sign-in page when the
 *   iframe is opened again. */
if (session.getAttribute("credentials") == null) {
    return startAuthFlow(model);
}

然后,如果这是重复访问者,则从 H2 数据库加载用户。如果收到 login_hint 查询参数,则表示就是重复访问者。如果用户存在于 H2 数据库中,请从之前设置的凭据数据存储区加载凭据,并在会话中设置凭据。如果不是从凭据数据存储区获取凭据,请调用 startAuthFlow 通过身份验证流程路由用户。

/** At this point, we know that credentials exist in the session, but we
 *   should update the session credentials with the credentials in persistent
 *   storage in case they were refreshed. If the credentials in persistent
 *   storage are null, we should navigate the user to the authorization flow
 *   to obtain persisted credentials. */

User storedUser = getUser(login_hint);

if (storedUser != null) {
    Credential credential = authService.loadFromCredentialDataStore(login_hint);
    if (credential != null) {
        session.setAttribute("credentials", credential);
    } else {
        return startAuthFlow(model);
    }
}

最后,将用户转到插件着陆页。

/** Finally, if there are credentials in the session and in persistent
 *   storage, direct the user to the addon-discovery page. */
return "addon-discovery";

测试插件

以您的教师测试用户之一登录 Google 课堂。导航至课业标签页,然后创建新的作业。点击文本区域下方的插件按钮,然后选择您的插件。iframe 会打开,并且该插件会加载您在 GWM SDK 的应用配置页面中指定的附件设置 URI

恭喜!您可以继续执行下一步:创建连接并确定用户的角色