Repository: deis/deis

Issue:
Can't change password once set
The Deis cli needs a way to change an existing user's password, perhaps `deis auth:update` aliased to `deis changepassword`?

The current workaround is this:

```
$ ssh core@deis.myhost.whatever
$ nse deis-controller
$ cd app && manage.py changepassword [user]
```



Discussion:
surprised that we didn't have a bug on this already. Yes, we should expose a password change policy.


Resolving patch:
diff --git a/client/deis.py b/client/deis.py
--- a/client/deis.py
+++ b/client/deis.py
@@ -659,10 +659,11 @@ def auth(self, args):
         Valid commands for auth:
 
         auth:register          register a new user
-        auth:cancel            remove the current account
         auth:login             authenticate against a controller
         auth:logout            clear the current user session
-        auth:whoami            display the authenticated user
+        auth:passwd            change the password for the current user
+        auth:whoami            display the current user
+        auth:cancel            remove the current user account
 
         Use `deis help [command]` to learn more.
         """
@@ -794,6 +795,40 @@ def auth_logout(self, args):
         self._settings.save()
         self._logger.info('Logged out')
 
+    def auth_passwd(self, args):
+        """
+        Changes the password for the current user.
+
+        Usage: deis auth:passwd [options]
+
+        Options:
+          --password=<password>
+            provide the current password for the account.
+          --new-password=<new-password>
+            provide a new password for the account.
+        """
+        if not self._settings.get('token'):
+            raise EnvironmentError(
+                'Could not find token. Use `deis login` or `deis register` to get started.')
+        password = args.get('--password')
+        if not password:
+            password = getpass('current password: ')
+        new_password = args.get('--new-password')
+        if not new_password:
+            new_password = getpass('new password: ')
+            confirm = getpass('new password (confirm): ')
+            if new_password != confirm:
+                self._logger.error('Password mismatch, not changing.')
+                sys.exit(1)
+        payload = {'password': password, 'new_password': new_password}
+        response = self._dispatch('post', "/v1/auth/passwd", json.dumps(payload))
+        if response.status_code == requests.codes.ok:
+            self._logger.info('Password change succeeded.')
+        else:
+            self._logger.info("Password change failed: {}".format(response.text))
+            sys.exit(1)
+        return True
+
     def auth_whoami(self, args):
         """
         Displays the currently logged in user.
@@ -1913,6 +1948,7 @@ def shortcuts(self, args):
     ('logout', 'auth:logout'),
     ('logs', 'apps:logs'),
     ('open', 'apps:open'),
+    ('passwd', 'auth:passwd'),
     ('pull', 'builds:create'),
     ('register', 'auth:register'),
     ('rollback', 'releases:rollback'),
diff --git a/controller/api/urls.py b/controller/api/urls.py
--- a/controller/api/urls.py
+++ b/controller/api/urls.py
@@ -169,10 +169,14 @@
 
   Create a new User.
 
-.. http:delete:: /v1/auth/register/
+.. http:delete:: /v1/auth/cancel/
 
   Destroy the logged-in User.
 
+.. http:post:: /v1/auth/passwd/
+
+  Update the password of the logged-in User.
+
 .. http:get:: /v1/auth/login/
 
   Generate an API key.
@@ -272,7 +276,9 @@
     url(r'^auth/register/?',
         views.UserRegistrationView.as_view({'post': 'create'})),
     url(r'^auth/cancel/?',
-        views.UserCancellationView.as_view({'delete': 'destroy'})),
+        views.UserManagementView.as_view({'delete': 'destroy'})),
+    url(r'^auth/passwd/?',
+        views.UserManagementView.as_view({'post': 'passwd'})),
     url(r'^auth/login/',
         'rest_framework.authtoken.views.obtain_auth_token'),
     # admin sharing
diff --git a/controller/api/views.py b/controller/api/views.py
--- a/controller/api/views.py
+++ b/controller/api/views.py
@@ -58,11 +58,20 @@ def pre_save(self, obj):
             obj.is_superuser = obj.is_staff = True
 
 
-class UserCancellationView(viewsets.GenericViewSet,
-                           viewsets.mixins.DestroyModelMixin):
+class UserManagementView(viewsets.GenericViewSet,
+                         viewsets.mixins.CreateModelMixin,
+                         viewsets.mixins.DestroyModelMixin):
     model = User
     permission_classes = (permissions.IsAuthenticated,)
 
+    def passwd(self, request, *args, **kwargs):
+        obj = self.request.user
+        if not obj.check_password(request.DATA['password']):
+            return Response("Current password did not match", status=status.HTTP_400_BAD_REQUEST)
+        obj.set_password(request.DATA['new_password'])
+        obj.save()
+        return Response({'status': 'password set'})
+
     def destroy(self, request, *args, **kwargs):
         obj = self.request.user
         obj.delete()


