這是 Classroom 外掛程式逐步操作說明系列的第三個逐步操作說明。
在本逐步操作教學課程中,您將自動擷取使用者先前授予的憑證,處理外掛程式的重複造訪。接著將使用者導向可立即發出 API 要求的頁面。這是 Classroom 外掛程式的必要行為。
在本逐步解說中,您將完成下列操作:
- 為使用者憑證實作永久儲存空間。
- 擷取並評估
login_hint
外掛程式查詢參數。這是登入使用者的專屬 Google ID 編號。
完成後,您就可以在網頁應用程式中完全授權使用者,並對 Google API 發出呼叫。
瞭解 iframe 查詢參數
Classroom 會在開啟時載入外掛程式的附件設定 URI。Classroom 會在 URI 後方附加多個 GET
查詢參數,其中包含實用的脈絡資訊。舉例來說,如果附件探索服務 URI 是 https://example.com/addon
,Classroom 會建立 iframe,並將來源網址設為 https://example.com/addon?courseId=XXX&itemId=YYY&itemType=courseWork&addOnToken=ZZZ
,其中 XXX
、YYY
和 ZZZ
是字串 ID。如需這個情境的詳細說明,請參閱 iframe 指南。
Discovery URL 有五種可能的查詢參數:
courseId
:目前 Classroom 課程的 ID。itemId
:使用者正在編輯或建立的串流項目 ID。itemType
:使用者建立或編輯的串流項目類型,可以是courseWork
、courseWorkMaterial
或announcement
。addOnToken
:用於授權特定 Classroom 外掛程式動作的權杖。login_hint
:目前使用者的 Google ID。
本逐步操作說明將說明 login_hint
。系統會根據是否提供這個查詢參數,將使用者導向授權流程 (如果缺少這個參數),或導向外掛程式探索頁面 (如果提供這個參數)。
存取查詢參數
查詢參數會以 URI 字串的形式傳遞至網路應用程式。將這些值儲存在工作階段中,授權流程會使用這些值,並儲存及擷取使用者資訊。這些查詢參數只會在首次開啟外掛程式時傳遞。
Python
前往 Flask 路由的定義 (如果您按照我們提供的範例操作,請按一下 routes.py
)。在外掛程式登陸路徑的頂端 (在我們提供的範例中為 /classroom-addon
),擷取並儲存 login_hint
查詢參數:
# If the login_hint query parameter is available, we'll store it in the session.
if flask.request.args.get("login_hint"):
flask.session["login_hint"] = flask.request.args.get("login_hint")
確認 login_hint
(如有) 儲存在工作階段中。這是儲存這些值的適當位置,因為這些值是暫時性的,而且外掛程式開啟時會收到新值。
# 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.
login_hint = flask.session.get("login_hint")
# If there's still no login_hint query parameter, this must be their first
# time signing in, so send the user to the sign in page.
if login_hint is None:
return start_auth_flow()
Java
在控制器類別中,前往外掛程式的登陸路徑 (在提供的範例中為 /addon-discovery
中的 AuthController.java
)。在這個路徑的開頭,擷取並儲存 login_hint
查詢參數。
/** Retrieve the login_hint query parameter from the request URL if present. */
String login_hint = request.getParameter("login_hint");
確認 login_hint
(如有) 儲存在工作階段中。這是儲存這些值的適當位置,因為這些值是暫時性的,而且外掛程式開啟時會收到新值。
/** If login_hint wasn't sent, use the values in the session. */
if (login_hint == null) {
login_hint = (String) session.getAttribute("login_hint");
}
/** If the there is still no login_hint, route the user to the authorization
* page. */
if (login_hint == null) {
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_hint
參數也應傳遞至 Google 的驗證伺服器。這有助於驗證程序;如果應用程式知道要驗證的使用者是誰,伺服器就會使用提示,在登入表單中預先填入電子郵件欄位,簡化登入流程。
Python
在 Flask 伺服器檔案中前往授權路徑 (在我們提供的範例中為 /authorize
)。在對 flow.authorization_url
的呼叫中新增 login_hint
引數。
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"),
Java
前往 AuthService.java
類別中的 authorize()
方法。將 login_hint
新增為方法參數,並將 login_hint
和引數新增至授權網址建構工具。
String authUrl = flow
.newAuthorizationUrl()
.setState(state)
.set("login_hint", login_hint)
.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
屬性、id
和 email
的資料欄。
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
中設定對應的資料表名稱。
請注意,程式碼範例包含這兩個屬性的建構函式和設定器。建構函式和設定器會在 AuthController.java
中使用,用於在資料庫中建立或更新使用者。您也可以視需要加入 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.username
和 spring.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()
方法,在 GoogleAuthorizationCodeFlow Builder()
方法中加入 setDataStoreFactory
,並呼叫 getCredentialDataStore()
來設定資料存放區。
GoogleAuthorizationCodeFlow authorizationCodeFlow =
new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
getClientSecrets(),
getScopes())
.setAccessType("offline")
.setDataStoreFactory(getCredentialDataStore())
.build();
接著,請更新 getAndSaveCredentials(String authorizationCode)
方法。
先前,這個方法會取得憑證,但不會將憑證儲存在任何位置。更新方法,將憑證儲存在以使用者 ID 編列索引的資料儲存庫中。
使用者 ID 可透過 id_token
使用 TokenResponse
物件的 id_token
取得,但必須先經過驗證。否則,用戶端應用程式可能會將修改過的使用者 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_hint
查詢參數。然後載入商店憑證 (如果這是回訪者)。如果收到 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_hint
查詢參數。
首先,請檢查工作階段中是否有憑證。如果沒有,請呼叫 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。前往「課堂作業」分頁,然後建立新的「作業」。按一下文字區域下方的「外掛程式」按鈕,然後選取外掛程式。系統會開啟 iframe,外掛程式會載入您在 Google Workspace Marketplace SDK 的「應用程式設定」頁面中指定的「附件設定 URI」。
恭喜!您已準備好進行下一個步驟:建立附件並識別使用者的角色。