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

Added AAD authentication for Azure SQL #78

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ tests/local_settings.py
# Virtual Env
/venv/
.idea/

# VS Code
.vscode/
68 changes: 64 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://pypi.org/project/django-pyodbc-azure/>`__
Support for AAD access has been adapted from
`django-azure-sql-backend <https://github.com/langholz/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 <https://docs.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server>`__,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<TENANT_ID>/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
~~~~~~~

Expand Down Expand Up @@ -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
-----------

Expand Down
16 changes: 9 additions & 7 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='[email protected]',
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',
)
123 changes: 123 additions & 0 deletions sql_server/pyodbc/aad_auth.py
Original file line number Diff line number Diff line change
@@ -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/<TENANT_ID>`.
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
12 changes: 10 additions & 2 deletions sql_server/pyodbc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down