mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-09 10:50:24 +00:00
add "sudo mode": confirm password before doing sysadmin work
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
from django.http import Http404
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import render_to_response
|
from django.shortcuts import render_to_response
|
||||||
from django.template import RequestContext
|
from django.template import RequestContext
|
||||||
|
from django.utils.http import urlquote
|
||||||
from seaserv import get_repo, is_passwd_set
|
from seaserv import get_repo, is_passwd_set
|
||||||
|
|
||||||
from seahub.options.models import UserOptions, CryptoOptionNotSetError
|
from seahub.options.models import UserOptions, CryptoOptionNotSetError
|
||||||
|
|
||||||
|
from seahub.base.sudo_mode import sudo_mode_check
|
||||||
from seahub.utils import render_error
|
from seahub.utils import render_error
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from seahub.views.modules import get_enabled_mods_by_user, \
|
from seahub.views.modules import get_enabled_mods_by_user, \
|
||||||
@@ -16,9 +19,12 @@ def sys_staff_required(func):
|
|||||||
Decorator for views that checks the user is system staff.
|
Decorator for views that checks the user is system staff.
|
||||||
"""
|
"""
|
||||||
def _decorated(request, *args, **kwargs):
|
def _decorated(request, *args, **kwargs):
|
||||||
if request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
return func(request, *args, **kwargs)
|
raise Http404
|
||||||
raise Http404
|
if not sudo_mode_check(request):
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse('sys_sudo_mode') + '?next=' + urlquote(request.get_full_path()))
|
||||||
|
return func(request, *args, **kwargs)
|
||||||
return _decorated
|
return _decorated
|
||||||
|
|
||||||
def user_mods_check(func):
|
def user_mods_check(func):
|
||||||
|
20
seahub/base/sudo_mode.py
Normal file
20
seahub/base/sudo_mode.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Ask the admin to provide his password before visiting sysadmin-only pages.
|
||||||
|
|
||||||
|
When an admin visist to the syadmin related pages, seahub would ask him to
|
||||||
|
confirm his/her password to ensure security. The admin only need to provide
|
||||||
|
the password once for several hours.
|
||||||
|
|
||||||
|
See https://help.github.com/articles/sudo-mode/ for an introduction to
|
||||||
|
github's sudo mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
_SUDO_EXPIRE_SECONDS = 2 * 3600 # 2 hours
|
||||||
|
_SUDO_MODE_SESSION_KEY = 'sudo_expire_ts'
|
||||||
|
|
||||||
|
def sudo_mode_check(request):
|
||||||
|
return request.session.get('_SUDO_MODE_SESSION_KEY', 0) > time.time()
|
||||||
|
|
||||||
|
def update_sudo_mode_ts(request):
|
||||||
|
request.session['_SUDO_MODE_SESSION_KEY'] = time.time() + _SUDO_EXPIRE_SECONDS
|
42
seahub/templates/sysadmin/sudo_mode.html
Normal file
42
seahub/templates/sysadmin/sudo_mode.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends base_template %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Confirm Password" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block main_panel %}
|
||||||
|
<div class="new-narrow-panel">
|
||||||
|
<h2 class="hd">{% trans "Confirm password to continue" %}</h2>
|
||||||
|
<form action="" method="post" class="con">{% csrf_token %}
|
||||||
|
<label for="password">{% trans "Password" %}</label>
|
||||||
|
<input type="password" name="password" value="" class="input" autocomplete="off" />
|
||||||
|
{% if form.errors %}
|
||||||
|
<p class="error">{% trans "Incorrect password" %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="error hide"></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<input type="submit" value="{% trans "Confirm Password" %}" class="submit" />
|
||||||
|
<div class="sudo-mode-tip">
|
||||||
|
<p><span class="bold">{% trans "Tip:" %}</span>{% trans "You are entering sudo mode, we won't ask for your password again for a few hours." %}</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_script %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$('input[type="submit"]').click(function(){
|
||||||
|
if (!$.trim($('input[name="password"]').val())) {
|
||||||
|
$('.error').removeClass('hide').html("{% trans "Password cannot be blank" %}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// set tabindex
|
||||||
|
$(function() {
|
||||||
|
$('input:not([type="hidden"])').each(function(index) {
|
||||||
|
$(this).attr('tabindex', index + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -226,6 +226,7 @@ urlpatterns = patterns('',
|
|||||||
url(r'^sys/orgadmin/(?P<org_id>\d+)/setting/$', sys_org_info_setting, name='sys_org_info_setting'),
|
url(r'^sys/orgadmin/(?P<org_id>\d+)/setting/$', sys_org_info_setting, name='sys_org_info_setting'),
|
||||||
url(r'^sys/publinkadmin/$', sys_publink_admin, name='sys_publink_admin'),
|
url(r'^sys/publinkadmin/$', sys_publink_admin, name='sys_publink_admin'),
|
||||||
url(r'^sys/notificationadmin/', notification_list, name='notification_list'),
|
url(r'^sys/notificationadmin/', notification_list, name='notification_list'),
|
||||||
|
url(r'^sys/sudo/', sys_sudo_mode, name='sys_sudo_mode'),
|
||||||
url(r'^useradmin/add/$', user_add, name="user_add"),
|
url(r'^useradmin/add/$', user_add, name="user_add"),
|
||||||
url(r'^useradmin/remove/(?P<user_id>[^/]+)/$', user_remove, name="user_remove"),
|
url(r'^useradmin/remove/(?P<user_id>[^/]+)/$', user_remove, name="user_remove"),
|
||||||
url(r'^useradmin/removetrial/(?P<user_or_org>[^/]+)/$', remove_trial, name="remove_trial"),
|
url(r'^useradmin/removetrial/(?P<user_or_org>[^/]+)/$', remove_trial, name="remove_trial"),
|
||||||
|
@@ -10,7 +10,7 @@ import csv, chardet, StringIO
|
|||||||
|
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpResponse, Http404, HttpResponseRedirect
|
from django.http import HttpResponse, Http404, HttpResponseRedirect, HttpResponseNotAllowed
|
||||||
from django.shortcuts import render_to_response
|
from django.shortcuts import render_to_response
|
||||||
from django.template import RequestContext
|
from django.template import RequestContext
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
@@ -22,6 +22,8 @@ from pysearpc import SearpcError
|
|||||||
from seahub.base.accounts import User
|
from seahub.base.accounts import User
|
||||||
from seahub.base.models import UserLastLogin
|
from seahub.base.models import UserLastLogin
|
||||||
from seahub.base.decorators import sys_staff_required
|
from seahub.base.decorators import sys_staff_required
|
||||||
|
from seahub.base.sudo_mode import update_sudo_mode_ts
|
||||||
|
from seahub.auth import authenticate
|
||||||
from seahub.auth.decorators import login_required, login_required_ajax
|
from seahub.auth.decorators import login_required, login_required_ajax
|
||||||
from seahub.constants import GUEST_USER, DEFAULT_USER
|
from seahub.constants import GUEST_USER, DEFAULT_USER
|
||||||
from seahub.utils import IS_EMAIL_CONFIGURED, string2list, is_valid_username, \
|
from seahub.utils import IS_EMAIL_CONFIGURED, string2list, is_valid_username, \
|
||||||
@@ -1418,3 +1420,28 @@ def batch_add_user(request):
|
|||||||
|
|
||||||
next = request.META.get('HTTP_REFERER', reverse(sys_user_admin))
|
next = request.META.get('HTTP_REFERER', reverse(sys_user_admin))
|
||||||
return HttpResponseRedirect(next)
|
return HttpResponseRedirect(next)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sys_sudo_mode(request):
|
||||||
|
if request.method not in ('GET', 'POST'):
|
||||||
|
return HttpResponseNotAllowed
|
||||||
|
|
||||||
|
# here we can't use @sys_staff_required
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return Http404
|
||||||
|
|
||||||
|
password_error = False
|
||||||
|
if request.method == 'POST':
|
||||||
|
password = request.POST.get('password')
|
||||||
|
if password:
|
||||||
|
user = authenticate(username=request.user.username, password=password)
|
||||||
|
update_sudo_mode_ts(request)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
request.GET.get('next', reverse('sys_useradmin')))
|
||||||
|
password_error = True
|
||||||
|
|
||||||
|
return render_to_response(
|
||||||
|
'sysadmin/sudo_mode.html', {
|
||||||
|
'password_error': True,
|
||||||
|
},
|
||||||
|
context_instance=RequestContext(request))
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
selenium==2.42.1
|
splinter==0.7.2
|
||||||
requests==2.3.0
|
requests==2.3.0
|
||||||
pytest
|
pytest==2.7.0
|
||||||
pytest-django==2.8.0
|
pytest-django==2.8.0
|
||||||
|
1
tests/ui/conftest.py
Normal file
1
tests/ui/conftest.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from tests.ui.fixtures import *
|
93
tests/ui/driver.py
Normal file
93
tests/ui/driver.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
import urlparse
|
||||||
|
import requests
|
||||||
|
import splinter
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.wait import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from tests.common.utils import urljoin
|
||||||
|
|
||||||
|
class Browser(object):
|
||||||
|
'''Drives the browser in the functional test'''
|
||||||
|
def __init__(self, start_url):
|
||||||
|
imp = os.environ.get('WEBDRIVER', 'firfox')
|
||||||
|
if imp in ('firefox', 'ff'):
|
||||||
|
driver = 'firefox'
|
||||||
|
else:
|
||||||
|
driver = 'phantomjs'
|
||||||
|
self.b = splinter.Browser(driver)
|
||||||
|
self.d = self.b.driver
|
||||||
|
self.d.set_window_size(1400, 1000)
|
||||||
|
self.start_url = start_url
|
||||||
|
|
||||||
|
def _el(self, selector):
|
||||||
|
return self.b.find_by_css(selector).first
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self):
|
||||||
|
return self.b.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
return urlparse.urlparse(self.b.url).path
|
||||||
|
|
||||||
|
def visit(self, url):
|
||||||
|
if not url.startswith('http'):
|
||||||
|
url = urljoin(self.start_url, url)
|
||||||
|
self.b.visit(url)
|
||||||
|
|
||||||
|
def gohome(self):
|
||||||
|
self.b.visit(self.start_url)
|
||||||
|
|
||||||
|
def click_link_by_text(self, text):
|
||||||
|
self.b.find_link_by_text(text).first.click()
|
||||||
|
|
||||||
|
def click_link_by_title(self, title):
|
||||||
|
self.b.find_by_xpath('//a[@title="%s"]' % title).first.click()
|
||||||
|
|
||||||
|
def find_link_by_text(self, text):
|
||||||
|
return self.b.find_link_by_text(text).first
|
||||||
|
|
||||||
|
def element_text(self, selector):
|
||||||
|
return self._el(selector).text
|
||||||
|
|
||||||
|
def element_attr(self, selector, name):
|
||||||
|
return self._el(selector)._element.get_attribute(name)
|
||||||
|
|
||||||
|
def click(self, selector):
|
||||||
|
self._el(selector).click()
|
||||||
|
|
||||||
|
def fill_form(self, form_kvs):
|
||||||
|
self.b.fill_form(form_kvs)
|
||||||
|
|
||||||
|
def find_by_name(self, name):
|
||||||
|
return self.b.find_by_name(name)
|
||||||
|
|
||||||
|
def submit(self, form_sel):
|
||||||
|
self._el(form_sel)._element.submit()
|
||||||
|
|
||||||
|
def submit_by_input_name(self, name):
|
||||||
|
self.b.find_by_name(name).first._element.submit()
|
||||||
|
|
||||||
|
def fill(self, name, value):
|
||||||
|
self.b.fill(name, value)
|
||||||
|
|
||||||
|
def fill_input_by_label(self, label, value):
|
||||||
|
# TODO: implement this, and use it to locate inputs in tests, instead
|
||||||
|
# of locating inputs by css selector. This is better for blackbox testing.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def click_btn_with_text(self, text):
|
||||||
|
# TODO: same as fill_input_by_label
|
||||||
|
pass
|
||||||
|
|
||||||
|
def quit(self):
|
||||||
|
self.b.quit()
|
||||||
|
|
||||||
|
def wait_for_element(self, selector, timeout):
|
||||||
|
wait = WebDriverWait(self.d, timeout)
|
||||||
|
wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, selector)))
|
||||||
|
|
||||||
|
def get_file_content(self, url):
|
||||||
|
sessionid = self.d.get_cookie('sessionid')['value']
|
||||||
|
return requests.get(url, cookies={'sessionid': sessionid}).text
|
53
tests/ui/fixtures.py
Normal file
53
tests/ui/fixtures.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from pytest import yield_fixture # pylint: disable=E1101
|
||||||
|
from tests.ui.driver import Browser
|
||||||
|
from tests.common.common import (
|
||||||
|
BASE_URL, USERNAME, PASSWORD, ADMIN_USERNAME, ADMIN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
@yield_fixture(scope='session')
|
||||||
|
def browser():
|
||||||
|
"""Get an instance of a browser that already logged in.
|
||||||
|
|
||||||
|
Note this browser instance are shared among all test cases.
|
||||||
|
"""
|
||||||
|
with _create_browser(admin=False) as browser:
|
||||||
|
yield browser
|
||||||
|
|
||||||
|
@yield_fixture(scope='session')
|
||||||
|
def admin_browser():
|
||||||
|
"""Get an instance of a browser that already logged in with admin credentials.
|
||||||
|
|
||||||
|
This browser instance are shared among all test cases.
|
||||||
|
"""
|
||||||
|
with _create_browser(admin=True) as browser:
|
||||||
|
yield browser
|
||||||
|
|
||||||
|
@yield_fixture(scope='function')
|
||||||
|
def admin_browser_once():
|
||||||
|
"""Get an instance of a browser that already logged in with admin credentials.
|
||||||
|
|
||||||
|
This browser instance are created/destroyed for each test case.
|
||||||
|
"""
|
||||||
|
with _create_browser(admin=True) as browser:
|
||||||
|
yield browser
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _create_browser(admin=False):
|
||||||
|
username, password = (ADMIN_USERNAME, ADMIN_PASSWORD) \
|
||||||
|
if admin else (USERNAME, PASSWORD)
|
||||||
|
b = Browser(BASE_URL)
|
||||||
|
b.gohome()
|
||||||
|
assert b.path == '/accounts/login/'
|
||||||
|
|
||||||
|
b.fill_form({
|
||||||
|
'username': username,
|
||||||
|
'password': password
|
||||||
|
})
|
||||||
|
b.submit_by_input_name('username')
|
||||||
|
assert b.path != '/accounts/login/'
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield b
|
||||||
|
finally:
|
||||||
|
b.quit()
|
@@ -1,38 +0,0 @@
|
|||||||
# import unittest
|
|
||||||
# from tests.common.common import BASE_URL, USERNAME, PASSWORD
|
|
||||||
# from selenium import webdriver
|
|
||||||
# from selenium.webdriver.common.keys import Keys
|
|
||||||
|
|
||||||
# LOGIN_URL = BASE_URL + u'/accounts/login/'
|
|
||||||
# HOME_URL = BASE_URL + u'/home/my/'
|
|
||||||
# LOGOUT_URL = BASE_URL + u'/accounts/logout/'
|
|
||||||
|
|
||||||
# def get_logged_instance():
|
|
||||||
# browser = webdriver.PhantomJS()
|
|
||||||
# browser.get(LOGIN_URL)
|
|
||||||
# username_input = browser.find_element_by_name('username')
|
|
||||||
# password_input = browser.find_element_by_name('password')
|
|
||||||
# username_input.send_keys(USERNAME)
|
|
||||||
# password_input.send_keys(PASSWORD)
|
|
||||||
# password_input.send_keys(Keys.RETURN)
|
|
||||||
# if browser.current_url != HOME_URL:
|
|
||||||
# browser.quit()
|
|
||||||
# return None
|
|
||||||
# return browser
|
|
||||||
|
|
||||||
# class LoginTestCase(unittest.TestCase):
|
|
||||||
|
|
||||||
# def setUp(self):
|
|
||||||
# self.browser = get_logged_instance()
|
|
||||||
# self.assertIsNotNone(self.browser)
|
|
||||||
# self.addCleanup(self.browser.quit)
|
|
||||||
|
|
||||||
# def test_login(self):
|
|
||||||
# self.assertRegexpMatches(self.browser.current_url, HOME_URL)
|
|
||||||
|
|
||||||
# def test_logout(self):
|
|
||||||
# myinfo_bar = self.browser.find_element_by_css_selector('#my-info')
|
|
||||||
# logout_input = self.browser.find_element_by_css_selector('a#logout')
|
|
||||||
# myinfo_bar.click()
|
|
||||||
# logout_input.click()
|
|
||||||
# self.assertRegexpMatches(self.browser.current_url, LOGOUT_URL)
|
|
24
tests/ui/test_sudo_mode.py
Normal file
24
tests/ui/test_sudo_mode.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from tests.common.common import ADMIN_USERNAME, ADMIN_PASSWORD
|
||||||
|
|
||||||
|
def test_sudo_mode_required(admin_browser_once):
|
||||||
|
b = admin_browser_once
|
||||||
|
b.visit('/sys/useradmin/')
|
||||||
|
assert b.path == '/sys/sudo/', (
|
||||||
|
'when viewing sysadmin-only pages for the first time, '
|
||||||
|
'the browser should be redirected to the sudo mode page'
|
||||||
|
)
|
||||||
|
|
||||||
|
b.fill_form({
|
||||||
|
'password': ADMIN_PASSWORD,
|
||||||
|
})
|
||||||
|
b.submit_by_input_name('password')
|
||||||
|
assert b.path == '/sys/useradmin/', (
|
||||||
|
'after entering password, '
|
||||||
|
'the browser should be redirected back to the previous page'
|
||||||
|
)
|
||||||
|
|
||||||
|
b.visit('/sys/groupadmin/')
|
||||||
|
assert b.path == '/sys/groupadmin/', (
|
||||||
|
'once the admin enters the password, '
|
||||||
|
'he would not be asked again within a certain time'
|
||||||
|
)
|
Reference in New Issue
Block a user