Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mfa branch #84

Merged
merged 14 commits into from
Jun 27, 2024
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,7 @@ input/dispatch_lists/*.xls*

**/.DS_Store
**/~$*.docx

access_link.txt

my_cache.bin
1 change: 1 addition & 0 deletions example-access_link.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
## Make a new file `access_link.txt` and paste in the URL given after the MFA (blank page)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions src/rred_reports/.example-secrets.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 19 additions & 1 deletion src/rred_reports/reports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -78,6 +78,24 @@ attachments look correct, you can send the reports to the RRED study team
rred reports send-school {year} --override-mailto="[email protected]"
```

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
Expand Down
3 changes: 3 additions & 0 deletions src/rred_reports/reports/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`"[email protected]"`

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.
90 changes: 77 additions & 13 deletions src/rred_reports/reports/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())

Expand All @@ -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())
Loading