0002-add-new-switch-user-tool-34308.patch
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 |
- |