From be9b613bbb02fc3ef27e0e707c3e4c200de67cbf Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 31 May 2020 16:49:39 -0400 Subject: [PATCH 1/8] Refactors user authentication endpoints so we can use the Swagger UI in production mode --- config/default.py | 5 +- config/testing.py | 3 +- config/travis-testing.py | 2 - crc/__init__.py | 15 +-- crc/api.yml | 65 ++++------- crc/api/user.py | 215 +++++++++++++++++++++++------------ crc/api/workflow.py | 4 +- tests/base_test.py | 4 +- tests/test_authentication.py | 113 ++++++++++++++++-- 9 files changed, 291 insertions(+), 135 deletions(-) diff --git a/config/default.py b/config/default.py index e368b32d..080c2753 100644 --- a/config/default.py +++ b/config/default.py @@ -9,9 +9,10 @@ JSON_SORT_KEYS = False # CRITICAL. Do not sort the data when returning values NAME = "CR Connect Workflow" FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5000") CORS_ALLOW_ORIGINS = re.split(r',\s*', environ.get('CORS_ALLOW_ORIGINS', default="localhost:4200, localhost:5002")) -DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" -PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") or (not DEVELOPMENT and not TESTING) +PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") +TEST_UID = environ.get('TEST_UID', default="dhf8r") +ADMIN_UIDS = re.split(r',\s*', environ.get('ADMIN_UIDS', default="dhf8r,ajl2j,cah13us,cl3wf")) # Add trailing slash to base path APPLICATION_ROOT = re.sub(r'//', '/', '/%s/' % environ.get('APPLICATION_ROOT', default="/").strip('/')) diff --git a/config/testing.py b/config/testing.py index a7c6a893..546ea829 100644 --- a/config/testing.py +++ b/config/testing.py @@ -4,7 +4,6 @@ from os import environ basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" -DEVELOPMENT = True TESTING = True TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." PB_ENABLED = False @@ -23,8 +22,8 @@ SQLALCHEMY_DATABASE_URI = environ.get( 'SQLALCHEMY_DATABASE_URI', default="postgresql://%s:%s@%s:%s/%s" % (DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME) ) +ADMIN_UIDS = ['dhf8r'] print('### USING TESTING CONFIG: ###') print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) diff --git a/config/travis-testing.py b/config/travis-testing.py index 17a4b914..8949061a 100644 --- a/config/travis-testing.py +++ b/config/travis-testing.py @@ -2,7 +2,6 @@ import os basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Workflow" -DEVELOPMENT = True TESTING = True SQLALCHEMY_DATABASE_URI = "postgresql://postgres:@localhost:5432/crc_test" TOKEN_AUTH_TTL_HOURS = 2 @@ -12,6 +11,5 @@ PB_ENABLED = False print('+++ USING TRAVIS TESTING CONFIG: +++') print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) -print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) print('FRONTEND_AUTH_CALLBACK = ', FRONTEND_AUTH_CALLBACK) diff --git a/crc/__init__.py b/crc/__init__.py index fe510daf..91e5c8f5 100644 --- a/crc/__init__.py +++ b/crc/__init__.py @@ -41,15 +41,16 @@ origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config[' cors = CORS(connexion_app.app, origins=origins_re) print('=== USING THESE CONFIG SETTINGS: ===') -print('DB_HOST = ', ) -print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) -print('DEVELOPMENT = ', app.config['DEVELOPMENT']) -print('TESTING = ', app.config['TESTING']) -print('PRODUCTION = ', app.config['PRODUCTION']) -print('PB_BASE_URL = ', app.config['PB_BASE_URL']) -print('LDAP_URL = ', app.config['LDAP_URL']) print('APPLICATION_ROOT = ', app.config['APPLICATION_ROOT']) +print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS']) +print('DB_HOST = ', app.config['DB_HOST']) +print('LDAP_URL = ', app.config['LDAP_URL']) +print('PB_BASE_URL = ', app.config['PB_BASE_URL']) print('PB_ENABLED = ', app.config['PB_ENABLED']) +print('PRODUCTION = ', app.config['PRODUCTION']) +print('TESTING = ', app.config['TESTING']) +print('TEST_UID = ', app.config['TEST_UID']) +print('ADMIN_UIDS = ', app.config['ADMIN_UIDS']) @app.cli.command() def load_example_data(): diff --git a/crc/api.yml b/crc/api.yml index edc3861b..f2ac27ce 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -9,54 +9,18 @@ servers: security: - jwt: ['secret'] paths: - /sso_backdoor: + /login: 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. + operationId: crc.api.user.login + summary: In production, logs the user in via SSO. If not in production, logs in as a specific user for testing. security: [] # Disable security for this endpoint only. parameters: - name: uid - in: query - required: true - schema: - type: string - - name: email_address in: query required: false schema: type: string - - name: display_name - in: query - required: false - schema: - type: string - - name: affiliation - in: query - required: false - schema: - type: string - - name: eppn - in: query - required: false - schema: - type: string - - name: first_name - in: query - required: false - schema: - type: string - - name: last_name - in: query - required: false - schema: - type: string - - name: title - in: query - required: false - schema: - type: string - - name: redirect + - name: redirect_url in: query required: false schema: @@ -150,6 +114,8 @@ paths: $ref: "#/components/schemas/Study" delete: operationId: crc.api.study.delete_study + security: + - jwt_admin: ['secret'] summary: Removes the given study completely. tags: - Studies @@ -227,6 +193,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" put: operationId: crc.api.workflow.update_workflow_specification + security: + - jwt_admin: ['secret'] summary: Modifies an existing workflow specification with the given parameters. tags: - Workflow Specifications @@ -244,6 +212,8 @@ paths: $ref: "#/components/schemas/WorkflowSpec" delete: operationId: crc.api.workflow.delete_workflow_specification + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow specification tags: - Workflow Specifications @@ -289,6 +259,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" post: operationId: crc.api.workflow.add_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Creates a new workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -326,6 +298,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" put: operationId: crc.api.workflow.update_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Modifies an existing workflow spec category with the given parameters. tags: - Workflow Specification Category @@ -343,6 +317,8 @@ paths: $ref: "#/components/schemas/WorkflowSpecCategory" delete: operationId: crc.api.workflow.delete_workflow_spec_category + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow spec category tags: - Workflow Specification Category @@ -542,6 +518,8 @@ paths: example: '' put: operationId: crc.api.file.set_reference_file + security: + - jwt_admin: ['secret'] summary: Update the contents of a named reference file. tags: - Files @@ -600,6 +578,8 @@ paths: $ref: "#/components/schemas/Workflow" delete: operationId: crc.api.workflow.delete_workflow + security: + - jwt_admin: ['secret'] summary: Removes an existing workflow tags: - Workflows and Tasks @@ -837,6 +817,11 @@ components: scheme: bearer bearerFormat: JWT x-bearerInfoFunc: crc.api.user.verify_token + jwt_admin: + type: http + scheme: bearer + bearerFormat: JWT + x-bearerInfoFunc: crc.api.user.verify_token_admin schemas: User: properties: diff --git a/crc/api/user.py b/crc/api/user.py index afa2e894..004d1420 100644 --- a/crc/api/user.py +++ b/crc/api/user.py @@ -8,34 +8,126 @@ from crc import app, db from crc.api.common import ApiError from crc.models.user import UserModel, UserModelSchema from crc.services.ldap_service import LdapService, LdapUserInfo +from crc.services.approval_service import ApprovalService """ .. module:: crc.api.user :synopsis: Single Sign On (SSO) user login and session handlers """ -def verify_token(token): + + +def verify_token(token=None): + """ + Verifies the token for the user (if provided). If in production environment and token is not provided, + gets user from the SSO headers and returns their token. + + Args: + token: Optional[str] + + Returns: + token: str + + Raises: + ApiError. If not on production and token is not valid, returns an 'invalid_token' 403 error. + If on production and user is not authenticated, returns a 'no_user' 403 error. + """ + print('=== verify_token ===') + print('_is_production()', _is_production()) + failure_error = ApiError("invalid_token", "Unable to decode the token you provided. Please re-authenticate", status_code=403) - if (not 'PRODUCTION' in app.config or not app.config['PRODUCTION']) and token == app.config["SWAGGER_AUTH_KEY"]: + + if not _is_production(): g.user = UserModel.query.first() token = g.user.encode_auth_token() - try: - token_info = UserModel.decode_auth_token(token) - g.user = UserModel.query.filter_by(uid=token_info['sub']).first() - except: - raise failure_error - if g.user is not None: - return token_info + if token: + try: + token_info = UserModel.decode_auth_token(token) + g.user = UserModel.query.filter_by(uid=token_info['sub']).first() + except: + raise failure_error + if g.user is not None: + return token_info + else: + raise failure_error + + # If there's no token and we're in production, get the user from the SSO headers and return their token + if not token and _is_production(): + uid = _get_request_uid() + + if uid is not None: + db_user = UserModel.query.filter_by(uid=uid).first() + + if db_user is not None: + g.user = db_user + token = g.user.encode_auth_token().decode() + token_info = UserModel.decode_auth_token(token) + return token_info + + else: + ApiError("no_user", "User not found. Please login via the frontend app before accessing this feature.", status_code=403) + raise failure_error + + +def verify_token_admin(token=None): + """ + Verifies the token for the user (if provided) in non-production environment. If in production environment, + checks that the user is in the list of authorized admins + + Args: + token: Optional[str] + + Returns: + token: str + """ + + print('=== verify_token_admin ===') + print('_is_production()', _is_production()) + + + # If this is production, check that the user is in the list of admins + if _is_production(): + uid = _get_request_uid() + + print('verify_token_admin uid', uid) + + if uid is not None and uid in app.config['ADMIN_UIDS']: + return verify_token() + + # If we're not in production, just use the normal verify_token method else: - raise failure_error + return verify_token(token) def get_current_user(): return UserModelSchema().dump(g.user) -@app.route('/v1.0/login') -def sso_login(): - # This what I see coming back: + +def login( + uid=None, + redirect_url=None, +): + """ + In non-production environment, provides an endpoint for end-to-end system testing that allows the system + to simulate logging in as a specific user. In production environment, simply logs user in via single-sign-on + (SSO) Shibboleth authentication headers. + + Args: + uid: Optional[str] + redirect_url: Optional[str] + + Returns: + str. If not on production, returns the frontend auth callback URL, with auth token appended. + If on production and user is authenticated via SSO, returns the frontend auth callback URL, + with auth token appended. + + Raises: + ApiError. If on production and user is not authenticated, returns a 404 error. + """ + + # ---------------------------------------- + # Shibboleth Authentication Headers + # ---------------------------------------- # X-Remote-Cn: Daniel Harold Funk (dhf8r) # X-Remote-Sn: Funk # X-Remote-Givenname: Daniel @@ -50,48 +142,54 @@ def sso_login(): # X-Forwarded-Host: dev.crconnect.uvadcos.io # X-Forwarded-Server: dev.crconnect.uvadcos.io # Connection: Keep-Alive - uid = request.headers.get("Uid") - if not uid: - uid = request.headers.get("X-Remote-Uid") - if not uid: - raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s" - % str(request.headers)) + print('=== login ===') + print('_is_production()', _is_production()) - redirect = request.args.get('redirect') - app.logger.info("SSO_LOGIN: Full URL: " + request.url) - app.logger.info("SSO_LOGIN: User Id: " + uid) - app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect)) + # If we're in production, override any uid with the uid from the SSO request headers + if _is_production(): + uid = _get_request_uid() - ldap_service = LdapService() - info = ldap_service.user_info(uid) + if uid: + app.logger.info("SSO_LOGIN: Full URL: " + request.url) + app.logger.info("SSO_LOGIN: User Id: " + uid) + app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect_url)) + + ldap_info = LdapService().user_info(uid) + + if ldap_info: + return _handle_login(ldap_info, redirect_url) + + raise ApiError('404', 'unknown') - return _handle_login(info, redirect) @app.route('/sso') def sso(): response = "" response += "

Headers

" response += "