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]: