这是 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 中进行管理。创建这些项目后,请实施一个四步流程来对用户进行身份验证和授权:
- 请求授权。在此请求中提供回调网址。完成后,您会收到一个授权网址。
- 将用户重定向到授权网址。生成的页面会告知用户您的应用需要哪些权限,并提示用户允许访问。完成后,系统会将用户定向到回调网址。
- 在回调路线中接收授权代码。将授权代码交换为访问令牌和刷新令牌。
- 使用令牌调用 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
)。添加逻辑,以便在会话不包含“credentials”键时呈现登录页面。
@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
的“Project Set Up”(项目设置)部分。
@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)
设置 flow
的 redirect_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_url
和 state
。将 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
方法。该方法需要 code
或 authorization_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。
恭喜!您已准备好继续执行下一步:处理对您的插件进行的多次访问。