Skip to content

Commit

Permalink
Merge pull request #652 from ODM2/develop
Browse files Browse the repository at this point in the history
Release 0.15.0
  • Loading branch information
ptomasula authored Apr 12, 2023
2 parents e3dd03c + e7c1ebc commit 40b0df1
Show file tree
Hide file tree
Showing 56 changed files with 1,524 additions and 1,413 deletions.
7 changes: 5 additions & 2 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ channels:
- defaults

dependencies:
- python =3.9.*
- python >=3.10.*

- boto3
- coverage >=5.5
- google-api-python-client >=2.12.0
- hs_restclient >=1.3.7 # https://github.com/hydroshare/hs_restclient
Expand Down Expand Up @@ -40,4 +41,6 @@ dependencies:
#used for unicode_compatiblity
#should confirm if this is still a dependency
- django-utils-six
- django-formtools
#there was an error with recursive depth reintroduce in 2.4
- django-formtools==2.3

24 changes: 21 additions & 3 deletions src/WebSDL/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
INSTALLED_APPS = [
# 'debug_toolbar',
'rest_framework',
'accounts.apps.AccountsConfig',
#'accounts.apps.AccountsConfig',
'dataloader.apps.DataloaderConfig',
'dataloaderservices.apps.DataloaderservicesConfig',
'dataloaderinterface.apps.DataloaderinterfaceConfig',
Expand All @@ -63,7 +63,7 @@
'reset_migrations',
'timeseries_visualization',
'formtools',

'accounts.apps.AccountsConfig',
]

MIDDLEWARE = [
Expand All @@ -77,6 +77,7 @@
'hydroshare_util.middleware.AuthMiddleware',
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django_cprofile_middleware.middleware.ProfilerMiddleware',
'accounts.user_middleware.UserMiddleware',
]

DJANGO_CPROFILE_MIDDLEWARE_REQUIRE_STAFF = False
Expand Down Expand Up @@ -128,6 +129,7 @@
'TEST': database['test'] if 'test' in database else {},
}
DATAMODELCACHE = os.path.join(BASE_DIR, 'odm2', 'modelcache.pkl')
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

# Password validation
# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
Expand Down Expand Up @@ -196,7 +198,23 @@

GOOGLE_API_CONF = data.get('google_api_conf', None)

AUTH_USER_MODEL = 'accounts.User'
#AUTH_USER_MODEL = 'cognito.User'

#AWS Congnito

COGNITO_SIGNUP_URL = data['cognito_signup_url']
COGNITO_SIGNIN_URL = data['cognito_signin_url']
COGNITO_REGION = data['cognito_region']
COGNITO_ACCESS_KEY = data['cognito_access_key']
COGNITO_SECRET_ACCESS_KEY = data['cognito_secret_access_key']
COGNITO_USER_POOL_ID = data['cognito_user_pool_id']
COGNITO_CLIENT_ID = data['cognito_client_id']
COGNITO_CLIENT_SECRET = data['cognito_client_secret']
COGNITO_OAUTH_URL = data['cognito_oauth_url']
COGNITO_REDIRECT_URL = data['cognito_redirect_url']
SESSION_KEY = '_auth_user_id'
BACKEND_SESSION_KEY = '_auth_user_backend'
HASH_SESSION_KEY = '_auth_user_hash'

#Static cache busting
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
Expand Down
11 changes: 11 additions & 0 deletions src/WebSDL/settings/settings_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
"tsa_url": "{{time series analyst address}}",
"sensor_data_period": "{{days it takes for the data to be considered stale}}",

"cognito_signin_url": "url to aws cognito user pool's sign-in page",
"cognito_signup_url": "url to aws cognito user pool's sign-up page",
"cognito_region": "aws region cognito user pool is located in",
"cognito_access_key": "IAM user access key for cognito",
"cognito_secret_access_key": "IAM user secret access key for cognito",
"cognito_user_pool_id": "user pool ID for cognito user pool",
"cognito_client_id": "client ID for cognito user pool",
"cognito_client_secret": "client secret for cognito user pool",
"cognito_oauth_url": "",
"cognito_redirect_url": "The url to redirect users to on successful sign-in",

