Skip to content

Commit

Permalink
Merge pull request #255 from clarkperkins/feature/token-auth
Browse files Browse the repository at this point in the history
PI-11 API auth with tokens
  • Loading branch information
clarkperkins committed Oct 25, 2016
2 parents 3a8aa3d + 7de3b00 commit e4d421a
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 8 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# limitations under the License.
#

from __future__ import print_function
from __future__ import print_function, unicode_literals

import os
import sys
Expand Down
4 changes: 1 addition & 3 deletions stackdio/api/stacks/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ def task(stack_id, *task_args, **task_kwargs):
sls_path,
host_ids)
logger.exception(e)
six.reraise()
raise

return task
Expand Down Expand Up @@ -732,8 +731,7 @@ def sync_all(stack):

if data.get('retcode', 1) != 0:
err_msg = six.text_type(data['ret'])
raise StackTaskException('Error syncing salt data: '
'{1!r}'.format(stack.title, err_msg))
raise StackTaskException('Error syncing salt data: {0!r}'.format(err_msg))

stack.log_history('Finished synchronizing salt systems on all hosts.')

Expand Down
31 changes: 30 additions & 1 deletion stackdio/api/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@
# limitations under the License.
#

from __future__ import unicode_literals

from django.conf import settings
from django.contrib.auth import get_user_model, update_session_auth_hash
from django.contrib.auth.models import Group
from rest_framework import generics
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.filters import DjangoFilterBackend, DjangoObjectPermissionsFilter
from rest_framework.response import Response

from rest_framework.settings import api_settings
from stackdio.core.config import StackdioConfigException
from stackdio.core.notifications.models import NotificationChannel
from stackdio.core.permissions import StackdioModelPermissions
Expand All @@ -31,6 +35,7 @@
StackdioObjectUserPermissionsViewSet,
StackdioObjectGroupPermissionsViewSet,
)

from . import filters, mixins, permissions, serializers

try:
Expand Down Expand Up @@ -238,3 +243,27 @@ def post(self, request, *args, **kwargs):
# This ensures that the user doesn't get logged out after the password change
update_session_auth_hash(request, user)
return Response(serializer.data)


class AuthToken(ObtainAuthToken):
"""
POST your username and password here to retrieve your API authentication token.
"""
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES


class ResetAuthToken(AuthToken):
"""
POST your username and password here to reset your API authentication token.
"""

def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
# Delete the current token
user.auth_token.delete()

# Create a new token
token = Token.objects.create(user=user)
return Response({'token': token.key})
6 changes: 6 additions & 0 deletions stackdio/api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ class UserSerializer(StackdioHyperlinkedModelSerializer):

channels = HyperlinkedField(view_name='api:users:currentuser-channel-list')

token = HyperlinkedField(view_name='api:users:currentuser-token')

reset_token = HyperlinkedField(view_name='api:users:currentuser-token-reset')

change_password = HyperlinkedField(view_name='api:users:currentuser-password')

class Meta:
Expand All @@ -183,6 +187,8 @@ class Meta:
'last_login',
'groups',
'channels',
'token',
'reset_token',
'change_password',
'settings',
)
Expand Down
10 changes: 9 additions & 1 deletion stackdio/api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
from __future__ import unicode_literals

from django.conf.urls import include, url

from stackdio.core import routers

from . import api


Expand Down Expand Up @@ -100,6 +100,14 @@
api.CurrentUserDetailAPIView.as_view(),
name='currentuser-detail'),

url(r'^user/token/$',
api.AuthToken.as_view(),
name='currentuser-token'),

url(r'^user/token/reset/$',
api.ResetAuthToken.as_view(),
name='currentuser-token-reset'),

url(r'^user/channels/$',
api.CurrentUserChannelListAPIView.as_view(),
name='currentuser-channel-list'),
Expand Down
17 changes: 16 additions & 1 deletion stackdio/core/tests/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# limitations under the License.
#

from __future__ import unicode_literals

from rest_framework import status

from stackdio.core.tests.utils import StackdioTestCase
Expand All @@ -30,6 +32,13 @@ class AuthenticationTestCase(StackdioTestCase):
# These don't allow get requests
EXEMPT_ENDPOINTS = (
'/api/user/password/',
'/api/user/token/',
'/api/user/token/reset/',
)

NO_AUTH_ENDPOINTS = (
'/api/user/token/',
'/api/user/token/reset/',
)

PERMISSION_MODELS = (
Expand Down Expand Up @@ -88,7 +97,13 @@ def setUp(self):
def test_permission_denied(self):
for url in self.list_endpoints:
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
expected = status.HTTP_403_FORBIDDEN
if url in self.NO_AUTH_ENDPOINTS:
expected = status.HTTP_200_OK
if url in self.EXEMPT_ENDPOINTS:
expected = status.HTTP_405_METHOD_NOT_ALLOWED

self.assertEqual(response.status_code, expected)

def test_success_admin(self):
self.client.login(username='test.admin', password='1234')
Expand Down
9 changes: 9 additions & 0 deletions stackdio/server/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@
# Authentication
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
),

