From 581434b453b4a165c1441c0fae4a9374552a8abc Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Thu, 20 Feb 2020 15:35:07 -0500 Subject: [PATCH] Adds SSO header attributes --- config/default.py | 11 +-- crc/api.yml | 61 +++++++++++++--- crc/api/user.py | 101 ++++++++++++++++++++------- crc/models/user.py | 5 ++ migrations/versions/0a6e0b829398_.py | 36 ++++++++++ tests/test_authentication.py | 19 +++-- 6 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 migrations/versions/0a6e0b829398_.py diff --git a/config/default.py b/config/default.py index 34ce2dd8..bdbea282 100644 --- a/config/default.py +++ b/config/default.py @@ -1,4 +1,5 @@ import os + basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" @@ -13,10 +14,10 @@ FRONTEND_AUTH_CALLBACK = "http://localhost:4200" SSO_ATTRIBUTE_MAP = { 'eppn': (False, 'eppn'), # dhf8r@virginia.edu 'uid': (True, 'uid'), # dhf8r - 'givenName': (False, 'givenName'), # Daniel - 'mail': (False, 'email'), # dhf8r@Virginia.EDU - 'sn': (False, 'surName'), # Funk - 'affiliation': (False, 'affiliation'), # 'staff@virginia.edu;member@virginia.edu' - 'displayName': (False, 'displayName'), # Daniel Harold Funk + 'givenName': (False, 'first_name'), # Daniel + 'mail': (False, 'email_address'), # dhf8r@Virginia.EDU + 'sn': (False, 'last_name'), # Funk + 'affiliation': (False, 'affiliation'), # 'staff@virginia.edu;member@virginia.edu' + 'displayName': (False, 'display_name'), # Daniel Harold Funk 'title': (False, 'title') # SOFTWARE ENGINEER V } diff --git a/crc/api.yml b/crc/api.yml index 5c2896e1..e6ad27d5 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -7,19 +7,52 @@ info: servers: - url: http://localhost:5000/v1.0 paths: - # /v1.0/sso - /sso_backdoor/{uid}: - parameters: - - name: uid - in: path - required: true - description: The id of the user to connect as. - schema: - type: string + /sso_backdoor: get: operationId: crc.api.user.backdoor summary: A backdoor that allows someone to log in as a specific user, if they are in a staging environment. + parameters: + - name: uid + in: header + required: true + schema: + type: string + - name: email_address + in: header + required: false + schema: + type: string + - name: display_name + in: header + required: false + schema: + type: string + - name: affiliation + in: header + required: false + schema: + type: string + - name: eppn + in: header + required: false + schema: + type: string + - name: first_name + in: header + required: false + schema: + type: string + - name: last_name + in: header + required: false + schema: + type: string + - name: title + in: header + required: false + schema: + type: string tags: - Users responses: @@ -602,6 +635,16 @@ components: type: string display_name: type: string + affiliation: + type: string + eppn: + type: string + first_name: + type: string + last_name: + type: string + title: + type: string DataModel: properties: id: diff --git a/crc/api/user.py b/crc/api/user.py index 8a594069..9707f2c9 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -1,13 +1,15 @@ +import connexion from flask import redirect, g from crc import sso, app, db, auth from crc.api.common import ApiError - - -# User Accounts -# ***************************** from crc.models.user import UserModel, UserModelSchema +""" +.. module:: crc.api.user + :synopsis: Single Sign On (SSO) user login and session handlers +""" + @auth.verify_token def verify_token(token): @@ -34,36 +36,81 @@ def sso_login(user_info): def _handle_login(user_info): + """On successful login, adds user to database if the user is not already in the system, + then returns the frontend auth callback URL, with auth token appended. + + Args: + user_info (dict of { + uid: str, + affiliation: Optional[str], + display_name: Optional[str], + email_address: Optional[str], + eppn: Optional[str], + first_name: Optional[str], + last_name: Optional[str], + title: Optional[str], + }): Dictionary of user attributes + + Returns: + str. returns the frontend auth callback URL, with auth token appended. + """ uid = user_info['uid'] user = db.session.query(UserModel).filter(UserModel.uid == uid).first() - if user is None: - user = UserModel(uid=uid, - display_name=user_info["givenName"], - email_address=user_info["email"]) - if "Surname" in user_info: - user.display_name = user.display_name + " " + user_info["Surname"] - if "displayName" in user_info and len(user_info["displayName"]) > 1: - user.display_name = user_info["displayName"] + # Update existing user data or create a new user + user = UserModelSchema().load(user_info, session=db.session) - db.session.add(user) - db.session.commit() - # redirect users back to the front end, include the new auth token. + # Build display_name if not set + if 'display_name' not in user_info or len(user_info['display_name']) == 0: + display_name_list = [] + + for prop in ['first_name', 'last_name']: + if prop in user_info and len(user_info[prop]) > 0: + display_name_list.append(user_info[prop]) + + user.display_name = ' '.join(display_name_list) + + db.session.add(user) + db.session.commit() + + # Return the frontend auth callback URL, with auth token appended. auth_token = user.encode_auth_token().decode() - response_url = ("%s/%s" % (app.config["FRONTEND_AUTH_CALLBACK"], auth_token)) + response_url = ('%s/%s' % (app.config['FRONTEND_AUTH_CALLBACK'], auth_token)) return redirect(response_url) -def backdoor(uid): - '''A backdoor that allows someone to log in as a specific user, if they - are in a staging environment. ''' - if not "PRODUCTION" in app.config or not app.config["PRODUCTION"]: - user_info = { - "uid": uid, - "givenName": uid, - "email": uid + "@virginia.edu" - } +def backdoor(): + """A backdoor for end-to-end system testing that allows the system to simulate logging in as a specific user. + Only works if the application is running in a non-production environment. + + Args: + headers (dict of { + uid: str, + affiliation: Optional[str], + display_name: Optional[str], + email_address: Optional[str], + eppn: Optional[str], + first_name: Optional[str], + last_name: Optional[str], + title: Optional[str], + }): Dictionary of user attributes + + Returns: + str. If not on production, returns the frontend auth callback URL, with auth token appended. + + Raises: + ApiError. If on production, returns a 404 error. + """ + if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']: + + # Translate uppercase HTTP_PROP_NAME to lowercase without HTTP_, if property exists in UserModel. + user_info = {} + for key, value in connexion.request.environ.items(): + if key.startswith('HTTP_'): + prop = key[5:].lower() + if hasattr(UserModel, prop): + user_info[prop] = value + return _handle_login(user_info) else: - raise ApiError("404", "unknown") - + raise ApiError('404', 'unknown') diff --git a/crc/models/user.py b/crc/models/user.py index 161e0777..16a528c8 100644 --- a/crc/models/user.py +++ b/crc/models/user.py @@ -13,6 +13,11 @@ class UserModel(db.Model): uid = db.Column(db.String, unique=True) email_address = db.Column(db.String) display_name = db.Column(db.String) + affiliation = db.Column(db.String, nullable=True) + eppn = db.Column(db.String, nullable=True) + first_name = db.Column(db.String, nullable=True) + last_name = db.Column(db.String, nullable=True) + title = db.Column(db.String, nullable=True) def encode_auth_token(self): """ diff --git a/migrations/versions/0a6e0b829398_.py b/migrations/versions/0a6e0b829398_.py new file mode 100644 index 00000000..25726d48 --- /dev/null +++ b/migrations/versions/0a6e0b829398_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 0a6e0b829398 +Revises: ad5483cb7f3b +Create Date: 2020-02-20 15:42:16.473470 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0a6e0b829398' +down_revision = 'ad5483cb7f3b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('affiliation', sa.String(), nullable=True)) + op.add_column('user', sa.Column('eppn', sa.String(), nullable=True)) + op.add_column('user', sa.Column('first_name', sa.String(), nullable=True)) + op.add_column('user', sa.Column('last_name', sa.String(), nullable=True)) + op.add_column('user', sa.Column('title', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'title') + op.drop_column('user', 'last_name') + op.drop_column('user', 'first_name') + op.drop_column('user', 'eppn') + op.drop_column('user', 'affiliation') + # ### end Alembic commands ### diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 65be91b6..ff81f764 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -8,31 +8,33 @@ class TestAuthentication(BaseTest): test_uid = "dhf8r" def logged_in_headers(self, user=None): - if user is None: uid = self.test_uid - headers = {'uid': self.test_uid, 'givenName': 'Daniel', 'mail': 'dhf8r@virginia.edu'} + headers = {'uid': self.test_uid, 'first_name': 'Daniel', 'last_name': 'Funk', 'email_address': 'dhf8r@virginia.edu'} else: uid = user.uid - headers = {'uid': user.uid, 'givenName': user.display_name, 'email': user.email_address} + headers = {'uid': user.uid, 'first_name': user.first_name, 'last_name': user.last_name, 'email_address': user.email_address} - rv = self.app.get("/v1.0/sso_backdoor/" + self.test_uid, follow_redirects=True, + rv = self.app.get("/v1.0/sso_backdoor", headers=headers, follow_redirects=True, content_type="application/json") user_model = UserModel.query.filter_by(uid=uid).first() + self.assertIsNotNone(user_model.display_name) return dict(Authorization='Bearer ' + user_model.encode_auth_token().decode()) def test_auth_token(self): + self.load_example_data() user = UserModel(uid="dhf8r") auth_token = user.encode_auth_token() self.assertTrue(isinstance(auth_token, bytes)) self.assertEqual("dhf8r", user.decode_auth_token(auth_token)) def test_auth_creates_user(self): + self.load_example_data() user = db.session.query(UserModel).filter(UserModel.uid == self.test_uid).first() self.assertIsNone(user) - headers = {'uid': self.test_uid, 'givenName': 'Daniel', 'mail': 'dhf8r@virginia.edu'} - rv = self.app.get("/v1.0/sso_backdoor/" + self.test_uid, headers=headers, follow_redirects=True, + headers = {'uid': self.test_uid, 'first_name': 'Daniel', 'email_address': 'dhf8r@virginia.edu'} + rv = self.app.get("/v1.0/sso_backdoor", headers=headers, follow_redirects=True, content_type="application/json") user = db.session.query(UserModel).filter(UserModel.uid == self.test_uid).first() self.assertIsNotNone(user) @@ -40,8 +42,13 @@ class TestAuthentication(BaseTest): self.assertIsNotNone(user.email_address) def test_current_user_status(self): + self.load_example_data() rv = self.app.get('/v1.0/user') self.assert_failure(rv, 401) rv = self.app.get('/v1.0/user', headers=self.logged_in_headers()) self.assert_success(rv) + + user = UserModel(uid="ajl2j", first_name='Aaron', last_name='Louie', email_address='ajl2j@virginia.edu') + rv = self.app.get('/v1.0/user', headers=self.logged_in_headers(user)) + self.assert_success(rv)