Skip to content

Commit

Permalink
Implement user_identity API endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
tuomas777 authored and juyrjola committed Mar 11, 2018
1 parent 8f2e9cc commit b03c447
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 0 deletions.
92 changes: 92 additions & 0 deletions identities/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import logging

from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework.exceptions import APIException, PermissionDenied
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import GenericViewSet

from tunnistamo.api_common import OidcTokenAuthentication

from .helmet_requests import (
HelmetConnectionException, HelmetGeneralException, HelmetImproperlyConfiguredException, validate_patron
)
from .models import UserIdentity

logger = logging.getLogger(__name__)

User = get_user_model()


class NotImplementedResponse(APIException):
status_code = 501
default_detail = 'Not implemented.'


class ThirdPartyAuthenticationFailed(APIException):
status_code = 401


def validate_credentials_helmet(identifier, secret):
try:
result = validate_patron(identifier, secret)
except HelmetImproperlyConfiguredException as e:
logger.error(e)
raise NotImplementedResponse()
except HelmetConnectionException as e:
logger.warning('Cannot validate patron from helmet, got connection exception: {}'.format(e))
raise ThirdPartyAuthenticationFailed({
'code': 'authentication_service_unavailable',
'detail': 'Connection to authentication service timed out',
})
except HelmetGeneralException as e:
logger.warning('Cannot validate patron from helmet, got general exception: {}'.format(e))
raise ThirdPartyAuthenticationFailed({
'code': 'unidentified_error',
'detail': 'Unidentified error',
})
if not result:
raise ThirdPartyAuthenticationFailed({
'code': 'invalid_credentials',
'detail': 'Invalid user credentials',
})


class UserIdentitySerializer(serializers.ModelSerializer):
secret = serializers.CharField(write_only=True)

class Meta:
model = UserIdentity
fields = ('id', 'identifier', 'service', 'secret')

def create(self, validated_data):
instance, _ = self.Meta.model.objects.update_or_create(
service=validated_data['service'],
user=validated_data['user'],
defaults={'identifier': validated_data['identifier']}
)
return instance


class UserIdentityViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, GenericViewSet):
queryset = UserIdentity.objects.all()
serializer_class = UserIdentitySerializer
authentication_classes = (OidcTokenAuthentication,)
permission_classes = (IsAuthenticated,)

def get_queryset(self):
return self.queryset.filter(user=self.request.user)

def perform_create(self, serializer):
data = serializer.validated_data
secret = data.pop('secret')

validate_credentials_helmet(data['identifier'], secret)

serializer.save(user=self.request.user)

def perform_destroy(self, instance):
if instance.user != self.request.user:
raise PermissionDenied()
instance.delete()
109 changes: 109 additions & 0 deletions identities/helmet_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import json
from urllib.parse import urljoin

import requests
from django.conf import settings
from django.core.cache import cache

ACCESS_TOKEN_CACHE_KEY = 'HELMET_API_ACCESS_TOKEN'


class HelmetException(Exception):
pass


class HelmetImproperlyConfiguredException(HelmetException):
pass


class HelmetConnectionException(HelmetException):
pass


class HelmetGeneralException(HelmetException):
pass


def validate_patron(identifier, secret):
token = _get_token()
return _validate_patron(identifier, secret, token)


def _get_token():
access_token = cache.get(ACCESS_TOKEN_CACHE_KEY)

if not access_token:
access_token, expires_in = _get_token_request()

# use 1 min shorter time for the cache to make sure it is invalidated before the token
expires_in = max(expires_in - 60, 0)

default_timeout = settings.CACHES['default'].get('TIMEOUT', 300)
timeout = expires_in if default_timeout is None else min(expires_in, default_timeout)

cache.set(ACCESS_TOKEN_CACHE_KEY, access_token, timeout)

return access_token


def _get_token_request():
username = _get_setting('HELMET_API_USERNAME')
password = _get_setting('HELMET_API_PASSWORD')
url = _create_api_url('token')

