Skip to content

Commit 8b4256d

Browse files
committed
Replace placeholder Walkthrough 4 directory.
Since this is a large commit, I will summarize some of the key changes: - Replace placeholder Walkthrough 4 directory (previously "04-context"). - Rename "routes.py" from previous walkthroughs to "attachment_discovery_routes.py". - Add "attachment_routes.py" for attachment-specific code. - Add Attachment model to "models.py". - Add "attachment-options.html" and "show-content-attachment.html". - Split out most credential-related code into "credential_handler.py": - Contains some credential-related routes. - Defines the CredentialHandler class. - Holds a singleton instance of the CredentialHandler class. Walkthrough 4 design proposal is here: https://docs.google.com/document/d/1ZTTwC5dUVQIH6KDB4-zFePTkhMLGylABpkqJ9xKojAo/edit?usp=sharing&resourcekey=0-BAugkNxe9mam5Ts_1zuN1A Fixes: b/229871841 Change-Id: I5dc1e2f6daf21fd6c1542601cd054ef250549610
1 parent 272a52b commit 8b4256d

35 files changed

+1110
-474
lines changed
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616

1717
import os
1818

19-
DATABASE_FILE_NAME = os.path.join(os.path.abspath(os.path.dirname(__file__)),
20-
'data.sqlite')
19+
DATABASE_FILE_NAME = os.path.join(
20+
os.path.abspath(os.path.dirname(__file__)), 'data.sqlite')
21+
2122

2223
class Config(object):
2324
# Note: A secret key is included in the sample so that it works.
@@ -33,6 +34,5 @@ class Config(object):
3334
SESSION_COOKIE_SAMESITE = "None"
3435

3536
# Point to a database file in the project root.
36-
37-
SQLALCHEMY_DATABASE_URI=f"sqlite:///{DATABASE_FILE_NAME}"
38-
SQLALCHEMY_TRACK_MODIFICATIONS=False
37+
SQLALCHEMY_DATABASE_URI = f"sqlite:///{DATABASE_FILE_NAME}"
38+
SQLALCHEMY_TRACK_MODIFICATIONS = False
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
### OPTION 3: Production- or cloud-ready server
66+
# Start a Gunicorn server, which is appropriate for use in production or a
67+
# cloud deployment.
68+
# server_port = os.environ.get("PORT", "8080")
69+
# app.run(debug=True, port=server_port, host="0.0.0.0")
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Flask==2.0.2
2+
Flask_SQLAlchemy==2.5.1
3+
Flask_WTF==1.0.0
4+
google_api_python_client==2.51.0
5+
google_auth_oauthlib==0.4.6
6+
protobuf==4.21.2
7+
requests==2.27.1
8+
WTForms==3.0.1

flask/04-context/webapp.py renamed to flask/04-content-attachments/webapp/__init__.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,23 @@
1212
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1313
# License for the specific language governing permissions and limitations under
1414
# the License.
15-
"""Entry point for the Flask server.
15+
"""Initialize the webapp module.
1616
17-
Loads the webapp module and starts the server."""
17+
Starts the flask server, loads the config, and initializes the database."""
1818

19-
from webapp import app
20-
import os
19+
import flask
20+
import config
21+
from flask_sqlalchemy import SQLAlchemy
22+
from os import path
2123

22-
if __name__ == "__main__":
23-
# Allow the OAuth flow to adjust scopes.
24-
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
24+
app = flask.Flask(__name__)
25+
app.config.from_object(config.Config)
2526

26-
app.run(host="localhost",
27-
ssl_context=("localhost.pem", "localhost-key.pem"),
28-
debug=True)
27+
db = SQLAlchemy(app)
28+
29+
from webapp import attachment_routes, attachment_discovery_routes, models
30+
from webapp import credential_handler as ch
31+
32+
# Initialize the database file if not created.
33+
if not path.exists(config.DATABASE_FILE_NAME):
34+
db.create_all()
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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 all 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 requests
23+
24+
import google.oauth2.credentials
25+
import googleapiclient.discovery
26+
27+
28+
@app.route("/")
29+
@app.route("/index")
30+
def index():
31+
"""
32+
Render the index page from the "index.html" template. This is meant to act
33+
as a facsimile of a company's home page.
34+
The Add-on Discovery URL should be set to the /classroom-addon route below.
35+
"""
36+
37+
return flask.render_template(
38+
"index.html", message="You've reached the index page.")
39+
40+
41+
@app.route("/classroom-addon")
42+
def classroom_addon():
43+
"""
44+
Checks if a user is signed in. If so, renders the addon discovery page from
45+
the "addon-discovery.html" template. This is meant to be the landing page
46+
when opening the web app in the Classroom add-on iframe.
47+
Otherwise, renders the "authorization.html" template.
48+
49+
Several GET query parameters can be passed when loading in the Classroom
50+
iframe. This example handles three additional parameters:
51+
- postId: The ID of the assignment the add-on is being loaded in.
52+
- courseId: The ID of the course the add-on is being loaded in.
53+
- addOnToken: A unique token provided by Classroom.
54+
55+
The full list of query parameters is available at
56+
https://developers.google.com/classroom/eap/add-ons-alpha/technical-details/iframes#attachment_discovery_iframe
57+
"""
58+
59+
# Retrieve the postId, courseId, and addOnToken query parameters.
60+
if flask.request.args.get("postId"):
61+
flask.session["postId"] = flask.request.args.get("postId")
62+
if flask.request.args.get("courseId"):
63+
flask.session["courseId"] = flask.request.args.get("courseId")
64+
if flask.request.args.get("addOnToken"):
65+
flask.session["addOnToken"] = flask.request.args.get("addOnToken")
66+
67+
# Retrieve the login_hint and hd query parameters.
68+
login_hint = flask.request.args.get("login_hint")
69+
hd = flask.request.args.get("hd")
70+
71+
# It's possible that we might return to this route later, in which case the
72+
# parameters will not be passed in. Instead, use the values cached in the session.
73+
74+
# If neither query parameter is available, use the values in the session.
75+
if login_hint is None and hd is None:
76+
login_hint = flask.session.get("login_hint")
77+
hd = flask.session.get("hd")
78+
79+
# If there's no login_hint query parameter, then check for hd.
80+
# Send the user to the sign in page.
81+
elif hd is not None:
82+
flask.session["hd"] = hd
83+
return ch.start_auth_flow("discovery_callback")
84+
85+
# If the login_hint query parameter is available, we'll store it in the session.
86+
else:
87+
flask.session["login_hint"] = login_hint
88+
89+
# Check if we have any stored credentials for this user.
90+
credentials = ch._credential_handler.get_credentials(login_hint)
91+
92+
# Redirect to the authorization page if we received login_hint but don't
93+
# have any stored credentials for this user. We need the refresh token
94+
# specifically.
95+
if credentials is None:
96+
return ch.start_auth_flow("discovery_callback")
97+
98+
return flask.render_template(
99+
"addon-discovery.html",
100+
message="You've reached the addon discovery page.")
101+
102+
103+
@app.route("/test/<request_type>")
104+
def test_api_request(request_type="username"):
105+
"""
106+
Tests an API request, rendering the result in the
107+
"show-api-query-result.html" template.
108+
109+
Args:
110+
request_type: The type of API request to test. Currently only "username"
111+
is supported.
112+
"""
113+
114+
credentials = ch._credential_handler.get_credentials()
115+
if credentials is None:
116+
return ch.start_auth_flow("discovery_callback")
117+
118+
# Create an API client and make an API request.
119+
fetched_data = ""
120+
121+
if request_type == "username":
122+
user_info_service = googleapiclient.discovery.build(
123+
serviceName="oauth2", version="v2", credentials=credentials)
124+
125+
flask.session["username"] = (
126+
user_info_service.userinfo().get().execute().get("name"))
127+
128+
fetched_data = flask.session.get("username")
129+
130+
# Save credentials in case access token was refreshed.
131+
flask.session[
132+
"credentials"] = ch._credential_handler.session_credentials_to_dict(
133+
credentials)
134+
ch._credential_handler.save_credentials_to_storage(credentials)
135+
136+
# Render the results of the API call.
137+
return flask.render_template(
138+
"show-api-query-result.html",
139+
data=json.dumps(fetched_data, indent=2),
140+
data_title=request_type)
141+
142+
143+
@app.route("/discovery-callback")
144+
def discovery_callback():
145+
"""
146+
Runs upon return from the OAuth 2.0 authorization server. Fetches and stores
147+
the user's credentials, including the access token, refresh token, and
148+
allowed scopes.
149+
"""
150+
151+
# Specify the state when creating the flow in the callback so that it can
152+
# verified in the authorization server response.
153+
flow = ch.build_flow_instance("discovery_callback", flask.session["state"])
154+
155+
# Use the authorization server's response to fetch the OAuth 2.0 tokens.
156+
authorization_response = flask.request.url
157+
flow.fetch_token(authorization_response=authorization_response)
158+
159+
# Store credentials in the session.
160+
credentials = flow.credentials
161+
flask.session[
162+
"credentials"] = ch._credential_handler.session_credentials_to_dict(
163+
credentials)
164+
165+
# The flow is complete!
166+
# Add the credentials to our persistent storage.
167+
# We'll extract the "id" value from the credentials to use as a key.
168+
# This is the user's unique Google ID, and will match the login_hint
169+
# query parameter in the future.
170+
171+
# If we've reached this point, and there is already a record in our
172+
# database for this user, they must be obtaining new credentials;
173+
# update the stored credentials.
174+
ch._credential_handler.save_credentials_to_storage(credentials)
175+
176+
return flask.render_template(
177+
"close-me.html", redirect_destination="classroom_addon")
178+
179+
180+
@app.route("/revoke")
181+
def revoke():
182+
"""
183+
Revokes the logged in user's credentials.
184+
"""
185+
186+
if "credentials" not in flask.session:
187+
return flask.render_template(
188+
"addon-discovery.html",
189+
message="You need to authorize before " +
190+
"attempting to revoke credentials.")
191+
192+
credentials = google.oauth2.credentials.Credentials(
193+
**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+
ch._credential_handler.clear_credentials_in_session()
201+
202+
status_code = getattr(revoke, "status_code")
203+
if status_code == 200:
204+
return ch.start_auth_flow("discovery_callback")
205+
else:
206+
return flask.render_template(
207+
"addon-discovery.html",
208+
message="An error occurred during revocation!")

0 commit comments

Comments
 (0)