1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-09 02:42:47 +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.template import RequestContext
from django.utils.http import urlquote
from seaserv import get_repo, is_passwd_set
from seahub.options.models import UserOptions, CryptoOptionNotSetError
from seahub.base.sudo_mode import sudo_mode_check
from seahub.utils import render_error
from django.utils.translation import ugettext as _
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.
"""
def _decorated(request, *args, **kwargs):
if request.user.is_staff:
return func(request, *args, **kwargs)
raise Http404
if not request.user.is_staff:
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
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/publinkadmin/$', sys_publink_admin, name='sys_publink_admin'),
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/remove/(?P<user_id>[^/]+)/$', user_remove, name="user_remove"),
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.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.template import RequestContext
from django.utils.translation import ugettext as _
@@ -22,6 +22,8 @@ from pysearpc import SearpcError
from seahub.base.accounts import User
from seahub.base.models import UserLastLogin
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.constants import GUEST_USER, DEFAULT_USER
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))
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
pytest
pytest==2.7.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'
)