Skip to content

Commit

Permalink
Implement JWT authentication based on RSA keys. Re-enable keybar.clie…
Browse files Browse the repository at this point in the history
…nt tests.
  • Loading branch information
EnTeQuAk committed Mar 19, 2016
1 parent 9bb4986 commit 242cbbd
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 41 deletions.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def read(*parts):

# For our REST Api
'djangorestframework==3.3.3',
'PyJWT==1.4.0',
'requests==2.9.1',
'urllib3==1.14',
'requests-toolbelt==0.6.0',
Expand Down
94 changes: 94 additions & 0 deletions src/keybar/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import jwt

from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from rest_framework import exceptions
from rest_framework.authentication import (
BaseAuthentication, get_authorization_header
)

from keybar.models.device import Device
from keybar.utils.jwt import decode_token


class JSONWebTokenAuthentication(BaseAuthentication):
"""Token based authentication using the JSON Web Token standard.
Clients should authenticate by passing the token key in the "Authorization"
HTTP header, prepended with the string "JWT".
.. code-block::
Authorization: JWT eyJhbGciOiAiSFMyNTYiLCAidHlwIj...
"""
www_authenticate_realm = 'api'
auth_header_prefix = 'JWT'

def get_jwt_value(self, request):
auth = get_authorization_header(request).split()
auth_header_prefix = self.auth_header_prefix.lower()

if not auth or smart_text(auth[0].lower()) != auth_header_prefix:
return None

if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string '
'should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)

return auth[1]

def authenticate(self, request):
"""Authenticate `request`.
Returns a two-tuple of `User` and token if a valid signature has been
supplied using JWT-based authentication. Otherwise returns `None`.
"""
jwt_value = self.get_jwt_value(request)

if jwt_value is None:
return None

unverified_data = jwt.decode(jwt_value, verify=False)
if 'iss' not in unverified_data:
msg = 'JWT iss (issuer) claim is missing'
raise exceptions.AuthenticationFailed(detail=msg)

device = Device.objects.get(pk=unverified_data['iss'])

try:
payload = decode_token(jwt_value, device)
except jwt.ExpiredSignature:
msg = _('Signature has expired.')
raise exceptions.AuthenticationFailed(msg)
except jwt.DecodeError:
msg = _('Error decoding signature.')
raise exceptions.AuthenticationFailed(msg)
except jwt.InvalidTokenError:
raise exceptions.AuthenticationFailed()

if unverified_data['iss'] != payload['iss']:
raise exceptions.AuthenticationFailed()

user = self.authenticate_credentials(device)

return (user, jwt_value)

def authenticate_credentials(self, device):
# TODO:
# if not user.is_active:
# msg = _('User account is disabled.')
# raise exceptions.AuthenticationFailed(msg)

return device.user

def authenticate_header(self, request):
"""
Return a string to be used as the value of the `WWW-Authenticate`
header in a `401 Unauthenticated` response, or `None` if the
authentication scheme should return `403 Permission Denied` responses.
"""
return '{0} realm="{1}"'.format(self.auth_header_prefix, self.www_authenticate_realm)
9 changes: 9 additions & 0 deletions src/keybar/api/endpoints/dummy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from rest_framework import views
from rest_framework.response import Response

from keybar.utils import json


class AuthenticatedDummyEndpoint(views.APIView):
def get(self, request, *args, **kwargs):
return Response(json.dumps({'dummy': 'ok'}))
6 changes: 6 additions & 0 deletions src/keybar/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
from django.conf.urls import url

from .endpoints.dummy import AuthenticatedDummyEndpoint


urlpatterns = [
url(r'^dummy/$', AuthenticatedDummyEndpoint.as_view())
]
28 changes: 6 additions & 22 deletions src/keybar/client.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import hashlib
import ssl
import urllib
import uuid
from base64 import encodebytes
from datetime import datetime
from email.utils import formatdate
from time import mktime
from urllib.parse import urlencode, urljoin

import pkg_resources
import requests
from django.conf import settings
from django.utils.encoding import force_bytes
from requests_toolbelt import SSLAdapter, user_agent

from keybar.utils import json
from keybar.utils.http import InsecureTransport, InvalidHost, is_secure_transport, verify_host
from keybar.utils.crypto import serialize_private_key
from keybar.utils.jwt import encode_token


class TLS12SSLAdapter(SSLAdapter):
Expand Down Expand Up @@ -80,12 +74,6 @@ def request(self, method, url, *args, **kwargs):

data = kwargs.get('data', {})

now = datetime.utcnow()
stamp = mktime(now.timetuple())

raw_data = force_bytes(json.dumps(data))
content_md5 = encodebytes(hashlib.md5(raw_data).digest()).strip()

parse_result = urllib.parse.urlparse(url)

