Skip to content

Commit

Permalink
Add OAuth 2.0 authorization code support for Apigee demos (#888)
Browse files Browse the repository at this point in the history
* Updates to support OAuth auth code grant for API access

* update placeholder for OAuth client ID

* Moved Apigee OAuth ConfigMap to extras directory

* Fixing linter errors

* More linting error fixes

* Revert changes to main frontend manifest
  • Loading branch information
drush1980 authored Aug 18, 2022
1 parent 90ab51e commit 3679b76
Show file tree
Hide file tree
Showing 8 changed files with 586 additions and 285 deletions.
12 changes: 12 additions & 0 deletions dev-kubernetes-manifests/frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ spec:
configMapKeyRef:
name: demo-data-config
key: DEMO_LOGIN_PASSWORD
- name: REGISTERED_OAUTH_CLIENT_ID
valueFrom:
configMapKeyRef:
name: oauth-config
key: DEMO_OAUTH_CLIENT_ID
optional: true
- name: ALLOWED_OAUTH_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: oauth-config
key: DEMO_OAUTH_REDIRECT_URI
optional: true
# Customize the metadata server hostname to query for metadata
#- name: METADATA_SERVER
# value: "my-metadata-server"
Expand Down
15 changes: 15 additions & 0 deletions extras/apigee/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Apigee + ASM

[Apigee](https://cloud.google.com/apigee) + [Anthos Service Mesh](https://cloud.google.com/anthos/service-mesh) can be leveraged to expose the Bank of Anthos microservices as a set of secure, managed APIs which can be accessed by other applications (e.g. mobile apps, voice assistants and more) or by partner developers.

[Apigee Developer Portals](https://cloud.google.com/apigee/docs/api-platform/publish/publishing-overview) provide self-service developer onboarding and allow for creation of easy-to-use [API documentation with try-it functionality](https://cloud.google.com/apigee/docs/api-platform/publish/intro-portals).

[Apigee Analytics](https://cloud.google.com/apigee/docs/api-platform/analytics/analytics-services-overview) provides API teams with rich dashboards, including operational metrics and detailed API product usage stats, plus [API Monitoring](https://cloud.google.com/apigee/docs/api-monitoring) capabilities.

[Apigee Monetization](https://cloud.google.com/apigee/docs/api-platform/monetization/overview) allows you to build paid rate plans to monetize access to API products.

Bank of Anthos supports the ability to authorize external access requests made using an [OAuth 2.0 Authorization Code grant type](https://oauth.net/2/grant-types/authorization-code/). Apigee can act as the [authorization server](https://cloud.google.com/apigee/docs/api-platform/security/oauth/oauth-home) that issues and validates the access tokens used to call the APIs securely. This directory contains a [ConfigMap](https://kubernetes.io/docs/concepts/configuration/configmap/) that defines an allowed OAuth client ID and redirect URI. The frontend service will use these to validate incoming OAuth requests. These values should be replaced with a client ID and redirect URI that is defined in an Apigee [API proxy](https://cloud.google.com/apigee/docs/api-platform/fundamentals/understanding-apis-and-api-proxies) that implements the [authorization code grant type](https://cloud.google.com/apigee/docs/api-platform/security/oauth/oauth-v2-policy-authorization-code-grant-type).

## Prerequisites

This requires an Apigee Organization that has already been provisioned. Learn how to provision a free eval org [here](https://cloud.google.com/apigee/docs/api-platform/get-started/eval-orgs).
23 changes: 23 additions & 0 deletions extras/apigee/oauth-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# [START gke_boa_kubernetes_manifests_config_configmap_oauth_config]
apiVersion: v1
kind: ConfigMap
metadata:
name: oauth-config
data:
DEMO_OAUTH_CLIENT_ID: "{APIGEE_CLIENT_ID}"
DEMO_OAUTH_REDIRECT_URI: "https://{APIGEE_DOMAIN}/oauth/callback"
# [END gke_boa_kubernetes_manifests_config_configmap_oauth_config]
12 changes: 12 additions & 0 deletions extras/cloudsql/kubernetes-manifests/frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ spec:
configMapKeyRef:
name: demo-data-config
key: DEMO_LOGIN_PASSWORD
- name: REGISTERED_OAUTH_CLIENT_ID
valueFrom:
configMapKeyRef:
name: oauth-config
key: DEMO_OAUTH_CLIENT_ID
optional: true
- name: ALLOWED_OAUTH_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: oauth-config
key: DEMO_OAUTH_REDIRECT_URI
optional: true
envFrom:
- configMapRef:
name: environment-config
Expand Down
152 changes: 138 additions & 14 deletions src/frontend/frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,15 +350,44 @@ def _add_contact(label, acct_num, routing_num, is_external_acct=False):
@app.route("/login", methods=['GET'])
def login_page():
"""
Renders login page. Redirects to /home if user already has a valid token
Renders login page. Redirects to /home if user already has a valid token.
If this is an oauth flow, then redirect to a consent form.
"""
token = request.cookies.get(app.config['TOKEN_NAME'])
if verify_token(token):
# already authenticated
app.logger.debug('User already authenticated. Redirecting to /home')
return redirect(url_for('home',
_external=True,
_scheme=app.config['SCHEME']))
response_type = request.args.get('response_type')
client_id = request.args.get('client_id')
app_name = request.args.get('app_name')
redirect_uri = request.args.get('redirect_uri')
state = request.args.get('state')
if ('REGISTERED_OAUTH_CLIENT_ID' in os.environ and
'ALLOWED_OAUTH_REDIRECT_URI' in os.environ and
response_type == 'code'):
app.logger.debug('Login with response_type=code')
if client_id != os.environ['REGISTERED_OAUTH_CLIENT_ID']:
return redirect(url_for('login',
msg='Error: Invalid client_id',
_external=True,
_scheme=app.config['SCHEME']))
if redirect_uri != os.environ['ALLOWED_OAUTH_REDIRECT_URI']:
return redirect(url_for('login',
msg='Error: Invalid redirect_uri',
_external=True,
_scheme=app.config['SCHEME']))
if verify_token(token):
app.logger.debug('User already authenticated. Redirecting to /consent')
return make_response(redirect(url_for('consent',
state=state,
redirect_uri=redirect_uri,
app_name=app_name,
_external=True,
_scheme=app.config['SCHEME'])))
else:
if verify_token(token):
# already authenticated
app.logger.debug('User already authenticated. Redirecting to /home')
return redirect(url_for('home',
_external=True,
_scheme=app.config['SCHEME']))

return render_template('login.html',
cymbal_logo=os.getenv('CYMBAL_LOGO', 'false'),
Expand All @@ -368,7 +397,11 @@ def login_page():
message=request.args.get('msg', None),
default_user=os.getenv('DEFAULT_USERNAME', ''),
default_password=os.getenv('DEFAULT_PASSWORD', ''),
bank_name=os.getenv('BANK_NAME', 'Bank of Anthos'))
bank_name=os.getenv('BANK_NAME', 'Bank of Anthos'),
response_type=response_type,
state=state,
redirect_uri=redirect_uri,
app_name=app_name)

@app.route('/login', methods=['POST'])
def login():
Expand All @@ -378,9 +411,10 @@ def login():
Fails if userservice does not accept input username and password
"""
return _login_helper(request.form['username'],
request.form['password'])
request.form['password'],
request.args)

def _login_helper(username, password):
def _login_helper(username, password, request_args):
try:
app.logger.debug('Logging in.')
req = requests.get(url=app.config["LOGIN_URI"],
Expand All @@ -391,9 +425,21 @@ def _login_helper(username, password):
token = req.json()['token'].encode('utf-8')
claims = decode_token(token)
max_age = claims['exp'] - claims['iat']
resp = make_response(redirect(url_for('home',
_external=True,
_scheme=app.config['SCHEME'])))

if ('response_type' in request_args and
'state' in request_args and
'redirect_uri' in request_args and
request_args['response_type'] == 'code'):
resp = make_response(redirect(url_for('consent',
state=request_args['state'],
redirect_uri=request_args['redirect_uri'],
app_name=request_args['app_name'],
_external=True,
_scheme=app.config['SCHEME'])))
else:
resp = make_response(redirect(url_for('home',
_external=True,
_scheme=app.config['SCHEME'])))
resp.set_cookie(app.config['TOKEN_NAME'], token, max_age=max_age)
app.logger.info('Successfully logged in.')
return resp
Expand All @@ -404,6 +450,81 @@ def _login_helper(username, password):
_external=True,
_scheme=app.config['SCHEME']))

@app.route("/consent", methods=['GET'])
def consent_page():
"""Renders consent page.
Retrieves auth code if the user has
already logged in and consented.
"""
redirect_uri = request.args.get('redirect_uri')
state = request.args.get('state')
app_name = request.args.get('app_name')
token = request.cookies.get(app.config['TOKEN_NAME'])
consented = request.cookies.get(app.config['CONSENT_COOKIE'])
if verify_token(token):
if consented == "true":
app.logger.debug('User consent already granted.')
resp = _auth_callback_helper(state, redirect_uri, token)
return resp

return render_template('consent.html',
cymbal_logo=os.getenv('CYMBAL_LOGO', 'false'),
cluster_name=cluster_name,
pod_name=pod_name,
pod_zone=pod_zone,
bank_name=os.getenv('BANK_NAME', 'Bank of Anthos'),
state=state,
redirect_uri=redirect_uri,
app_name=app_name)

return make_response(redirect(url_for('login',
response_type="code",
state=state,
redirect_uri=redirect_uri,
app_name=app_name,
_external=True,
_scheme=app.config['SCHEME'])))

@app.route('/consent', methods=['POST'])
def consent():
"""
Check consent, write cookie if yes, and redirect accordingly
"""
consent = request.args['consent']
state = request.args['state']
redirect_uri = request.args['redirect_uri']
token = request.cookies.get(app.config['TOKEN_NAME'])

app.logger.debug('Checking consent. consent:' + consent)

if consent == "true":
app.logger.info('User consent granted.')
resp = _auth_callback_helper(state, redirect_uri, token)
resp.set_cookie(app.config['CONSENT_COOKIE'], 'true')
else:
app.logger.info('User consent denied.')
resp = make_response(redirect(redirect_uri + '#error=access_denied', 302))
return resp

def _auth_callback_helper(state, redirect_uri, token):
try:
app.logger.debug('Retrieving authorization code.')
callback_response = requests.post(url=redirect_uri,
data={'state': state, 'id_token': token},
timeout=app.config['BACKEND_TIMEOUT'],
allow_redirects=False)
if callback_response.status_code == requests.codes.found:
app.logger.info('Successfully retrieved auth code.')
location = callback_response.headers['Location']
return make_response(redirect(location, 302))

app.logger.error('Unexpected response status: %s', callback_response.status_code)
return make_response(redirect(redirect_uri + '#error=server_error', 302))
except requests.exceptions.RequestException as err:
app.logger.error('Error retrieving auth code: %s', str(err))
return make_response(redirect(redirect_uri + '#error=server_error', 302))

@app.route("/signup", methods=['GET'])
def signup_page():
"""
Expand Down Expand Up @@ -440,7 +561,8 @@ def signup():
# user created. Attempt login
app.logger.info('New user created.')
return _login_helper(request.form['username'],
request.form['password'])
request.form['password'],
request.args)
except requests.exceptions.RequestException as err:
app.logger.error('Error creating new user: %s', str(err))
return redirect(url_for('login',
Expand All @@ -458,6 +580,7 @@ def logout():
_external=True,
_scheme=app.config['SCHEME'])))
resp.delete_cookie(app.config['TOKEN_NAME'])
resp.delete_cookie(app.config['CONSENT_COOKIE'])
return resp

def decode_token(token):
Expand Down Expand Up @@ -522,6 +645,7 @@ def format_currency(int_amount):
app.config['LOCAL_ROUTING'] = os.getenv('LOCAL_ROUTING_NUM')
app.config['BACKEND_TIMEOUT'] = 4 # timeout in seconds for calls to the backend
app.config['TOKEN_NAME'] = 'token'
app.config['CONSENT_COOKIE'] = 'consented'
app.config['TIMESTAMP_FORMAT'] = '%Y-%m-%dT%H:%M:%S.%f%z'
app.config['SCHEME'] = os.environ.get('SCHEME', 'http')

Expand Down
Loading

0 comments on commit 3679b76

Please sign in to comment.