Skip to content

Commit

Permalink
Merge pull request #12 from stormsherpa/aws_auth
Browse files Browse the repository at this point in the history
Add aws_identity grant_type for getting access tokens
  • Loading branch information
skruger authored Aug 12, 2024
2 parents ae98848 + a654fb6 commit 2b2a8ec
Show file tree
Hide file tree
Showing 26 changed files with 623 additions and 29 deletions.
35 changes: 35 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2

# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"
# You can also specify other tool versions:
# nodejs: "20"
# rust: "1.70"
# golang: "1.20"

# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
# builder: "dirhtml"
# Fail on all warnings to avoid broken references
# fail_on_warning: true

# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub

# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
# python:
# install:
# - requirements: docs/requirements.txt
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ARG PYVERSION=3.9.19-bullseye

FROM python:${PYVERSION} AS dev

WORKDIR /app

COPY requirements.txt /app/

RUN apt-get update \
&& apt-get install -q -y \
jq \
&& apt-get clean

RUN pip install -r requirements.txt

FROM dev as prod

COPY ./ /app/


10 changes: 5 additions & 5 deletions Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ pipeline {
}
stage('Test'){
parallel {
stage('Unit Test Django 3.0'){
steps {
sh 'toxtest/bin/tox -e py3.8-django{3.0}'
}
}
stage('Unit Test Django 3.1'){
steps {
sh 'toxtest/bin/tox -e py3.8-django{3.1}'
Expand All @@ -36,6 +31,11 @@ pipeline {
sh 'toxtest/bin/tox -e py3.8-django{4.1}'
}
}
stage('Unit Test Django 4.2'){
steps {
sh 'toxtest/bin/tox -e py3.8-django{4.2}'
}
}
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ License
=======

*django-oauth2* is a fork of *django-oauth2-provider* which is released under the MIT License. Please see the LICENSE file for details.


Packaging
=========

$ python -m build

68 changes: 68 additions & 0 deletions aws_identity_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import os
import sys
import json

from datetime import datetime
from urllib import request, error
import requests

import boto3
# aws-v4-signature==2.0
from awsv4sign import generate_http11_header

service = 'sts'
region = 'us-west-2'

session = boto3.Session()
creds = session.get_credentials()
access_key = creds.access_key
secret_key = creds.secret_key
session_token = creds.token

print(f"access_key: {access_key[:10]}<redacted...>")
print(f"secret_key: {secret_key[:10]}<redacted...>")
print(f"session_token: {session_token[:20]}<redacted...>")
print(f"profile: {os.environ.get('AWS_PROFILE')}")

url = 'https://sts.{region}.amazonaws.com/'.format(region=region)
httpMethod = 'post'
canonicalHeaders = {
'host': f'sts.{region}.amazonaws.com',
'x-amz-date': datetime.utcnow().strftime('%Y%m%dT%H%M%SZ'),
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
if session_token:
canonicalHeaders['x-amz-security-token'] = session_token

payload_str = "Action=GetCallerIdentity&Version=2011-06-15"

headers = generate_http11_header(
service, region, access_key, secret_key,
url, 'post', canonicalHeaders, {},
'', payload_str
)

token_request_args = {
"grant_type": "aws_identity",
"region": region,
"post_body": payload_str,
"headers_json": json.dumps(headers),
}
print(payload_str)
print(json.dumps(headers, indent=4))

req = request.Request("https://sts.us-west-2.amazonaws.com/", data=payload_str.encode('utf-8'), headers=headers, method='POST')
try:
response = request.urlopen(req)
print(f"Local request test result: {response.read()}")
except error.HTTPError as e:
print(f"HTTPError: {e}: {e.fp.read()}")
sys.exit(1)

print("Attempting access_token grant request with same signed request:\n")

token_response = requests.post("http://localhost:8000/oauth2/access_token",
data=token_request_args)
token_info = token_response.json()

print(token_info)
24 changes: 24 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

services:
test:
build:
context: .
target: dev
user: ${UID}
volumes:
- ${WORKSPACE:-.}:/app
environment:
- DJANGO_SETTINGS_MODULE=tests.settings

web:
build:
context: .
target: dev
user: ${UID}
volumes:
- ${WORKSPACE:-.}:/app
ports:
- "8000:8000"
environment:
- DJANGO_SETTINGS_MODULE=tests.settings
# entrypoint: [ "python3", "manage.py", "runserver" ]
9 changes: 9 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
v 4.1
-----
* Add aws_identity grant_type
* Update for Django 3.1-4.2

v 4.0
-----
* Update for Django 3.0-4.1

v 2.4
-----
* Add HTTP Authorization Bearer token support to Oauth2UserMiddleware
Expand Down
23 changes: 22 additions & 1 deletion docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Add :attr:`provider.oauth2.urls` to your root ``urls.py`` file.

::

url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')),
path('oauth2/', include(('provider.oauth2.urls', 'oauth2'))),
.. note:: The namespace argument is required.
Expand Down Expand Up @@ -92,6 +92,27 @@ in :rfc:`4`.
.. note:: Remember that you should always use HTTPS for all your OAuth
2 requests otherwise you won't be secured.

Request an Access Token using AWS credentials
---------------------------------------------

The new aws_identity grant_type uses the parameters for a signed GetCallerIdentity
request to prove the caller's identity.

Your client needs to submit a :attr:`POST` request to
:attr:`/oauth2/access_token` including the following parameters:

* ``region`` - AWS Region
* ``post_body`` - The post body used for signing the request. Usually ``Action=GetCallerIdentity&Version=2011-06-15``
* ``headers_json`` - The headers produced by the AWSv4 signing process

The region value is used to produce the standard https://sts.(region).amazonaws.com/ url used to
make the GetCallerIdentity request. The URL is generated server side to reduce the risk of an
attack based on sending an improperly crafted full URL.

The aws-v4-signature library implements awsv4sign.generate_http11_header(). An example is
presented in the root of the repository in aws_identity_examply.py.


Integrate with Django Authentication
####################################

Expand Down
2 changes: 1 addition & 1 deletion provider/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "4.0"
__version__ = "4.2"
# The major version is expected to follow the current django major version:q
6 changes: 6 additions & 0 deletions provider/oauth2/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ class AuthorizedClientAdmin(admin.ModelAdmin):
raw_id_fields = ('user',)


class AwsAccountAdmin(admin.ModelAdmin):
list_display = ('arn', 'client', 'max_token_lifetime')
raw_id_fields = ('acting_user',)


admin.site.register(models.AccessToken, AccessTokenAdmin)
admin.site.register(models.Grant, GrantAdmin)
admin.site.register(models.Client, ClientAdmin)
admin.site.register(models.AuthorizedClient, AuthorizedClientAdmin)
admin.site.register(models.AwsAccount, AwsAccountAdmin)
admin.site.register(models.RefreshToken)
admin.site.register(models.Scope)
3 changes: 3 additions & 0 deletions provider/oauth2/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ class Oauth2(AppConfig):
name = 'provider.oauth2'
label = 'oauth2'
verbose_name = "Provider Oauth2"

def ready(self):
import provider.oauth2.signals
32 changes: 32 additions & 0 deletions provider/oauth2/fixtures/test_oauth2.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@
"model": "auth.user",
"pk": 2
},
{
"fields": {
"date_joined": "2012-01-23 05:53:31",
"email": "",
"first_name": "",
"groups": [],
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": "2012-01-23 05:53:31",
"last_name": "",
"password": "sha1$0cf1b$d66589690edd96b410170fcae5cc2bdfb68821e7",
"user_permissions": [],
"username": "test-user-aws"
},
"model": "auth.user",
"pk": 3
},
{
"fields": {
"name": "basic",
Expand All @@ -88,5 +106,19 @@
},
"model": "oauth2.scope",
"pk": 2
},
{
"fields": {
"arn": "arn:aws:iam::123456789012:role/testrole",
"account_id": "123456789012",
"name": "testrole",
"general_type": "role",
"client": 2,
"autoprovision_user": false,
"acting_user": 3,
"scope": ["basic", "advanced"]
},
"model": "oauth2.awsaccount",
"pk": 1
}
]
52 changes: 49 additions & 3 deletions provider/oauth2/forms.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from six import string_types
import logging
from io import StringIO
from urllib import request
from urllib.error import HTTPError
from xml.etree import ElementTree

from django import forms
from django.contrib.auth import authenticate
from django.conf import settings
from django.utils.translation import gettext as _
from django.utils import timezone
from provider.constants import RESPONSE_TYPE_CHOICES, SCOPES, PUBLIC
from provider.forms import OAuthForm, OAuthValidationError
from provider.utils import now
from provider.utils import now, ArnHelper
from provider.oauth2.models import Client, Grant, RefreshToken, Scope

log = logging.getLogger('provider.oauth2')

DEFAULT_SCOPE = getattr(settings, 'OAUTH2_DEFAULT_SCOPE', 'read')

Expand Down Expand Up @@ -53,7 +59,7 @@ class ScopeModelChoiceField(forms.ModelMultipleChoiceField):
# widget = forms.TextInput

def to_python(self, value):
if isinstance(value, string_types):
if isinstance(value, str):
return [s for s in value.split(' ') if s != '']
elif isinstance(value, list):
value_list = list()
Expand Down Expand Up @@ -311,6 +317,46 @@ def clean(self):
return data


class AwsGrantForm(OAuthForm):
grant_type = forms.CharField(required=True)
region = forms.CharField(required=True)
post_body = forms.CharField(required=True)
headers_json = forms.JSONField(required=True)

def clean_grant_type(self):
grant_type = self.cleaned_data.get('grant_type')

if grant_type != 'aws_identity':
raise OAuthValidationError({'error': 'invalid_grant'})

return grant_type

def clean(self):
region = self.cleaned_data['region']

sts_url = f"https://sts.{region}.amazonaws.com/"

post_body = self.cleaned_data['post_body']
headers_json = self.cleaned_data['headers_json']

req = request.Request(sts_url, data=post_body.encode('utf-8'), headers=headers_json, method='POST')
try:
response = request.urlopen(req)
except HTTPError as e:
log.info("Error calling GetCallerIdentity for aws_identity grant: %s", e)
raise OAuthValidationError({'error': 'invalid_grant'})

xmldata = response.read()

et = ElementTree.parse(StringIO(xmldata.decode('utf-8')))
root = et.getroot()
result = root.find('{https://sts.amazonaws.com/doc/2011-06-15/}GetCallerIdentityResult')
caller_arn = result.find('{https://sts.amazonaws.com/doc/2011-06-15/}Arn').text
self.cleaned_data['arn_string'] = caller_arn
self.cleaned_data['arn'] = ArnHelper(caller_arn)
return self.cleaned_data


class PublicClientForm(OAuthForm):
client_id = forms.CharField(required=True)
grant_type = forms.CharField(required=True)
Expand Down
Loading

0 comments on commit 2b2a8ec

Please sign in to comment.