"databases": [
{
"name": "default",
Expand Down
23 changes: 12 additions & 11 deletions src/WebSDL/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
from django.urls import reverse_lazy
from django.conf.urls.static import static

from accounts.views import UserRegistrationView, UserUpdateView, logout_view

import accounts

#BASE_URL = settings.SITE_URL[1:]
BASE_URL = ''
Expand All @@ -42,21 +41,23 @@
'post_reset_redirect': 'password_reset_complete'
}

#TODO: Clean out old auth urls
urlpatterns = [
url(r'^' + BASE_URL + 'password-reset/$', auth_views.PasswordResetView.as_view(), password_reset_configuration, name='password_reset'),
url(r'^' + BASE_URL + 'password-reset/done/$', auth_views.PasswordChangeView.as_view(), name='password_reset_done'),
url(r'^' + BASE_URL + 'password-reset/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$', auth_views.PasswordResetConfirmView.as_view(), password_done_configuration, name='password_reset_confirm'),
url(r'^' + BASE_URL + 'password-reset/completed/$', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
#url(r'^' + BASE_URL + 'password-reset/$', auth_views.PasswordResetView.as_view(), password_reset_configuration, name='password_reset'),
#url(r'^' + BASE_URL + 'password-reset/done/$', auth_views.PasswordChangeView.as_view(), name='password_reset_done'),
#url(r'^' + BASE_URL + 'password-reset/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$', auth_views.PasswordResetConfirmView.as_view(), password_done_configuration, name='password_reset_confirm'),
#url(r'^' + BASE_URL + 'password-reset/completed/$', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
url(r'^' + BASE_URL + 'admin/', admin.site.urls),
url(r'^' + BASE_URL + 'login/$', auth_views.LoginView.as_view(), login_configuration, name='login'),
url(r'^' + BASE_URL + 'logout/$', logout_view, name='logout'),
url(r'^' + BASE_URL + 'register/$', UserRegistrationView.as_view(), name='user_registration'),
url(r'^' + BASE_URL + 'account/$', UserUpdateView.as_view(), name='user_account'),
##url(r'^' + BASE_URL + 'login/$', auth_views.LoginView.as_view(), login_configuration, name='login'),
##url(r'^' + BASE_URL + 'logout/$', logout_view, name='logout'),
#url(r'^' + BASE_URL + 'register/$', auth.views.signup, name='user_registration'),
#url(r'^' + BASE_URL + 'account/$', UserUpdateView.as_view(), name='user_account'),
url(r'^' + BASE_URL + 'api-auth/', include('rest_framework.urls', namespace='rest_framework')),
url(r'^' + BASE_URL + 'hydroshare/', include('hydroshare.urls', namespace='hydroshare')),
url(BASE_URL, include('dataloaderinterface.urls')),
url(BASE_URL, include('dataloaderservices.urls')),
url(BASE_URL, include('timeseries_visualization.urls'))
url(BASE_URL, include('timeseries_visualization.urls')),
url(BASE_URL, include('accounts.urls')),
] + static(settings.STATIC_URL,document_root=settings.STATIC_ROOT)

# if settings.DEBUG:
Expand Down
Empty file removed src/accounts/__init__.py
Empty file.
9 changes: 0 additions & 9 deletions src/accounts/admin.py

This file was deleted.

9 changes: 1 addition & 8 deletions src/accounts/apps.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.apps import AppConfig


class AccountsConfig(AppConfig):
name = 'accounts'

def ready(self):
import accounts.signals
name = 'accounts'
183 changes: 183 additions & 0 deletions src/accounts/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
from django.conf import settings
from django.contrib.auth.backends import BaseBackend
from django.middleware.csrf import rotate_token
from django.utils.crypto import constant_time_compare
import django.http

import requests

import boto3
import base64
import hashlib
import hmac

from typing import Union
from accounts.base_user import User, AnonymousUser
from accounts.user import ODM2User

# AWS Credential Info which should be specified in the application settings
AWS_REGION_NAME = settings.COGNITO_REGION
AWS_ACCESS_KEY_ID = settings.COGNITO_ACCESS_KEY
AWS_SECRET_ACCESS_KEY = settings.COGNITO_SECRET_ACCESS_KEY
AWS_USER_POOL_ID = settings.COGNITO_USER_POOL_ID
AWS_CLIENT_ID = settings.COGNITO_CLIENT_ID
AWS_CLIENT_SECRET = settings.COGNITO_CLIENT_SECRET
AWS_OAUTH_URL = settings.COGNITO_OAUTH_URL
AWS_REDIRECT_URL = settings.COGNITO_REDIRECT_URL
AWS_USERFIELD = 'sub'

USER_MODEL = ODM2User
ANONYMOUS_USER_MODEL = AnonymousUser

SESSION_KEY = settings.SESSION_KEY
BACKEND_SESSION_KEY = settings.BACKEND_SESSION_KEY
HASH_SESSION_KEY = settings.HASH_SESSION_KEY

def login_required(view, *args, **kwargs) -> None:
def authenicated(request, *args, **kwargs):
user = request.user
if user.is_authenticated: return view(request, *args, **kwargs)
return django.http.HttpResponse('Unauthorized', status=401)
return authenicated

class CognitoBackend(BaseBackend):
"""Customized UserAuth Backend to use AWS Cognito for validation in place of django user/password model"""
def __init__(self):
self._client = boto3.client('cognito-idp',
region_name=AWS_REGION_NAME,
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY)

def authenticate(self, username:str=None, password:str=None, token:str=None, code:str=None) -> User:
"""
Main method for user authenication which interfaces with AWS Cognito and exchanges provide information for Cognito username which
is later mapped to an application user_id. There are 3 acceptable inputs for authenications
1) The classic 'username' and 'password' which are the user's AWS username and password which can be exchanged with AWS Cognito for an Access Token.
This method is not utlized by the limnos application. We instead redirect user's to a thirdparty AWS based login page. However this
method could be used if we ever wanted to develop a user login page on this application.
2) An AWS Authorization Code which is provided by AWS after successful authentication through their service
(i.e. using facebook, twitter, or username and password) at AWS login page assoicated with this application's user pool.
This approach is used by the callback url of the AWS login page which passes an authenication code to our 'oauth2_cognito' method
which in turn envokes this authenication method.
3) An AWS User Refresh Token which can be exchanged for an Access Token and subsequently user information like username.
With oauth2 utlimately the other 2 authenication approaches end up through this method as the end point.
"""
if token is not None: return(self._authenticate_token(token))
elif code is not None: return(self._authenticate_code(code))
elif username is not None and password is not None: return(self._authenticate_password(username, password))

def _authenticate_password(self, username:str, password:str) -> User:
"""Interal method - exchanges username and password for AWS Access Token."""
self.username = username
auth_response = self._client.initiate_auth(
AuthFlow='USER_PASSWORD_AUTH',
AuthParameters={
'USERNAME':username,
'PASSWORD':password,
'SECRET_HASH': self._secret_hash
},
ClientId=AWS_CLIENT_ID
)

auth_result = auth_response.get('AuthenticationResult')
token = auth_result.get('AccessToken')
return (self._authenticate_token(token))

def _authenticate_token(self,token) -> User:
"""Internal Method - Uses a user Access Token to fetch user information (primarily need username) from AWS Cognito user pool"""
response = self._client.get_user(AccessToken=token)
return self._init_user_response(response, token)

def _authenticate_code(self, code) -> User:
"""Internal Method - Exchanges Authorization Code for User Refresh Token
see AWS doc for additional detail https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#post-token
"""
message = AWS_CLIENT_ID+':'+AWS_CLIENT_SECRET
authorization = base64.b64encode(message.encode('utf-8')).decode('utf-8')
headers = {
'Content-Type':R"application/x-www-form-urlencoded",
'Authorization':'Basic '+ authorization,
'Accept':'*/*'
}
data = {
'grant_type':'authorization_code',
'client_id':AWS_CLIENT_ID,
'code':code,
'scope':'aws.cognito.signin.user.admin',
'redirect_uri': AWS_REDIRECT_URL
}
response = requests.post(url=AWS_OAUTH_URL, headers=headers, data=data)

if response.status_code != 200:
# error handling needed - redirect to failed login
raise RuntimeError('Amazon returned non-valid client user response!')

else:
token = response.json()['access_token']
return (self._authenticate_token(token))

@property
def _secret_hash(self):
""" Internal Method - generates encryption key required by AWS Congnito"""
message = bytearray(self.username + AWS_CLIENT_ID, 'utf-8')
hmac_obj = hmac.new(bytearray(AWS_CLIENT_SECRET, 'utf-8'), message, hashlib.sha256)
return base64.standard_b64encode(hmac_obj.digest()).decode('utf-8')

def _init_user_response(self, response, token:str) -> User:
""" Internal Method - Takes AWS response and return an instance of a User
Uses the 'from_mapping' method of the User class. If the method returns a None,
indicating no user record exists, the _create_user method will be invoked.
"""
user_attributes = {list(item.values())[0]:list(item.values())[1] for item in response['UserAttributes']}
if 'preferred_username' not in user_attributes:
user_attributes['preferred_username'] = response['Username']

user = USER_MODEL.from_cognitoid(user_attributes[AWS_USERFIELD])
if user is not None:
user._set_access_token(token)
return user

user = USER_MODEL.create_new_user(user_attributes)
user._set_access_token(token)
return user

@classmethod
def init_user_from_id(cls, userid:Union[None,str,int] ) -> User:
if not userid: return ANONYMOUS_USER_MODEL()
user = USER_MODEL.from_userid(userid)
if user is not None: return(user)
return ANONYMOUS_USER_MODEL()

def login(self, request, user):
"""
Uses session to create a persistent user_id so user doesn't need to log in after each request
based on logic in contrib.auth.login method
"""
session_auth_hash = ''
if user is None:
user = request.user
if hasattr(user, 'get_session_auth_hash'):
session_auth_hash = user.get_session_auth_hash()

if SESSION_KEY in request.session:
if request.session[SESSION_KEY] != user.user_id or (
session_auth_hash and not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):

# create an empty session if the existing session corresponds to a different user
request.session.flush()
else:
request.session.cycle_key()

request.session[SESSION_KEY] = user.user_id
request.session['TOKEN'] = user._get_access_token()
request.session[BACKEND_SESSION_KEY] = 'CognitoBackend'
request.session[HASH_SESSION_KEY] = session_auth_hash
if hasattr(request, 'user'):
request.user = user
rotate_token(request)


Loading

0 comments on commit 40b0df1

Please sign in to comment.