Test patch:
diff --git a/controller/api/tests/test_auth.py b/controller/api/tests/test_auth.py
--- a/controller/api/tests/test_auth.py
+++ b/controller/api/tests/test_auth.py
@@ -99,3 +99,49 @@ def test_cancel(self):
         response = self.client.delete(url,
                                       HTTP_AUTHORIZATION='token {}'.format(token))
         self.assertEqual(response.status_code, 204)
+
+    def test_passwd(self):
+        """Test that a registered user can change the password."""
+        # test registration workflow
+        username, password = 'newuser', 'password'
+        first_name, last_name = 'Otto', 'Test'
+        email = 'autotest@deis.io'
+        submit = {
+            'username': username,
+            'password': password,
+            'first_name': first_name,
+            'last_name': last_name,
+            'email': email,
+        }
+        url = '/v1/auth/register'
+        response = self.client.post(url, json.dumps(submit), content_type='application/json')
+        self.assertEqual(response.status_code, 201)
+        # change password
+        url = '/v1/auth/passwd'
+        user = User.objects.get(username=username)
+        token = Token.objects.get(user=user).key
+        submit = {
+            'password': 'password2',
+            'new_password': password,
+        }
+        response = self.client.post(url, json.dumps(submit), content_type='application/json',
+                                    HTTP_AUTHORIZATION='token {}'.format(token))
+        self.assertEqual(response.status_code, 400)
+        submit = {
+            'password': password,
+            'new_password': 'password2',
+        }
+        response = self.client.post(url, json.dumps(submit), content_type='application/json',
+                                    HTTP_AUTHORIZATION='token {}'.format(token))
+        self.assertEqual(response.status_code, 200)
+        # test login with old password
+        url = '/v1/auth/login/'
+        payload = urllib.urlencode({'username': username, 'password': password})
+        response = self.client.post(url, data=payload,
+                                    content_type='application/x-www-form-urlencoded')
+        self.assertEqual(response.status_code, 400)
+        # test login with new password
+        payload = urllib.urlencode({'username': username, 'password': 'password2'})
+        response = self.client.post(url, data=payload,
+                                    content_type='application/x-www-form-urlencoded')
+        self.assertEqual(response.status_code, 200)
diff --git a/tests/auth_test.go b/tests/auth_test.go
--- a/tests/auth_test.go
+++ b/tests/auth_test.go
@@ -20,6 +20,7 @@ func TestAuth(t *testing.T) {
 	authLogoutTest(t, params)
 	authLoginTest(t, params)
 	authWhoamiTest(t, params)
+	authPasswdTest(t, params)
 	authCancel(t, params)
 }
 
@@ -44,6 +45,15 @@ func authLogoutTest(t *testing.T, params *utils.DeisTestConfig) {
 	utils.Execute(t, authLogoutCmd, params, false, "")
 }
 
+func authPasswdTest(t *testing.T, params *utils.DeisTestConfig) {
+	password := "aNewPassword"
+	utils.AuthPasswd(t, params, password)
+	cmd := authLoginCmd
+	utils.Execute(t, cmd, params, true, "400 BAD REQUEST")
+	params.Password = password
+	utils.Execute(t, cmd, params, false, "")
+}
+
 func authRegisterTest(t *testing.T, params *utils.DeisTestConfig) {
 	cmd := authRegisterCmd
 	utils.Execute(t, cmd, params, false, "")
diff --git a/tests/utils/itutils.go b/tests/utils/itutils.go
--- a/tests/utils/itutils.go
+++ b/tests/utils/itutils.go
@@ -149,7 +149,38 @@ func AuthCancel(t *testing.T, params *DeisTestConfig) {
 		t.Fatalf("command executiuon failed\n%v", err)
 	}
 	child.Close()
+}
 
+// AuthPasswd tests whether `deis auth:passwd` updates a user's password.
+func AuthPasswd(t *testing.T, params *DeisTestConfig, password string) {
+	fmt.Println("deis auth:passwd")
+	child, err := gexpect.Spawn(Deis + " auth:passwd")
+	if err != nil {
+		t.Fatalf("command not started\n%v", err)
+	}
+	fmt.Println("current password:")
+	err = child.Expect("current password: ")
+	if err != nil {
+		t.Fatalf("expect password failed\n%v", err)
+	}
+	child.SendLine(params.Password)
+	fmt.Println("new password:")
+	err = child.Expect("new password: ")
+	if err != nil {
+		t.Fatalf("expect password failed\n%v", err)
+	}
+	child.SendLine(password)
+	fmt.Println("new password (confirm):")
+	err = child.Expect("new password (confirm): ")
+	if err != nil {
+		t.Fatalf("expect password failed\n%v", err)
+	}
+	child.SendLine(password)
+	err = child.Expect("Password change succeeded")
+	if err != nil {
+		t.Fatalf("command executiuon failed\n%v", err)
+	}
+	child.Close()
 }
 
 // CheckList executes a command and optionally tests whether its output does