-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
65115ad
commit e94efaa
Showing
14 changed files
with
442 additions
and
17 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,9 @@ | ||
*.pyc | ||
__pycache__ | ||
*.pyo | ||
dist/** | ||
*.egg-info/** | ||
.idea/ | ||
venv/ | ||
build/ | ||
.tox/ |
Empty file.
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 |
---|---|---|
@@ -1,21 +1,27 @@ | ||
MIT License | ||
Copyright (c) 2016, Christian Kreuzberger. | ||
All rights reserved. | ||
|
||
Copyright (c) 2016 Christian Kreuzberger | ||
Redistribution and use in source and binary forms, with or without modification, | ||
are permitted provided that the following conditions are met: | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
1. Redistributions of source code must retain the above copyright notice, | ||
this list of conditions and the following disclaimer. | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
2. Redistributions in binary form must reproduce the above copyright | ||
notice, this list of conditions and the following disclaimer in the | ||
documentation and/or other materials provided with the distribution. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. | ||
3. Neither the name of django_userforeignkey nor the names of its contributors may be used | ||
to endorse or promote products derived from this software without | ||
specific prior written permission. | ||
|
||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND | ||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | ||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR | ||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES | ||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; | ||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON | ||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | ||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
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,3 @@ | ||
include LICENSE | ||
include README.md | ||
recursive-include docs * |
Empty file.
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,8 @@ | ||
""" contains basic admin views for MultiToken """ | ||
from django.contrib import admin | ||
from eric.coreauth.models import MultiToken | ||
|
||
|
||
@admin.register(MultiToken) | ||
class MultiTokenAdmin(admin.ModelAdmin): | ||
list_display = ('user', 'key', 'user_agent') |
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,25 @@ | ||
""" | ||
Provides our custom MultiToken Authentication (based on normal Token Authentication) | ||
""" | ||
from __future__ import unicode_literals | ||
|
||
from rest_framework.authentication import TokenAuthentication | ||
|
||
from eric.coreauth.models import MultiToken | ||
|
||
|
||
class MultiTokenAuthentication(TokenAuthentication): | ||
""" | ||
Simple token based authentication. | ||
Clients should authenticate by passing the token key in the "Authorization" | ||
HTTP header, prepended with the string "Token ". For example: | ||
Authorization: Token 401f7ac837da42b97f613d789819ff93537bee6a | ||
""" | ||
|
||
# override get_model (use a custom model) | ||
def get_model(self): | ||
if self.model is not None: | ||
return self.model | ||
return MultiToken |
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,69 @@ | ||
import binascii | ||
import os | ||
|
||
from rest_framework.authtoken.models import Token | ||
from django.conf import settings | ||
from django.db import models | ||
from django.utils.encoding import python_2_unicode_compatible | ||
from django.utils.translation import ugettext_lazy as _ | ||
|
||
# Prior to Django 1.5, the AUTH_USER_MODEL setting does not exist. | ||
# Note that we don't perform this code in the compat module due to | ||
# bug report #1297 | ||
# See: https://github.com/tomchristie/django-rest-framework/issues/1297 | ||
AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') | ||
|
||
|
||
@python_2_unicode_compatible | ||
class MultiToken(models.Model): | ||
""" | ||
The multi token model with user agent and IP address. | ||
""" | ||
key = models.CharField( | ||
_("Key"), | ||
max_length=64, | ||
primary_key=True | ||
) | ||
user = models.ForeignKey( | ||
AUTH_USER_MODEL, | ||
related_name='auth_tokens', | ||
on_delete=models.CASCADE, | ||
verbose_name=_("User") | ||
) | ||
created = models.DateTimeField( | ||
_("Created"), | ||
auto_now_add=True | ||
) | ||
last_known_ip = models.GenericIPAddressField( | ||
_("The IP address of this session"), | ||
default="127.0.0.1" | ||
) | ||
user_agent = models.CharField( | ||
max_length=256, | ||
verbose_name=_("HTTP User Agent"), | ||
default="" | ||
) | ||
|
||
class Meta: | ||
# Work around for a bug in Django: | ||
# https://code.djangoproject.com/ticket/19422 | ||
# | ||
# Also see corresponding ticket: | ||
# https://github.com/tomchristie/django-rest-framework/issues/705 | ||
abstract = 'eric.coreauth' not in settings.INSTALLED_APPS | ||
verbose_name = _("Token") | ||
verbose_name_plural = _("Tokens") | ||
|
||
def save(self, *args, **kwargs): | ||
if not self.key: | ||
self.key = self.generate_key() | ||
return super(MultiToken, self).save(*args, **kwargs) | ||
|
||
@staticmethod | ||
def generate_key(): | ||
""" generates a pseudo random code using os.urandom and binascii.hexlify """ | ||
return binascii.hexlify(os.urandom(32)).decode() | ||
|
||
def __str__(self): | ||
return self.key + " (user " + str(self.user) + " with IP " + self.last_known_IP + \ | ||
" and user agent " + self.user_agent + ")" |
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,201 @@ | ||
import json | ||
|
||
from django.contrib.auth.models import User | ||
from rest_framework.test import APITestCase | ||
from rest_framework import status | ||
|
||
from eric.coreauth.models import MultiToken | ||
|
||
# read http://www.django-rest-framework.org/api-guide/testing/ for more info about testing with django rest framework | ||
|
||
|
||
class LoginLogoutTest(APITestCase): | ||
""" Extensive testing of login and logout using multitoken""" | ||
|
||
def setUp(self): | ||
""" set up a couple of users""" | ||
self.user1 = User.objects.create_user( | ||
username='johndoe', email='[email protected]', password='top_secret') | ||
|
||
self.user2 = User.objects.create_user( | ||
username='foobar', email='[email protected]', password='foobar') | ||
|
||
self.user3 = User.objects.create_user( | ||
username='alice', email='[email protected]', password='alice_secret') | ||
|
||
self.user4 = User.objects.create_user( | ||
username='bob', email='[email protected]', password='bob_secret') | ||
|
||
def set_client_credentials(self, token): | ||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) | ||
|
||
def reset_client_credentials(self): | ||
self.client.credentials() | ||
|
||
def logout_with_token(self, token, HTTP_USER_AGENT='API_TEST_CLIENT', REMOTE_ADDR='127.0.0.1'): | ||
""" Checks if the token exists, then log sthe user out, and checks that the token no longer exists """ | ||
# token should be in database | ||
self.assertEqual(len(MultiToken.objects.filter(key=token)), 1) | ||
# set client credentials to the current token | ||
self.set_client_credentials(token) | ||
|
||
# call logout | ||
response = self.client.post('/api/auth/logout', HTTP_USER_AGENT=HTTP_USER_AGENT, REMOTE_ADDR=REMOTE_ADDR) | ||
|
||
# make sure the response is "logged_out" | ||
self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
self.assertContains(response, "{\"status\":\"logged out\"}") | ||
|
||
# verify the token has been deleted | ||
self.assertEqual(len(MultiToken.objects.filter(key=token)), 0) | ||
|
||
def login_and_return_token(self, username, password, HTTP_USER_AGENT='API_TEST_CLIENT', REMOTE_ADDR='127.0.0.1'): | ||
""" logs in and returns the token | ||
checks with assert calls that the login was successful (token exists, user agent and remote addr are set) | ||
""" | ||
|
||
# reset auth token in header, if it exists | ||
self.reset_client_credentials() | ||
|
||
# check if the user exists | ||
avail_users = User.objects.filter(username=username) | ||
self.assertEqual(len(avail_users), 1) | ||
cur_user = avail_users[0] | ||
|
||
# login with self.user1, a given user agent and remote address | ||
response = self.client.post('/api/auth/login', | ||
{'username': username, 'password': password}, | ||
HTTP_USER_AGENT=HTTP_USER_AGENT, REMOTE_ADDR=REMOTE_ADDR) | ||
|
||
# check if login was successful | ||
self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
self.assertContains(response, "{\"token\":\"") | ||
|
||
content = json.loads(response.content.decode()) | ||
token = content['token'] | ||
|
||
# there should now be exactly one token | ||
self.assertEqual(len(MultiToken.objects.filter(key=token, user=cur_user)), 1) | ||
self.assertEqual(MultiToken.objects.filter(key=token, user=cur_user)[0].user_agent, HTTP_USER_AGENT) | ||
self.assertEqual(MultiToken.objects.filter(key=token, user=cur_user)[0].last_known_ip, REMOTE_ADDR) | ||
|
||
return token | ||
|
||
def test_login_logout_user1_success(self): | ||
""" Tries to login and checks if a token has been created, | ||
logs out after that and checks if token has been deleted""" | ||
|
||
# initial state: no tokens should exist | ||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
# login and get token | ||
token = self.login_and_return_token(self.user1.username, 'top_secret') | ||
# check that token is not empty | ||
self.assertNotEqual(token, "") | ||
self.assertEqual(len(MultiToken.objects.all()), 1) | ||
|
||
# add credentials for the logout call | ||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) | ||
response = self.client.post('/api/auth/logout') | ||
|
||
self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
|
||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
def test_login_logout_user1_wrong_password(self): | ||
""" Tries to login with a wrong password or wrong username, and checks if a token has been created. | ||
in the end, the login is successful with correct password.""" | ||
|
||
# initial state: no tokens should exist | ||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
# reset auth token in header, if it exists | ||
self.reset_client_credentials() | ||
|
||
# login with self.user1 but a wrong password | ||
response = self.client.post('/api/auth/login', | ||
{'username': "johndoe", 'password': "top_sicret"}, | ||
HTTP_USER_AGENT="TestClient", REMOTE_ADDR="127.0.0.1") | ||
|
||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
# check if login failed | ||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||
self.assertTrue("Unable to log in" in response.content.decode(), | ||
msg="Checking if 'Unable to log in' is in the response") | ||
|
||
# login with a wrong user name, but the correct password | ||
response = self.client.post('/api/auth/login', | ||
{'username': "john_doe", 'password': "top_secret"}, | ||
HTTP_USER_AGENT="TestClient", REMOTE_ADDR="127.0.0.1") | ||
|
||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
# check if login failed | ||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||
self.assertTrue("Unable to log in" in response.content.decode(), | ||
msg="Checking if 'Unable to log in' is in the response") | ||
|
||
# login with a wrong user name, and a wrong password | ||
response = self.client.post('/api/auth/login', | ||
{'username': "john_doe", 'password': "top_sicret"}, | ||
HTTP_USER_AGENT="TestClient", REMOTE_ADDR="127.0.0.1") | ||
|
||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
# check if login failed | ||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||
self.assertTrue("Unable to log in" in response.content.decode(), | ||
msg="Checking if 'Unable to log in' is in the response") | ||
|
||
# finally, log in with the right username and password | ||
response = self.client.post('/api/auth/login', | ||
{'username': "johndoe", 'password': "top_secret"}, | ||
HTTP_USER_AGENT="TestClient", REMOTE_ADDR="127.0.0.1") | ||
self.assertEqual(response.status_code, status.HTTP_200_OK) | ||
self.assertEqual(len(MultiToken.objects.all()), 1) | ||
|
||
def test_logout_with_wrong_token(self): | ||
""" Tests that logout does not work with an invalid token """ | ||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
token1 = self.login_and_return_token("johndoe", "top_secret") | ||
self.assertEqual(len(MultiToken.objects.all()), 1) | ||
# add credentials for the logout call | ||
self.client.credentials(HTTP_AUTHORIZATION='Token ' + "wrong token") | ||
response = self.client.post('/api/auth/logout') | ||
|
||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||
self.assertEqual(len(MultiToken.objects.all()), 1) | ||
|
||
def test_login_logout_multiple_times_success(self): | ||
""" Tries to login and checks if a token has been created, | ||
logs out after that and checks if token has been deleted""" | ||
|
||
# initial state: no tokens should exist | ||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
||
token1 = self.login_and_return_token(self.user1.username, 'top_secret') | ||
self.assertEqual(len(MultiToken.objects.all()), 1) | ||
|
||
token2 = self.login_and_return_token(self.user1.username, 'top_secret', REMOTE_ADDR="127.0.0.2") | ||
self.assertEqual(len(MultiToken.objects.all()), 2) | ||
# tokens should not be equal | ||
self.assertNotEqual(token1, token2) | ||
|
||
token3 = self.login_and_return_token(self.user1.username, 'top_secret', REMOTE_ADDR="127.0.0.3") | ||
self.assertEqual(len(MultiToken.objects.all()), 3) | ||
self.assertNotEqual(token1, token3) | ||
self.assertNotEqual(token2, token3) | ||
|
||
# now log out with token2 | ||
self.logout_with_token(token2) | ||
# check the other two tokens still exist | ||
self.assertEqual(len(MultiToken.objects.all()), 2) | ||
self.assertEqual(len(MultiToken.objects.filter(key=token1)), 1) | ||
self.assertEqual(len(MultiToken.objects.filter(key=token3)), 1) | ||
|
||
self.logout_with_token(token1) | ||
self.logout_with_token(token3) | ||
|
||
self.assertEqual(len(MultiToken.objects.all()), 0) | ||
|
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,9 @@ | ||
""" URL Configuration for core auth | ||
""" | ||
from django.conf.urls import url, include | ||
from eric.coreauth.views import login_and_obtain_auth_token, logout_and_delete_auth_token | ||
|
||
urlpatterns = [ | ||
url(r'^login', login_and_obtain_auth_token), # normal login with session | ||
url(r'^logout', logout_and_delete_auth_token), | ||
] |
Oops, something went wrong.