Repository: ckan/ckan

Issue:
Changing your user name produces an error and logs you out
If you edit your user and change your user name, it says "Profile updated" (which is good - it worked) and "Unauthorized to edit a user" and you are now logged out.

All the other user fields work ok.



Discussion:
BTW there is a test for this in https://github.com/ckan/ckan/pull/2393 with the name field commented out

@davidread do you have time to have a look at investigate a bit more what might be happening? cheers

I've got a bit of a backlog, and we don't use this feature in DGU, so I'd rather not, unless it can wait a couple of weeks

So I believe I've tracked this down to the [BaseController](https://github.com/ckan/ckan/blob/master/ckan/lib/base.py#L325), which is superseding the redirect_to call in _save_edit. The comment in there says the user needs to be forced to logout when they update their name (I'm not sure if this is correct) which is what is causing the issue. 

I think this is redirecting back to the /user/edit/<username> page after performing the logout function which is what is generating the 401 error and then forcing the user to the login page since they're currently unauthenticated.

As for a fix, I'm not sure what the best way to approach this is, but I can probably dig around this week and see what I come up with.

Good spot finding the relevant code.

The authtkt cookie contains the user's name, so when you change your user.name in the database, then the cookie is out of date. Then when it gets to that code in BaseController you referenced, it finds that user name isn't in the database and therefore the best thing is to log you out. (The cookie's user name is read by the AuthKit middleware and that sets request.environ['REMOTE_USER'].)

The best solution would be to tell authtkt to change the cookie to have the new username.
Failing that, CKAN could just warn the user that they will have to log-in again if they change their username.


Resolving patch:
diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py
--- a/ckan/controllers/user.py
+++ b/ckan/controllers/user.py
@@ -35,6 +35,15 @@
 unflatten = dictization_functions.unflatten
 
 
+def set_repoze_user(user_id):
+    '''Set the repoze.who cookie to match a given user_id'''
+    if 'repoze.who.plugins' in request.environ:
+        rememberer = request.environ['repoze.who.plugins']['friendlyform']
+        identity = {'repoze.who.userid': user_id}
+        response.headerlist += rememberer.remember(request.environ,
+                                                   identity)
+
+
 class UserController(base.BaseController):
     def __before__(self, action, **env):
         base.BaseController.__before__(self, action, **env)
@@ -245,10 +254,7 @@ def _save_new(self, context):
             return self.new(data_dict, errors, error_summary)
         if not c.user:
             # log the user in programatically
-            rememberer = request.environ['repoze.who.plugins']['friendlyform']
-            identity = {'repoze.who.userid': data_dict['name']}
-            response.headerlist += rememberer.remember(request.environ,
-                                                       identity)
+            set_repoze_user(data_dict['name'])
             h.redirect_to(controller='user', action='me', __ckan_no_root=True)
         else:
             # #1799 User has managed to register whilst logged in - warn user
@@ -321,6 +327,12 @@ def edit(self, id=None, data=None, errors=None, error_summary=None):
 
     def _save_edit(self, id, context):
         try:
+            if id in (c.userobj.id, c.userobj.name):
+                current_user = True
+            else:
+                current_user = False
+            old_username = c.userobj.name
+
             data_dict = logic.clean_dict(unflatten(
                 logic.tuplize_dict(logic.parse_params(request.params))))
             context['message'] = data_dict.get('log_message', '')
@@ -343,6 +355,11 @@ def _save_edit(self, id, context):
 
             user = get_action('user_update')(context, data_dict)
             h.flash_success(_('Profile updated'))
+
+            if current_user and data_dict['name'] != old_username:
+                # Changing currently logged in user's name.
+                # Update repoze.who cookie to match
+                set_repoze_user(data_dict['name'])
             h.redirect_to(controller='user', action='read', id=user['name'])
         except NotAuthorized:
             abort(403, _('Unauthorized to edit user %s') % id)


Test patch:
diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py
--- a/ckan/tests/controllers/test_user.py
+++ b/ckan/tests/controllers/test_user.py
@@ -289,6 +289,100 @@ def test_email_change_with_password(self):
         response = submit_and_follow(app, form, env, 'save')
         assert_true('Profile updated' in response)
 
+    def test_edit_user_logged_in_username_change(self):
+
+        user_pass = 'pass'
+        user = factories.User(password=user_pass)
+        app = self._get_test_app()
+
+        # Have to do an actual login as this test relys on repoze cookie handling.
+        # get the form
+        response = app.get('/user/login')
+        # ...it's the second one
+        login_form = response.forms[1]
+        # fill it in
+        login_form['login'] = user['name']
+        login_form['password'] = user_pass
+        # submit it
+        login_form.submit()
+
+        # Now the cookie is set, run the test
+        response = app.get(
+            url=url_for(controller='user', action='edit'),
+        )
+        # existing values in the form
+        form = response.forms['user-edit-form']
+
+        # new values
+        form['name'] = 'new-name'
+        response = submit_and_follow(app, form, name='save')
+        response = helpers.webtest_maybe_follow(response)
+
+        expected_url = url_for(controller='user', action='read', id='new-name')
+        assert response.request.path == expected_url
+
+    def test_edit_user_logged_in_username_change_by_name(self):
+        user_pass = 'pass'
+        user = factories.User(password=user_pass)
+        app = self._get_test_app()
+
+        # Have to do an actual login as this test relys on repoze cookie handling.
+        # get the form
+        response = app.get('/user/login')
+        # ...it's the second one
+        login_form = response.forms[1]
+        # fill it in
+        login_form['login'] = user['name']
+        login_form['password'] = user_pass
+        # submit it
+        login_form.submit()
+
+        # Now the cookie is set, run the test
+        response = app.get(
+            url=url_for(controller='user', action='edit', id=user['name']),
+        )
+        # existing values in the form
+        form = response.forms['user-edit-form']
+
+        # new values
+        form['name'] = 'new-name'
+        response = submit_and_follow(app, form, name='save')
+        response = helpers.webtest_maybe_follow(response)
+
+        expected_url = url_for(controller='user', action='read', id='new-name')
+        assert response.request.path == expected_url
+
+    def test_edit_user_logged_in_username_change_by_id(self):
+        user_pass = 'pass'
+        user = factories.User(password=user_pass)
+        app = self._get_test_app()
+
+        # Have to do an actual login as this test relys on repoze cookie handling.
+        # get the form
+        response = app.get('/user/login')
+        # ...it's the second one
+        login_form = response.forms[1]
+        # fill it in
+        login_form['login'] = user['name']
+        login_form['password'] = user_pass
+        # submit it
+        login_form.submit()
+
+        # Now the cookie is set, run the test
+        response = app.get(
+            url=url_for(controller='user', action='edit', id=user['id']),
+        )
+        # existing values in the form
+        form = response.forms['user-edit-form']
+
+        # new values
+        form['name'] = 'new-name'
+        response = submit_and_follow(app, form, name='save')
+        response = helpers.webtest_maybe_follow(response)
+
+        expected_url = url_for(controller='user', action='read', id='new-name')
+        assert response.request.path == expected_url
+
     def test_perform_reset_for_key_change(self):
         password = 'password'
         params = {'password1': password, 'password2': password}
diff --git a/ckan/tests/helpers.py b/ckan/tests/helpers.py
--- a/ckan/tests/helpers.py
+++ b/ckan/tests/helpers.py
@@ -188,6 +188,8 @@ def _apply_config_changes(cls, cfg):
     def setup(self):
         '''Reset the database and clear the search indexes.'''
         reset_db()
+        if hasattr(self, '_test_app'):
+            self._test_app.reset()
         search.clear_all()
 
     @classmethod