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