diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..d8362a9 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,49 @@ +name: Linting and tests + +on: + push: + branches: [master, mom/dev] + pull_request: + branches: [master, mom/dev] + +jobs: + check: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.13] + + steps: + - name: Install ldap dependencies + run: sudo apt-get update && sudo apt-get install libldap2-dev libsasl2-dev + - uses: actions/checkout@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + mypy --non-interactive --install-types + - name: Lint with pylint + run: | + pylint app.py config.env.py eac + - name: Typecheck with mypy + run: | + mypy app.py config.env.py eac + - name: Format with yapf + run: | + yapf --diff -r app.py config.env.py eac + + docker-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Build Image + run: | + docker build . --file Dockerfile diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..92469ba --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,3 @@ +[mypy] +disable_error_code = import +disallow_untyped_defs = True diff --git a/.pylintrc b/.pylintrc index 9de855b..1878f7a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -9,7 +9,6 @@ disable = duplicate-code, no-member, parse-error, - bad-continuation, too-few-public-methods, global-statement, cyclic-import, @@ -17,20 +16,14 @@ disable = file-ignored [REPORTS] -output-format = text -files-output = no +output-format = colorized reports = no [FORMAT] max-line-length = 120 -max-statement-lines = 75 single-line-if-stmt = no -no-space-check = trailing-comma,dict-separator max-module-lines = 1000 indent-string = ' ' -string-quote=single-avoid-escape -triple-quote=single -docstring-quote=double [MISCELLANEOUS] notes = FIXME,XXX,TODO @@ -76,9 +69,6 @@ good-names=logger,id,ID # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata -# List of builtins function names that should not be used, separated by a comma -bad-functions=apply,input - [DESIGN] max-args = 10 ignored-argument-names = _.* @@ -92,4 +82,4 @@ min-public-methods = 2 max-public-methods = 20 [EXCEPTIONS] -overgeneral-exceptions = Exception +overgeneral-exceptions = builtins.Exception diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7d0d017..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: python -python: - - "3.13" -script: - - "pylint --load-plugins pylint_quotes eac" -notifications: - email: false diff --git a/Dockerfile b/Dockerfile index 512d0bf..0c6527e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,13 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \ COPY . /opt/eac RUN ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime +RUN git config --system --add safe.directory /opt/eac ARG PORT=8080 ENV PORT=${PORT} EXPOSE ${PORT} -CMD ["gunicorn app:application --bind=0.0.0.0:${PORT} --access-logfile=- --timeout=600"] +# --access-logfile - prints access log to stdout +# --error-log - prints errors to stdout +# --capture-output logging and print go to error log (stdout) +CMD ["sh", "-c", "gunicorn app:application --bind=0.0.0.0:${PORT} --access-logfile - --error-log - --capture-output --timeout=600"] diff --git a/README.md b/README.md index 4cd456c..d25e181 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,16 @@ pip3 install -r requirements.txt flask run -h localhost -p 5000 ``` + +### Linting + ``` +# Install types +mypy --install-types + +# Check linting +mypy app.py config.env.py eac +# Check Typing +pylint app.py config.env.py eac +# Format +yapf -ir app.py config.env.py eac + ``` diff --git a/config.env.py b/config.env.py index e863de5..dab496a 100644 --- a/config.env.py +++ b/config.env.py @@ -5,10 +5,12 @@ IP = os.environ.get('IP', '127.0.0.1') PORT = os.environ.get('PORT', 5000) SERVER_NAME = os.environ.get('SERVER_NAME', 'localhost:5000') -SECRET_KEY = os.environ.get('SESSION_KEY', default=''.join(secrets.token_hex(16))) +SECRET_KEY = os.environ.get('SESSION_KEY', + default=''.join(secrets.token_hex(16))) # OpenID Connect SSO config -OIDC_ISSUER = os.environ.get('OIDC_ISSUER', 'https://sso.csh.rit.edu/auth/realms/csh') +OIDC_ISSUER = os.environ.get('OIDC_ISSUER', + 'https://sso.csh.rit.edu/auth/realms/csh') OIDC_CLIENT_CONFIG = { 'client_id': os.environ.get('OIDC_CLIENT_ID', ''), 'client_secret': os.environ.get('OIDC_CLIENT_SECRET', ''), @@ -28,7 +30,8 @@ # GitHub secrets GITHUB_CLIENT_ID = os.environ.get('GITHUB_ID', '') GITHUB_SECRET = os.environ.get('GITHUB_SECRET', '') -ORG_TOKEN = os.environ.get('GITHUB_ORG_TOKEN', '') +GITHUB_APP_ID = os.environ.get('GITHUB_APP_ID', '') +GITHUB_APP_PRIVATE_KEY = os.environ.get('GITHUB_APP_PRIVATE_KEY', '') # Twitch secrets TWITCH_CLIENT_ID = os.environ.get('TWITCH_CLIENT_ID', '') @@ -36,9 +39,14 @@ # Twitter secrets TWITTER_CONSUMER_KEY = os.environ.get('TWITTER_OAUTH_CONSUMER_KEY', '') -TWITTER_CONSUMER_SECRET_KEY = os.environ.get('TWITTER_OAUTH_CONSUMER_SECRET_KEY', '') +TWITTER_CONSUMER_SECRET_KEY = os.environ.get( + 'TWITTER_OAUTH_CONSUMER_SECRET_KEY', '') TWITTER_TOKEN = os.environ.get('TWITTER_OAUTH_TOKEN', '') TWITTER_TOKEN_SECRET = os.environ.get('TWITTER_OAUTH_TOKEN_SECRET', '') # Common secrets STATE = os.environ.get('STATE', 'auth') + +# Connection controls +REQUEST_TIMEOUT = os.environ.get("EAC_REQUEST_TIMEOUT", + 60) # default to a minute timeout diff --git a/eac/__init__.py b/eac/__init__.py index 34147a6..8c98ef3 100644 --- a/eac/__init__.py +++ b/eac/__init__.py @@ -9,16 +9,21 @@ import hmac from hashlib import sha1 import base64 +from typing import Any +import jwt +from requests.models import HTTPError + +import flask +import werkzeug from flask import Flask, request, redirect, session, render_template, send_from_directory, jsonify from flask_pyoidc.flask_pyoidc import OIDCAuthentication -from flask_pyoidc.provider_configuration import * +from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata import csh_ldap import requests import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration - APP = Flask(__name__) if os.path.exists(os.path.join(os.getcwd(), 'config.py')): @@ -26,19 +31,14 @@ else: APP.config.from_pyfile(os.path.join(os.getcwd(), 'config.env.py')) -sentry_sdk.init( - dsn=APP.config['SENTRY_DSN'], - integrations=[FlaskIntegration()] - ) +sentry_sdk.init(dsn=APP.config['SENTRY_DSN'], + integrations=[FlaskIntegration()]) APP.secret_key = APP.config['SECRET_KEY'] _CONFIG = ProviderConfiguration( APP.config['OIDC_ISSUER'], - client_metadata=ClientMetadata( - **APP.config['OIDC_CLIENT_CONFIG'] - ) -) + client_metadata=ClientMetadata(**APP.config['OIDC_CLIENT_CONFIG'])) _AUTH = OIDCAuthentication({'default': _CONFIG}, APP) _LDAP = csh_ldap.CSHLDAP(APP.config['LDAP_DN'], APP.config['LDAP_SECRET']) @@ -54,7 +54,7 @@ + '&code=%s' _GITHUB_AUTH_URI = 'https://github.com/login/oauth/authorize' \ - + '?client_id=%s'\ + + '?client_id=%s' \ + '&state=%s' _GITHUB_TOKEN_URI = 'https://github.com/login/oauth/access_token' \ + '?client_id=%s' \ @@ -79,19 +79,22 @@ _TWITTER_ACCESS_TOKEN_URI = 'https://api.twitter.com/oauth/access_token' _TWITTER_ACCOUNT_INFO_URI = 'https://api.twitter.com/1.1/account/verify_credentials.json' _TWITTER_AUTH_TOKEN_CACHE = {} -_ORG_HEADER = {'Authorization' : 'token ' + APP.config['ORG_TOKEN'], - 'Accept' : 'application/vnd.github.v3+json'} +_ORG_HEADER = { + 'Authorization': 'token ' + APP.config['ORG_TOKEN'], + 'Accept': 'application/vnd.github.v3+json' +} @APP.route('/static/', methods=['GET']) -def _send_static(path): +def _send_static(path: str) -> flask.wrappers.Response: return send_from_directory('static', path) @APP.route('/') @_AUTH.oidc_auth('default') -def _index(): - commit_hash = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).strip().decode('utf-8') +def _index() -> str: + commit_hash = subprocess.check_output( + ['git', 'rev-parse', '--short', 'HEAD']).strip().decode('utf-8') uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) services = { @@ -109,14 +112,14 @@ def _index(): @APP.route('/slack', methods=['GET']) @_AUTH.oidc_auth('default') -def _auth_slack(): +def _auth_slack() -> werkzeug.Response: return redirect(_SLACK_AUTH_URI % (APP.config['SLACK_CLIENT_ID'], APP.config['STATE'])) @APP.route('/slack/return', methods=['GET']) @_AUTH.oidc_auth('default') -def _link_slack(): # pylint: disable=inconsistent-return-statements +def _link_slack() -> tuple[str, int]: """ Links Slack into LDAP via slackUID """ # Determine if we have a valid reason to do things @@ -124,18 +127,21 @@ def _link_slack(): # pylint: disable=inconsistent-return-statements if state != APP.config['STATE']: return 'Invalid state', 400 - resp = requests.get(_SLACK_ACCESS_URI % - (APP.config['SLACK_CLIENT_ID'], - APP.config['SLACK_SECRET'], request.args.get('code'))) + resp = requests.get( + _SLACK_ACCESS_URI % + (APP.config['SLACK_CLIENT_ID'], APP.config['SLACK_SECRET'], + request.args.get('code')), + timeout=APP.config['REQUEST_TIMEOUT'], + ) uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) member.slackUID = resp.json()['user']['id'] - return render_template('callback.html') + return render_template('callback.html'), 200 @APP.route('/slack', methods=['DELETE']) @_AUTH.oidc_auth('default') -def _revoke_slack(): +def _revoke_slack() -> werkzeug.Response: """ Revokes Slack by clearing slackUID """ uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) @@ -145,7 +151,7 @@ def _revoke_slack(): @APP.route('/github', methods=['GET']) @_AUTH.oidc_auth('default') -def _auth_github(): +def _auth_github() -> werkzeug.Response: # Redirect to github for authorisation return redirect(_GITHUB_AUTH_URI % (APP.config['GITHUB_CLIENT_ID'], APP.config['STATE'])) @@ -153,24 +159,43 @@ def _auth_github(): @APP.route('/github/return', methods=['GET']) @_AUTH.oidc_auth('default') -def _github_landing(): # pylint: disable=inconsistent-return-statements +def _github_landing() -> tuple[str, int]: # Determine if we have a valid reason to do things state = request.args.get('state') if state != APP.config['STATE']: return 'Invalid state', 400 # Get token from github - resp = requests.post(_GITHUB_TOKEN_URI % - (APP.config['GITHUB_CLIENT_ID'], APP.config['GITHUB_SECRET'], - request.args.get('code')), - headers={'Accept':'application/json'}) - token = resp.json()['access_token'] - header = {'Authorization' : 'token ' + token, - 'Accept' : 'application/vnd.github.v3+json'} - - user_resp = requests.get('https://api.github.com/user', headers=header) + resp = requests.post( + _GITHUB_TOKEN_URI % + (APP.config['GITHUB_CLIENT_ID'], APP.config['GITHUB_SECRET'], + request.args.get('code')), + headers={'Accept': 'application/json'}, + timeout=APP.config['REQUEST_TIMEOUT']) + try: + resp.raise_for_status() + except HTTPError as e: + print('response:', resp.json()) + raise e + + resp_json = resp.json() + token = resp_json['access_token'] + header = { + 'Authorization': 'token ' + token, + 'Accept': 'application/vnd.github.v3+json' + } + + user_resp = requests.get('https://api.github.com/user', + headers=header, + timeout=APP.config['REQUEST_TIMEOUT']) + try: + user_resp.raise_for_status() + except HTTPError as e: + print('response:', user_resp.json()) + raise e + user_resp_json = user_resp.json() - + github_username = user_resp_json['login'] github_id = user_resp_json['id'] @@ -179,37 +204,128 @@ def _github_landing(): # pylint: disable=inconsistent-return-statements member = _LDAP.get_member(uid, uid=True) _link_github(github_username, github_id, member) - return render_template('callback.html') + return render_template('callback.html'), 200 + + +def _get_github_jwt() -> str: + signing_key = APP.config["GITHUB_APP_PRIVATE_KEY"] + + payload = { + 'iat': int(time.time()), + 'exp': int(time.time() + 600), + 'iss': APP.config['GITHUB_APP_ID'], + } + + encoded_jwt = jwt.encode(payload, signing_key, algorithm='RS256') + + return encoded_jwt + + +def _auth_github_org() -> str: + jwt_auth = _get_github_jwt() + + headers = { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': f'Bearer {jwt_auth}', + } + + org_installation_resp = requests.get( + 'https://api.github.com/orgs/ComputerScienceHouse/installation', + headers=headers, + timeout=APP.config['REQUEST_TIMEOUT']) + try: + org_installation_resp.raise_for_status() + except HTTPError as e: + print('response:', org_installation_resp.json()) + raise e + + org_installation_json = org_installation_resp.json() + org_installation_id = org_installation_json['id'] + + org_token_resp = requests.post( + f'https://api.github.com/app/installations/{org_installation_id}/access_tokens', + headers=headers, + timeout=APP.config['REQUEST_TIMEOUT']) + try: + org_token_resp.raise_for_status() + except HTTPError as e: + print('response:', org_token_resp.json()) + raise e + + org_token_json = org_token_resp.json() + org_token = org_token_json['token'] + + return org_token -def _link_github(github_username, github_id, member): +def _link_github(github_username: str, github_id: str, member: Any) -> None: """ Puts a member's github into LDAP and adds them to the org. :param github_username: the user's github username :param github_id: the user's github id :param member: the member's LDAP object """ - payload={ - 'invitee_id': github_id + org_token = _auth_github_org() + + payload = { + 'org': 'ComputerScienceHouse', + 'invitee_id': github_id, + 'role': 'direct_member' + } + + github_org_headers = { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': f'Token {org_token}', } - requests.post('https://api.github.com/orgs/ComputerScienceHouse/invitations', headers=_ORG_HEADER, data=payload) + + resp = requests.post( + 'https://api.github.com/orgs/ComputerScienceHouse/invitations', + headers=github_org_headers, + json=payload, + timeout=APP.config['REQUEST_TIMEOUT']) + try: + resp.raise_for_status() + except HTTPError as e: + print('response:', resp.json()) + raise e + member.github = github_username @APP.route('/github', methods=['DELETE']) @_AUTH.oidc_auth('default') -def _revoke_github(): +def _revoke_github() -> werkzeug.Response: """ Clear's a member's github in LDAP and removes them from the org. """ uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) - requests.delete('https://api.github.com/orgs/ComputerScienceHouse/members/' + member.github, headers=_ORG_HEADER) + + org_token = _auth_github_org() + + headers = { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': f'Token {org_token}', + } + + resp = requests.delete( + 'https://api.github.com/orgs/ComputerScienceHouse/members/' + + member.github, + headers=headers, + timeout=APP.config['REQUEST_TIMEOUT'], + ) + + try: + resp.raise_for_status() + except HTTPError as e: + print('response:', resp.json()) + raise e + member.github = None return jsonify(success=True) @APP.route('/twitch', methods=['GET']) @_AUTH.oidc_auth('default') -def _auth_twitch(): +def _auth_twitch() -> werkzeug.Response: # Redirect to twitch for authorisation return redirect(_TWITCH_AUTH_URI % (APP.config['TWITCH_CLIENT_ID'], APP.config['STATE'])) @@ -217,32 +333,40 @@ def _auth_twitch(): @APP.route('/twitch/return', methods=['GET']) @_AUTH.oidc_auth('default') -def _twitch_landing(): # pylint: disable=inconsistent-return-statements +def _twitch_landing() -> tuple[str, int]: # Determine if we have a valid reason to do things state = request.args.get('state') if state != APP.config['STATE']: return 'Invalid state', 400 - resp = requests.post(_TWITCH_TOKEN_URI % - (APP.config['TWITCH_CLIENT_ID'], APP.config['TWITCH_CLIENT_SECRET'], - request.args.get('code')), - headers={'Accept':'application/json'}) - - header = {'Authorization' : 'OAuth ' + resp.json()['access_token'], } - resp = requests.get('https://id.twitch.tv/oauth2/validate', headers=header) + resp = requests.post( + _TWITCH_TOKEN_URI % + (APP.config['TWITCH_CLIENT_ID'], APP.config['TWITCH_CLIENT_SECRET'], + request.args.get('code')), + headers={'Accept': 'application/json'}, + timeout=APP.config['REQUEST_TIMEOUT'], + ) + header = { + 'Authorization': 'OAuth ' + resp.json()['access_token'], + } + resp = requests.get( + 'https://id.twitch.tv/oauth2/validate', + headers=header, + timeout=APP.config['REQUEST_TIMEOUT'], + ) # Pull member from LDAP uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) member.twitchlogin = resp.json()['login'] - return render_template('callback.html') + return render_template('callback.html'), 200 @APP.route('/twitch', methods=['DELETE']) @_AUTH.oidc_auth('default') -def _revoke_twitch(): +def _revoke_twitch() -> werkzeug.Response: """ Clear's a member's twitch login in LDAP.""" uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) @@ -252,9 +376,11 @@ def _revoke_twitch(): @APP.route('/twitter', methods=['GET']) @_AUTH.oidc_auth('default') -def _auth_twitter(): +def _auth_twitter() -> werkzeug.Response: # Make a POST request to get the request token - oauth_nonce = ''.join([random.choice(string.ascii_letters + string.digits) for n in range(32)]) + oauth_nonce = ''.join([ + random.choice(string.ascii_letters + string.digits) for n in range(32) + ]) oauth_timestamp = int(time.time()) oauth_parameter_string = f'oauth_callback={urllib.parse.quote("https://eac.csh.rit.edu/twitter/return", safe="")}' \ f'&oauth_consumer_key={APP.config["TWITTER_CONSUMER_KEY"]}' \ @@ -266,9 +392,10 @@ def _auth_twitter(): + urllib.parse.quote(_TWITTER_REQUEST_TOKEN_URI, safe='') + '&' \ + urllib.parse.quote(oauth_parameter_string, safe='') oauth_signing_key = f'{APP.config["TWITTER_CONSUMER_SECRET_KEY"]}&' - oauth_signature = base64.b64encode(hmac.new(oauth_signing_key.encode(), - oauth_signature_base_string.encode(), - sha1).digest()).decode('UTF-8') + oauth_signature = base64.b64encode( + hmac.new(oauth_signing_key.encode(), + oauth_signature_base_string.encode(), + sha1).digest()).decode('UTF-8') oauth_header = f'OAuth oauth_callback="https://eac.csh.rit.edu/twitter/return"' \ f'oauth_consumer_key="{APP.config["TWITTER_CONSUMER_KEY"]}", ' \ @@ -278,31 +405,47 @@ def _auth_twitter(): f'oauth_timestamp="{oauth_timestamp}", ' \ f'oauth_version="1.0"' - resp = requests.post(_TWITTER_REQUEST_TOKEN_URI, - headers={'Accept': '*/*', - 'Authorization': oauth_header}) + resp = requests.post( + _TWITTER_REQUEST_TOKEN_URI, + headers={ + 'Accept': '*/*', + 'Authorization': oauth_header + }, + timeout=APP.config['REQUEST_TIMEOUT'], + ) + if resp.status_code != 200: print(f'Status: {resp.status_code}\nMessage: {resp.text}') - return 'Error fetching request_token', 500 - returned_params = dict((key.strip(), val.strip()) - for key, val in (element.split('=') - for element in resp.text.split('&'))) - - _TWITTER_AUTH_TOKEN_CACHE[returned_params['oauth_token']] = returned_params['oauth_token_secret'] + return flask.make_response(('Error fetching request_token', 500)) + returned_params = dict( + (key.strip(), val.strip()) + for key, val in (element.split('=') + for element in resp.text.split('&'))) + + _TWITTER_AUTH_TOKEN_CACHE[ + returned_params['oauth_token']] = returned_params['oauth_token_secret'] # Redirect to twitter for authorisation - return redirect(f'{_TWITTER_AUTHORIZATION_URI}?oauth_token={returned_params["oauth_token"]}') + return redirect( + f'{_TWITTER_AUTHORIZATION_URI}?oauth_token={returned_params["oauth_token"]}' + ) @APP.route('/twitter/return', methods=['GET']) @_AUTH.oidc_auth('default') -def _twitter_landing(): # pylint: disable=inconsistent-return-statements +def _twitter_landing() -> tuple[str, int]: oauth_token = request.args.get('oauth_token') + if oauth_token is None: + return "Failed to get outh token", 400 oauth_verifier = request.args.get('oauth_verifier') - oauth_nonce = ''.join([random.choice(string.ascii_letters + string.digits) for n in range(32)]) + if oauth_verifier is None: + return "Failed to get outh verifier", 400 + oauth_nonce = ''.join([ + random.choice(string.ascii_letters + string.digits) for n in range(32) + ]) oauth_timestamp = int(time.time()) oauth_parameter_string = f'oauth_consumer_key={APP.config["TWITTER_CONSUMER_KEY"]}' \ f'&oauth_nonce={oauth_nonce}' \ - f'&oauth_signature_method=HMAC-SHA1'- \ + f'&oauth_signature_method=HMAC-SHA1' \ f'&oauth_timestamp={oauth_timestamp}' \ f'&oauth_token={urllib.parse.quote(oauth_token, safe="")}' \ f'&oauth_verifier={urllib.parse.quote(oauth_verifier, safe="")}' \ @@ -311,9 +454,10 @@ def _twitter_landing(): # pylint: disable=inconsistent-return-statements + urllib.parse.quote(_TWITTER_ACCESS_TOKEN_URI, safe='') + '&' \ + urllib.parse.quote(oauth_parameter_string, safe='') oauth_signing_key = f'{APP.config["TWITTER_CONSUMER_SECRET_KEY"]}&{_TWITTER_AUTH_TOKEN_CACHE[oauth_token]}' - oauth_signature = base64.b64encode(hmac.new(oauth_signing_key.encode(), - oauth_signature_base_string.encode(), - sha1).digest()).decode('UTF-8') + oauth_signature = base64.b64encode( + hmac.new(oauth_signing_key.encode(), + oauth_signature_base_string.encode(), + sha1).digest()).decode('UTF-8') oauth_header = f'OAuth oauth_consumer_key="{APP.config["TWITTER_CONSUMER_KEY"]}", ' \ f'oauth_nonce="{oauth_nonce}", ' \ @@ -322,18 +466,27 @@ def _twitter_landing(): # pylint: disable=inconsistent-return-statements f'oauth_timestamp="{oauth_timestamp}", ' \ f'oauth_token="{oauth_token}"' \ f'oauth_version="1.0"' - resp = requests.post(_TWITTER_REQUEST_TOKEN_URI, - data=f'oauth_verifier={oauth_verifier}', - headers={'Accept': '*/*', - 'Authorization': oauth_header, - 'Content-Type': 'application/x-www-form-urlencoded'}) - returned_params = dict((key.strip(), val.strip()) - for key, val in (element.split('=') - for element in resp.text.split('&'))) + resp = requests.post( + _TWITTER_REQUEST_TOKEN_URI, + data=f'oauth_verifier={oauth_verifier}', + headers={ + 'Accept': '*/*', + 'Authorization': oauth_header, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + timeout=APP.config['REQUEST_TIMEOUT'], + ) + + returned_params = dict( + (key.strip(), val.strip()) + for key, val in (element.split('=') + for element in resp.text.split('&'))) oauth_token = returned_params['oauth_token'] oauth_token_secret = returned_params['oauth_token_secret'] # OK, now that we have the proper token and secret, we can get the user's information - oauth_nonce = ''.join([random.choice(string.ascii_letters + string.digits) for n in range(32)]) + oauth_nonce = ''.join([ + random.choice(string.ascii_letters + string.digits) for n in range(32) + ]) oauth_timestamp = int(time.time()) oauth_parameter_string = f'auth_consumer_key={APP.config["TWITTER_CONSUMER_KEY"]}' \ f'&oauth_nonce={oauth_nonce}' \ @@ -345,9 +498,10 @@ def _twitter_landing(): # pylint: disable=inconsistent-return-statements + urllib.parse.quote(_TWITTER_ACCOUNT_INFO_URI, safe='') + '&' \ + urllib.parse.quote(oauth_parameter_string, safe='') oauth_signing_key = f"{APP.config['TWITTER_CONSUMER_SECRET_KEY']}&{oauth_token_secret}" - oauth_signature = base64.b64encode(hmac.new(oauth_signing_key.encode(), - oauth_signature_base_string.encode(), - sha1).digest()).decode('UTF-8') + oauth_signature = base64.b64encode( + hmac.new(oauth_signing_key.encode(), + oauth_signature_base_string.encode(), + sha1).digest()).decode('UTF-8') oauth_header = f'OAuth oauth_consumer_key="{APP.config["TWITTER_CONSUMER_KEY"]}", ' \ f'oauth_nonce="{oauth_nonce}", ' \ @@ -356,19 +510,24 @@ def _twitter_landing(): # pylint: disable=inconsistent-return-statements f'oauth_timestamp="{oauth_timestamp}", ' \ f'oauth_token="{oauth_token}"' \ f'oauth_version="1.0"' - resp = requests.get(_TWITTER_ACCOUNT_INFO_URI, - headers={'Accept': '*/*', - 'Authorization': oauth_header}) + resp = requests.get( + _TWITTER_ACCOUNT_INFO_URI, + headers={ + 'Accept': '*/*', + 'Authorization': oauth_header + }, + timeout=APP.config['REQUEST_TIMEOUT'], + ) # Pull member from LDAP uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) member.twittername = resp.json()[0]['screen_name'] - return render_template('callback.html') + return render_template('callback.html'), 200 @APP.route('/twitter', methods=['DELETE']) @_AUTH.oidc_auth('default') -def _revoke_twitter(): +def _revoke_twitter() -> werkzeug.Response: """ Clear's a member's twitter login in LDAP.""" uid = str(session['userinfo'].get('preferred_username', '')) member = _LDAP.get_member(uid, uid=True) @@ -378,5 +537,5 @@ def _revoke_twitter(): @APP.route('/logout') @_AUTH.oidc_logout -def logout(): +def logout() -> werkzeug.Response: return redirect('/', 302) diff --git a/requirements.txt b/requirements.txt index 72ae495..57641c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,12 @@ -csh-ldap==2.4.0 +csh-ldap @ git+https://github.com/ComputerScienceHouse/csh_ldap@2.5.1 Flask==3.1.2 Flask-pyoidc==3.14.3 gunicorn==23.0.0 +mypy==1.18.1 pylint==3.3.8 pylint-quotes==0.2.3 requests==2.32.5 sentry-sdk[flask]==2.37.1 +PyJWT==2.10.1 +cryptography==46.0.2 +yapf==0.43.0