-
-
+ {% block content %}
+
-
-{% endblock %}
+ {% endblock content %}
+{% endblock body %}
diff --git a/rdiffweb/templates/mfa.html b/rdiffweb/templates/mfa.html
new file mode 100644
index 00000000..0a6ff730
--- /dev/null
+++ b/rdiffweb/templates/mfa.html
@@ -0,0 +1,21 @@
+{% extends 'login.html' %}
+{% block content %}
+
+{% endblock %}
diff --git a/rdiffweb/templates/prefs.html b/rdiffweb/templates/prefs.html
index 83f4adc3..c8a2b2a4 100644
--- a/rdiffweb/templates/prefs.html
+++ b/rdiffweb/templates/prefs.html
@@ -14,6 +14,7 @@
{% trans %}User profile{% endtrans %}
(_('General'), url_for('prefs','general'), active_panelid=='general'),
(_('Notification'), url_for('prefs', 'notification'), active_panelid=='notification'),
(_('SSH Keys'), url_for('prefs','sshkeys'), active_panelid=='sshkeys'),
+ (_('Two-Factor Authentication'), url_for('prefs','mfa'), active_panelid=='mfa'),
(_('Active Sessions'), url_for('prefs','session'), active_panelid=='session'),
] %}
{{ nav_list(nav_items) }}
diff --git a/rdiffweb/templates/prefs_general.html b/rdiffweb/templates/prefs_general.html
index 513857e2..4b4e7865 100644
--- a/rdiffweb/templates/prefs_general.html
+++ b/rdiffweb/templates/prefs_general.html
@@ -1,59 +1,29 @@
-{#
-Rdiffweb SSHKeys plugins
-
-Copyright (C) 2012-2021 rdiffweb contributors
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program. If not, see
.
-#}
{% extends 'prefs.html' %}
{% from 'include/panel.html' import panel %}
{% set active_panelid='general' %}
{% block panel %}
{% include 'message.html' %}
-
+ {# Panel to set user info. #}
{% call panel(title=_("Account settings"), description=_("General information about your account.")) %}
{% endcall %}
-
+ {# Panel to change password. #}
{% call panel(title=_("Password"), description=_("Change your current password")) %}
{% endcall %}
-
+ {# Panel to refresh repository list. #}
{% call panel(title=_("Refresh")) %}
{% endcall %}
diff --git a/rdiffweb/templates/prefs_mfa.html b/rdiffweb/templates/prefs_mfa.html
new file mode 100644
index 00000000..49dbf181
--- /dev/null
+++ b/rdiffweb/templates/prefs_mfa.html
@@ -0,0 +1,18 @@
+{% extends 'prefs.html' %}
+{% from 'include/panel.html' import panel %}
+{% set active_panelid='mfa' %}
+{% block panel %}
+
+
{% trans %}Two-Factor Authentication{% endtrans %}
+
+
+ {% trans %}You can enhance the security of your account by enabling two-factor authentication (2FA).{% endtrans %}
+ {% trans %}When enabled, a verification code is sent to your email address each time you log in from a new location to verify that it is you.{% endtrans %}
+
+ {% include 'message.html' %}
+
+
+
+{% endblock %}
diff --git a/rdiffweb/test.py b/rdiffweb/test.py
index 1d7ec872..2cd452c2 100644
--- a/rdiffweb/test.py
+++ b/rdiffweb/test.py
@@ -215,6 +215,6 @@ def getJson(self, *args, **kwargs):
return json.loads(self.body.decode('utf8'))
def _login(self, username=USERNAME, password=PASSWORD):
- self.getPage("/logout/")
+ self.getPage("/logout")
self.getPage("/login/", method='POST', body={'login': username, 'password': password})
self.assertStatus('303 See Other')
diff --git a/rdiffweb/tools/auth_basic.py b/rdiffweb/tools/auth_basic.py
deleted file mode 100644
index a919621c..00000000
--- a/rdiffweb/tools/auth_basic.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# -*- coding: utf-8 -*-
-# rdiffweb, A web interface to rdiff-backup repositories
-# Copyright (C) 2012-2021 rdiffweb contributors
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see
.
-import base64
-import datetime
-
-import cherrypy
-
-SESSION_KEY = '_cp_username'
-
-# Monkey patch cherrypy base64 calls
-base64.decodestring = base64.b64decode
-
-
-def basic_auth(realm, checkpassword, debug=False, session_key=SESSION_KEY):
- """
- Tool supporting basic authentication but also support session authentication.
- If user is already authenticated, this tools will let him in.
- """
- # When session is not enable, simply validate credentials
- sessions_on = cherrypy.request.config.get('tools.sessions.on', False)
- if not sessions_on:
- cherrypy.lib.auth_basic.basic_auth(realm, checkpassword, debug)
- return
-
- # When session, is enabled, let check if user is already authenticated
- username = cherrypy.session.get(session_key)
- if not username:
- # User is not authenticated.
- # Verify credential, will raise an exception if credentials are invalid.
- cherrypy.lib.auth_basic.basic_auth(realm, checkpassword, debug)
- # User is authenticated, let save this into the session.
- cherrypy.session[session_key] = cherrypy.request.login
- cherrypy.session['login_time'] = datetime.datetime.now()
-
-
-cherrypy.tools.auth_basic = cherrypy.Tool('before_handler', basic_auth, priority=70)
diff --git a/rdiffweb/tools/auth_form.py b/rdiffweb/tools/auth_form.py
index a08478fc..d27a1fb5 100644
--- a/rdiffweb/tools/auth_form.py
+++ b/rdiffweb/tools/auth_form.py
@@ -14,25 +14,102 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see
.
+import datetime
+import time
+import urllib.parse
+
import cherrypy
+from cherrypy.lib import httputil
SESSION_KEY = '_cp_username'
+LOGIN_TIME = 'login_time'
+LOGIN_REDIRECT_URL = '_auth_form_redirect_url'
+LOGIN_PERSISTENT = 'login_persistent'
+
+
+class CheckAuthForm(cherrypy.Tool):
+ def __init__(self, priority=73):
+ super().__init__(point='before_handler', callable=self.run, priority=priority)
+
+ def _is_login(self):
+ """
+ Verify if the login expired and we need to prompt the user to authenticated again using either credentials and/or MFA.
+ """
+ # Verify if current user exists
+ request = cherrypy.serving.request
+ if not getattr(request, 'currentuser', None):
+ return False
+
+ # Verify if session is enabled
+ sessions_on = request.config.get('tools.sessions.on', False)
+ if not sessions_on:
+ return False
+
+ # Verify session
+ session = cherrypy.session
+ return (
+ session.get(SESSION_KEY) is not None
+ and session.get(LOGIN_TIME) is not None
+ and session[LOGIN_TIME] + datetime.timedelta(minutes=session.timeout) > session.now()
+ )
+
+ def _get_redirect_url(self):
+ """
+ Return the original URL the user browser before getting redirect to login.
+ """
+ return cherrypy.session.get(LOGIN_REDIRECT_URL) or '/'
+
+ def _set_redirect_url(self):
+ # Kepp reference to the current URL
+ request = cherrypy.serving.request
+ original_url = urllib.parse.quote(request.path_info, encoding=request.uri_encoding)
+ qs = request.query_string
+ new_url = cherrypy.url(original_url, qs=qs, base='')
+ cherrypy.session[LOGIN_REDIRECT_URL] = new_url
+
+ def redirect_to_original_url(self):
+ # Redirect user to original URL
+ raise cherrypy.HTTPRedirect(self._get_redirect_url())
+
+ def run(self, login_url='/login/', logout_url='/logout', debug=False):
+ """
+ A tool that verify if the session is associated to a user by tracking
+ a session key. If session is not authenticated, redirect user to login page.
+ """
+ request = cherrypy.serving.request
+ # Skip execution of this tools when browsing the login page.
+ if request.path_info == login_url:
+ if self._is_login():
+ raise cherrypy.HTTPRedirect('/')
+ return
+
+ # Clear session when browsing /logout
+ if request.path_info == logout_url or request.path_info.startswith(logout_url):
+ cherrypy.session.clear()
+ raise cherrypy.HTTPRedirect('/')
+
+ # Check if login
+ if not self._is_login():
+ # Store original URL
+ self._set_redirect_url()
+ # And redirect to login page
+ raise cherrypy.HTTPRedirect(login_url)
+ # If login, update the cookie max-age/expires according to "Remember me" (persistent)
+ if cherrypy.session.get(LOGIN_PERSISTENT, False):
+ timeout = cherrypy.session.timeout
+ cookie = cherrypy.serving.response.cookie
+ cookie['session_id']['max-age'] = timeout * 60
+ cookie['session_id']['expires'] = httputil.HTTPDate(time.time() + timeout * 60)
-def check_auth_form(login_url='/login/', session_key=SESSION_KEY):
- """
- A tool that verify if the session is associated to a user by tracking
- a session key. If session is not authenticated, redirect him to login page.
- """
- # Session is required for this tools
- username = cherrypy.session.get(session_key)
- if not username:
- redirect = cherrypy.serving.request.path_info
- query_string = cherrypy.serving.request.query_string
- if query_string:
- redirect = redirect + '?' + query_string
- new_url = cherrypy.url(login_url, qs={'redirect': redirect})
- raise cherrypy.HTTPRedirect(new_url)
+ def login(self, username, persistent=False):
+ """
+ Must be called by the page hanlder when the authentication is successful.
+ """
+ # Store session data
+ cherrypy.session[LOGIN_PERSISTENT] = persistent
+ cherrypy.session[SESSION_KEY] = username
+ cherrypy.session[LOGIN_TIME] = cherrypy.session.now()
-cherrypy.tools.auth_form = cherrypy.Tool('before_handler', check_auth_form, priority=72)
+cherrypy.tools.auth_form = CheckAuthForm()
diff --git a/rdiffweb/tools/auth_mfa.py b/rdiffweb/tools/auth_mfa.py
new file mode 100644
index 00000000..da106ac5
--- /dev/null
+++ b/rdiffweb/tools/auth_mfa.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# rdiffweb, A web interface to rdiff-backup repositories
+# Copyright (C) 2012-2021 rdiffweb contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see
.
+import datetime
+import secrets
+import string
+import urllib.parse
+
+import cherrypy
+
+from rdiffweb.core.passwd import check_password, hash_password
+
+from .auth_form import LOGIN_PERSISTENT, LOGIN_TIME
+
+MFA_USERNAME = '_auth_mfa_username'
+MFA_VERIFICATION_TIME = '_auth_mfa_time'
+MFA_TRUSTED_IP_LIST = '_auth_mfa_trusted_ip_list'
+MFA_REDIRECT_URL = '_auth_mfa_redirect_url'
+MFA_CODE = '_auth_mfa_code'
+MFA_CODE_TIME = '_auth_mfa_code_time'
+MFA_CODE_ATTEMPT = '_auth_mfa_code_attempt'
+
+MFA_DEFAULT_LENGTH = 8
+MFA_DEFAULT_CODE_TIMEOUT = 60 # 1 hour
+MFA_DEFAULT_CODE_MAX_ATTEMPT = 4
+
+
+class CheckAuthMfa(cherrypy.Tool):
+ def __init__(self, priority=74):
+ super().__init__(point='before_handler', callable=self.run, priority=priority)
+
+ def _get_code_length(self):
+ """
+ Return the configured code length.
+ """
+ length = cherrypy.request.config.get('tools.auth_mfa.code_length')
+ return MFA_DEFAULT_LENGTH if length is None else int(length)
+
+ def _get_code_timeout(self):
+ """
+ Return the configured code timeout.
+ """
+ timeout = cherrypy.request.config.get('tools.auth_mfa.code_timeout')
+ return MFA_DEFAULT_CODE_TIMEOUT if timeout is None else int(timeout)
+
+ def _get_code_max_attempt(self):
+ """
+ Return the configured code attempt.
+ """
+ attempt = cherrypy.request.config.get('tools.auth_mfa.code_max_attempt')
+ return MFA_DEFAULT_CODE_MAX_ATTEMPT if attempt is None else int(attempt)
+
+ def _get_redirect_url(self):
+ """
+ Return the original URL the user browser before getting redirect to mfa.
+ """
+ return cherrypy.session.get(MFA_REDIRECT_URL) or '/'
+
+ def generate_code(self):
+ """
+ Generate a random code of given length.
+ """
+ # Get verification code length
+ length = self._get_code_length()
+ assert length > 0, 'length must be greater than zero'
+
+ # Generate random code
+ code = ''.join(secrets.choice(string.digits) for i in range(length))
+
+ # Store hash code in session
+ session = cherrypy.session
+ session[MFA_USERNAME] = cherrypy.request.login
+ session[MFA_CODE] = hash_password(code)
+ session[MFA_CODE_TIME] = cherrypy.session.now()
+ session[MFA_CODE_ATTEMPT] = 0
+ return code
+
+ def _is_verified(self):
+ # Check if user is login
+ assert cherrypy.request.login, 'auth_mfa requires auth_form tools'
+ # Verify if session is enabled
+ assert cherrypy.request.config.get('tools.sessions.on', False), 'auth_mfa requires sessions tools'
+
+ # Verify session
+ session = cherrypy.session
+ return bool(
+ session.get(MFA_USERNAME) == cherrypy.request.login
+ and session.get(MFA_VERIFICATION_TIME, None)
+ and session[MFA_VERIFICATION_TIME] + datetime.timedelta(minutes=session.timeout) > session.now()
+ and session.get(MFA_TRUSTED_IP_LIST, [])
+ and cherrypy.serving.request.remote.ip in session[MFA_TRUSTED_IP_LIST]
+ )
+
+ def is_code_expired(self):
+ """
+ Return True if the verification code expired and must be re-generate.
+ """
+ code_timeout = self._get_code_timeout()
+ code_max_attempt = self._get_code_max_attempt()
+ session = cherrypy.session
+ return (
+ getattr(cherrypy, 'session', None) is None
+ or session.get(MFA_USERNAME) != cherrypy.request.login
+ or session.get(MFA_CODE) is None
+ or session.get(MFA_CODE_TIME) is None
+ or session.get(MFA_CODE_TIME) + datetime.timedelta(minutes=code_timeout) < session.now()
+ or session.get(MFA_CODE_ATTEMPT) >= code_max_attempt
+ )
+
+ def run(self, mfa_url='/mfa/', mfa_enabled=True, debug=False, **kwargs):
+ """
+ A tool that verify Multi-Factor authentication.
+ """
+ # Check if MFA is enabled. `mfa_enabled` could be a function.
+ enabled = mfa_enabled(cherrypy.request.login) if hasattr(mfa_enabled, '__call__') else mfa_enabled
+
+ # Check if `/mfa/` us request
+ request = cherrypy.serving.request
+ if request.path_info == mfa_url:
+ # If MFA is disable or user already verified, redirect user to root page.
+ if not enabled or self._is_verified():
+ raise cherrypy.HTTPRedirect('/')
+ # Otherwise, skip verification.
+ return
+
+ # Skip verification if MFA is disabled.
+ if not enabled:
+ return
+
+ # Check "remember me" status. Need to verify password every day.
+ session = cherrypy.session
+ if (
+ session.get(LOGIN_PERSISTENT, False)
+ and session.get(LOGIN_TIME, False)
+ and session[LOGIN_TIME] + datetime.timedelta(days=1) < session.now()
+ ):
+ # Clear login_time to force login
+ del session[LOGIN_TIME]
+ self._set_redirect_url()
+ self.redirect_to_original_url()
+
+ # Check if verified
+ if not self._is_verified():
+ # Store original URL
+ self._set_redirect_url()
+ # And redirect to mfa page
+ raise cherrypy.HTTPRedirect(mfa_url)
+
+ def redirect_to_original_url(self):
+ # Redirect user to original URL
+ raise cherrypy.HTTPRedirect(self._get_redirect_url())
+
+ def _set_redirect_url(self):
+ # Keep reference to the current URL
+ request = cherrypy.serving.request
+ original_url = urllib.parse.quote(request.path_info, encoding=request.uri_encoding)
+ qs = request.query_string
+ new_url = cherrypy.url(original_url, qs=qs, base='')
+ if hasattr(cherrypy, 'session'):
+ cherrypy.session[MFA_REDIRECT_URL] = new_url
+
+ def verify_code(self, code, persistent=False):
+ """
+ Must be called by the page handler to verify MFA.
+ """
+ # Check if code expired
+ if self.is_code_expired():
+ return False
+
+ # Verify code.
+ session = cherrypy.session
+ if not check_password(code, session.get(MFA_CODE)):
+ # If invalid increase attempt
+ session[MFA_CODE_ATTEMPT] = session.get(MFA_CODE_ATTEMPT, 0) + 1
+ return False
+
+ # Store information in session
+ session[LOGIN_PERSISTENT] = persistent
+ session[MFA_VERIFICATION_TIME] = session.now()
+ session[MFA_TRUSTED_IP_LIST] = session.get(MFA_TRUSTED_IP_LIST, []) + [cherrypy.serving.request.remote.ip]
+ session[MFA_CODE] = None
+ session[MFA_CODE_TIME] = None
+ return True
+
+
+cherrypy.tools.auth_mfa = CheckAuthMfa()
diff --git a/rdiffweb/tools/currentuser.py b/rdiffweb/tools/currentuser.py
index 5cf4bb7e..b1699030 100644
--- a/rdiffweb/tools/currentuser.py
+++ b/rdiffweb/tools/currentuser.py
@@ -48,4 +48,4 @@ def get_currentuser(userobj, session_key=SESSION_KEY):
cherrypy.request.hooks.attach('on_end_resource', clear_currentuser)
-cherrypy.tools.currentuser = cherrypy.Tool('before_handler', get_currentuser, priority=73)
+cherrypy.tools.currentuser = cherrypy.Tool('before_handler', get_currentuser, priority=72)
diff --git a/rdiffweb/tools/i18n.py b/rdiffweb/tools/i18n.py
index 4aad65f8..af9008eb 100644
--- a/rdiffweb/tools/i18n.py
+++ b/rdiffweb/tools/i18n.py
@@ -154,6 +154,8 @@ def gettext_lazy(message):
"""
def get_translation():
+ if not hasattr(cherrypy.response, "i18n"):
+ return message
return cherrypy.response.i18n.trans.ugettext(message)
return LazyProxy(get_translation)
diff --git a/rdiffweb/tools/ratelimit.py b/rdiffweb/tools/ratelimit.py
index bafde581..9c535713 100644
--- a/rdiffweb/tools/ratelimit.py
+++ b/rdiffweb/tools/ratelimit.py
@@ -43,7 +43,7 @@ def get_and_increment(self, token, delay):
self._save(tracker)
return tracker.hits
- def _save(self, token, hits, timeout):
+ def _save(self, tracker):
raise NotImplementedError
def _load(self, token):
@@ -111,7 +111,7 @@ def _save(self, tracker):
f.close()
-def check_ratelimmit(delay=60, anonymous_limit=0, registered_limit=0, rate_exceed_status=429, debug=False, **conf):
+def check_ratelimit(delay=60, anonymous_limit=0, registered_limit=0, rate_exceed_status=429, debug=False, **conf):
"""
Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request).
@@ -150,4 +150,4 @@ def index(self):
raise cherrypy.HTTPError(rate_exceed_status)
-cherrypy.tools.ratelimit = cherrypy.Tool('before_handler', check_ratelimmit, priority=60)
+cherrypy.tools.ratelimit = cherrypy.Tool('before_handler', check_ratelimit, priority=60)
diff --git a/tox.ini b/tox.ini
index 4ea3dff5..d7c24b67 100644
--- a/tox.ini
+++ b/tox.ini
@@ -77,7 +77,7 @@ commands = black --check --diff setup.py rdiffweb
skip_install = true
[testenv:djlint]
-deps = djlint==1.12.0
+deps = djlint==1.12.1
allowlist_externals = sh
commands = sh -c 'djlint --check rdiffweb/templates/*.html rdiffweb/templates/**/*.html'
skip_install = true