dist = pkg_resources.get_distribution('keybar')
Expand All @@ -97,20 +85,16 @@ def request(self, method, url, *args, **kwargs):
'Path': parse_result.path,
'Accept': self.content_type,
'Content-Type': self.content_type,
'X-Device-Id': self.device_id,
'Content-MD5': content_md5,
'Date': formatdate(timeval=stamp, localtime=False, usegmt=True)
}

headers.update(kwargs.pop('headers', {}))

if self.device_id and self.secret:
auth = ()
else:
auth = ()
headers['Authorization'] = 'JWT {}'.format(
encode_token(self.device_id, self.secret)
)

headers.update(kwargs.pop('headers', {}))

kwargs.update({
'auth': auth,
'headers': headers,
'data': data,
'cert': (settings.KEYBAR_CLIENT_CERTIFICATE, settings.KEYBAR_CLIENT_KEY),
Expand Down
2 changes: 1 addition & 1 deletion src/keybar/conf/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@
# Django REST Framework related settings.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'keybar.api.auth.KeybarApiSignatureAuthentication',
'keybar.api.auth.JSONWebTokenAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
Expand Down
47 changes: 29 additions & 18 deletions src/keybar/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from keybar.client import TLS12SSLAdapter
from keybar.tests.helpers import LiveServerTest
from keybar.tests.factories.user import UserFactory
from keybar.tests.factories.device import (
AuthorizedDeviceFactory, PRIVATE_KEY, PRIVATE_KEY2)
from keybar.utils.http import InsecureTransport


Expand All @@ -29,34 +32,42 @@ def test_url_must_be_https(self):
with pytest.raises(InsecureTransport):
client.get('http://fails.xy')

# def test_simple(self):
# user = UserFactory.create(is_superuser=True)
# device = AuthorizedDeviceFactory.create(user=user)
def test_simple_unauthorized(self):
user = UserFactory.create()
device = AuthorizedDeviceFactory.create(user=user)

# client = self.get_client(device.id, PRIVATE_KEY)
client = self.get_client(device.id, None)

# endpoint = '{0}/api/users/'.format(self.liveserver.url)
endpoint = '{0}/api/dummy/'.format(self.liveserver.url)

# response = client.get(endpoint)
response = client.get(endpoint)

# assert response.status_code == 200
assert response.status_code == 401

# def test_simple_wrong_device_secret(self, settings):
# user = UserFactory.create(is_superuser=True)
# device = AuthorizedDeviceFactory.create(user=user)
def test_simple_authorized(self):
user = UserFactory.create(is_superuser=True)
device = AuthorizedDeviceFactory.create(user=user)

# fpath = os.path.join(settings.BASE_DIR, 'tests', 'resources', 'rsa_keys', 'id_rsa2')
client = self.get_client(device.id, PRIVATE_KEY)

# with open(fpath, 'rb') as fobj:
# wrong_secret = fobj.read()
endpoint = '{0}/api/dummy/'.format(self.liveserver.url)

# client = self.get_client(device.id, wrong_secret)
response = client.get(endpoint)

# endpoint = '{0}/api/users/'.format(self.liveserver.url)
assert response.status_code == 200
assert response.content == b'"{\\"dummy\\":\\"ok\\"}"'

def test_simple_wrong_device_secret(self, settings):
user = UserFactory.create(is_superuser=True)
device = AuthorizedDeviceFactory.create(user=user)

client = self.get_client(device.id, PRIVATE_KEY2)

endpoint = '{0}/api/dummy/'.format(self.liveserver.url)

# response = client.get(endpoint)
# assert response.status_code == 401
# assert response.json()['detail'] == 'Bad signature'
response = client.get(endpoint)
assert response.status_code == 401
assert response.json()['detail'] == 'Error decoding signature.'

def test_to_server_without_tls_10(self, allow_offline):
"""
Expand Down
51 changes: 51 additions & 0 deletions src/keybar/utils/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from datetime import datetime, timedelta

import jwt
from django.conf import settings

from keybar.utils.crypto import load_private_key


JWT_OPTIONS = {
'verify_signature': True,
'verify_exp': True,
'verify_nbf': True,
'verify_iat': True,
'verify_aud': True,
'require_exp': True,
'require_iat': True,
'require_nbf': True
}

LEEWAY = 10
EXPIRATION_DELTA = timedelta(seconds=60)


def decode_token(token, device):
return jwt.decode(
token,
verify=True,
key=device.loaded_public_key,
options=JWT_OPTIONS,
leeway=LEEWAY,
audience=settings.KEYBAR_HOST,
algorithms=['RS256']
)


def encode_token(device_id, private_key):
# TODO: make aud configurable
issued_at = datetime.utcnow()
payload = {
'iss': device_id,
'exp': issued_at + EXPIRATION_DELTA,
'iat': issued_at,
'nbf': issued_at,
'aud': 'local.keybar.io:9999',
}

return jwt.encode(
payload,
key=load_private_key(private_key),
algorithm='RS256'
).decode('utf-8')

0 comments on commit 242cbbd

Please sign in to comment.