Projet

Général

Profil

0002-add-new-switch-user-tool-34308.patch

Benjamin Dauvergne, 01 juillet 2019 10:51

Télécharger (16,6 ko)

Voir les différences:

Subject: [PATCH 2/2] add new switch-user tool (#34308)

 .../authentic2/manager/user_detail.html       |  2 +-
 .../templates/authentic2/manager/user_su.html | 19 ++++++
 src/authentic2/manager/urls.py                |  2 +
 src/authentic2/manager/user_views.py          | 49 ++++++++++++---
 .../static/authentic2/js/js_seconds_until.js  | 59 ++++++++++++-------
 src/authentic2/urls.py                        |  1 +
 src/authentic2/utils.py                       | 51 ++++++++--------
 src/authentic2/views.py                       | 25 ++++----
 tests/conftest.py                             | 15 ++++-
 tests/test_user_manager.py                    | 34 +++++++++++
 10 files changed, 186 insertions(+), 71 deletions(-)
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/user_su.html
src/authentic2/manager/templates/authentic2/manager/user_detail.html
1 1
{% extends "authentic2/manager/form.html" %}
2
{% load i18n %}
2
{% load i18n staticfiles %}
3 3

  
4 4
{% block bodyclasses %}{{ block.super }} with-actions{% endblock %}
5 5

  
src/authentic2/manager/templates/authentic2/manager/user_su.html
1
{% extends "authentic2/manager/base.html" %}
2
{% load i18n %}
3

  
4
{% block content %}
5
<form>
6
    <p>
7
      {% blocktrans trimmed with fullname=user.get_full_name %}
8
      To switch to user {{ fullname }}, use the following link
9
      (it expires after <span class="js-seconds-until" data-target="#su-link" data-replace="Expired!">{{ duration }}</span> seconds).
10
      {% endblocktrans %}
11
    </p>
12
    <p>
13
        <a id='su-link' href="{{ su_url }}">{{ su_url }}</a>
14
    </p>
15
    <script>
16
      window.a2_js_seconds_until();
17
    </script>
18
</form>
19
{% endblock %}
src/authentic2/manager/urls.py
64 64
        url(r'^users/(?P<pk>\d+)/change-email/$',
65 65
            user_views.user_change_email,
66 66
            name='a2-manager-user-change-email'),
67
        url(r'^users/(?P<pk>\d+)/su/$', user_views.su,
68
            name='a2-manager-user-su'),
67 69
        # by uuid
68 70
        url(r'^users/uuid:(?P<slug>[a-z0-9]+)/$', user_views.user_detail,
69 71
            name='a2-manager-user-by-uuid-detail'),
src/authentic2/manager/user_views.py
21 21
from django.db import models
22 22
from django.utils.translation import ugettext_lazy as _, ugettext
23 23
from django.utils.html import format_html
24
from django.core.exceptions import PermissionDenied
24 25
from django.core.mail import EmailMultiAlternatives
25 26
from django.template import loader
26 27
from django.core.urlresolvers import reverse
27
from django.contrib.auth import get_user_model
28
from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME
28 29
from django.contrib.contenttypes.models import ContentType
29 30
from django.contrib import messages
30
from django.views.generic import FormView, TemplateView
31
from django.views.generic import FormView, TemplateView, DetailView
31 32
from django.http import Http404, FileResponse
32 33

  
33 34
import tablib
34 35

  
35 36
from authentic2.models import Attribute, AttributeValue, PasswordReset
36
from authentic2.utils import switch_user, send_password_reset_mail, redirect, select_next_url
37
from authentic2.utils import build_su_url, send_password_reset_mail, redirect, select_next_url, make_url
37 38
from authentic2.a2_rbac.utils import get_default_ou
38 39
from authentic2 import hooks
39 40
from django_rbac.utils import get_role_model, get_role_parenting_model, get_ou_model
......
42 43
from .views import (BaseTableView, BaseAddView, BaseEditView, ActionMixin,
43 44
                    OtherActionsMixin, Action, ExportMixin, BaseSubTableView,
44 45
                    HideOUColumnMixin, BaseDeleteView, BaseDetailView,
45
                    PermissionMixin, MediaMixin)
46
                    TitleMixin, PermissionMixin, MediaMixin)