try:
response = requests.post(url, auth=(username, password))
response.raise_for_status()
except requests.RequestException as e:
raise HelmetConnectionException(e)

try:
data = response.json()
access_token = data['access_token']
expires_in = int(data['expires_in'])
except (json.JSONDecodeError, KeyError) as e:
raise HelmetGeneralException(
'Cannot parse token response.\nResponse: {}.\nException: {}.\n'.format(response.text, e)
)
return access_token, expires_in


def _validate_patron(identifier, secret, token):
headers = {'Authorization': 'Bearer {}'.format(token)}
data = {'barcode': identifier, 'pin': secret}
url = _create_api_url('patrons/validate')

response = requests.post(url, headers=headers, json=data)

if response.status_code == 204:
return True

exc = None

if response.status_code in (400, 403):
try:
code = response.json()['code']
if (response.status_code, code) in ((400, 108), (403, 143)):
return False
except (json.JSONDecodeError, KeyError) as e:
exc = e

error_message = 'Got invalid patron validate response.\nResponse: ({}) {}.\n'.format(
response.status_code, response.text
)
if exc:
error_message += 'Exception: {}.\n'.format(exc)

raise HelmetGeneralException(error_message)


def _create_api_url(endpoint):
base_url = _get_setting('HELMET_API_BASE_URL')
return urljoin(base_url, endpoint)


def _get_setting(setting_name):
value = getattr(settings, setting_name, None)
if value is None:
raise HelmetImproperlyConfiguredException('Setting {} not set.')
return value
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ django-compressor
django-sass-processor
social-auth-core[openidconnect,saml]
social-auth-app-django
requests
37 changes: 37 additions & 0 deletions tunnistamo/api_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import logging

from oidc_provider.lib.errors import BearerTokenError
from oidc_provider.lib.utils.oauth2 import extract_access_token
from oidc_provider.models import Token
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

logger = logging.getLogger(__name__)


class OidcTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
access_token = extract_access_token(request)
scopes = ['openid']

try:
try:
token = Token.objects.get(access_token=access_token)
except Token.DoesNotExist:
logger.debug('[UserInfo] Token does not exist: %s', access_token)
raise BearerTokenError('invalid_token')

if token.has_expired():
logger.debug('[UserInfo] Token has expired: %s', access_token)
raise BearerTokenError('invalid_token')

if not set(scopes).issubset(set(token.scope)):
logger.debug('[UserInfo] Missing openid scope.')
raise BearerTokenError('insufficient_scope')
except BearerTokenError as error:
raise AuthenticationFailed(error.description)

return (token.user, token)

def authenticate_header(self, request):
return "Bearer"
1 change: 1 addition & 0 deletions tunnistamo/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
'hkijwt',
'oidc_apis',
'devices',
'identities',
)

MIDDLEWARE = (
Expand Down
7 changes: 7 additions & 0 deletions tunnistamo/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from django.contrib.staticfiles import views as static_views
from django.http import HttpResponse
from django.views.defaults import permission_denied
from rest_framework.routers import SimpleRouter

from identities.api import UserIdentityViewSet
from oidc_apis.views import get_api_tokens_view
from tunnistamo import social_auth_urls
from users.views import EmailNeededView, LoginView, LogoutView
Expand All @@ -30,6 +32,10 @@ def show_login(request):
return HttpResponse(html)


router = SimpleRouter()
router.register('user_identity', UserIdentityViewSet)


urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
url(r'^api-tokens/?$', get_api_tokens_view),
Expand All @@ -46,6 +52,7 @@ def show_login(request):
url(r'^login/$', LoginView.as_view()),
url(r'^logout/$', LogoutView.as_view()),
url(r'^email-needed/$', EmailNeededView.as_view(), name='email_needed'),
url(r'^v1/', include(router.urls, namespace='v1')),
]

if settings.DEBUG:
Expand Down

0 comments on commit b03c447

Please sign in to comment.