Revision cbf34809
calamari_rest: User account PATCH/PUT (password change)
Users can change their own password but not those of
other users. Refactored the user view code to
avoid a separate view for /user/me, so that all
ops can refer to a PK or 'me'.
Fixes: #7097
rest-api/calamari_rest/serializers/v1.py | ||
---|---|---|
43 | 43 |
""" |
44 | 44 |
class Meta: |
45 | 45 |
model = User |
46 |
fields = ('id', 'username', 'password') |
|
46 |
fields = ('id', 'username', 'password', 'email')
|
|
47 | 47 |
|
48 | 48 |
def to_native(self, obj): |
49 | 49 |
# Before conversion, remove the password field. This prevents the hash |
rest-api/calamari_rest/urls/v1.py | ||
---|---|---|
4 | 4 |
|
5 | 5 |
router = routers.DefaultRouter(trailing_slash=False) |
6 | 6 |
|
7 |
# In v1, the /user list URL existed but only told you usernames |
|
8 |
# In v2 it should include displaynames and email addresses too (TODO #7097) |
|
7 |
# In v1, the user/ URL existed but did almost nothing, it only supported GET and told |
|
8 |
# you your current username. |
|
9 |
# In v2, the view gets filled out to return the displayname and email address to, and support |
|
10 |
# PUT/PATCH operations for users to change their passwords |
|
9 | 11 |
router.register(r'user', calamari_rest.views.v1.UserViewSet) |
10 | 12 |
|
11 | 13 |
# The cluster view exists in both v1 and v2 |
... | ... | |
17 | 19 |
urlpatterns = patterns( |
18 | 20 |
'', |
19 | 21 |
|
20 |
# In v1, the user/me URL existed but did almost nothing, it only supported GET and told |
|
21 |
# you your current username. |
|
22 |
# In v2, the view gets filled out to return the displayname and email address to, and support |
|
23 |
# PUT/PATCH operations for users to change their passwords (TODO #7097) |
|
24 |
url(r'^user/me$', calamari_rest.views.v1.UserMe.as_view()), |
|
25 | 22 |
# In v1 this required a POST but also allowed GET for some reason |
26 | 23 |
# In v2 it's post only |
27 | 24 |
url(r'^auth/login', calamari_rest.views.v1.login), |
rest-api/calamari_rest/urls/v2.py | ||
---|---|---|
20 | 20 |
url(r'^info', calamari_rest.views.v1.Info.as_view()), |
21 | 21 |
|
22 | 22 |
# Wrapping django auth |
23 |
url(r'^user/me', calamari_rest.views.v1.UserMe.as_view()), |
|
24 | 23 |
url(r'^auth/login', calamari_rest.views.v1.login), |
25 | 24 |
url(r'^auth/logout', calamari_rest.views.v1.logout), |
26 | 25 |
|
rest-api/calamari_rest/views/v1.py | ||
---|---|---|
1 | 1 |
|
2 | 2 |
import logging |
3 |
from django.core.exceptions import ValidationError, PermissionDenied |
|
3 | 4 |
from django.core.urlresolvers import reverse |
5 |
from django.http import Http404 |
|
4 | 6 |
import pytz |
5 | 7 |
import socket |
6 | 8 |
|
7 | 9 |
from rest_framework import viewsets |
10 |
from rest_framework.exceptions import AuthenticationFailed |
|
8 | 11 |
from rest_framework.response import Response |
9 | 12 |
from rest_framework.decorators import api_view |
10 | 13 |
from rest_framework.decorators import permission_classes |
... | ... | |
178 | 181 |
|
179 | 182 |
class UserViewSet(viewsets.ModelViewSet): |
180 | 183 |
""" |
181 |
The Calamari UI/API user account information |
|
182 |
""" |
|
183 |
queryset = User.objects.all() |
|
184 |
serializer_class = UserSerializer |
|
184 |
The Calamari UI/API user account information. |
|
185 | 185 |
|
186 |
You may pass 'me' as the user ID to refer to the currently logged in user, |
|
187 |
otherwise the user ID is a numeric ID. |
|
186 | 188 |
|
187 |
class UserMe(APIView): |
|
189 |
Because all users are superusers, everybody can see each others accounts |
|
190 |
using this resource. However, users can only modify their own account (i.e. |
|
191 |
the user being modified must be the user associated with the current login session). |
|
188 | 192 |
""" |
189 |
Return information about the current user. If the user is not authenticated |
|
190 |
(i.e. an anonymous user), then 401 is returned with an error message. |
|
191 |
""" |
|
192 |
permission_classes = (AllowAny,) |
|
193 |
queryset = User.objects.all() |
|
193 | 194 |
serializer_class = UserSerializer |
194 | 195 |
|
195 |
@never_cache |
|
196 |
def get(self, request): |
|
197 |
if request.user.is_authenticated(): |
|
198 |
return Response(self.serializer_class(request.user).data) |
|
199 |
return Response({ |
|
200 |
'message': 'Session expired or invalid', |
|
201 |
}, status.HTTP_401_UNAUTHORIZED) |
|
196 |
def _get_user(self, request, user_id): |
|
197 |
if user_id == "me": |
|
198 |
if request.user.is_authenticated(): |
|
199 |
return request.user |
|
200 |
else: |
|
201 |
raise AuthenticationFailed() |
|
202 |
else: |
|
203 |
try: |
|
204 |
user = self.queryset.get(pk=user_id) |
|
205 |
except User.DoesNotExist: |
|
206 |
raise Http404("User not found") |
|
207 |
else: |
|
208 |
return user |
|
209 |
|
|
210 |
def update(self, request, *args, **kwargs): |
|
211 |
# Note that unlike the parent update() we do not support |
|
212 |
# creating users with PUT. |
|
213 |
partial = kwargs.pop('partial', False) |
|
214 |
user = self.get_object() |
|
215 |
|
|
216 |
if user.id != self.request.user.id: |
|
217 |
raise PermissionDenied("May not change another user's password") |
|
218 |
|
|
219 |
serializer = self.get_serializer(user, data=request.DATA, partial=partial) |
|
220 |
|
|
221 |
if serializer.is_valid(): |
|
222 |
try: |
|
223 |
self.pre_save(serializer.object) |
|
224 |
except ValidationError as err: |
|
225 |
# full_clean on model instance may be called in pre_save, so we |
|
226 |
# have to handle eventual errors. |
|
227 |
return Response(err.message_dict, status=status.HTTP_400_BAD_REQUEST) |
|
228 |
user = serializer.save(force_update=True) |
|
229 |
log.debug("saved user %s" % user) |
|
230 |
# self.post_save(user, created=False) |
|
231 |
return Response(serializer.data, status=status.HTTP_200_OK) |
|
232 |
else: |
|
233 |
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) |
|
234 |
|
|
235 |
def get_object(self, queryset=None): |
|
236 |
user = self._get_user(self.request, self.kwargs['pk']) |
|
237 |
if self.kwargs['pk'] == 'me': |
|
238 |
self.kwargs['pk'] = user.id |
|
239 |
return user |
|
202 | 240 |
|
203 | 241 |
|
204 | 242 |
@api_view(['GET', 'POST']) |
rest-api/tests/rest_api_unit_test.py | ||
---|---|---|
1 |
|
|
2 |
from django.contrib.auth.models import User |
|
3 |
from rest_framework.test import APIClient |
|
4 |
from django.test import TestCase |
|
5 |
import mock |
|
6 |
|
|
7 |
import calamari_rest.views.rpc_view |
|
8 |
|
|
9 |
|
|
10 |
class RestApiUnitTest(TestCase): |
|
11 |
login = True # Should setUp log in for us? |
|
12 |
USERNAME = 'admin' |
|
13 |
PASSWORD = 'admin' |
|
14 |
|
|
15 |
def setUp(self): |
|
16 |
# Patch in a mock for RPCs |
|
17 |
rpc = mock.Mock() |
|
18 |
self.rpc = rpc |
|
19 |
old_init = calamari_rest.views.rpc_view.RPCViewSet.__init__ |
|
20 |
|
|
21 |
def init(_self, *args, **kwargs): |
|
22 |
old_init(_self, *args, **kwargs) |
|
23 |
_self.client = rpc |
|
24 |
|
|
25 |
self._old_init = old_init |
|
26 |
calamari_rest.views.rpc_view.RPCViewSet.__init__ = init |
|
27 |
|
|
28 |
# Create a user to log in as |
|
29 |
User.objects.create_superuser(self.USERNAME, 'admin@admin.com', self.PASSWORD) |
|
30 |
|
|
31 |
# A client for performing requests to the API |
|
32 |
self.client = APIClient() |
|
33 |
if self.login: |
|
34 |
self.client.login(username='admin', password='admin') |
|
35 |
|
|
36 |
def tearDown(self): |
|
37 |
calamari_rest.views.rpc_view.RPCViewSet.__init__ = self._old_init |
|
38 |
|
|
39 |
def assertStatus(self, response, status_code): |
|
40 |
self.assertEqual(response.status_code, status_code, "Bad status %s, wanted %s (%s)" % ( |
|
41 |
response.status_code, status_code, response.data |
|
42 |
)) |
rest-api/tests/test_auth.py | ||
---|---|---|
1 |
from django.contrib.auth.models import User |
|
2 |
from rest_framework import status |
|
3 |
from tests.rest_api_unit_test import RestApiUnitTest |
|
4 |
|
|
5 |
|
|
6 |
class TestAuthentication(RestApiUnitTest): |
|
7 |
login = False |
|
8 |
|
|
9 |
# A URI that should require login |
|
10 |
SOMETHING_PRIVILEGED = "/api/v2/grains" |
|
11 |
|
|
12 |
def test_login(self): |
|
13 |
# I should get a 403 accessing something privileged |
|
14 |
response = self.client.get(self.SOMETHING_PRIVILEGED) |
|
15 |
self.assertStatus(response, status.HTTP_403_FORBIDDEN) |
|
16 |
|
|
17 |
# I should get a 403 trying to read /user/me |
|
18 |
response = self.client.get("/api/v2/user/me") |
|
19 |
self.assertStatus(response, status.HTTP_403_FORBIDDEN) |
|
20 |
|
|
21 |
# Now log in with valid credentials |
|
22 |
response = self.client.post("/api/v2/auth/login", { |
|
23 |
'username': self.USERNAME, |
|
24 |
'password': self.PASSWORD |
|
25 |
}, format="json") |
|
26 |
self.assertStatus(response, status.HTTP_200_OK) |
|
27 |
|
|
28 |
# I should be able to access something privileged |
|
29 |
response = self.client.get(self.SOMETHING_PRIVILEGED) |
|
30 |
self.assertStatus(response, status.HTTP_200_OK) |
|
31 |
|
|
32 |
# Now log out |
|
33 |
response = self.client.post("/api/v2/auth/logout") |
|
34 |
self.assertStatus(response, status.HTTP_200_OK) |
|
35 |
|
|
36 |
# I should have lost the ability to access something privileged |
|
37 |
response = self.client.get(self.SOMETHING_PRIVILEGED) |
|
38 |
self.assertStatus(response, status.HTTP_403_FORBIDDEN) |
|
39 |
|
|
40 |
|
|
41 |
class TestUserChanges(RestApiUnitTest): |
|
42 |
login = True |
|
43 |
|
|
44 |
def test_change_my_password(self): |
|
45 |
""" |
|
46 |
That users can change their own password |
|
47 |
""" |
|
48 |
new_password = 'bob' |
|
49 |
self.assertNotEqual(new_password, self.PASSWORD) |
|
50 |
|
|
51 |
# Do a read-write for PUT |
|
52 |
response = self.client.get("/api/v2/user/me") |
|
53 |
self.assertStatus(response, status.HTTP_200_OK) |
|
54 |
user = response.data |
|
55 |
user['password'] = new_password |
|
56 |
response = self.client.put("/api/v2/user/me", user) |
|
57 |
self.assertStatus(response, status.HTTP_200_OK) |
|
58 |
|
|
59 |
# Now log out |
|
60 |
response = self.client.post("/api/v2/auth/logout") |
|
61 |
self.assertStatus(response, status.HTTP_200_OK) |
|
62 |
|
|
63 |
# Try logging in with old password, should fail |
|
64 |
response = self.client.post("/api/v2/auth/login", { |
|
65 |
'username': self.USERNAME, |
|
66 |
'password': self.PASSWORD |
|
67 |
}, format="json") |
|
68 |
self.assertStatus(response, status.HTTP_401_UNAUTHORIZED) |
|
69 |
|
|
70 |
# Try logging in with new password, should succeed |
|
71 |
response = self.client.post("/api/v2/auth/login", { |
|
72 |
'username': self.USERNAME, |
|
73 |
'password': new_password |
|
74 |
}, format="json") |
|
75 |
self.assertStatus(response, status.HTTP_200_OK) |
|
76 |
|
|
77 |
def test_change_anothers_password(self): |
|
78 |
""" |
|
79 |
That users cannot change one anothers' password. |
|
80 |
""" |
|
81 |
stranger = User.objects.create_superuser("stranger", "stranger@admin.com", "stranger_pwd") |
|
82 |
me = User.objects.get(username=self.USERNAME) |
|
83 |
# I can't change his |
|
84 |
response = self.client.patch("/api/v2/user/%s" % stranger.id, {'password': 'new'}) |
|
85 |
self.assertStatus(response, status.HTTP_403_FORBIDDEN) |
|
86 |
|
|
87 |
# But I can change my own |
|
88 |
response = self.client.patch("/api/v2/user/%s" % me.id, {'password': 'new'}) |
|
89 |
self.assertStatus(response, status.HTTP_200_OK) |
rest-api/tests/test_keys.py | ||
---|---|---|
1 | 1 |
|
2 |
from django.contrib.auth.models import User |
|
3 | 2 |
from rest_framework import status |
4 |
from rest_framework.test import APIClient |
|
5 |
from django.test import TestCase |
|
6 | 3 |
import mock |
7 | 4 |
|
8 |
import calamari_rest.views.rpc_view |
|
9 |
|
|
10 |
|
|
11 |
class RestApiUnitTest(TestCase): |
|
12 |
def setUp(self): |
|
13 |
# Patch in a mock for RPCs |
|
14 |
rpc = mock.Mock() |
|
15 |
self.rpc = rpc |
|
16 |
old_init = calamari_rest.views.rpc_view.RPCViewSet.__init__ |
|
17 |
|
|
18 |
def init(_self, *args, **kwargs): |
|
19 |
old_init(_self, *args, **kwargs) |
|
20 |
_self.client = rpc |
|
21 |
|
|
22 |
self._old_init = old_init |
|
23 |
calamari_rest.views.rpc_view.RPCViewSet.__init__ = init |
|
24 |
|
|
25 |
# Create a user to log in as |
|
26 |
User.objects.create_superuser('admin', 'admin@admin.com', 'admin') |
|
27 |
|
|
28 |
# A client for performing requests to the API |
|
29 |
self.client = APIClient() |
|
30 |
self.client.login(username='admin', password='admin') |
|
31 |
|
|
32 |
def tearDown(self): |
|
33 |
calamari_rest.views.rpc_view.RPCViewSet.__init__ = self._old_init |
|
5 |
from tests.rest_api_unit_test import RestApiUnitTest |
|
34 | 6 |
|
35 | 7 |
|
36 | 8 |
class TestKey(RestApiUnitTest): |
Also available in: Unified diff