diff --git a/.gitignore b/.gitignore index 46f49af..0431557 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,7 @@ input/dispatch_lists/*.xls* **/.DS_Store **/~$*.docx + +access_link.txt + +my_cache.bin diff --git a/example-access_link.txt b/example-access_link.txt new file mode 100644 index 0000000..0b19083 --- /dev/null +++ b/example-access_link.txt @@ -0,0 +1 @@ +## Make a new file `access_link.txt` and paste in the URL given after the MFA (blank page) diff --git a/pyproject.toml b/pyproject.toml index 7211744..b4626c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,9 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "python_version>='3.9'", "exchangelib == 4.9.0", "docx2pdf == 0.1.8", - "dynaconf == 3.1.12", + "dynaconf == 3.2.5", "loguru == 0.6.0", "openpyxl == 3.1.1", "pandas == 1.5.3", @@ -40,6 +39,7 @@ dependencies = [ "tomli == 2.0.1", "tomli-w == 1.0.0", "typer == 0.7.0", + "msal == 1.29.0" ] [project.optional-dependencies] diff --git a/src/rred_reports/.example-secrets.toml b/src/rred_reports/.example-secrets.toml index 1f252a2..208859f 100644 --- a/src/rred_reports/.example-secrets.toml +++ b/src/rred_reports/.example-secrets.toml @@ -2,6 +2,9 @@ client_id = "" client_secret="" tenant_id="" send_emails_as="" -# Note: username is without `@ucl.ac.uk`, e.g. sejjpia +# Note: username is with `@ucl.ac.uk`, e.g. sejjpia@ucl.ac.uk username = "" -password = "" +scope = 'https://outlook.office.com/.default' +server = 'outlook.office365.com' + +Go to README for info on where to find these values diff --git a/src/rred_reports/reports/README.md b/src/rred_reports/reports/README.md index 167fe29..2cbc7dc 100644 --- a/src/rred_reports/reports/README.md +++ b/src/rred_reports/reports/README.md @@ -51,7 +51,7 @@ virtualenv/conda env there is some first-time setup required: reports joined together will be created and its filepath logged. - Review the UAT file for any errors and send it to the study team for sign off. -## User Acceptance Testing of emails +## User Acceptance Testing of emails with MFA The email body text may be changed and emailed to us, if so update it in `src/rred_reports/reports/emails.py` in the `formatted_mail_content` function, @@ -78,6 +78,24 @@ attachments look correct, you can send the reports to the RRED study team rred reports send-school {year} --override-mailto="ilc.comms@ucl.ac.uk" ``` +You will receive a message saying, +`"Please follow the authentication flow by visiting this link and entering the final redirect URL: {some URL}"`. +Click on this link to open a Microsoft login page in your web browser. Once +logged in, you will see a 2-digit number. Use the Microsoft Authenticator app on +your mobile device to authenticate the app by entering this number. After +authentication, the Microsoft sign-in page should redirect to a blank screen. + +Copy the URL from the browser's address bar and save it to `access_link.txt`. + +Next, return to the command line and enter any text: + +`Please follow auth flow by going to this link, then enter in the final redirect URL: {some URL}` +: `type something here (it doesn't matter what)` + +After completing these steps, a new file named `my_cache.bin` will have been +created. This file contains your access tokens necessary for sending out emails. +The process should now begin sending emails; please check your inbox. + ## Emailing of reports to teachers Once the RRED study team has reviewed the reports, we can run the full email to diff --git a/src/rred_reports/reports/SETUP.md b/src/rred_reports/reports/SETUP.md index ec28eb7..f9eccf6 100644 --- a/src/rred_reports/reports/SETUP.md +++ b/src/rred_reports/reports/SETUP.md @@ -64,6 +64,9 @@ from the app registration, or documented internally. For the study, we will want so `send_email_as` to be set to `"ilc.comms@ucl.ac.uk"` +The `scope` would be `'https://outlook.office.com/.default'` and `server` = +`'outlook.office365.com'` + Fill in the username and password combination that will be used to authenticate the application. `.secrets.toml` has been added to the `.gitignore`, but this file should **never** be committed to version control. diff --git a/src/rred_reports/reports/auth.py b/src/rred_reports/reports/auth.py index 807d47d..bb41887 100644 --- a/src/rred_reports/reports/auth.py +++ b/src/rred_reports/reports/auth.py @@ -1,36 +1,82 @@ """Mail server authentication""" from dataclasses import dataclass +from pathlib import Path +from urllib import parse +import click +import msal from dynaconf.base import Settings from exchangelib import ( DELEGATE, Account, Configuration, - OAuth2LegacyCredentials, + OAuth2AuthorizationCodeCredentials, ) +from loguru import logger +from oauthlib.oauth2 import OAuth2Token from rred_reports.reports import get_settings +CACHE_PATH = Path("my_cache.bin") + @dataclass class RREDAuthenticator: - """Class to handle MS exchange server authentication""" + """Class to handle MS Exchange server authentication""" settings: Settings = get_settings() - def get_credentials(self) -> OAuth2LegacyCredentials: + def _get_app_access_token(self) -> dict: + """Acquire an access token for the Azure app""" + authority = f"https://login.microsoftonline.com/{self.settings.tenant_id}" + global_token_cache = _check_or_set_up_cache() + app = msal.ClientApplication( + self.settings.client_id, + authority=authority, + token_cache=global_token_cache, + ) + + accounts = app.get_accounts(username=self.settings.username) + + if accounts: + logger.info("Account(s) exists in cache, probably with token too. Let's try.") + result = app.acquire_token_silent([self.settings.scope], account=accounts[0]) + else: + logger.info("No suitable token exists in cache. Let's initiate interactive login.") + auth_code_flow = app.initiate_auth_code_flow(scopes=[self.settings.scope], login_hint=self.settings.username, max_age=60 * 60 * 24) + click.confirm( + f"Please follow auth flow by going to this link, then enter in the final redirect URL in access_link.txt {auth_code_flow['auth_uri']}\n" + ) + final_url = (Path(__file__).parents[3] / "access_link.txt").read_text() + auth_response = self._convert_url_to_auth_dict(final_url) + result = app.acquire_token_by_auth_code_flow(auth_code_flow, auth_response) + + if "access_token" not in result: + message = "Access token could not be acquired" + raise RuntimeError(message, result["error_description"]) + + # Save cache if it changed after authentication setup + _save_cache(global_token_cache, CACHE_PATH) + + return result + + @staticmethod + def _convert_url_to_auth_dict(auth_url: str) -> dict: + query_string = parse.urlsplit(auth_url).query + query_data = parse.parse_qs(query_string) + # state needs to be a string to match the auth_code_flow + query_data["state"] = query_data["state"][0] + return query_data + + def get_credentials(self) -> OAuth2AuthorizationCodeCredentials: """Builds a user credential object for exchangelib Returns: - OAuth2LegacyCredentials: Credentials to authenticate a user + OAuth2AuthorizationCodeCredentials: Credentials to authenticate a user """ - return OAuth2LegacyCredentials( - client_id=self.settings.client_id, - client_secret=self.settings.client_secret, - tenant_id=self.settings.tenant_id, - username=f"{self.settings.username}@ucl.ac.uk", - password=self.settings.password, - ) + token_result = self._get_app_access_token() + access_token = OAuth2Token(token_result) + return OAuth2AuthorizationCodeCredentials(access_token=access_token) def get_config(self) -> Configuration: """Retrieve an exchangelib Configuration object @@ -39,7 +85,7 @@ def get_config(self) -> Configuration: used to send emails Returns: - Configuration: _description_ + Configuration: Configuration object for Exchange server """ return Configuration(server=self.settings.server, credentials=self.get_credentials()) @@ -51,4 +97,22 @@ def get_account(self) -> Account: Returns: Account: exchangelib Account object for an authenticated user """ - return Account(primary_smtp_address=f"{self.settings.send_emails_as}", config=self.get_config(), access_type=DELEGATE) + conf = self.get_config() + return Account(primary_smtp_address=self.settings.send_emails_as, config=conf, access_type=DELEGATE) + + +def _check_or_set_up_cache(): + """Set up MSAL token cache and load existing token""" + cache = msal.SerializableTokenCache() + + if CACHE_PATH.exists(): + with CACHE_PATH.open("rb") as cache_file: + cache.deserialize(cache_file.read()) + + return cache + + +def _save_cache(cache: msal.SerializableTokenCache, cache_path: Path) -> None: + if cache.has_state_changed: + with cache_path.open("w") as cache_file: + cache_file.write(cache.serialize())