forked from City-of-Turku/tunnistamo
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement user_identity API endpoint
- Loading branch information
Showing
6 changed files
with
247 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,3 +20,4 @@ django-compressor | |
django-sass-processor | ||
social-auth-core[openidconnect,saml] | ||
social-auth-app-django | ||
requests |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -62,6 +62,7 @@ | |
'hkijwt', | ||
'oidc_apis', | ||
'devices', | ||
'identities', | ||
) | ||
|
||
MIDDLEWARE = ( | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters