diff --git a/seahub/base/decorators.py b/seahub/base/decorators.py index cac6ca2151..a4aa8ff15b 100644 --- a/seahub/base/decorators.py +++ b/seahub/base/decorators.py @@ -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): diff --git a/seahub/base/sudo_mode.py b/seahub/base/sudo_mode.py new file mode 100644 index 0000000000..b49206b001 --- /dev/null +++ b/seahub/base/sudo_mode.py @@ -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 diff --git a/seahub/templates/sysadmin/sudo_mode.html b/seahub/templates/sysadmin/sudo_mode.html new file mode 100644 index 0000000000..246917b9ec --- /dev/null +++ b/seahub/templates/sysadmin/sudo_mode.html @@ -0,0 +1,42 @@ +{% extends base_template %} +{% load i18n %} + +{% block title %}{% trans "Confirm Password" %}{% endblock %} + +{% block main_panel %} +
+

{% trans "Confirm password to continue" %}

+
{% csrf_token %} + + + {% if form.errors %} +

{% trans "Incorrect password" %}

+ {% else %} +

+ {% endif %} + + +
+

{% trans "Tip:" %}{% trans "You are entering sudo mode, we won't ask for your password again for a few hours." %}

+
+
+
+{% endblock %} + +{% block extra_script %} + +{% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index 300036aac5..3a697e3d93 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -226,6 +226,7 @@ urlpatterns = patterns('', url(r'^sys/orgadmin/(?P\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_remove, name="user_remove"), url(r'^useradmin/removetrial/(?P[^/]+)/$', remove_trial, name="remove_trial"), diff --git a/seahub/views/sysadmin.py b/seahub/views/sysadmin.py index 71c78aa686..c82a9c0f34 100644 --- a/seahub/views/sysadmin.py +++ b/seahub/views/sysadmin.py @@ -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, \ @@ -647,7 +649,7 @@ def user_remove(request, user_id): @sys_staff_required def remove_trial(request, user_or_org): """Remove trial account. - + Arguments: - `request`: """ @@ -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)) diff --git a/test-requirements.txt b/test-requirements.txt index ea99934c44..c44f83c656 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -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 diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 0000000000..3f70f562ec --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1 @@ +from tests.ui.fixtures import * diff --git a/tests/ui/driver.py b/tests/ui/driver.py new file mode 100644 index 0000000000..8ded7845dd --- /dev/null +++ b/tests/ui/driver.py @@ -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 diff --git a/tests/ui/fixtures.py b/tests/ui/fixtures.py new file mode 100644 index 0000000000..5a63b44c70 --- /dev/null +++ b/tests/ui/fixtures.py @@ -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() diff --git a/tests/ui/test_login.py b/tests/ui/test_login.py deleted file mode 100644 index 9c2b27510b..0000000000 --- a/tests/ui/test_login.py +++ /dev/null @@ -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) diff --git a/tests/ui/test_sudo_mode.py b/tests/ui/test_sudo_mode.py new file mode 100644 index 0000000000..09137e4232 --- /dev/null +++ b/tests/ui/test_sudo_mode.py @@ -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' + )