From b83d3df0d8a78980d856acb5e0a2ff458b4e8d18 Mon Sep 17 00:00:00 2001 From: Jeff Squyres Date: Fri, 10 Jul 2026 11:51:46 -0400 Subject: [PATCH 1/2] Authenticate M365 SMTP via OAuth2 certificate instead of password Microsoft 365 has disabled Basic Authentication (username/password) for SMTP AUTH, so email_sender.py could no longer log in with a mailbox password. Switch to app-only (client-credentials) OAuth2 using a certificate, and send via the SMTP XOAUTH2 mechanism. - email_sender.py: acquire an app-only access token from Entra ID via MSAL using a certificate credential (private key + SHA-1 thumbprint computed from the PEM), then authenticate with AUTH XOAUTH2 instead of SMTP.login(). Load .env from the project root so cron runs resolve it. - dialogueBox.py: replace the "Email Password" field with Tenant ID, Client ID, and Certificate Path (with a Browse button); persist as AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CERT_PATH. - requirements.txt / initial_setup.sh: add msal and cryptography (the cron venv installs them too). - .env.example: replace EMAIL_PASSWORD with the AZURE_* keys. - README.md: add a "Microsoft 365 email setup" section covering the app registration, SMTP.SendAsApp admin consent, self-signed certificate creation/upload, and Exchange Online mailbox scoping. --- .env.example | 4 +- README.md | 107 ++++++++++++++++++++++++++++++++++++++- requirements.txt | 2 + scripts/initial_setup.sh | 5 +- src/dialogueBox.py | 47 ++++++++++++++--- src/email_sender.py | 92 ++++++++++++++++++++++++++++----- 6 files changed, 232 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index d14be81..68fc356 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,7 @@ VERKADA_API_KEY=This should be your API Key== ELASTIC_PASSWORD=At least six characters long KIBANA_SYSTEM_PASSWORD=At least six characters long and you will need to set in Kibana refer to README KIBANA_ENCRYPTION_KEY=Create Key using command "openssl rand -base64 32" in linux or wsl== -EMAIL_PASSWORD = Password for the email account you are sending the dashboard from +AZURE_TENANT_ID=Directory (tenant) ID from your Microsoft Entra app registration +AZURE_CLIENT_ID=Application (client) ID from your Microsoft Entra app registration +AZURE_CERT_PATH=Path to the PEM file holding the private key + certificate uploaded to the app registration WINDOWS_IP=This is the IP for your device \ No newline at end of file diff --git a/README.md b/README.md index c779a82..be767eb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,110 @@ Follow this [guide](https://docs.docker.com/desktop/features/wsl/) to set up Doc Clone the VerityBot git repository into the WSL2 terminal ```git clone git@github.com:VerCalBot/VerityBot.git``` +### Microsoft 365 email setup 📧 + +VerityBot emails the Kibana dashboard link on a schedule. Because Microsoft 365 +has **disabled Basic Authentication (username/password) for SMTP**, VerityBot +authenticates to Microsoft 365 using **OAuth2 with an app registration** +(app-only / client-credentials flow, using a **certificate**). No mailbox +password is used or stored. + +You must complete the steps below **before** running `initial_setup.sh`, because +the setup dialogue box asks for the **Tenant ID**, **Client ID**, and +**Certificate Path** produced here. These steps require a **Microsoft 365 / +Entra ID administrator**. + +> The exact wording of some portal buttons changes over time; the resource names +> (`Office 365 Exchange Online`, permission `SMTP.SendAsApp`, scope +> `https://outlook.office365.com/.default`) are the parts that matter. + +#### 1. Choose or create the sender account +Pick the mailbox that reports will be sent *from* (this is the `Email Sender` +value in the dialogue box). This can be a normal licensed user or, preferably, a +dedicated **service account** / **shared mailbox** (e.g. `veritybot@yourdomain`). +It must have an Exchange Online mailbox. + +#### 2. Register an application in Microsoft Entra ID +1. Go to the [Microsoft Entra admin center](https://entra.microsoft.com) → + **Identity → Applications → App registrations → New registration**. +2. Name it (e.g. `VerityBot Email Sender`), leave redirect URI empty, and + register. (No redirect URI / SSO is needed for app-only auth.) +3. On the app **Overview** page, copy the **Directory (tenant) ID** and the + **Application (client) ID** — these become `AZURE_TENANT_ID` and + `AZURE_CLIENT_ID`. + +#### 3. Grant the SMTP send permission (admin consent required) +1. In the app, go to **API permissions → Add a permission → APIs my + organization uses**, and search for **Office 365 Exchange Online**. +2. Choose **Application permissions**, select **`SMTP.SendAsApp`**, and add it. +3. Click **Grant admin consent for <tenant>** and confirm the permission + shows a green "Granted" status. + +#### 4. Create and upload a certificate +Generate a self-signed certificate on the machine that will run VerityBot (in +WSL). This produces two files: a **private key + certificate** PEM that stays on +the VerityBot host, and a **public certificate** (`.crt`) that you upload to +Entra. + +```bash +# Creates veritybot.key (private key), veritybot.crt (public cert), valid 2 years. +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout veritybot.key -out veritybot.crt \ + -days 730 -subj "/CN=VerityBot Email Sender" + +# Combine the private key + certificate into one PEM for VerityBot to load. +cat veritybot.key veritybot.crt > veritybot.pem +chmod 600 veritybot.key veritybot.pem # keep the private key readable only by you +``` + +1. In the app, go to **Certificates & secrets → Certificates → Upload + certificate**, and upload **`veritybot.crt`** (the public cert only — never + upload the `.key` or `.pem`). +2. Note the absolute path to **`veritybot.pem`** on the VerityBot host — this is + the `Certificate Path` you enter in the dialogue box (`AZURE_CERT_PATH`). + > Certificates expire (2 years above). Before the expiry date, generate a new + > cert, upload the new `.crt`, and re-run the setup dialogue box (or just + > point `AZURE_CERT_PATH` at the new `.pem`), or the nightly email will start + > failing. Choose a longer `-days` value if you prefer fewer rotations. + +#### 5. Scope the app to the sender mailbox and enable SMTP AUTH +This restricts the app so it can only send as your sender mailbox, and ensures +SMTP client submission is allowed for that mailbox. Run these in +[Exchange Online PowerShell](https://learn.microsoft.com/powershell/exchange/connect-to-exchange-online-powershell) +as an Exchange administrator: + +```powershell +Connect-ExchangeOnline + +# Register the app's service principal in Exchange Online. +# AppId = Application (client) ID from step 2. +# ObjectId = the *Enterprise application* Object ID for this app +# (Entra → Enterprise applications → your app → Object ID). +New-ServicePrincipal -AppId -ObjectId -DisplayName "VerityBot Email Sender" + +# Allow the app to send only as the sender mailbox. +Add-MailboxPermission -Identity "veritybot@yourdomain.com" -User -AccessRights FullAccess + +# Make sure SMTP AUTH is enabled for the sender mailbox. +Set-CASMailbox -Identity "veritybot@yourdomain.com" -SmtpClientAuthenticationDisabled $false +``` + +> If your tenant disables SMTP AUTH org-wide (the default), the per-mailbox +> `Set-CASMailbox` override above re-enables it for just this one mailbox. + +#### 6. Confirm the sender domain is mapped +VerityBot connects to `smtp.office365.com` for Microsoft 365 domains. If your +sender domain is not already listed in `domain_to_server_mapping` in +`src/email_sender.py`, add it there mapping to `office365.com`. + +You now have the three values the dialogue box needs: + +| Dialogue box field | Value from above | `.env` key | +| ------------------ | -------------------------------------- | ----------------- | +| Tenant ID | Directory (tenant) ID (step 2) | `AZURE_TENANT_ID` | +| Client ID | Application (client) ID (step 2) | `AZURE_CLIENT_ID` | +| Certificate Path | Path to `veritybot.pem` (step 4) | `AZURE_CERT_PATH` | + ### Initial Setup 🧑‍💻 Ensure that Docker Desktop is open and running on your host machine \ Docker Running @@ -35,7 +139,8 @@ When prompted, enter your User password \ Enter your credentials into the popup dialogue box **Note**: All fields must be filled, use the ```Generate Key``` button to create a Kibana Encryption Key. -**Note**: All passwords must be at least 6 characters long. \ +**Note**: All passwords must be at least 6 characters long. +**Note**: The ```Tenant ID```, ```Client ID```, and ```Certificate Path``` fields come from the [Microsoft 365 email setup](#microsoft-365-email-setup) steps above. \ Dialogue Box Click ```Save``` and exit the popup box \ diff --git a/requirements.txt b/requirements.txt index f10b6f0..aed2153 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ certifi==2026.1.4 charset-normalizer==3.4.4 +cryptography==44.0.0 idna==3.11 +msal==1.31.1 python-dotenv==1.2.1 requests==2.32.5 urllib3==2.6.3 diff --git a/scripts/initial_setup.sh b/scripts/initial_setup.sh index dd52451..ecc530e 100755 --- a/scripts/initial_setup.sh +++ b/scripts/initial_setup.sh @@ -29,7 +29,10 @@ if [ ! -d ".venv" ]; then python3 -m venv .venv fi source .venv/bin/activate -pip install python-dotenv +# email_sender.py runs from cron via this venv; it needs python-dotenv (config), +# msal (Microsoft 365 OAuth2 token acquisition), and cryptography (loading the +# certificate credential). +pip install python-dotenv msal cryptography echo echo "Getting Windows IP" diff --git a/src/dialogueBox.py b/src/dialogueBox.py index 51df303..2d25c48 100644 --- a/src/dialogueBox.py +++ b/src/dialogueBox.py @@ -1,5 +1,6 @@ import os import tkinter as tk +from tkinter import filedialog import configparser import dotenv import secrets @@ -22,7 +23,9 @@ def Save_Button(): newSendTime = inpSendTime.get() newFreq = freqVar.get() newFreqUnit = timeUnit.get() - newEmailPass = inpEmailPass.get() + newTenantId = inpTenantId.get().strip() + newClientId = inpClientId.get().strip() + newCertPath = inpCertPath.get().strip() newVerkAPI = inpApiKey.get("1.0", "end").strip() newElastPass = inpElastPass.get() newKibPass =inpKibPass.get() @@ -35,7 +38,9 @@ def Save_Button(): co["Email"]["EMAIL_BODY_PREFIX"] = newMessage co["Email"]["EMAIL_SEND_TIME"] = newSendTime co["Verkada"]["ELASTIC_UPDATE_INTERVAL"] = newFreq + newFreqUnit - dotenv.set_key(dotenv_file, "EMAIL_PASSWORD", newEmailPass) + dotenv.set_key(dotenv_file, "AZURE_TENANT_ID", newTenantId) + dotenv.set_key(dotenv_file, "AZURE_CLIENT_ID", newClientId) + dotenv.set_key(dotenv_file, "AZURE_CERT_PATH", newCertPath) dotenv.set_key(dotenv_file, "VERKADA_API_KEY", newVerkAPI) dotenv.set_key(dotenv_file, "ELASTIC_PASSWORD", newElastPass) dotenv.set_key(dotenv_file,"KIBANA_SYSTEM_PASSWORD", newKibPass) @@ -57,6 +62,15 @@ def generate_kibana_key(): inpKibEnc.delete("1.0", "end") inpKibEnc.insert("1.0", key or "") + def browse_cert(): + path = filedialog.askopenfilename( + title="Select Microsoft 365 certificate (private key + cert PEM)", + filetypes=[("PEM files", "*.pem"), ("All files", "*.*")], + ) + if path: + inpCertPath.delete(0, "end") + inpCertPath.insert(0, path) + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) co = configparser.ConfigParser() @@ -93,9 +107,18 @@ def generate_kibana_key(): sendTimeLabel = tk.Label(root, text="Email Send Time (24HR)").grid(row=5, column=0) inpSendTime = tk.Entry(root, width = 7) - #Email Password - emailPassLabel = tk.Label(root, text="Email Password").grid(row=6, column=0) - inpEmailPass = tk.Entry(root, width=50) + #Microsoft 365 app registration (OAuth2) -- three fields grouped in one row. + #See the "Microsoft 365 email setup" section of the README for how to obtain + #these values. + azureLabel = tk.Label(root, text="Microsoft 365 App").grid(row=6, column=0) + azureFrame = tk.Frame(root) + tk.Label(azureFrame, text="Tenant ID").grid(row=0, column=0, sticky='e') + inpTenantId = tk.Entry(azureFrame, width=40) + tk.Label(azureFrame, text="Client ID").grid(row=1, column=0, sticky='e') + inpClientId = tk.Entry(azureFrame, width=40) + tk.Label(azureFrame, text="Certificate Path").grid(row=2, column=0, sticky='e') + inpCertPath = tk.Entry(azureFrame, width=40) + certBrowseButton = tk.Button(azureFrame, text="Browse", command=browse_cert) #ELASTIC_UPDATE_INTERVAL freqLabel1 = tk.Label(root, text="Update Elastic every") @@ -136,7 +159,11 @@ def generate_kibana_key(): inpSubject.grid(row=3, column=1, padx=5, pady=5, sticky='w') inpMessage.grid(row=4, column=1, padx=(5,15), pady=5, sticky='w') inpSendTime.grid(row=5, column=1, padx=5, pady=5, sticky='w') - inpEmailPass.grid(row=6, column=1, padx=5, pady=5, sticky='w') + azureFrame.grid(row=6, column=1, padx=5, pady=5, sticky='w') + inpTenantId.grid(row=0, column=1, padx=5, pady=2, sticky='w') + inpClientId.grid(row=1, column=1, padx=5, pady=2, sticky='w') + inpCertPath.grid(row=2, column=1, padx=5, pady=2, sticky='w') + certBrowseButton.grid(row=2, column=2, padx=5, pady=2, sticky='w') #Verkada Entries freqLabel1.grid(row=7, column=0, padx=5, pady=5) @@ -160,7 +187,9 @@ def generate_kibana_key(): emailSubject = co.get("Email", "EMAIL_SUBJECT") emailMessage = co.get("Email", "EMAIL_BODY_PREFIX") emailSendTime = co.get("Email", "EMAIL_SEND_TIME") - emailPass = os.getenv("EMAIL_PASSWORD") + tenantId = os.getenv("AZURE_TENANT_ID") + clientId = os.getenv("AZURE_CLIENT_ID") + certPath = os.getenv("AZURE_CERT_PATH") verkAPIKey = os.getenv("VERKADA_API_KEY") elasticPass = os.getenv("ELASTIC_PASSWORD") kibanaPass = os.getenv("KIBANA_SYSTEM_PASSWORD") @@ -172,7 +201,9 @@ def generate_kibana_key(): inpSubject.insert(0, emailSubject) inpMessage.insert("1.0", emailMessage) inpSendTime.insert(0, emailSendTime) - inpEmailPass.insert(0, emailPass or "") + inpTenantId.insert(0, tenantId or "") + inpClientId.insert(0, clientId or "") + inpCertPath.insert(0, certPath or "") inpApiKey.insert("1.0", verkAPIKey or "") inpElastPass.insert(0, elasticPass or "") inpKibPass.insert(0, kibanaPass or "") diff --git a/src/email_sender.py b/src/email_sender.py index 1579b16..c0b738a 100644 --- a/src/email_sender.py +++ b/src/email_sender.py @@ -1,3 +1,4 @@ +import base64 import logging import os import smtplib @@ -5,6 +6,10 @@ import subprocess import Utils +import msal + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization from dotenv import load_dotenv from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -33,21 +38,75 @@ def domain_to_server(email: str) -> str | None: return smtp_server return None +# Microsoft 365 has disabled Basic Authentication (username/password) for SMTP +# AUTH. Instead we authenticate with an OAuth2 access token using the SMTP +# XOAUTH2 mechanism. The token is obtained app-only (client credentials) from +# Microsoft Entra ID using an app registration authenticated with a certificate +# -- see the "Microsoft 365 email setup" section of the README for how to create +# the app, upload the certificate, and grant it the SMTP.SendAsApp permission +# scoped to the sender mailbox. + +# Build the MSAL certificate credential from a PEM file that contains both the +# private key and the certificate. MSAL needs the private key plus the cert's +# SHA-1 thumbprint, which we compute here so the operator never has to copy it. +def load_client_credential() -> dict: + with open(cert_path, "rb") as f: + pem_data = f.read() + cert = x509.load_pem_x509_certificate(pem_data) + return { + "private_key": pem_data.decode(), + "thumbprint": cert.fingerprint(hashes.SHA1()).hex(), + "public_certificate": cert.public_bytes(serialization.Encoding.PEM).decode(), + } + +def get_access_token() -> str: + if not (tenant_id and client_id and cert_path): + raise RuntimeError( + "Missing Microsoft 365 credentials. AZURE_TENANT_ID, AZURE_CLIENT_ID " + "and AZURE_CERT_PATH must all be set in the .env file " + "(run the setup dialogue box to configure them)." + ) + if not os.path.isfile(cert_path): + raise RuntimeError(f"Certificate file not found: {cert_path}") + + app = msal.ConfidentialClientApplication( + client_id, + authority=f"https://login.microsoftonline.com/{tenant_id}", + client_credential=load_client_credential(), + ) + # ".default" requests the application permissions already consented to the + # app registration (i.e. SMTP.SendAsApp on Office 365 Exchange Online). + result = app.acquire_token_for_client( + scopes=["https://outlook.office365.com/.default"] + ) + if "access_token" not in result: + raise RuntimeError( + "Unable to acquire Microsoft 365 access token: " + f"{result.get('error_description', result)}" + ) + return result["access_token"] + def send_email(): smtp_server = domain_to_server(sender_email) - if smtp_server: - try: - with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s: - s.set_debuglevel(1) - s.starttls() - s.login(sender_email, str(email_password)) - s.sendmail(sender_email, recipient_email, message.as_string()) - s.quit() - except Exception as e: - logging.error(f"Unable to send email: error code {e.args.index}") - exit(1) - else: + if not smtp_server: logging.error("Unable to send email. Make sure the sender email is correct!") + return + + try: + access_token = get_access_token() + # XOAUTH2 SASL initial-response string; smtplib base64-encodes it for us. + auth_string = f"user={sender_email}\1auth=Bearer {access_token}\1\1" + + with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s: + s.set_debuglevel(1) + s.ehlo() + s.starttls() + s.ehlo() + s.auth("XOAUTH2", lambda challenge=None: auth_string) + s.sendmail(sender_email, recipient_email, message.as_string()) + except Exception as e: + logging.error(f"Unable to send email: {e}") + exit(1) # for sending the link to Kibana through email # IP address is likely to change due to proxies or DHCP lease termination @@ -68,7 +127,9 @@ def get_ip(): script_path = Utils.get_project_root() / "scripts" / "get_ip.sh" subprocess.run([script_path], check=True) -load_dotenv() +# Load .env from the project root explicitly so this works when cron runs the +# script from an arbitrary working directory. +load_dotenv(Utils.get_project_root() / ".env") IP = os.getenv("WINDOWS_IP") or get_ip() # read prefix only from config (no env fallback) @@ -87,7 +148,10 @@ def get_ip(): message.attach(MIMEText(html, "html")) -email_password = os.getenv("EMAIL_PASSWORD") +# Microsoft 365 app-only (certificate credentials) OAuth2 credentials. +tenant_id = os.getenv("AZURE_TENANT_ID") +client_id = os.getenv("AZURE_CLIENT_ID") +cert_path = os.getenv("AZURE_CERT_PATH") send_email() From e06bda4e2e269c9063006237cf42283ddf410d7c Mon Sep 17 00:00:00 2001 From: Jeff Squyres Date: Fri, 10 Jul 2026 12:19:04 -0400 Subject: [PATCH 2/2] README: clarify which M365 setup steps have a web UI vs PowerShell-only --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index be767eb..0843001 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,15 @@ SMTP client submission is allowed for that mailbox. Run these in [Exchange Online PowerShell](https://learn.microsoft.com/powershell/exchange/connect-to-exchange-online-powershell) as an Exchange administrator: +> **Is there a web UI for this instead?** Mostly no. `New-ServicePrincipal` +> (registering the app in Exchange) and granting that service principal +> `FullAccess` to the mailbox are **PowerShell-only** — Microsoft provides no +> admin-center UI for them, and the Exchange delegation picker only lists users +> and groups, not app service principals. The **last** command, enabling +> Authenticated SMTP, *does* have a UI: Microsoft 365 admin center → +> **Users → Active users** → select the sender account → **Mail** → +> **Manage email apps** → check **Authenticated SMTP** → **Save changes**. + ```powershell Connect-ExchangeOnline