1
0
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:
Shuai Lin
2015-05-04 13:57:10 +08:00
parent 801abe9026
commit c449e3842c
11 changed files with 275 additions and 46 deletions

View File

@@ -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
View 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

View 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 %}

View File

@@ -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"),

View File

@@ -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))

View File

@@ -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
View File

@@ -0,0 +1 @@
from tests.ui.fixtures import *

93
tests/ui/driver.py Normal file
View 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
View 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()

View File

@@ -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)

View 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'
)