diff --git a/ENV_LIST.md b/ENV_LIST.md index 852d19f48..651c3b04b 100644 --- a/ENV_LIST.md +++ b/ENV_LIST.md @@ -3,9 +3,9 @@ The list of environment variables that can be set for this system is as follows. ## Basic Settings -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------------|----------|------------------------------------------|-------------------------------------------------|-----------------------------------------------------------| -| APP_ENV | False | Running environment | local / dev / live | local | +| APP_ENV | False | Running environment | local / dev / live | local | | NETWORK | False | Running network | IBET / IBETFIN | IBET | | WEB3_CHAINID | False | Blockchain network ID | 1010032 | IBET: 1500002, IBETFIN: 1010032 | | WEB3_HTTP_PROVIDER | False | Web3 provider | http://localhost:8545 | http://localhost:8545 | @@ -18,7 +18,7 @@ The list of environment variables that can be set for this system is as follows. | APP_LOGFILE | False | Output location for application logs | /some/directory | /dev/stdout (standard output) | | ACCESS_LOGFILE | False | Output location for access logs | /some/directory | /dev/stdout (standard output) | | TZ | False | Time Zone | Europe/Berlin | Asia/Tokyo | -| DEFAULT_CURRENCY | False | Default currency code | EUR | JPY | +| DEFAULT_CURRENCY | False | Default currency code | EUR | JPY | ## API Server Settings @@ -27,9 +27,9 @@ The following parameters can be set as environment variables as startup paramete See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run.html#commonly-used-arguments) for details. -| Variable Name | Required | Details | Default | +| Variable Name | Required | Details | Default | |----------------------------|----------|--------------------------------------------------------------------------|---------| -| WORKER_COUNT | False | The number of worker processes. | 2 | +| WORKER_COUNT | False | The number of worker processes. | 2 | | WORKER_TIMEOUT | False | Workers silent for more than this many seconds are killed and restarted. | 60 | | WORKER_MAX_REQUESTS | False | The maximum number of requests a worker will process before restarting. | 0 | | WORKER_MAX_REQUESTS_JITTER | False | The maximum jitter to add to the max_requests setting. | 0 | @@ -38,7 +38,7 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. ## Settings for each use case ### Token -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |--------------------------------|----------|--------------------------------------------------------------------|--------------------------------------------|---------| | BOND_TOKEN_ENABLED | False | Using ibet Bond token (security token) | 0 (not using) / 1 (using) | 0 | | SHARE_TOKEN_ENABLED | False | Using ibet Share token (security token) | 0 (not using) / 1 (using) | 0 | @@ -52,19 +52,19 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. | TOKEN_SHORT_TERM_CACHE_TTL | False | Token attribute data cache (Short-Term) expiration time (seconds) | 60 | 40 | ### Token Escrow -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|---------------------------------------------|--------------------------------------------|---------| | IBET_ESCROW_CONTRACT_ADDRESS | False | Ibet Escrow contract address | 0x0000000000000000000000000000000000000000 | -- | | IBET_SECURITY_TOKEN_ESCROW_CONTRACT_ADDRESS | False | Ibet Security Token Escrow contract address | 0x0000000000000000000000000000000000000000 | -- | ### Token DVP -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|---------------------------------------------|--------------------------------------------|---------| | IBET_SECURITY_TOKEN_DVP_CONTRACT_ADDRESS | False | Ibet Security Token DVP contract address | 0x0000000000000000000000000000000000000000 | -- | ### On-chain Exchange (Only for utility tokens) -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|--------------------------------------------------------------------------|--------------------------------------------|---------| | PAYMENT_GATEWAY_CONTRACT_ADDRESS | False | PaymentGateway contract address | 0x0000000000000000000000000000000000000000 | -- | | IBET_MEMBERSHIP_EXCHANGE_CONTRACT_ADDRESS | False | IbetExchange contract address for Membership tokens | 0x0000000000000000000000000000000000000000 | -- | @@ -72,22 +72,23 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. | EXCHANGE_NOTIFICATION_ENABLED | True* | Use of exchange-related notification (*Set only if you use IbetExchange) | 0 (not using) / 1 (using) | -- | ### Blockchain Explorer -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------|----------|-----------------------------------------------------|---------------------------|---------| | BC_EXPLORER_ENABLED | False | Parameter for starting the Blockchain Explorer | 0 (not using) / 1 (using) | 0 | ### Email Common -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------|----------|--------------------------------------------|---------------------------------|---------| | SMTP_METHOD | False | Email sending method | 0:SMTP server, 1:Amazon SES | 0 | +| SMTP_AUTH_METHOD | False | Authentication method | 0:PASSWORD, 1:XOAUTH2 | 0 | | SMTP_SENDER_NAME | False | Sender name | | -- | | SMTP_SENDER_EMAIL | False | Sender email address | example@example.com | -- | SMTP server -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------------------|----------|-------------------------------------------|---------------------------------|---------| | SMTP_SERVER_HOST | False | SMTP server name | smtp.office365.com | -- | | SMTP_SERVER_PORT | False | SMTP server port | 587 | -- | @@ -96,13 +97,21 @@ SMTP server Amazon SES -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------|----------|-----------------|-----------|---------| | AWS_SES_REGION_NAME | False | AWS region name | us-east-1 | -- | +SMTP XOAUTH2 (Microsoft) + +| Variable Name | Required | Details | Example | Default | +|-----------------------|----------|-------------------------|---------|---------| +| SMTP_MS_TENANT_ID | True | Microsoft Entra ID (Azure AD) Tenant ID | | -- | +| SMTP_MS_CLIENT_ID | True | Microsoft Entra ID (Azure AD) Client ID | | -- | +| SMTP_MS_CLIENT_SECRET | True* | Microsoft Entra ID (Azure AD) Client Secret (*Required if Client Certificate is unused) | | -- | + Send settings -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |--------------------------------------------|----------|---------------------------------------------------------------------------------|-------------------------------|---------| | ALLOWED_EMAIL_DESTINATION_DOMAIN_LIST | False | Domains allowed to send email. Not set if all domains are allowed. | example.com,example.net | -- | | DISALLOWED_DESTINATION_EMAIL_ADDRESS_REGEX | False | Regular expression for destination email addresses that are not allowed to send | ^[a-zA-Z0-9_.+-]+@example.com | -- | @@ -111,6 +120,31 @@ Send settings ### Chat Webhook -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------|----------|------------------|--------------------------------------------------------------------------------|---------| | CHAT_WEBHOOK_URL | False | Chat webhook url | https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX | -- | + + +## Appendix + +### Email Configuration Matrix + +| Variable / Pattern | SMTP Password | SMTP XOAUTH2 | Amazon SES | Details | +| :--- | :---: | :---: | :---: | :--- | +| **BASE SETTINGS** | | | | | +| `SMTP_METHOD` | `0` | `0` | `1` | 0:SMTP, 1:SES | +| `SMTP_SENDER_EMAIL` | Required | Required | Required | Sender definition | +| `SMTP_SENDER_NAME` | Optional | Optional | Optional | Sender display name | +| **SMTP AUTH** | | | | | +| `SMTP_AUTH_METHOD` | `0` | `1` | - | 0:Password, 1:XOAUTH2 | +| **SMTP SERVER** | | | | | +| `SMTP_SERVER_HOST` | Required | Required | - | e.g. smtp.office365.com | +| `SMTP_SERVER_PORT` | Required | Required | - | e.g. 587 | +| `SMTP_SERVER_ENCRYPTION_METHOD`| Optional | Optional | - | 0:STARTTLS (Default), 1:SSL, 2:None | +| `SMTP_SENDER_PASSWORD` | Required | - | - | For SMTP Auth | +| **MICROSOFT OAUTH** | | | | | +| `SMTP_MS_TENANT_ID` | - | Required | - | | +| `SMTP_MS_CLIENT_ID` | - | Required | - | | +| `SMTP_MS_CLIENT_SECRET` | - | Required | - | | +| **AMAZON SES** | | | | | +| `AWS_SES_REGION_NAME` | - | - | Required | | diff --git a/app/config.py b/app/config.py index b88530f91..098167517 100644 --- a/app/config.py +++ b/app/config.py @@ -20,6 +20,7 @@ import configparser import os from email.policy import HTTP, SMTP, SMTPUTF8 +from typing import Literal from dotenv import load_dotenv @@ -358,6 +359,19 @@ # Amazon SES AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME") +# SMTP Auth Method (0:PASSWORD, 1:XOAUTH2) +SMTP_AUTH_METHOD: int = int(os.environ.get("SMTP_AUTH_METHOD", 0)) +# Token Provider (microsoft fixed) +SMTP_AUTH_PROVIDER: Literal["microsoft"] = "microsoft" + +# Microsoft OAuth settings +# - Tenant ID: Microsoft Entra ID (Azure AD) Tenant ID +SMTP_MS_TENANT_ID: str | None = os.environ.get("SMTP_MS_TENANT_ID") +# - Client ID: Microsoft Entra ID (Azure AD) Client ID +SMTP_MS_CLIENT_ID: str | None = os.environ.get("SMTP_MS_CLIENT_ID") +# - Client Secret: Microsoft Entra ID (Azure AD) Client Secret +SMTP_MS_CLIENT_SECRET: str | None = os.environ.get("SMTP_MS_CLIENT_SECRET") + # Send settings ALLOWED_EMAIL_DESTINATION_DOMAIN_LIST = ( os.environ.get("AUTHORIZED_EMAIL_DESTINATION_DOMAIN_LIST").split(",") diff --git a/app/model/db/messaging.py b/app/model/db/messaging.py index e69c08225..47a4d09c3 100644 --- a/app/model/db/messaging.py +++ b/app/model/db/messaging.py @@ -44,7 +44,7 @@ class Mail(Base): # file name file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # file content - file_content: Mapped[str | None] = mapped_column( + file_content: Mapped[bytes | None] = mapped_column( LargeBinary().with_variant(LONGBLOB, "mysql"), nullable=True ) diff --git a/app/model/mail/mail.py b/app/model/mail/mail.py index 698586a59..438435bc6 100644 --- a/app/model/mail/mail.py +++ b/app/model/mail/mail.py @@ -17,6 +17,7 @@ SPDX-License-Identifier: Apache-2.0 """ +import base64 import mimetypes import smtplib import ssl @@ -30,6 +31,8 @@ from app.config import ( AWS_SES_REGION_NAME, + SMTP_AUTH_METHOD, + SMTP_AUTH_PROVIDER, SMTP_METHOD, SMTP_POLICY, SMTP_SENDER_EMAIL, @@ -39,6 +42,7 @@ SMTP_SERVER_HOST, SMTP_SERVER_PORT, ) +from app.model.mail.token_provider import MicrosoftTokenProvider class File: @@ -59,13 +63,20 @@ def __init__( html_content: str, file: File | None, ): + if SMTP_SENDER_EMAIL is None: + raise RuntimeError("SMTP sender email is not set.") self.sender_email = SMTP_SENDER_EMAIL self.to_email = to_email if SMTP_METHOD == 0: # SMTP server + if SMTP_SERVER_HOST is None or SMTP_SERVER_PORT is None: + raise RuntimeError("SMTP server host or port is not set.") self.server_host = SMTP_SERVER_HOST self.server_port = SMTP_SERVER_PORT - self.sender_password = SMTP_SENDER_PASSWORD + if SMTP_AUTH_METHOD == 0: # PASSWORD + self.sender_password = SMTP_SENDER_PASSWORD + elif SMTP_AUTH_METHOD == 1: # XOAUTH2 + pass elif SMTP_METHOD == 1: # Amazon SES self.aws_region_name = AWS_SES_REGION_NAME self.msg = MIMEMultipart("alternative", policy=SMTP_POLICY) @@ -94,22 +105,43 @@ def send_mail(self): if SMTP_METHOD == 0: # SMTP server # Initialize a new smtp client if SMTP_SERVER_ENCRYPTION_METHOD == 0: # STARTTLS - smtp_client = smtplib.SMTP(host=self.server_host, port=self.server_port) + smtp_client = smtplib.SMTP( + host=self.server_host, port=int(self.server_port) + ) smtp_client.ehlo() smtp_client.starttls() smtp_client.ehlo() elif SMTP_SERVER_ENCRYPTION_METHOD == 1: # SSL smtp_client = smtplib.SMTP_SSL( host=self.server_host, - port=self.server_port, + port=int(self.server_port), context=ssl.create_default_context(), ) else: # NO-ENCRYPT - smtp_client = smtplib.SMTP(host=self.server_host, port=self.server_port) - + smtp_client = smtplib.SMTP( + host=self.server_host, port=int(self.server_port) + ) # LOGIN - if self.sender_password is not None: - smtp_client.login(self.sender_email, self.sender_password) + if SMTP_AUTH_METHOD == 0: # PASSWORD + if self.sender_password is not None: + smtp_client.login(self.sender_email, self.sender_password) + elif SMTP_AUTH_METHOD == 1: # XOAUTH2 + # Get Access Token + match SMTP_AUTH_PROVIDER: + case "microsoft": + token_provider = MicrosoftTokenProvider() + case _: + raise ValueError( + f"Unknown SMTP_AUTH_PROVIDER: {SMTP_AUTH_PROVIDER}" + ) + access_token = token_provider.get_access_token() + + # Auth + auth_str = ( + f"user={self.sender_email}\x01auth=Bearer {access_token}\x01\x01" + ) + auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") + smtp_client.docmd("AUTH", "XOAUTH2 " + auth_b64) # Send mail try: diff --git a/app/model/mail/token_provider.py b/app/model/mail/token_provider.py new file mode 100644 index 000000000..35ba916b5 --- /dev/null +++ b/app/model/mail/token_provider.py @@ -0,0 +1,115 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +import abc +import base64 +import json +import time +import uuid + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from pydantic import BaseModel, ValidationError +from requests.adapters import HTTPAdapter +from urllib3 import Retry + +from app.config import ( + SMTP_MS_CLIENT_ID, + SMTP_MS_CLIENT_SECRET, + SMTP_MS_TENANT_ID, +) + + +class TokenProvider(metaclass=abc.ABCMeta): + @abc.abstractmethod + def get_access_token(self) -> str: + """ + Get access token for authentication + """ + pass + + +class MicrosoftTokenResponse(BaseModel): + """ + Schema for Microsoft Identity Platform token response + """ + + access_token: str + expires_in: int + token_type: str + + +class MicrosoftTokenProvider(TokenProvider): + """ + Provider that fetches access token from Microsoft Identity Platform + using a Refresh Token. + """ + + _access_token: str | None = None + _token_expiry: float = 0.0 + + def get_access_token(self) -> str: + # Return cached token if valid (with 60 seconds safety buffer) + if self._access_token and time.time() < self._token_expiry - 60: + return self._access_token + + tenant_id = SMTP_MS_TENANT_ID + client_id = SMTP_MS_CLIENT_ID + client_secret = SMTP_MS_CLIENT_SECRET + + if tenant_id is None or client_id is None or (client_secret is None): + raise ValueError("Missing Microsoft OAuth configuration") + + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + data = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "https://outlook.office365.com/.default", + } + + try: + with requests.Session() as session: + retries = Retry( + total=3, + backoff_factor=1, + status_forcelist=[500, 502, 503, 504], + allowed_methods=["POST"], + ) + adapter = HTTPAdapter(max_retries=retries) + session.mount("https://", adapter) + + response = session.post(token_url, data=data, timeout=10) + response.raise_for_status() + + # Validate response schema + token_data = MicrosoftTokenResponse.model_validate(response.json()) + + # Update cache + self.__class__._access_token = token_data.access_token + # Set expiry time relative to now + self.__class__._token_expiry = time.time() + token_data.expires_in + + return token_data.access_token + + except (requests.exceptions.RequestException, ValidationError) as e: + # You might want to log this error in a real app + raise RuntimeError(f"Failed to refresh access token: {str(e)}") from e diff --git a/pyproject.toml b/pyproject.toml index 13ec616a9..dc5ce561f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc>=1.33.1,<2.0.0", "requests>=2.32.3", "python-dotenv~=1.1.0", + "cryptography>=45.0.3", ] [tool.uv] diff --git a/tests/app/model/mail/test_mail_unit.py b/tests/app/model/mail/test_mail_unit.py new file mode 100644 index 000000000..7fade04ac --- /dev/null +++ b/tests/app/model/mail/test_mail_unit.py @@ -0,0 +1,131 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +from unittest.mock import MagicMock, patch + +from app.model.mail import Mail + + +@patch( + "app.model.mail.mail.SMTP_SENDER_EMAIL", + "test@example.com", +) +@patch( + "app.model.mail.mail.SMTP_SERVER_HOST", + "example.com", +) +@patch( + "app.model.mail.mail.SMTP_SERVER_PORT", + 587, +) +class TestMailUnit: + """ + Unit tests for app.model.mail.Mail + Mocks smtplib to verify command interactions + """ + + def test_send_mail_password_auth(self): + """ + Verify that PASSWORD auth (default) calls login() + """ + # Arrange + with ( + patch("app.model.mail.mail.SMTP_METHOD", 0), + patch("app.model.mail.mail.SMTP_AUTH_METHOD", 0), + patch("app.model.mail.mail.SMTP_SENDER_NAME", "Sender Name"), + patch("app.model.mail.mail.SMTP_SENDER_EMAIL", "sender@example.com"), + patch("smtplib.SMTP") as mock_smtp_cls, + ): + mock_smtp = MagicMock() + mock_smtp_cls.return_value = mock_smtp + + mail = Mail( + to_email="test@example.com", + subject="Test Subject", + text_content="Body", + html_content="
Body
", + file=None, + ) + mail.sender_password = "password" + + # Act + mail.send_mail() + + # Assert + mock_smtp.login.assert_called_once() + mock_smtp.docmd.assert_not_called() + mock_smtp.sendmail.assert_called_once() + mock_smtp.quit.assert_called_once() + + def test_send_mail_xoauth2_auth(self): + """ + Verify that XOAUTH2 auth calls docmd("AUTH", ...) + """ + # Arrange + with ( + patch("app.model.mail.mail.SMTP_METHOD", 0), + patch("app.model.mail.mail.SMTP_AUTH_METHOD", 1), + patch("app.model.mail.mail.SMTP_SENDER_NAME", "Sender Name"), + patch("app.model.mail.mail.SMTP_SENDER_EMAIL", "sender@example.com"), + patch("app.config.SMTP_AUTH_PROVIDER", "microsoft"), + patch("app.model.mail.mail.MicrosoftTokenProvider") as MockTokenProvider, + patch("smtplib.SMTP") as mock_smtp_cls, + ): + mock_smtp = MagicMock() + mock_smtp_cls.return_value = mock_smtp + + mock_token_provider = MagicMock() + MockTokenProvider.return_value = mock_token_provider + mock_token_provider.get_access_token.return_value = "test_token" + + mail = Mail( + to_email="test@example.com", + subject="Test Subject", + text_content="Body", + html_content="Body
", + file=None, + ) + + # Act + mail.send_mail() + + # Assert + mock_smtp.login.assert_not_called() + + # Verify AUTH XOAUTH2 command + # Expected auth string: user=