46 47
from .tables import UserTable, UserRolesTable, OuUserRolesTable
47 48
from .forms import (UserSearchForm, UserAddForm, UserEditForm,
48 49
                    UserChangePasswordForm, ChooseUserRoleForm,
......
52 53
from .utils import get_ou_count, has_show_username
53 54
from . import app_settings
54 55

  
56
User = get_user_model()
57

  
55 58

  
56 59
class UsersView(HideOUColumnMixin, BaseTableView):
57 60
    template_name = 'authentic2/manager/users.html'
......
240 243
                     url_name='a2-manager-user-change-password',
241 244
                     permission='custom_user.change_password_user')
242 245
        if self.request.user.is_superuser:
243
            yield Action('switch_user', _('Impersonate this user'))
246
            yield Action('su', _('Impersonate this user'),
247
                         url_name='a2-manager-user-su')
244 248
        if self.object.ou and self.object.ou.validate_emails:
245 249
            yield Action('change_email', _('Change user email'),
246 250
                         url_name='a2-manager-user-change-email',
......
274 278
    def action_delete_password_reset(self, request, *args, **kwargs):
275 279
        PasswordReset.objects.filter(user=self.object).delete()
276 280

  
277
    def action_switch_user(self, request, *args, **kwargs):
278
        return switch_user(request, self.object)
281
    def action_su(self, request, *args, **kwargs):
282
        return redirect(request, 'auth_logout',
283
                        params={REDIRECT_FIELD_NAME: build_su_url(self.object)})
279 284

  
280 285
    # Copied from PasswordResetForm implementation
281 286
    def send_mail(self, subject_template_name, email_template_name,
......
752 757
        return ctx
753 758

  
754 759
user_import_report = UserImportReportView.as_view()
760

  
761

  
762
class UserSuView(MediaMixin, TitleMixin, PermissionMixin, DetailView):
763
    model = User
764
    template_name = 'authentic2/manager/user_su.html'
765
    title = _('Switch user')
766
    duration = 30  # seconds
767

  
768
    class Media:
769
        js = (
770
            'authentic2/js/js_seconds_until.js',
771
        )
772

  
773
    def dispatch(self, request, *args, **kwargs):
774
        if not request.user.is_superuser:
775
            raise PermissionDenied
776
        return super(UserSuView, self).dispatch(request, *args, **kwargs)
777

  
778
    def get_context_data(self, **kwargs):
779
        ctx = super(UserSuView, self).get_context_data(**kwargs)
780
        ctx['su_url'] = make_url(
781
            'auth_logout',
782

  
783
            params={REDIRECT_FIELD_NAME: build_su_url(self.object, self.duration)},
784
            request=self.request,
785
            absolute=True)
786
        ctx['duration'] = self.duration
787
        return ctx
788

  
789
su = UserSuView.as_view()
src/authentic2/static/authentic2/js/js_seconds_until.js
1 1
(function () {
2
  var spans = document.getElementsByClassName('js-seconds-until');
3
  if (! spans.length) {
4
    return;
5
  }
6
  var span = spans[0];
7
  var timeout_id;
8
  var initial_time = Date.now();
9
  var until = initial_time + parseInt(span.textContent) * 1000;
2
  window.a2_js_seconds_until = function () {
3
      var spans = document.getElementsByClassName('js-seconds-until');
4
      if (! spans.length) {
5
        return;
6
      }
7
      var span = spans[0];
8
      var timeout_id;
9
      var initial_time = Date.now();
10
      var until = initial_time + parseInt(span.textContent) * 1000;
10 11

  
11
  function decrease_seconds() {
12
    var now = Date.now();
13
    var duration = (until - now) / 1000;
14
    if (duration < 1) {
15
       /* remove the container */
16
       span.parentNode.parentNode.removeChild(span.parentNode);
17
       clearInterval(timeout_id);
18
    } else {
19
       /* decrease seconds before retry */
20
       span.textContent = Math.floor(duration).toString();
21
    }
12
      function decrease_seconds() {
13
        var now = Date.now();
14
        var duration = (until - now) / 1000;
15
        if (duration < 1) {
16
          var target_selector = span.getAttribute('data-target');
17
          if (target_selector) {
18
            var target = document.querySelector(target_selector);
19
            var replace = span.getAttribute('data-replace');
20
            if (replace) {
21
              target.innerHtml = '';
22
              target.textContent = replace;
23
              if (target.href) {
24
                  target.href = '';
25
              }
26
            } else {
27
              /* remove the target */
28
              target.parentNode.removeChild(target);
29
            }
30
          } else {
31
            /* remove the container */
32
            span.parentNode.parentNode.removeChild(span.parentNode);
33
          }
34
          clearInterval(timeout_id);
35
        } else {
36
          /* decrease seconds before retry */
37
          span.textContent = Math.floor(duration).toString();
38
        }
39
      }
40
      timeout_id = setInterval(decrease_seconds, 500);
22 41
  }
23
  timeout_id = setInterval(decrease_seconds, 500);
42
  window.a2_js_seconds_until();
24 43
})()
src/authentic2/urls.py
105 105
    url(r'^$', views.homepage, name='auth_homepage'),
106 106
    url(r'^login/$', views.login, name='auth_login'),
107 107
    url(r'^logout/$', views.logout, name='auth_logout'),
108
    url(r'^su/(?P<token>[a-f0-9]+)/$', views.su, name='su'),
108 109
    url(r'^accounts/', include(accounts_urlpatterns)),
109 110
    url(r'^admin/', include(admin.site.urls)),
110 111
    url(r'^idp/', include('authentic2.idp.urls')),
src/authentic2/utils.py
22 22
import datetime
23 23
import copy
24 24
import ctypes
25
import re
25 26

  
26 27
from functools import wraps
27 28
from itertools import islice, chain, count
......
821 822
    return dict((k, set(v)) for k, v in d.items())
822 823

  
823 824

  
824
def switch_user(request, new_user):
825
    '''Switch to another user and remember currently logged in user in the
826
       session. Reserved to superusers.'''
825
def build_su_url(user, duration=30):
826
    token = get_hex_uuid()
827
    data = {'user_pk': user.pk}
828
    cache.set('switch-%s' % token, data, duration)
829
    return make_url('su', kwargs={'token': token})
827 830

  
828
    logger = logging.getLogger(__name__)
829
    if constants.SWITCH_USER_SESSION_KEY in request.session:
830
        messages.error(request, _('Your user is already switched, go to your '
831
                                  'account page and come back to your original '
832
                                  'user to do it again.'))
833
    else:
834
        if not request.user.is_superuser:
835
            raise PermissionDenied
836
        switched = {}
837
        for key in (SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY,
838
                    constants.LAST_LOGIN_SESSION_KEY):
839
            switched[key] = request.session[key]
840
        user = authenticate(user=new_user)
841
        login(request, user, 'switch')
842
        request.session[constants.SWITCH_USER_SESSION_KEY] = switched
843
        if constants.LAST_LOGIN_SESSION_KEY not in request.session:
844
            request.session[constants.LAST_LOGIN_SESSION_KEY] = \
845
                localize(to_current_timezone(new_user.last_login), True)
846
        messages.info(request, _('Successfully switched to user %s') %
847
                      new_user.get_full_name())
848
        logger.info(u'switched to user %s', new_user)
849
        return continue_to_next_url(request)
831
HEX_RE = re.compile('^[a-f0-9]+$')
832

  
833

  
834
def get_su_user(token):
835
    User = get_user_model()
836
    if not token:
837
        return None
838
    if not HEX_RE.match(token):
839
        return None
840
    key = 'switch-%s' % token
841
    data = cache.get(key)
842
    if not isinstance(data, dict):
843
        return None
844
    if not data.get('user_pk'):
845
        return None
846
    cache.delete(key)
847
    try:
848
        return User.objects.get(pk=data['user_pk'])
849
    except User.DoesNotExist:
850
        return None
850 851

  
851 852

  
852 853
def datetime_to_utc(dt):
src/authentic2/views.py
163 163
    login_required(EditProfile.as_view()))
164 164

  
165 165

  
166
def su(request, username, redirect_url='/'):
167
    '''To use this view add:
168

  
169
       url(r'^su/(?P<username>.*)/$', 'authentic2.views.su', {'redirect_url': '/'}),
170
    '''
171
    if request.user.is_superuser or request.session.get('has_superuser_power'):
172
        su_user = shortcuts.get_object_or_404(User, username=username)
173
        if su_user.is_active:
174
            request.session[SESSION_KEY] = su_user.id
175
            request.session['has_superuser_power'] = True
176
            return http.HttpResponseRedirect(redirect_url)
177
    else:
178
        return http.HttpResponseRedirect('/')
179

  
180

  
181 166
class EmailChangeView(cbv.TemplateNamesMixin, FormView):
182 167
    template_names = [
183 168
        'profiles/email_change.html',
......
1163 1148

  
1164 1149
def notimplemented_view(request):
1165 1150
    raise NotImplementedError
1151

  
1152

  
1153
class SuView(View):
1154
    def get(self, request, token):
1155
        user = utils.get_su_user(token)
1156
        if not user:
1157
            raise Http404
1158
        return utils.simulate_authentication(request, user, 'su')
1159

  
1160
su = SuView.as_view()
tests/conftest.py
38 38

  
39 39

  
40 40
@pytest.fixture
41
def app(request):
41
def app_factory():
42 42
    wtm = django_webtest.WebTestMixin()
43 43
    wtm._patch_settings()
44
    request.addfinalizer(wtm._unpatch_settings)
45
    return django_webtest.DjangoTestApp(extra_environ={'HTTP_HOST': 'localhost'})
44
    try:
45
        def factory(hostname='localhost'):
46
            return django_webtest.DjangoTestApp(extra_environ={'HTTP_HOST': hostname})
47
        yield factory
48
    finally:
49
        wtm._unpatch_settings()
50

  
51

  
52
@pytest.fixture
53
def app(app_factory):
54
    return app_factory()
46 55

  
47 56

  
48 57
@pytest.fixture
tests/test_user_manager.py
316 316
    app.get('/manage/users/import/%s/' % _import.uuid, status=403)
317 317
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, simulate.uuid), status=403)
318 318
    app.get('/manage/users/import/%s/%s/' % (_import.uuid, execute.uuid), status=403)
319

  
320

  
321
def test_su_permission(app, admin, simple_user):
322
    resp = login(app, admin, '/manage/users/%s/' % simple_user.pk)
323
    assert len(resp.pyquery('button[name="su"]')) == 0
324
    assert app.get('/manage/users/%s/su/' % simple_user.pk, status=403)
325

  
326

  
327
def test_su_superuser_post(app, app_factory, superuser, simple_user):
328
    resp = login(app, superuser, '/manage/users/%s/' % simple_user.pk)
329
    assert len(resp.pyquery('button[name="su"]')) == 1
330
    su_resp = resp.form.submit(name='su')
331

  
332
    new_app = app_factory()
333
    new_app.get(su_resp.location).maybe_follow()
334
    assert new_app.session['_auth_user_id'] == str(simple_user.pk)
335

  
336

  
337
def test_su_superuser_dialog(app, app_factory, superuser, simple_user):
338
    resp = login(app, superuser, '/manage/users/%s/' % simple_user.pk)
339
    assert len(resp.pyquery('button[name="su"]')) == 1
340

  
341
    su_view_url = resp.pyquery('button[name="su"]')[0].get('data-url')
342

  
343
    resp = app.get(su_view_url)
344

  
345
    anchors = resp.pyquery('a#su-link')
346
    assert len(anchors) == 1
347

  
348
    su_url = anchors[0].get('href')
349

  
350
    new_app = app_factory()
351
    new_app.get(su_url).maybe_follow()
352
    assert new_app.session['_auth_user_id'] == str(simple_user.pk)
319
-