處理重複登入

這是 Classroom 外掛程式逐步操作說明系列中的第三個逐步操作說明。

在本逐步操作說明中,您將自動擷取使用者先前授予的憑證,處理再次造訪外掛程式的情況。然後,您將使用者轉送至可以立即發出 API 要求的頁面。這是 Classroom 外掛程式的必要行為。

在本逐步操作說明中,您將完成下列操作:

  • 為我們的使用者憑證導入永久儲存空間。
  • 擷取並評估下列外掛程式查詢參數:
    • login_hint:已登入使用者的 Google ID 號碼。
    • hd:已登入使用者的網域。

請注意,系統只會傳送其中一項資訊。如果使用者尚未「尚未」授權您的應用程式,Classroom API 會傳送 hd 參數。否則,API 會傳送 login_hint。如需查詢參數的完整清單,請參閱 iframe 指南頁面

完成後,您就能在網頁應用程式中完整授權使用者,並呼叫 Google API。

瞭解 iframe 查詢參數

Classroom 會在開啟時載入外掛程式的連結設定 URI。Classroom 會在 URI 中附加數個 GET 查詢參數,這些參數包含實用的背景資訊。舉例來說,如果您的附件探索 URI 是 https://example.com/addon,Classroom 會建立來源網址,並將來源網址設為 https://example.com/addon?courseId=XXX&postId=YYY&addOnToken=ZZZ,其中 XXXYYYZZZ 都是字串 ID。如需這種情境的詳細說明,請參閱 iframe 指南

探索網址可能有五種查詢參數:

  • courseId:目前 Classroom 課程的 ID。
  • postId:使用者正在編輯或建立的作業貼文 ID。
  • addOnToken:用來授權特定 Classroom 外掛程式動作的權杖。
  • login_hint:目前使用者的 Google ID。
  • hd:目前使用者的主機網域,例如 example.com

這份逐步操作說明會介紹 hdlogin_hint。系統會根據提供的查詢參數,將使用者轉送到授權流程:如果是 hd,則會轉送至外掛程式探索頁面 (如果為 login_hint)。

存取查詢參數

如上所述,查詢參數會透過 URI 字串傳送至網頁應用程式。請將這些值儲存在工作階段中;這些值將用於授權流程,以及儲存及擷取使用者的相關資訊。只有在初次開啟外掛程式時,才會傳遞這些查詢參數。

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。使用您在 schema.sql 中設定的對應資料表名稱新增 @Table 註解。

請注意,這個程式碼範例包含兩個屬性的建構函式和 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 資料庫。後續逐步操作說明中也會使用這個資料庫儲存其他 Classroom 相關資訊。如要設定 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 Identity 頁面,瞭解如何驗證 Google ID 權杖]。

// 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

首先,定義一種方法,可在 H2 資料庫中儲存或更新 User 物件。

/** 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 Classroom。前往「課堂作業」分頁並建立新的「作業」。按一下文字區域下方的「Add-ons」按鈕,然後選取外掛程式。該 iframe 會隨即開啟,外掛程式會載入您在 GWM SDK「App Configuration」頁面中指定的連結設定 URI

恭喜!您可以開始進行下一個步驟:建立連結並找出使用者角色