Skip to content

Commit fc52541

Browse files
committed
Add walkthrough 7 example code.
Most files are just copied from Step #6. These are the newly added ones: - coursework_routes.py - coursework-assignment-created.html - coursework-modified.html - example-coursework-assignment.html Also, index.html was modified from previous steps to add a couple of buttons and handle sign-in. Change-Id: I3201c3bd10afde223235bc9ecdfeb6eb9dbed839
1 parent 0a95237 commit fc52541

30 files changed

+1963
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2021 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
# use this file except in compliance with the License. You may obtain a copy of
6+
# the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations under
14+
# the License.
15+
"""The Flask server configuration."""
16+
17+
import os
18+
19+
DATABASE_FILE_NAME = os.path.join(
20+
os.path.abspath(os.path.dirname(__file__)), "data.sqlite"
21+
)
22+
23+
24+
class Config(object):
25+
# Note: A secret key is included in the sample so that it works.
26+
# If you use this code in your application, replace this with a truly secret
27+
# key. See https://flask.palletsprojects.com/quickstart/#sessions.
28+
SECRET_KEY = (
29+
os.environ.get("SECRET_KEY")
30+
or "REPLACE ME - this value is here as a placeholder."
31+
)
32+
33+
# Configure the flask cookie settings per the iframe security recommendations:
34+
# https://developers.google.com/classroom/add-ons/developer-guides/iframes#iframe_security_guidelines
35+
SESSION_COOKIE_SECURE = True
36+
SESSION_COOKIE_HTTPONLY = True
37+
SESSION_COOKIE_SAMESITE = "None"
38+
39+
# Point to a database file in the project root.
40+
SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_FILE_NAME}"
41+
SQLALCHEMY_TRACK_MODIFICATIONS = False
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2021 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
# use this file except in compliance with the License. You may obtain a copy of
6+
# the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations under
14+
# the License.
15+
"""Entry point for the Flask server.
16+
17+
Loads the webapp module and starts the server. Choose an appropriate launch
18+
method below before running this program.
19+
20+
WARNING: NOT FOR PRODUCTION
21+
----------------------------
22+
This is a sample application for development purposes. You should follow
23+
best practices when securing your production application and in particular
24+
how you securely store and use OAuth tokens.
25+
26+
Note that:
27+
+ Storing tokens in the session is for demonstration purposes. Be sure to store
28+
your tokens securely in your production application.
29+
+ Be careful not to lose a user's refresh token. You will have to ask the user
30+
to re-authorize your add-on to receive a new one.
31+
32+
Review these resources for additional security considerations:
33+
+ Google Identity developer website: https://developers.google.com/identity
34+
+ OAuth 2.0 Security Best Current Practice:
35+
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
36+
+ OAuth 2.0 Threat Model and Security Considerations:
37+
https://datatracker.ietf.org/doc/html/rfc6819"""
38+
39+
from webapp import app
40+
import os
41+
42+
if __name__ == "__main__":
43+
# You have several options for running the web server.
44+
45+
### OPTION 1: Unsecured localhost
46+
# When running locally on unsecured HTTP, use this line to disable
47+
# OAuthlib's HTTPs verification.
48+
49+
# Important: When running in production *do not* leave this option enabled.
50+
# os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
51+
52+
# Run the application on a local server, defaults to http://localhost:5000.
53+
# Note: the OAuth flow requires a TLD, *not* an IP address; "localhost" is
54+
# acceptable, but http://127.0.0.1 is not.
55+
# app.run(debug=True)
56+
57+
### OPTION 2: Secure localhost
58+
# Run the application over HTTPs with a locally stored certificate and key.
59+
# Defaults to https://localhost:5000.
60+
app.run(
61+
host="localhost",
62+
ssl_context=("localhost.pem", "localhost-key.pem"),
63+
debug=True,
64+
)
65+
66+
### OPTION 3: Production- or cloud-ready server
67+
# Start a Gunicorn server, which is appropriate for use in production or a
68+
# cloud deployment.
69+
# server_port = os.environ.get("PORT", "8080")
70+
# app.run(debug=True, port=server_port, host="0.0.0.0")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Flask==2.2.2
2+
SQLAlchemy==1.4
3+
Flask_SQLAlchemy==2.5.1
4+
Flask_WTF==1.0.0
5+
google_api_python_client==2.56.0
6+
google_auth_oauthlib==0.4.6
7+
protobuf==4.21.5
8+
requests==2.27.1
9+
WTForms==3.0.1
10+
Werkzeug==2.3.7
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2021 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
# use this file except in compliance with the License. You may obtain a copy of
6+
# the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations under
14+
# the License.
15+
"""Initialize the webapp module.
16+
17+
Starts the flask server, loads the config, and initializes the database."""
18+
19+
import flask
20+
import config
21+
from flask_sqlalchemy import SQLAlchemy
22+
from os import path
23+
24+
app = flask.Flask(__name__)
25+
app.config.from_object(config.Config)
26+
27+
db = SQLAlchemy(app)
28+
29+
from webapp import attachment_routes
30+
from webapp import attachment_discovery_routes
31+
from webapp import coursework_routes
32+
from webapp import models
33+
from webapp import credential_handler as ch
34+
35+
# Initialize the database file if not created.
36+
if not path.exists(config.DATABASE_FILE_NAME):
37+
db.create_all()
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2021 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
# use this file except in compliance with the License. You may obtain a copy of
6+
# the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations under
14+
# the License.
15+
"""Defines Attachment Discovery iframe-related routes for the Flask server."""
16+
17+
from webapp import app
18+
from webapp import credential_handler as ch
19+
20+
import json
21+
import flask
22+
import pprint
23+
import requests
24+
25+
import google.oauth2.credentials
26+
import googleapiclient.discovery
27+
28+
29+
@app.route("/")
30+
@app.route("/index")
31+
def index():
32+
"""
33+
Render the index page from the "index.html" template. This is meant to act
34+
as a facsimile of a company's home page.
35+
The Add-on Discovery URL should be set to the /addon-discovery route below.
36+
"""
37+
38+
return flask.render_template(
39+
"index.html",
40+
message="You've reached the index page.\nClick the button below to create a "
41+
"new Classroom assignment.",
42+
)
43+
44+
45+
@app.route("/addon-discovery")
46+
def classroom_addon():
47+
"""
48+
Checks if a user is signed in. If so, renders the addon discovery page from
49+
the "addon-discovery.html" template. This is meant to be the landing page
50+
when opening the web app in the Classroom add-on iframe.
51+
Otherwise, renders the "authorization.html" template.
52+
53+
Several GET query parameters can be passed when loading in the Classroom
54+
iframe. This example handles three additional parameters:
55+
- itemId: The ID of the stream item the add-on is being loaded in.
56+
- itemType: The type of the stream item the add-on is being loaded in.
57+
- courseId: The ID of the course the add-on is being loaded in.
58+
- addOnToken: A unique token provided by Classroom.
59+
60+
The full list of query parameters is available at
61+
https://developers.google.com/classroom/add-ons/developer-guides/iframes#attachment_discovery_iframe
62+
"""
63+
64+
# Retrieve the itemId, itemType, courseId, and addOnToken query parameters.
65+
if flask.request.args.get("itemId"):
66+
flask.session["itemId"] = flask.request.args.get("itemId")
67+
# itemType will be one of the following: "announcement", "assignment", "material".
68+
# Use this value to route the user to the correct flow or to show an error message
69+
# if your app only supports specific item types.
70+
if flask.request.args.get("itemType"):
71+
flask.session["itemType"] = flask.request.args.get("itemType")
72+
if flask.request.args.get("courseId"):
73+
flask.session["courseId"] = flask.request.args.get("courseId")
74+
if flask.request.args.get("addOnToken"):
75+
flask.session["addOnToken"] = flask.request.args.get("addOnToken")
76+
77+
# If the login_hint query parameter is available, we'll store it in the session.
78+
if flask.request.args.get("login_hint"):
79+
flask.session["login_hint"] = flask.request.args.get("login_hint")
80+
81+
# It's possible that we might return to this route later, in which case the
82+
# parameters will not be passed in. Instead, use the login_hint cached in the session.
83+
login_hint = flask.session.get("login_hint")
84+
85+
# If there's still no login_hint query parameter, this must be their first time signing
86+
# in, so send the user to the sign in page.
87+
if login_hint is None:
88+
return ch.start_auth_flow("discovery_callback")
89+
90+
# Check if we have any stored credentials for this user.
91+
credentials = ch._credential_handler.get_credentials(login_hint)
92+
93+
# Redirect to the authorization page if we received login_hint but don't
94+
# have any stored credentials for this user. We need the refresh token
95+
# specifically.
96+
if credentials is None:
97+
return ch.start_auth_flow("discovery_callback")
98+
99+
return flask.render_template(
100+
"addon-discovery.html", message="You've reached the addon discovery page."
101+
)
102+
103+
104+
@app.route("/test/<request_type>")
105+
def test_api_request(request_type="username"):
106+
"""
107+
Tests an API request, rendering the result in the
108+
"show-api-query-result.html" template.
109+
110+
Args:
111+
request_type: The type of API request to test. Currently only "username"
112+
is supported.
113+
"""
114+
115+
credentials = ch._credential_handler.get_credentials()
116+
if credentials is None:
117+
return ch.start_auth_flow("discovery_callback")
118+
119+
# Create an API client and make an API request.
120+
fetched_data = ""
121+
122+
if request_type == "username":
123+
user_info_service = googleapiclient.discovery.build(
124+
serviceName="oauth2", version="v2", credentials=credentials
125+
)
126+
127+
flask.session["username"] = user_info_service.userinfo().get().execute().get("name")
128+
129+
fetched_data = flask.session.get("username")
130+
131+
# Save credentials in case access token was refreshed.
132+
flask.session["credentials"] = ch._credential_handler.session_credentials_to_dict(
133+
credentials
134+
)
135+
ch._credential_handler.save_credentials_to_storage(credentials)
136+
137+
# Render the results of the API call.
138+
return flask.render_template(
139+
"show-api-query-result.html",
140+
data=json.dumps(fetched_data, indent=2),
141+
data_title=request_type,
142+
)
143+
144+
145+
@app.route("/discovery-callback")
146+
def discovery_callback():
147+
"""
148+
Runs upon return from the OAuth 2.0 authorization server. Fetches and stores
149+
the user's credentials, including the access token, refresh token, and
150+
allowed scopes.
151+
"""
152+
153+
# Specify the state when creating the flow in the callback so that it can
154+
# verified in the authorization server response.
155+
flow = ch.build_flow_instance("discovery_callback", flask.session["state"])
156+
157+
# Use the authorization server's response to fetch the OAuth 2.0 tokens.
158+
authorization_response = flask.request.url
159+
flow.fetch_token(authorization_response=authorization_response)
160+
161+
# Store credentials in the session.
162+
credentials = flow.credentials
163+
flask.session["credentials"] = ch._credential_handler.session_credentials_to_dict(
164+
credentials
165+
)
166+
167+
# The flow is complete!
168+
# Add the credentials to our persistent storage.
169+
# We'll extract the "id" value from the credentials to use as a key.
170+
# This is the user's unique Google ID, and will match the login_hint
171+
# query parameter in the future.
172+
173+
# If we've reached this point, and there is already a record in our
174+
# database for this user, they must be obtaining new credentials;
175+
# update the stored credentials.
176+
ch._credential_handler.save_credentials_to_storage(credentials)
177+
178+
return flask.render_template("close-me.html", redirect_destination="classroom_addon")
179+
180+
181+
@app.route("/revoke")
182+
def revoke():
183+
"""
184+
Revokes the logged in user's credentials.
185+
"""
186+
187+
if "credentials" not in flask.session:
188+
return flask.render_template(
189+
"addon-discovery.html",
190+
message="You need to authorize before " + "attempting to revoke credentials.",
191+
)
192+
193+
credentials = google.oauth2.credentials.Credentials(**flask.session["credentials"])
194+
195+
revoke = requests.post(
196+
"https://oauth2.googleapis.com/revoke",
197+
params={"token": credentials.token},
198+
headers={"content-type": "application/x-www-form-urlencoded"},
199+
)
200+
201+
ch._credential_handler.clear_credentials_in_session()
202+
203+
status_code = getattr(revoke, "status_code")
204+
if status_code == 200:
205+
return ch.start_auth_flow("discovery_callback")
206+
else:
207+
return flask.render_template(
208+
"addon-discovery.html", message="An error occurred during revocation!"
209+
)

0 commit comments

Comments
 (0)