Expand Down Expand Up @@ -381,6 +382,14 @@
CELERY_REDIRECT_STDOUTS = False
CELERY_DEFAULT_QUEUE = 'default'

# Make sure workers don't prefetch tasks - otherwise you can end up with a single worker
# claiming multiple orchestration tasks, and it will only run 1 at a time even though
# there are other idle workers
CELERYD_PREFETCH_MULTIPLIER = 1

# Also enable late acks, so tasks don't get acked until they're finished
CELERY_ACKS_LATE = True

# Serializer settings
# We'll use json since pickle can sometimes be insecure
CELERY_RESULT_SERIALIZER = 'json'
Expand Down
67 changes: 66 additions & 1 deletion stackdio/ui/static/stackdio/app/viewmodels/user-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,20 @@
define([
'jquery',
'knockout',
'bootbox',
'utils/utils',
'models/user'
], function($, ko, User) {
], function($, ko, bootbox, utils, User) {
'use strict';

return function() {
var self = this;

// View variables
self.user = null;
self.userTokenShown = ko.observable();
self.userToken = ko.observable();
self.apiRootUrl = ko.observable();

// Override the breadcrumbs
self.breadcrumbs = [
Expand All @@ -46,6 +51,9 @@ define([

// Create the user object.
self.user = new User(null, self);
self.userTokenShown(false);
self.userToken(null);
self.apiRootUrl(window.location.origin + '/api/');
var $el = $('.checkbox-custom');
self.subscription = self.user.advanced.subscribe(function (newVal) {
if (newVal) {
Expand All @@ -60,6 +68,63 @@ define([
});
};

self.promptPassword = function (msg, callback) {
bootbox.prompt({
title: msg,
inputType: 'password',
callback: function (password) {
if (!password) return;
callback(password);
}
});
};

self.showUserToken = function () {
self.promptPassword('Enter password to retrieve token',
function (password) {
$.ajax({
method: 'POST',
url: '/api/user/token/',
data: JSON.stringify({
username: self.user.username(),
password: password
})
}).done(function (resp) {
self.userToken(resp.token);
self.userTokenShown(true);
}).fail(function (jqxhr) {
utils.growlAlert('Failed to retrieve API token. Make sure you entered the correct password.', 'danger');
});
});
};

self.resetUserToken = function () {
self.promptPassword('Enter password to reset token',
function (password) {
bootbox.confirm({
title: 'Confirm token reset',
message: 'Are you sure you want to reset your API token? It will permanently be deleted and will be deactivated immediately.',
callback: function (result) {
// Bail now if the didn't confirm
if (!result) return;

$.ajax({
method: 'POST',
url: '/api/user/token/reset/',
data: JSON.stringify({
username: self.user.username(),
password: password
})
}).done(function (resp) {
self.userToken(resp.token);
self.userTokenShown(true);
}).fail(function (jqxhr) {
utils.growlAlert('Failed to reset API token. Make sure you entered the correct password.', 'danger');
});
}
});
});
};

// Start everything up
self.reset();
Expand Down
36 changes: 36 additions & 0 deletions stackdio/ui/templates/users/user-profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@
data-bind="text: user.lastLogin().calendar()"></p>
</div>

<div class="form-group" id="token">
<label for="userToken">API Token</label>
<p class="form-control-static" id="userToken">
<a href="" data-bind="visible: !userTokenShown(), click: showUserToken">Show API Token</a>
<span data-bind="visible: userTokenShown">
<code data-bind="text: userToken"></code>
<br>
<br>
<a href="" data-toggle="modal" data-target="#token-help">API Token Help</a>
<br>
<br>
<a href="" data-bind="click: resetUserToken">Reset API Token</a>
</span>
</p>
</div>

<button type="submit" class="btn btn-primary">Save</button>
{% if not ldap %}
<a class="btn btn-info" href="{% url 'ui:user-password-change' %}">Change Password</a>
Expand Down Expand Up @@ -93,4 +109,24 @@
</div>
</div>
</form>

<div class="modal fade" id="token-help" tabindex="-1" role="dialog" aria-labelledby="token-help-label">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="token-help-label">How to use the API Token</h4>
</div>
<div class="modal-body">
<p>To use your API token, you must set the <code>Authorization</code> HTTP header, prefixed by the string literal &quot;Token&quot;. For example:</p>
<pre>Authorization: Token <span data-bind="text: userToken"></span></pre>
<p>Try running this example curl command: (It already contains <strong>your</strong> token)</p>
<pre>curl -X GET <span data-bind="text: apiRootUrl"></span> \<br> -H 'Authorization: Token <span data-bind="text: userToken"></span>'</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}

0 comments on commit e4d421a

Please sign in to comment.