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" %}
+
+
+{% 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'
+ )