diff --git a/.gitignore b/.gitignore index 0c91415f..a529ba35 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ tests/local_settings.py # Virtual Env /venv/ .idea/ + +# VS Code +.vscode/ \ No newline at end of file diff --git a/README.rst b/README.rst index 625b1d15..37ebb2e6 100644 --- a/README.rst +++ b/README.rst @@ -1,17 +1,21 @@ django-mssql-backend ==================== -.. image:: https://img.shields.io/pypi/v/django-mssql-backend.svg - :target: https://pypi.python.org/pypi/django-mssql-backend +.. image:: https://img.shields.io/pypi/v/django-mssql-backend-azure.svg + :target: https://pypi.org/project/django-mssql-backend-azure/ *django-mssql-backend* is a fork of `django-pyodbc-azure `__ +Support for AAD access has been adapted from +`django-azure-sql-backend `__ +, which only support Django 2.1. Features -------- - Supports Django 2.2, 3.0 -- Supports Microsoft SQL Server 2008/2008R2, 2012, 2014, 2016, 2017, 2019 +- Supports Microsoft SQL Server 2008/2008R2, 2012, 2014, 2016, 2017, 2019 and Azure SQL Database +- AAD authentication through registered application access token - Passes most of the tests of the Django test suite - Compatible with `Micosoft ODBC Driver for SQL Server `__, @@ -74,7 +78,8 @@ in DATABASES control the behavior of the backend: - USER - String. Database user name in ``"user"`` format. + String. Database user name in ``"user"`` (on-premise) or + ``"user@server"`` (Azure SQL Database) format. If not given then MS Integrated Security will be used. - PASSWORD @@ -112,6 +117,37 @@ for any given database-level settings dictionary: mirror during testing. Default value is ``None``. See the official Django documentation for more details. +AAD-AUTH +~~~~~~~~ + +When provided, ``USER`` and ``PASSWORD`` are not used and AAD authentication using +application access token is used instead. + +References: + +- https://github.com/AzureAD/microsoft-authentication-library-for-python +- https://github.com/AzureAD/azure-activedirectory-library-for-python/wiki/Connect-to-Azure-SQL-Database +- https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp +- https://docs.microsoft.com/en-us/sql/connect/python/pyodbc/step-3-proof-of-concept-connecting-to-sql-using-pyodbc?view=sql-server-ver15 + + +Dictionary. Current available keys are: + +- tenant_id + + String. Refers to the registered application tenant identifier to use. + It is also known as the directory identifier and can sometimes be provided + within the STS url like so: ``https://login.microsoftonline.com//oauth2/v2.0/token`` + +- client_id + + String. Refers to the registered application client identifier to use. + It is also known as the application identifier. + +- secret + + String. Refers to the secret that will be use to authenticate with AAD. + OPTIONS ~~~~~~~ @@ -228,6 +264,30 @@ Here is an example of the database settings: # set this to False if you want to turn off pyodbc's connection pooling DATABASE_CONNECTION_POOLING = False +Here is an example of the database settings using AAD access token authentication: + +:: + + DATABASES = { + 'default': { + 'ENGINE': 'sql_server.pyodbc', + 'NAME': 'mydb', + 'HOST': 'myserver.database.windows.net', + 'PORT': '', + 'AAD-AUTH': { + 'tenant_id': '02a2e49f-b581-45c4-84a9-bdee0198b26f', + 'client_id': '818979f8-a731-48d9-bf42-b00a04e1e618', + 'secret': "MY_SUPER_SECRET", + }, + 'OPTIONS': { + 'driver': 'ODBC Driver 13 for SQL Server', + }, + }, + } + + # set this to False if you want to turn off pyodbc's connection pooling + DATABASE_CONNECTION_POOLING = False + Limitations ----------- diff --git a/setup.py b/setup.py index 57f920c6..15eaa699 100644 --- a/setup.py +++ b/setup.py @@ -15,19 +15,21 @@ ] setup( - name='django-mssql-backend', - version='2.8.1', - description='Django backend for Microsoft SQL Server', + name='django-mssql-backend-azure', + version='2.9.0', + description='Django backend for Microsoft SQL Server and Azure', long_description=open('README.rst').read(), - author='ES Solutions AB', - author_email='info@essolutions.se', - url='https://github.com/ESSolutions/django-mssql-backend', + author='Monkeyclass', + author_email='', + url='https://github.com/monkeyclass/django-mssql-backend-azure', + download_url='https://github.com/monkeyclass/django-mssql-backend/archive/v_2.9.0.tar.gz' license='BSD', packages=find_packages(), install_requires=[ 'pyodbc>=3.0', + 'msal>=1.2.0' ], package_data={'sql_server.pyodbc': ['regex_clr.dll']}, classifiers=CLASSIFIERS, - keywords='django', + keywords='AZURE django', ) diff --git a/sql_server/pyodbc/aad_auth.py b/sql_server/pyodbc/aad_auth.py new file mode 100644 index 00000000..2f71106b --- /dev/null +++ b/sql_server/pyodbc/aad_auth.py @@ -0,0 +1,123 @@ +import struct +import msal + + +class AADAuthAccessTokenRetrievalError(Exception): + pass + + +class AADAuth(object): + """ + Defines the Azure Active Directory authentication class. + """ + + @classmethod + def _get_access_token(cls, authority, client_id, scopes, secret): + """ + Retrieves the application's access token through AAD. + + ParametersĀ½ + ---------- + authority : str + The AAD authority url which is of the form + `https://login.microsoftonline.com/`. + client_id : str + The client identifier used to authenticate. + scopest : list + The list of scopes to use to authenticate. For database + connections we should be using`["https://database.windows.net//.default"]`. + secret : str + The secret to authenticate with. + + Returns + ------- + str + The retrieved access token if present; otherwise, `None`. + """ + app = msal.ConfidentialClientApplication( + client_id, authority=authority, client_credential=secret + ) + result = app.acquire_token_silent(scopes, account=None) + if not result: + result = app.acquire_token_for_client(scopes=scopes) + access_token = result.get("access_token", None) + return access_token + + @classmethod + def _struct_from_access_token(cls, access_token): + """ + Create a structure from the access token. + + Parameters + ---------- + access_token : str + The access token to convert. + + Returns + ------- + str + The expanded token structure required. + """ + token_bytes = bytes(access_token, "UTF-8") + expanded_token = b"" + for i in token_bytes: + expanded_token += bytes({i}) + expanded_token += bytes(1) + token_struct = struct.pack("=i", len(expanded_token)) + expanded_token + return token_struct + + @classmethod + def _construct_attrs_before_using_access_token(cls, access_token): + """ + Constructs the `attrs_before` `pyodbc` parameter from an access token. + + Parameters + ---------- + access_token : str + The access token to construct the parameter with. + + Returns + ------- + dict + The `attrs_before` dictionary of values that will be used to authenticate + using the access token. + """ + SQL_COPT_SS_ACCESS_TOKEN = 1256 + token_struct = cls._struct_from_access_token(access_token) + attrs_before = {SQL_COPT_SS_ACCESS_TOKEN: token_struct} + return attrs_before + + @classmethod + def create_attrs_before_with_access_token(cls, config): + """ + Creates the `attrs_before` `pyodbc` parameter by first retrieving an AAD + access token for the application and then using it to build the parameter. + + Parameters + ---------- + config : dict + The dictionary of values needed to retrieve an application access token. + Should contain `tenant_id`, `client_id` and `secret`. + + Returns + ------- + dict + The `attrs_before` dictionary of values that will be used to authenticate + using the access token. + + Raises + ------ + AADAuthAccessTokenRetrievalError + When unable to retrieve a valid access token, the exception is raised. + """ + tenant_id = config["tenant_id"] + client_id = config["client_id"] + secret = config["secret"] + authority = "https://login.microsoftonline.com/" + tenant_id + scopes = ["https://database.windows.net//.default"] + access_token = cls._get_access_token(authority, client_id, scopes, secret) + if not access_token: + raise AADAuthAccessTokenRetrievalError("Unable to retrieve access token!") + + attrs_before = cls._construct_attrs_before_using_access_token(access_token) + return attrs_before diff --git a/sql_server/pyodbc/base.py b/sql_server/pyodbc/base.py index 297a90b8..2a5a64ae 100644 --- a/sql_server/pyodbc/base.py +++ b/sql_server/pyodbc/base.py @@ -34,6 +34,7 @@ from .introspection import DatabaseIntrospection # noqa from .operations import DatabaseOperations # noqa from .schema import DatabaseSchemaEditor # noqa +from .aad_auth import AADAuth EDITION_AZURE_SQL_DB = 5 @@ -241,6 +242,7 @@ def get_new_connection(self, conn_params): user = conn_params.get('USER', None) password = conn_params.get('PASSWORD', None) port = conn_params.get('PORT', None) + aad_auth = conn_params.get('AAD-AUTH', None) options = conn_params.get('OPTIONS', {}) driver = options.get('driver', 'ODBC Driver 13 for SQL Server') @@ -276,7 +278,12 @@ def get_new_connection(self, conn_params): else: cstr_parts['SERVERNAME'] = host - if user: + attrs_before = {} + if aad_auth: + # For AAD auth using access token we need to set the `attrs_before` + # parameter with the retrieved access token struct. + attrs_before = AADAuth.create_attrs_before_with_access_token(aad_auth) + elif user: cstr_parts['UID'] = user cstr_parts['PWD'] = password else: @@ -311,7 +318,8 @@ def get_new_connection(self, conn_params): try: conn = Database.connect(connstr, unicode_results=unicode_results, - timeout=timeout) + timeout=timeout, + attrs_before=attrs_before) except Exception as e: for error_number in self._transient_error_numbers: if error_number in e.args[1]: