From dade9a9e92fb50772dddf4629d565112b458bec4 Mon Sep 17 00:00:00 2001 From: Patrik Dufresne Date: Wed, 14 Sep 2022 11:40:22 -0400 Subject: [PATCH] Implement Multi-Factor Authentication #201 * Make use of session variable to redirect user during login * Centralize management of login into `auth_form` tool * Create a new module `auth_mfa` to handle email authentication --- README.md | 3 +- doc/index.rst | 2 +- doc/two_factor_authentication.md | 28 ++ doc/usage.rst | 12 + rdiffweb/controller/__init__.py | 1 + rdiffweb/controller/api.py | 20 +- rdiffweb/controller/dispatch.py | 5 + rdiffweb/controller/filter_authorization.py | 10 +- rdiffweb/controller/form.py | 15 +- rdiffweb/controller/page_admin_users.py | 23 + rdiffweb/controller/page_login.py | 64 +-- rdiffweb/controller/page_mfa.py | 114 +++++ rdiffweb/controller/page_pref_general.py | 84 ++-- rdiffweb/controller/page_pref_mfa.py | 132 ++++++ rdiffweb/controller/page_prefs.py | 2 + rdiffweb/controller/tests/test_api.py | 47 ++ .../controller/tests/test_page_admin_users.py | 42 +- rdiffweb/controller/tests/test_page_delete.py | 2 +- rdiffweb/controller/tests/test_page_error.py | 13 +- rdiffweb/controller/tests/test_page_login.py | 203 +++------ rdiffweb/controller/tests/test_page_mfa.py | 267 ++++++++++++ .../controller/tests/test_page_prefs_mfa.py | 160 +++++++ .../tests/test_page_settings_remove_older.py | 12 - .../tests/test_page_settings_set_encoding.py | 10 - rdiffweb/core/config.py | 16 +- rdiffweb/core/login.py | 7 - rdiffweb/core/model/__init__.py | 5 +- rdiffweb/core/model/_session.py | 4 +- rdiffweb/core/model/_user.py | 35 +- rdiffweb/locales/fr/LC_MESSAGES/messages.po | 406 +++++++++++++----- rdiffweb/locales/messages.pot | 349 +++++++++++---- rdiffweb/plugins/smtp.py | 2 +- rdiffweb/rdw_app.py | 30 +- rdiffweb/templates/components/form.html | 32 +- rdiffweb/templates/email_changed.html | 2 +- rdiffweb/templates/email_mfa.html | 18 + rdiffweb/templates/email_notification.html | 2 +- rdiffweb/templates/login.html | 25 +- rdiffweb/templates/mfa.html | 21 + rdiffweb/templates/prefs.html | 1 + rdiffweb/templates/prefs_general.html | 40 +- rdiffweb/templates/prefs_mfa.html | 18 + rdiffweb/test.py | 2 +- rdiffweb/tools/auth_basic.py | 50 --- rdiffweb/tools/auth_form.py | 107 ++++- rdiffweb/tools/auth_mfa.py | 199 +++++++++ rdiffweb/tools/currentuser.py | 2 +- rdiffweb/tools/i18n.py | 2 + rdiffweb/tools/ratelimit.py | 6 +- tox.ini | 2 +- 50 files changed, 2044 insertions(+), 610 deletions(-) create mode 100644 doc/two_factor_authentication.md create mode 100644 doc/usage.rst create mode 100644 rdiffweb/controller/page_mfa.py create mode 100644 rdiffweb/controller/page_pref_mfa.py create mode 100644 rdiffweb/controller/tests/test_page_mfa.py create mode 100644 rdiffweb/controller/tests/test_page_prefs_mfa.py create mode 100644 rdiffweb/templates/email_mfa.html create mode 100644 rdiffweb/templates/mfa.html create mode 100644 rdiffweb/templates/prefs_mfa.html delete mode 100644 rdiffweb/tools/auth_basic.py create mode 100644 rdiffweb/tools/auth_mfa.py diff --git a/README.md b/README.md index b5cf8586..dda9b261 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/ ## Next Release - 2.5.0 -This next release focus on multi-factor-authentication as a measure to increase security of user's account. +This next release focus on two-factor-authentication as a measure to increase security of user's account. * Store User's session information into database * Update ldap plugin to load additional attributes from LDAP server @@ -128,6 +128,7 @@ This next release focus on multi-factor-authentication as a measure to increase * Show number of active users within the last 24 hours in dashboard * Handle migration of older Rdiffweb database by adding the missing `repos.Encoding`, `repos.keepdays` and `users.role` columns #185 * Replace deprecated references of `disutils.spawn.find_executable()` by `shutil.which()` #208 +* Add two-factor authentication with email verification #201 Breaking changes: diff --git a/doc/index.rst b/doc/index.rst index 67b87d42..5c4793e3 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,7 +14,7 @@ Welcome to Rdiffweb's documentation! installation quickstart configuration - settings + usage networking faq development diff --git a/doc/two_factor_authentication.md b/doc/two_factor_authentication.md new file mode 100644 index 00000000..a2cf6c8f --- /dev/null +++ b/doc/two_factor_authentication.md @@ -0,0 +1,28 @@ +# Two-Factor Authentication + +Two-factor authentication (2FA) provides an additional level of security to your Rdiffweb account. In order for others to access your account, they must have your username and password, as well as access to your second factor of authentication. + +As of version 2.5.0, Rdiffweb supports email verification as a second authentication factor. + +When enabled, users must log in with a username and password. Then a verification code is emailed to the user. To successfully authenticate, the user must provide this verification code. + +## Enable 2FA as Administrator + +For 2FA to work properly, [SMTP must be configured properly](configuration.html#configure-email-notifications). + +In the administration view, an administrator can enable 2FA for a specific user. By doing so, the next time this user tries to connect to Rdiffweb, he will be prompted to enter a verification code that will be sent to his email. + +1. Go to **Admin Area > Users** +2. **Edit** a user +3. Change the value of *Two-factor authentication* to *Enabled* + +## Enabled 2FA as User + +For 2FA to work properly, [SMTP must be configured properly](configuration.html#configure-email-notifications). + +A user may enabled 2FA for is own account from it's user's profile. To enabled 2FA, the user must provide the verification code that get sent to him by email. + +1. Go to **Edit profile > Two-Factor Authentication** +2. Click **Enable Two-Factor Authentication** +3. A verification code should be sent to your email address +4. Enter this verification code diff --git a/doc/usage.rst b/doc/usage.rst new file mode 100644 index 00000000..15ceccc2 --- /dev/null +++ b/doc/usage.rst @@ -0,0 +1,12 @@ + +Use Rdiffweb +============ + +Learn how to use Rdiffweb end-to-end. Create users, manage user's permission and authentication. + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + settings + two_factor_authentication diff --git a/rdiffweb/controller/__init__.py b/rdiffweb/controller/__init__.py index 16190947..18d882a7 100644 --- a/rdiffweb/controller/__init__.py +++ b/rdiffweb/controller/__init__.py @@ -114,6 +114,7 @@ def _compile_template(self, template_name, **kwargs): parms.update( { 'username': self.app.currentuser.username, + 'fullname': self.app.currentuser.fullname, 'is_admin': self.app.currentuser.is_admin, 'is_maintainer': self.app.currentuser.is_maintainer, } diff --git a/rdiffweb/controller/api.py b/rdiffweb/controller/api.py index 7e967877..26fdb075 100644 --- a/rdiffweb/controller/api.py +++ b/rdiffweb/controller/api.py @@ -28,6 +28,7 @@ from rdiffweb.controller import Controller from rdiffweb.core.librdiff import RdiffTime +from rdiffweb.core.model import UserObject try: import simplejson as json @@ -51,13 +52,26 @@ def default(o): yield chunk.encode('utf-8') +def _checkpassword(realm, username, password): + """ + Check basic authentication. + """ + # Disable password authentication for MFA + userobj = UserObject.get_user(username) + if userobj is None or userobj.mfa == UserObject.ENABLED_MFA: + return False + # Otherwise validate username password + return any(cherrypy.engine.publish('login', username, password)) + + @cherrypy.tools.json_out(handler=json_handler) @cherrypy.config(**{'error_page.default': False}) -@cherrypy.tools.auth_basic() +@cherrypy.tools.auth_basic(realm='rdiffweb', checkpassword=_checkpassword, priority=70) @cherrypy.tools.auth_form(on=False) -@cherrypy.tools.sessions(on=True) +@cherrypy.tools.auth_mfa(on=False) +@cherrypy.tools.sessions(on=False) @cherrypy.tools.i18n(on=False) -@cherrypy.tools.ratelimit(on=True) +@cherrypy.tools.ratelimit() class ApiPage(Controller): """ This class provide a restful API to access some of the rdiffweb resources. diff --git a/rdiffweb/controller/dispatch.py b/rdiffweb/controller/dispatch.py index 8765096e..b9a0a6db 100644 --- a/rdiffweb/controller/dispatch.py +++ b/rdiffweb/controller/dispatch.py @@ -27,6 +27,9 @@ import cherrypy from cherrypy.lib.static import mimetypes, serve_file +import rdiffweb.tools.auth_form # noqa +import rdiffweb.tools.auth_mfa # noqa +import rdiffweb.tools.ratelimit # noqa from rdiffweb.core.rdw_helpers import unquote_url @@ -121,6 +124,8 @@ def static(path): @cherrypy.expose @cherrypy.tools.auth_form(on=False) + @cherrypy.tools.auth_mfa(on=False) + @cherrypy.tools.ratelimit(on=False) @cherrypy.tools.sessions(on=False) def handler(*args, **kwargs): if cherrypy.request.method not in ('GET', 'HEAD'): diff --git a/rdiffweb/controller/filter_authorization.py b/rdiffweb/controller/filter_authorization.py index f64870f5..172427c2 100644 --- a/rdiffweb/controller/filter_authorization.py +++ b/rdiffweb/controller/filter_authorization.py @@ -24,19 +24,13 @@ def is_admin(): - # Authentication may have remove the default handle to let the user login. - if cherrypy.serving.request.handler is None: - return True - # Otherwise, validate the permissions. + # Validate the permissions. if not cherrypy.serving.request.currentuser or not cherrypy.serving.request.currentuser.is_admin: raise cherrypy.HTTPError("403 Forbidden") def is_maintainer(): - # Authentication may have remove the default handle to let the user login. - if cherrypy.serving.request.handler is None: - return True - # Otherwise, validate the permissions. + # Validate the permissions. if not cherrypy.serving.request.currentuser or not cherrypy.serving.request.currentuser.is_maintainer: raise cherrypy.HTTPError("403 Forbidden") diff --git a/rdiffweb/controller/form.py b/rdiffweb/controller/form.py index 9c15d990..1770da18 100644 --- a/rdiffweb/controller/form.py +++ b/rdiffweb/controller/form.py @@ -57,7 +57,7 @@ def __init__(self, **kwargs): if 'formdata' in kwargs: formdata = kwargs.pop('formdata') else: - formdata = _AUTO if self.is_submitted() else None + formdata = _AUTO if CherryForm.is_submitted(self) else None super().__init__(formdata=formdata, **kwargs) def is_submitted(self): @@ -77,7 +77,18 @@ def validate_on_submit(self): @property def error_message(self): if self.errors: - return ' '.join(['%s: %s' % (field, ', '.join(messages)) for field, messages in self.errors.items()]) + msg = Markup("") + for field, messages in self.errors.items(): + if msg: + msg += Markup('
') + # Field name + if field in self: + msg += "%s: " % self[field].label.text + else: + msg += "%s: " % field + for m in messages: + msg += m + return msg def __html__(self): """ diff --git a/rdiffweb/controller/page_admin_users.py b/rdiffweb/controller/page_admin_users.py index f250f8e0..45a78bbc 100644 --- a/rdiffweb/controller/page_admin_users.py +++ b/rdiffweb/controller/page_admin_users.py @@ -74,6 +74,19 @@ class UserForm(CherryForm): validators=[validators.optional()], description=_('To create an LDAP user, you must leave the password empty.'), ) + mfa = SelectField( + _('Two-Factor Authentication (2FA)'), + coerce=int, + choices=[ + (UserObject.DISABLED_MFA, _("Disabled")), + (UserObject.ENABLED_MFA, _("Enabled")), + ], + default=UserObject.DISABLED_MFA, + description=_( + "When Two-Factor Authentication (2FA) is enabled for a user, a verification code get sent by email when user login from a new location." + ), + render_kw={'data-beta': 1}, + ) user_root = StringField( _('Root directory'), description=_("Absolute path defining the location of the repositories for this user."), @@ -120,6 +133,12 @@ def validate_role(self, field): if self.username.data == currentuser.username and self.role.data != currentuser.role: raise ValueError(_('Cannot edit your own role.')) + def validate_mfa(self, field): + # Don't allow the user to changes it's "mfa" state. + currentuser = cherrypy.request.currentuser + if self.username.data == currentuser.username and self.mfa.data != currentuser.mfa: + raise ValueError(_('Cannot change your own two-factor authentication settings.')) + def populate_obj(self, userobj): # Save password if defined if self.password.data: @@ -128,6 +147,10 @@ def populate_obj(self, userobj): userobj.fullname = self.fullname.data or '' userobj.email = self.email.data or '' userobj.user_root = self.user_root.data + if self.mfa.data and not userobj.email: + flash(_("User email is required to enabled Two-Factor Authentication"), level='error') + else: + userobj.mfa = self.mfa.data if not userobj.valid_user_root(): flash(_("User's root directory %s is not accessible!") % userobj.user_root, level='error') logger.warning("user's root directory %s is not accessible" % userobj.user_root) diff --git a/rdiffweb/controller/page_login.py b/rdiffweb/controller/page_login.py index 4e7e6c16..5fcbb00b 100644 --- a/rdiffweb/controller/page_login.py +++ b/rdiffweb/controller/page_login.py @@ -17,13 +17,12 @@ import logging import cherrypy -from wtforms.fields import PasswordField, StringField -from wtforms.fields.simple import HiddenField +from wtforms.fields import BooleanField, PasswordField, StringField, SubmitField from wtforms.validators import InputRequired from rdiffweb.controller import Controller, flash from rdiffweb.controller.form import CherryForm -from rdiffweb.core.config import Option +from rdiffweb.tools.auth_form import LOGIN_PERSISTENT, SESSION_KEY from rdiffweb.tools.i18n import gettext_lazy as _ # Define the logger @@ -31,8 +30,11 @@ class LoginForm(CherryForm): + # Sanitize the redirect URL to avoid Open Redirect + # redirect = HiddenField(default='/', filters=[lambda v: v if v.startswith('/') else '/']) login = StringField( _('Username'), + default=lambda: cherrypy.session.get(SESSION_KEY, None), validators=[InputRequired()], render_kw={ "placeholder": _('Username'), @@ -43,8 +45,14 @@ class LoginForm(CherryForm): }, ) password = PasswordField(_('Password'), validators=[InputRequired()], render_kw={"placeholder": _('Password')}) - # Sanitize the redirect URL to avoid Open Redirect - redirect = HiddenField(default='/', filters=[lambda v: v if v.startswith('/') else '/']) + persistent = BooleanField( + _('Remember me'), + default=lambda: cherrypy.session.get(LOGIN_PERSISTENT, False), + ) + submit = SubmitField( + _('Sign in'), + render_kw={"class": "btn-primary btn-lg btn-block"}, + ) class LoginPage(Controller): @@ -52,45 +60,37 @@ class LoginPage(Controller): This page is used by the authentication to enter a user/pass. """ - _welcome_msg = Option("welcome_msg") - @cherrypy.expose() - @cherrypy.config(**{'tools.auth_form.on': False, 'tools.ratelimit.on': True}) + @cherrypy.tools.auth_mfa(on=False) + @cherrypy.tools.ratelimit() def index(self, **kwargs): + """ + Called by auth_form to generate the /login/ page. + """ form = LoginForm() - # Redirect user to main page if already login. - if self.app.currentuser is not None: - raise cherrypy.HTTPRedirect(form.redirect.data or '/') - # Validate user's credentials if form.validate_on_submit(): try: - login = any(cherrypy.engine.publish('login', form.login.data, form.password.data)) + results = cherrypy.engine.publish('login', form.login.data, form.password.data) except Exception: - logger.exception('fail to validate credential') - flash(_("Fail to validate user credential.")) + logger.exception('fail to validate user [%s] credentials', form.login.data) + flash(_("Failed to validate user credentials."), level='error') else: - if login: - raise cherrypy.HTTPRedirect(form.redirect.data or '/') + if len(results) > 0 and results[0]: + cherrypy.tools.auth_form.login(username=results[0].username, persistent=form.persistent.data) + cherrypy.tools.auth_form.redirect_to_original_url() else: flash(_("Invalid username or password.")) - - params = {'form': form} - + params = { + 'form': form, + } # Add welcome message to params. Try to load translated message. - if self._welcome_msg: - params["welcome_msg"] = self._welcome_msg.get('') + welcome_msg = self.app.cfg.welcome_msg + if welcome_msg: + params["welcome_msg"] = welcome_msg.get('') if hasattr(cherrypy.response, 'i18n'): locale = cherrypy.response.i18n.locale.language - params["welcome_msg"] = self._welcome_msg.get(locale, params["welcome_msg"]) - - return self._compile_template("login.html", **params).encode("utf-8") - + params["welcome_msg"] = welcome_msg.get(locale, params["welcome_msg"]) -class LogoutPage(Controller): - @cherrypy.expose - @cherrypy.config(**{'tools.auth_form.on': False}) - def default(self): - cherrypy.session.clear() - raise cherrypy.HTTPRedirect('/') + return self._compile_template("login.html", **params) diff --git a/rdiffweb/controller/page_mfa.py b/rdiffweb/controller/page_mfa.py new file mode 100644 index 00000000..3f8ec37c --- /dev/null +++ b/rdiffweb/controller/page_mfa.py @@ -0,0 +1,114 @@ +# -*- 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 logging + +import cherrypy +from wtforms.fields import BooleanField, StringField, SubmitField + +from rdiffweb.controller import Controller, flash +from rdiffweb.controller.form import CherryForm +from rdiffweb.tools.auth_form import LOGIN_PERSISTENT +from rdiffweb.tools.i18n import gettext_lazy as _ + +# Define the logger +logger = logging.getLogger(__name__) + + +class MfaForm(CherryForm): + code = StringField( + _('Verification code'), + description=_('Enter the code to verify your identity.'), + render_kw={ + "class": "form-control-lg", + "placeholder": _('Enter verification code here'), + "autocomplete": "off", + "autocorrect": "off", + "autofocus": "autofocus", + }, + ) + persistent = BooleanField( + _('Remember me'), + default=lambda: cherrypy.session.get(LOGIN_PERSISTENT, False), + ) + submit = SubmitField( + _('Sign in'), + render_kw={"class": "btn-primary btn-lg btn-block"}, + ) + resend_code = SubmitField( + _('Resend code to my email'), + render_kw={"class": "btn-link btn-sm btn-block"}, + ) + + def validate_code(self, field): + # Code is required when submit. + if self.submit.data: + if not self.code.data: + raise ValueError(_('Invalid verification code.')) + # Validate verification code. + if not cherrypy.tools.auth_mfa.verify_code(code=self.code.data, persistent=self.persistent.data): + raise ValueError(_('Invalid verification code.')) + + def validate(self, extra_validators=None): + if not (self.submit.data or self.resend_code.data): + raise ValueError(_('Invalid operation')) + return super().validate() + + +class MfaPage(Controller): + @cherrypy.expose() + @cherrypy.tools.ratelimit() + def index(self, **kwargs): + form = MfaForm() + + # Validate MFA + if form.is_submitted(): + if form.validate(): + if form.submit.data: + cherrypy.tools.auth_mfa.redirect_to_original_url() + elif form.resend_code.data: + self.send_code() + if cherrypy.tools.auth_mfa.is_code_expired(): + # Send verification code if previous code expired. + self.send_code() + params = { + 'form': form, + } + # Add welcome message to params. Try to load translated message. + welcome_msg = self.app.cfg.welcome_msg + if welcome_msg: + params["welcome_msg"] = welcome_msg.get('') + if hasattr(cherrypy.response, 'i18n'): + locale = cherrypy.response.i18n.locale.language + params["welcome_msg"] = welcome_msg.get(locale, params["welcome_msg"]) + return self._compile_template("mfa.html", **params) + + def send_code(self): + # Send verification code by email + userobj = cherrypy.serving.request.currentuser + if not userobj.email: + flash( + _( + "Multi-factor authentication is enabled for your account, but your account does not have a valid email address to send the verification code to. Check your account settings with your administrator." + ) + ) + else: + code = cherrypy.tools.auth_mfa.generate_code() + body = self.app.templates.compile_template( + "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code} + ) + cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body) + flash(_("A new verification code has been sent to your email.")) diff --git a/rdiffweb/controller/page_pref_general.py b/rdiffweb/controller/page_pref_general.py index 0ed8fded..21c21b96 100644 --- a/rdiffweb/controller/page_pref_general.py +++ b/rdiffweb/controller/page_pref_general.py @@ -23,9 +23,8 @@ import re import cherrypy -from wtforms.fields import PasswordField, StringField +from wtforms.fields import HiddenField, PasswordField, StringField, SubmitField from wtforms.fields.html5 import EmailField -from wtforms.fields.simple import PasswordField from wtforms.validators import DataRequired, EqualTo, InputRequired, Length, Regexp from rdiffweb.controller import Controller, flash @@ -39,9 +38,15 @@ class UserProfileForm(CherryForm): + action = HiddenField(default='set_profile_info') username = StringField(_('Username'), render_kw={'readonly': True}) fullname = StringField(_('Fullname')) email = EmailField(_('Email'), validators=[DataRequired(), Regexp(PATTERN_EMAIL, message=_("Invalid email."))]) + set_profile_info = SubmitField(_('Save changes')) + + def is_submitted(self): + # Validate only if action is set_profile_info + return super().is_submitted() and self.action.data == 'set_profile_info' def populate_obj(self, user): user.fullname = self.fullname.data @@ -50,6 +55,7 @@ def populate_obj(self, user): class UserPasswordForm(CherryForm): + action = HiddenField(default='set_password') current = PasswordField( _('Current password'), validators=[InputRequired(_("Current password is missing."))], @@ -65,6 +71,7 @@ class UserPasswordForm(CherryForm): confirm = PasswordField( _('Confirm new password'), validators=[InputRequired(_("Confirmation password is missing."))] ) + set_password = SubmitField(_('Update password')) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -80,49 +87,72 @@ def __init__(self, *args, **kwargs): def app(self): return cherrypy.request.app + def is_submitted(self): + # Validate only if action is set_profile_info + return super().is_submitted() and self.action.data == 'set_password' -class PagePrefsGeneral(Controller): - """ - Plugin to change user profile and password. - """ - - def _handle_set_password(self, action, form): - """ - Called when changing user password. - """ - assert self.app.currentuser - assert action == 'set_password' - assert form - # Validate form - if not form.validate(): - flash(form.error_message, level='error') - return - # Update user password + def populate_obj(self, user): try: - self.app.currentuser.set_password(form.new.data, old_password=form.current.data) + user.set_password(self.new.data, old_password=self.current.data) flash(_("Password updated successfully."), level='success') except ValueError as e: flash(str(e), level='warning') + +class RefreshForm(CherryForm): + action = HiddenField(default='update_repos') + update_repos = SubmitField( + _('Refresh repositories'), + description=_( + "Refresh the list of repositories associated to your account. If you recently add a new repository and it doesn't show, you may try to refresh the list." + ), + ) + + def is_submitted(self): + # Validate only if action is set_profile_info + return super().is_submitted() and self.action.data == 'update_repos' + + def populate_obj(self, user): + try: + user.refresh_repos(delete=True) + flash(_("Repositories successfully updated"), level='success') + except ValueError as e: + flash(str(e), level='warning') + + +class PagePrefsGeneral(Controller): + """ + Plugin to change user profile and password. + """ + @cherrypy.expose def default(self, action=None, **kwargs): # Process the parameters. profile_form = UserProfileForm(obj=self.app.currentuser) password_form = UserPasswordForm() - if action == "set_profile_info": - if profile_form.validate_on_submit(): + refresh_form = RefreshForm() + if profile_form.is_submitted(): + if profile_form.validate(): profile_form.populate_obj(self.app.currentuser) flash(_("Profile updated successfully."), level='success') - elif action == "set_password": - self._handle_set_password(action, password_form) - elif action == "update_repos": - self.app.currentuser.refresh_repos(delete=True) - flash(_("Repositories successfully updated"), level='success') + else: + flash(profile_form.error_message, level='error') + elif password_form.is_submitted(): + if password_form.validate(): + password_form.populate_obj(self.app.currentuser) + else: + flash(password_form.error_message, level='error') + elif refresh_form.is_submitted(): + if refresh_form.validate(): + refresh_form.populate_obj(self.app.currentuser) + else: + flash(refresh_form.error_message, level='error') elif action is not None: _logger.warning("unknown action: %s", action) raise cherrypy.NotFound("Unknown action") params = { 'profile_form': profile_form, 'password_form': password_form, + 'refresh_form': refresh_form, } return self._compile_template("prefs_general.html", **params) diff --git a/rdiffweb/controller/page_pref_mfa.py b/rdiffweb/controller/page_pref_mfa.py new file mode 100644 index 00000000..cf4d4295 --- /dev/null +++ b/rdiffweb/controller/page_pref_mfa.py @@ -0,0 +1,132 @@ +# -*- 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 cherrypy +from wtforms.fields import SelectField, StringField, SubmitField +from wtforms.widgets import HiddenInput + +from rdiffweb.controller import Controller, flash +from rdiffweb.controller.form import CherryForm +from rdiffweb.core.model import UserObject +from rdiffweb.tools.i18n import gettext_lazy as _ + + +class AbstractMfaForm(CherryForm): + def __init__(self, obj, **kwargs): + assert obj + super().__init__(obj=obj, **kwargs) + # Keep only one of the enable or disable button + if obj.mfa: + self.enable_mfa.widget = HiddenInput() + self.enable_mfa.data = '' + else: + self.disable_mfa.widget = HiddenInput() + self.disable_mfa.data = '' + + +class MfaStatusForm(AbstractMfaForm): + mfa = SelectField( + _('Two-Factor Authentication (2FA) Status'), + coerce=int, + choices=[ + (UserObject.DISABLED_MFA, _("Disabled")), + (UserObject.ENABLED_MFA, _("Enabled")), + ], + render_kw={'readonly': True, 'disabled': True, 'data-beta': '1'}, + ) + enable_mfa = SubmitField(_('Enable Two-Factor Authentication'), render_kw={"class": "btn-success"}) + disable_mfa = SubmitField(_('Disable Two-Factor Authentication'), render_kw={"class": "btn-warning"}) + + +class MfaToggleForm(AbstractMfaForm): + code = StringField( + _('Verification code'), + render_kw={ + "placeholder": _('Enter verification code here'), + "autocomplete": "off", + "autocorrect": "off", + "autofocus": "autofocus", + }, + ) + enable_mfa = SubmitField(_('Enable Two-Factor Authentication'), render_kw={"class": "btn-success"}) + disable_mfa = SubmitField(_('Disable Two-Factor Authentication'), render_kw={"class": "btn-warning"}) + resend_code = SubmitField( + _('Resend code to my email'), + render_kw={"class": "btn-link"}, + ) + + @property + def app(self): + return cherrypy.request.app + + def populate_obj(self, userobj): + # Enable or disable MFA only when a code is provided. + if self.enable_mfa.data: + userobj.mfa = UserObject.ENABLED_MFA + flash(_("Two-Factor authentication enabled successfully."), level='success') + elif self.disable_mfa.data: + userobj.mfa = UserObject.DISABLED_MFA + flash(_("Two-Factor authentication disabled successfully."), level='success') + + def validate_code(self, field): + # Code is required for enable_mfa and disable_mfa + if self.enable_mfa.data or self.disable_mfa.data: + if not self.code.data: + raise ValueError(_("Enter the verification code to continue.")) + # Validate code + if not cherrypy.tools.auth_mfa.verify_code(self.code.data, False): + raise ValueError(_("Invalid verification code.")) + + def validate(self, extra_validators=None): + if not (self.enable_mfa.data or self.disable_mfa.data or self.resend_code.data): + raise ValueError(_('Invalid operation')) + return super().validate() + + +class PagePrefMfa(Controller): + @cherrypy.expose + def default(self, action=None, **kwargs): + form = MfaToggleForm(obj=self.app.currentuser) + if form.is_submitted(): + if form.validate(): + if form.resend_code.data: + self.send_code() + elif form.enable_mfa.data or form.disable_mfa.data: + form.populate_obj(self.app.currentuser) + form = MfaStatusForm(obj=self.app.currentuser) + # Send verification code if previous code expired. + elif cherrypy.tools.auth_mfa.is_code_expired(): + self.send_code() + else: + form = MfaStatusForm(obj=self.app.currentuser) + params = { + 'form': form, + } + return self._compile_template("prefs_mfa.html", **params) + + def send_code(self): + userobj = self.app.currentuser + if not userobj.email: + flash(_("To continue, you must set up an email address for your account."), level='warning') + return + code = cherrypy.tools.auth_mfa.generate_code() + body = self.app.templates.compile_template( + "email_mfa.html", **{"header_name": self.app.cfg.header_name, 'user': userobj, 'code': code} + ) + cherrypy.engine.publish('queue_mail', to=userobj.email, subject=_("Your verification code"), message=body) + flash(_("A new verification code has been sent to your email.")) diff --git a/rdiffweb/controller/page_prefs.py b/rdiffweb/controller/page_prefs.py index 4c42f053..92b9d8cb 100644 --- a/rdiffweb/controller/page_prefs.py +++ b/rdiffweb/controller/page_prefs.py @@ -21,6 +21,7 @@ from rdiffweb.controller import Controller from rdiffweb.controller.page_pref_general import PagePrefsGeneral +from rdiffweb.controller.page_pref_mfa import PagePrefMfa from rdiffweb.controller.page_pref_notification import PagePrefNotification from rdiffweb.controller.page_pref_session import PagePrefSession from rdiffweb.controller.page_pref_sshkeys import PagePrefSshKeys @@ -36,6 +37,7 @@ class PreferencesPage(Controller): notification = PagePrefNotification() sshkeys = PagePrefSshKeys() session = PagePrefSession() + mfa = PagePrefMfa() @cherrypy.expose def index(self, panelid=None, **kwargs): diff --git a/rdiffweb/controller/tests/test_api.py b/rdiffweb/controller/tests/test_api.py index bf2290e6..9da2eaa0 100644 --- a/rdiffweb/controller/tests/test_api.py +++ b/rdiffweb/controller/tests/test_api.py @@ -49,6 +49,53 @@ def test_get_currentuser(self): self.assertEqual(repo.get('name'), 'testcases') self.assertEqual(repo.get('maxage'), 0) + def test_getapi_without_authorization(self): + """ + Check if 401 is return when authorization is not provided. + """ + self.getPage('/api/') + self.assertStatus('401 Unauthorized') + + def test_getapi_without_username(self): + """ + Check if error 401 is raised when requesting /login without a username. + """ + self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b":admin123").decode('ascii'))]) + self.assertStatus('401 Unauthorized') + + def test_getapi_with_empty_password(self): + """ + Check if 401 is return when authorization is not provided. + """ + self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:").decode('ascii'))]) + self.assertStatus('401 Unauthorized') + + def test_getapi_with_invalid_password(self): + """ + Check if 401 is return when authorization is not provided. + """ + self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:invalid").decode('ascii'))]) + self.assertStatus('401 Unauthorized') + + def test_getapi_with_authorization(self): + """ + Check if 200 is return when authorization is not provided. + """ + self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:admin123").decode('ascii'))]) + self.assertStatus('200 OK') + + def test_getapi_with_session(self): + # Given an authenticate user + b = {'login': self.USERNAME, 'password': self.PASSWORD} + self.getPage('/login/', method='POST', body=b) + self.assertStatus('303 See Other') + self.getPage('/') + self.assertStatus('200 OK') + # When querying the API + self.getPage('/api/') + # Then access is refused + self.assertStatus('401 Unauthorized') + class APIRatelimitTest(rdiffweb.test.WebCase): diff --git a/rdiffweb/controller/tests/test_page_admin_users.py b/rdiffweb/controller/tests/test_page_admin_users.py index d92c5ca7..f7fc7631 100644 --- a/rdiffweb/controller/tests/test_page_admin_users.py +++ b/rdiffweb/controller/tests/test_page_admin_users.py @@ -58,7 +58,7 @@ def _store_quota(self, userobj, value): def _load_quota(self, userobj): return self._quota.get(userobj.username, 0) - def _add_user(self, username=None, email=None, password=None, user_root=None, role=None): + def _add_user(self, username=None, email=None, password=None, user_root=None, role=None, mfa=None): b = {} b['action'] = 'add' if username is not None: @@ -71,9 +71,13 @@ def _add_user(self, username=None, email=None, password=None, user_root=None, ro b['user_root'] = user_root if role is not None: b['role'] = str(role) + if mfa is not None: + b['mfa'] = str(mfa) self.getPage("/admin/users/", method='POST', body=b) - def _edit_user(self, username=None, email=None, password=None, user_root=None, role=None, disk_quota=None): + def _edit_user( + self, username=None, email=None, password=None, user_root=None, role=None, disk_quota=None, mfa=None + ): b = {} b['action'] = 'edit' if username is not None: @@ -88,6 +92,8 @@ def _edit_user(self, username=None, email=None, password=None, user_root=None, r b['role'] = str(role) if disk_quota is not None: b['disk_quota'] = disk_quota + if mfa is not None: + b['mfa'] = str(mfa) self.getPage("/admin/users/", method='POST', body=b) def _delete_user(self, username='test1'): @@ -120,7 +126,7 @@ def test_add_user_with_invalid_role(self): self._add_user("invalid", "invalid@test.com", "password", "/home/", 'admin') # Then an error message is displayed to the user self.assertStatus(200) - self.assertInBody('role: Invalid Choice: could not coerce') + self.assertInBody('Role: Invalid Choice: could not coerce') # Then listener are not called self.listener.user_added.assert_not_called() @@ -128,14 +134,16 @@ def test_add_user_with_invalid_role(self): self._add_user("invalid", "invalid@test.com", "password", "/home/", -1) # Then an error message is displayed to the user self.assertStatus(200) - self.assertInBody('role: Not a valid choice') + self.assertInBody('User Role: Not a valid choice') # Then listener are not called self.listener.user_added.assert_not_called() def test_add_edit_delete(self): # Add user to be listed self.listener.user_password_changed.reset_mock() - self._add_user("test2", "test2@test.com", "password", "/home/", UserObject.USER_ROLE) + self._add_user( + "test2", "test2@test.com", "password", "/home/", UserObject.USER_ROLE, mfa=UserObject.DISABLED_MFA + ) self.assertInBody("User added successfully.") self.assertInBody("test2") self.assertInBody("test2@test.com") @@ -143,7 +151,9 @@ def test_add_edit_delete(self): self.listener.user_password_changed.assert_called_once() self.listener.user_password_changed.reset_mock() # Update user - self._edit_user("test2", "chaned@test.com", "new-password", "/tmp/", UserObject.ADMIN_ROLE) + self._edit_user( + "test2", "chaned@test.com", "new-password", "/tmp/", UserObject.ADMIN_ROLE, mfa=UserObject.ENABLED_MFA + ) self.listener.user_attr_changed.assert_called() self.listener.user_password_changed.assert_called_once() self.assertInBody("User information modified successfully.") @@ -199,7 +209,7 @@ def test_add_user_with_empty_username(self): """ self._add_user("", "test1@test.com", "password", "/tmp/", UserObject.USER_ROLE) self.assertStatus(200) - self.assertInBody("username: This field is required.") + self.assertInBody("Username: This field is required.") def test_add_user_with_existing_username(self): """ @@ -258,7 +268,7 @@ def test_delete_user_admin(self): """ # Create another admin user self._add_user('admin2', '', 'password', '', UserObject.ADMIN_ROLE) - self.getPage("/logout/") + self.getPage("/logout") self.assertStatus(303) self.assertHeaderItemValue('Location', self.baseurl + '/') self._login('admin2', 'password') @@ -413,3 +423,19 @@ def test_set_quota_unsupported(self): self.listener.set_disk_quota.assert_called_once_with(ANY, 8765432) self.assertInBody("Setting user's quota is not supported") self.assertStatus(200) + + def test_edit_own_role(self): + # Given an administrator + # When trygin to update your own role + self._edit_user(username=self.USERNAME, role=UserObject.MAINTAINER_ROLE) + # Then an error is returned + self.assertStatus(200) + self.assertInBody("Cannot edit your own role.") + + def test_edit_own_mfa(self): + # Given an administrator + # When trygin to update your own role + self._edit_user(username=self.USERNAME, mfa=UserObject.ENABLED_MFA) + # Then an error is returned + self.assertStatus(200) + self.assertInBody("Cannot change your own two-factor authentication settings.") diff --git a/rdiffweb/controller/tests/test_page_delete.py b/rdiffweb/controller/tests/test_page_delete.py index da9eff45..3bd664ba 100644 --- a/rdiffweb/controller/tests/test_page_delete.py +++ b/rdiffweb/controller/tests/test_page_delete.py @@ -121,7 +121,7 @@ def test_delete_repo_without_confirm(self): self._delete(self.USERNAME, self.REPO, None) # Make sure the repository is not delete self.assertStatus(400) - self.assertInBody('confirm: This field is required') + self.assertInBody('Confirmation: This field is required') userobj.expire() self.assertEqual(['broker-repo', 'testcases'], [r.name for r in userobj.repo_objs]) diff --git a/rdiffweb/controller/tests/test_page_error.py b/rdiffweb/controller/tests/test_page_error.py index 742cca25..421efda9 100644 --- a/rdiffweb/controller/tests/test_page_error.py +++ b/rdiffweb/controller/tests/test_page_error.py @@ -38,15 +38,22 @@ class ErrorPageTest(rdiffweb.test.WebCase): login = True - def test_error_page(self): + def test_error_page_html(self): # When browsing the invalid URL self.getPage('/invalid/') # Then a 404 error page is return using jinja2 template self.assertStatus("404 Not Found") self.assertInBody("Oops!") - self.assertStatus("404 Not Found") - self.assertInBody("Oops!") if self.expect_stacktrace: self.assertInBody('Traceback (most recent call last):') else: self.assertNotInBody('Traceback (most recent call last):') + self.assertInBody("The path '/invalid/' was not found.") + + def test_error_page_plain_text(self): + # When browsing a an invalid path + self.getPage('/invalid/', headers=[("Accept", "text/plain")]) + # Then a 404 error page is return in plain text + self.assertStatus("404 Not Found") + self.assertInBody(b"The path '/invalid/' was not found.") + self.assertNotInBody(b"") diff --git a/rdiffweb/controller/tests/test_page_login.py b/rdiffweb/controller/tests/test_page_login.py index 3da4509a..0581da42 100644 --- a/rdiffweb/controller/tests/test_page_login.py +++ b/rdiffweb/controller/tests/test_page_login.py @@ -20,23 +20,21 @@ @author: Patrik Dufresne """ -from base64 import b64encode + +from parameterized import parameterized import rdiffweb.test -from rdiffweb.core.model import DbSession, SessionObject -from rdiffweb.tools.auth_form import SESSION_KEY +from rdiffweb.core.model import DbSession, SessionObject, UserObject +from rdiffweb.tools.auth_form import LOGIN_TIME, SESSION_KEY class LoginPageTest(rdiffweb.test.WebCase): def test_getpage(self): - """ - Make sure the login page can be rendered without error. - """ # When making a query to a page while unauthenticated self.getPage('/') # Then user is redirected to login page self.assertStatus('303 See Other') - self.assertHeaderItemValue('Location', self.baseurl + '/login/?redirect=%2F') + self.assertHeaderItemValue('Location', self.baseurl + '/login/') # Then a session object is created without a username self.assertEqual(1, SessionObject.query.filter(SessionObject.id == self.session_id).count()) SessionObject.query.filter(SessionObject.id == self.session_id).first() @@ -44,10 +42,7 @@ def test_getpage(self): session.load() self.assertIsNone(session.get(SESSION_KEY)) - def test_getpage_success(self): - """ - Make sure the login page can be rendered without error. - """ + def test_login_success(self): # When authenticating with valid credentials. self.getPage('/login/', method='POST', body={'login': self.USERNAME, 'password': self.PASSWORD}) # Then user is redirected @@ -59,6 +54,7 @@ def test_getpage_success(self): session = DbSession(id=self.session_id) session.load() self.assertEqual('admin', session.get(SESSION_KEY)) + self.assertIsNotNone(session.get(LOGIN_TIME)) def test_cookie_http_only(self): # Given a request made to rdiffweb @@ -68,88 +64,61 @@ def test_cookie_http_only(self): cookie = self.assertHeader('Set-Cookie') self.assertIn('HttpOnly', cookie) - def test_getpage_with_plaintext(self): + def test_login_with_plaintext(self): """ Requesting plain text without being authenticated should show the login form. """ + # When querying root page without authentication self.getPage('/', headers=[("Accept", "text/plain")]) + # Then user is redirected to /login page + self.assertStatus('303 See Other') + self.assertHeaderItemValue('Location', self.baseurl + '/login/') + + @parameterized.expand( + [ + ('with_root', '/'), + ('with_browse_url', '/browse/admin/testcases/Revisions/'), + ('with_encoded_url', '/browse/admin/testcases/DIR%EF%BF%BD/'), + ( + 'with_broken_encoding', + '/restore/admin/testcases/Fichier%20avec%20non%20asci%20char%20%C9velyne%20M%E8re.txt/?date=1415221507', + ), + ('with_query_string', '/restore/admin/testcases/Revisions?date=1477434528'), + ('with_multiple_query_string', '/restore/admin/testcases/Revisions?date=1477434528&kind=tar.gz'), + ('with_admin', '/admin/'), + ] + ) + def test_login(self, unused, original_url): + # Given an unauthenticated user + # Query the page without login-in + self.getPage(original_url) + # Then user is redirected to the login page + self.assertStatus('303 See Other') + self.assertHeaderItemValue('Location', self.baseurl + '/login/') + # When authentication is successful + self.getPage('/login/', method='POST', body={'login': self.USERNAME, 'password': self.PASSWORD}) + # Then user is redirected to original URL self.assertStatus('303 See Other') - self.assertHeaderItemValue('Location', self.baseurl + '/login/?redirect=%2F') - - def test_getpage_with_redirect_get(self): - """ - Check encoding of redirect url when send using GET method. - """ - # Query the page without login-in - self.getPage('/browse/' + self.USERNAME + "/" + self.REPO + '/DIR%EF%BF%BD/') - self.assertStatus('303 See Other') - self.assertHeaderItemValue( - 'Location', self.baseurl + '/login/?redirect=%2Fbrowse%2Fadmin%2Ftestcases%2FDIR%C3%AF%C2%BF%C2%BD%2F' - ) - - def test_getpage_with_open_redirect(self): - # Given a user browsing a URL with open redirect - # When the user visit the login page - self.getPage('/login/?redirect=https://attacker.com') - # The URL is sanitize. - self.assertNotInBody('https://attacker.com') - - def test_getpage_with_broken_encoding(self): - """ - Check encoding of redirect url when send using GET method. - """ - # Query the page without login-in - self.getPage( - '/restore/' - + self.USERNAME - + "/" - + self.REPO - + '/Fichier%20avec%20non%20asci%20char%20%C9velyne%20M%E8re.txt' - ) - self.assertStatus('303 See Other') - self.assertHeaderItemValue( - 'Location', - self.baseurl - + '/login/?redirect=%2Frestore%2Fadmin%2Ftestcases%2FFichier+avec+non+asci+char+%C3%89velyne+M%C3%A8re.txt', - ) + self.assertHeaderItemValue('Location', self.baseurl + original_url) + # When requesting the original page + self.getPage(original_url) + # Then page return without error + self.assertStatus(200) def test_getpage_with_redirect_post(self): """ Check encoding of redirect url when send using POST method. """ + # When posting invalid credentials b = {'login': 'admin', 'password': 'invalid', 'redirect': '/browse/' + self.REPO + '/DIR%EF%BF%BD/'} self.getPage('/login/', method='POST', body=b) + # Then page return without HTTP Error self.assertStatus('200 OK') + # Then page display an error + self.assertInBody('Invalid username or password.') self.assertInBody('id="form-login"') - self.assertInBody('/browse/' + self.REPO + '/DIR%EF%BF%BD/"') - - def test_getpage_with_querystring_redirect_get(self): - """ - Check if unauthenticated users are redirect properly to login page. - """ - self.getPage('/browse/' + self.REPO + '/?restore=T') - self.assertStatus('303 See Other') - self.assertHeaderItemValue('Location', self.baseurl + '/login/?redirect=%2Fbrowse%2Ftestcases%2F%3Frestore%3DT') - - def test_getpage_with_multiple_querystring_redirect_get(self): - self.getPage('/restore/' + self.REPO + '?date=1414871387&kind=zip') - self.assertStatus('303 See Other') - self.assertHeaderItemValue( - 'Location', self.baseurl + '/login/?redirect=%2Frestore%2Ftestcases%3Fdate%3D1414871387%26kind%3Dzip' - ) - - def test_getpage_with_redirection(self): - """ - Check if redirect url is properly rendered in HTML. - """ - b = { - 'login': 'admin', - 'password': 'admin123', - 'redirect': '/restore/' + self.REPO + '?date=1414871387&kind=zip', - } - self.getPage('/login/', method='POST', body=b) - self.assertStatus('303 See Other') - self.assertHeaderItemValue('Location', self.baseurl + '/restore/' + self.REPO + '?date=1414871387&kind=zip') + # Then redirect URL is ignored + self.assertNotInBody('/browse/' + self.REPO + '/DIR%EF%BF%BD/"') def test_getpage_without_username(self): """ @@ -175,60 +144,24 @@ def test_post_with_invalid_url(self): self.getPage('/login/kefuxian.mvc', method='POST') self.assertStatus('303 See Other') - def test_getpage_admin(self): - """ - Access to admin area without session should redirect to login page. - """ - self.getPage('/admin/') - self.assertStatus('303 See Other') - - def test_getapi_without_authorization(self): - """ - Check if 401 is return when authorization is not provided. - """ - self.getPage('/api/') - self.assertStatus('401 Unauthorized') - - def test_getapi_without_username(self): - """ - Check if error 401 is raised when requesting /login without a username. - """ - self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b":admin123").decode('ascii'))]) - self.assertStatus('401 Unauthorized') - - def test_getapi_with_empty_password(self): - """ - Check if 401 is return when authorization is not provided. - """ - self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:").decode('ascii'))]) - self.assertStatus('401 Unauthorized') - - def test_getapi_with_invalid_password(self): - """ - Check if 401 is return when authorization is not provided. - """ - self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:invalid").decode('ascii'))]) - self.assertStatus('401 Unauthorized') - - def test_getapi_with_authorization(self): - """ - Check if 200 is return when authorization is not provided. - """ - self.getPage('/api/', headers=[("Authorization", "Basic " + b64encode(b"admin:admin123").decode('ascii'))]) - self.assertStatus('200 OK') - - def test_getapi_with_session(self): - """ - Check if 200 is return when authorization is not provided. - """ - b = {'login': 'admin', 'password': 'admin123'} - self.getPage('/login/', method='POST', body=b) - self.assertStatus('303 See Other') + def test_login_twice(self): + # Given an authenticated user + self.getPage('/login/', method='POST', body={'login': self.USERNAME, 'password': self.PASSWORD}) + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + "/") self.getPage('/') - self.assertStatus('200 OK') - # Get api using the same session. - self.getPage('/api/') - self.assertStatus('200 OK') + self.assertStatus(200) + self.assertInBody(self.USERNAME) + # Given another user + UserObject.add_user('otheruser', password='password') + # When trying to re-authenticated with login page + self.getPage('/login/', method='POST', body={'login': 'otheruser', 'password': 'password'}) + # Then user is still authenticated with previous user + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + "/") + self.getPage('/') + self.assertStatus(200) + self.assertInBody(self.USERNAME) class LoginPageWithWelcomeMsgTest(rdiffweb.test.WebCase): @@ -299,7 +232,7 @@ def test_login_ratelimit(self): class LogoutPageTest(rdiffweb.test.WebCase): def test_getpage_without_login(self): # Accessing logout page directly will redirect to "/". - self.getPage('/logout/') + self.getPage('/logout') self.assertStatus('303 See Other') self.assertHeaderItemValue('Location', self.baseurl + '/') @@ -312,10 +245,10 @@ def test_getpage_with_login(self): self.getPage("/prefs/general") self.assertStatus('200 OK') # Then logout - self.getPage('/logout/') + self.getPage('/logout') self.assertStatus('303 See Other') self.assertHeaderItemValue('Location', self.baseurl + '/') # Get content of a page. self.getPage("/prefs/general") self.assertStatus('303 See Other') - self.assertHeaderItemValue('Location', self.baseurl + '/login/?redirect=%2Fprefs%2Fgeneral') + self.assertHeaderItemValue('Location', self.baseurl + '/login/') diff --git a/rdiffweb/controller/tests/test_page_mfa.py b/rdiffweb/controller/tests/test_page_mfa.py new file mode 100644 index 00000000..3056f585 --- /dev/null +++ b/rdiffweb/controller/tests/test_page_mfa.py @@ -0,0 +1,267 @@ +# -*- 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 +from unittest.mock import MagicMock + +import cherrypy + +import rdiffweb.test +from rdiffweb.core.model import DbSession, UserObject + + +class MfaPageTest(rdiffweb.test.WebCase): + + # Authenticated by default. + login = True + + def _get_code(self): + # Register an email listeer to capture email send + self.listener = MagicMock() + cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50) + # Query MFA page to generate a code + self.getPage("/mfa/") + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + # Extract code from email between and + self.listener.queue_email.assert_called_once() + message = self.listener.queue_email.call_args[1]['message'] + return message.split('', 1)[1].split('')[0] + + def setUp(self): + super().setUp() + # Enabled MFA for all test cases + userobj = UserObject.get_user(self.USERNAME) + userobj.mfa = UserObject.ENABLED_MFA + userobj.email = 'admin@example.com' + userobj.add() + + def test_get_without_login(self): + # Given an unauthenticated user + self.getPage("/logout") + self.assertStatus(303) + # When requesting /mfa/ + self.getPage("/mfa/") + # Then user is redirected to /login/ + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/login/') + + def test_get_with_mfa_disabled(self): + # Given an authenticated user with MFA Disable + userobj = UserObject.get_user(self.USERNAME) + userobj.mfa = UserObject.DISABLED_MFA + userobj.add() + self.getPage("/") + self.assertStatus(200) + # When requesting /mfa/ page + self.getPage("/mfa/") + # Then user is redirected to root page + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/') + + def test_get_with_user_without_email(self): + # Given an authenticated user without email. + userobj = UserObject.get_user(self.USERNAME) + userobj.email = '' + userobj.add() + # When requesting /mfa/ page + self.getPage("/mfa/") + # Then user is redirected to root page + self.assertStatus(200) + self.assertInBody( + "Multi-factor authentication is enabled for your account, but your account does not have a valid email address to send the verification code to. Check your account settings with your administrator." + ) + + def test_get_with_trusted(self): + # Given an authenticated user with MFA enabled and already verified + session = DbSession(id=self.session_id) + session.load() + session['_auth_mfa_username'] = self.USERNAME + session['_auth_mfa_time'] = session.now() + session['_auth_mfa_trusted_ip_list'] = ['127.0.0.1'] + session.save() + # When requesting /mfa/ page when we are already trusted + self.getPage("/mfa/") + # Then user is redirected to root page + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/') + + def test_get_with_trusted_expired(self): + # Given an authenticated user with MFA enabled and already verified + session = DbSession(id=self.session_id) + session.load() + session['_auth_mfa_username'] = self.USERNAME + session['_auth_mfa_time'] = session.now() - datetime.timedelta(minutes=session.timeout) + session.save() + # When requesting /mfa/ page + self.getPage("/mfa/") + # Then an email get send with a new code + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + + def test_get_with_trusted_different_ip(self): + # Given an authenticated user with MFA enabled and already verified + session = DbSession(id=self.session_id) + session.load() + session['_auth_mfa_username'] = self.USERNAME + session['_auth_mfa_time'] = session.now() + session.save() + # When requesting /mfa/ page from a different ip + self.getPage("/mfa/", headers=[('X-Forwarded-For', '10.255.14.23')]) + # Then an email get send with a new code + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + + def test_get_without_verified(self): + # Given an authenticated user With MFA enabled + # When requesting /mfa/ page + self.getPage("/mfa/") + # Then an email get send with a new code + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + + def test_verify_code_valid(self): + # Given an authenticated user With MFA enabled + code = self._get_code() + # When sending a valid verification code + self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'}) + # Then user is redirected to root page + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/') + # Then user has access + self.getPage("/") + self.assertStatus(200) + + def test_verify_code_invalid(self): + # Given an authenticated user With MFA enabled + # When sending an invalid verification code + self.getPage("/mfa/", method='POST', body={'code': '1234567', 'submit': '1'}) + # Then an error get displayed to the user + self.assertStatus(200) + self.assertInBody("Invalid verification code.") + + def test_verify_code_expired(self): + # Given an authenticated user With MFA enabled + code = self._get_code() + # When sending a valid verification code that expired + session = DbSession(id=self.session_id) + session.load() + session['_auth_mfa_code_time'] = session.now() - datetime.timedelta(minutes=session.timeout + 1) + session.save() + self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'}) + # Then a new code get generated. + self.assertStatus(200) + self.assertInBody("Invalid verification code.") + self.assertInBody("A new verification code has been sent to your email.") + + def test_verify_code_invalid_after_4_tentative(self): + # Given an authenticated user With MFA + self._get_code() + # When user enter an invalid verification code 3 times + self.getPage("/mfa/", method='POST', body={'code': '1234567', 'submit': '1'}) + self.assertStatus(200) + self.getPage("/mfa/", method='POST', body={'code': '1234567', 'submit': '1'}) + self.assertStatus(200) + self.getPage("/mfa/", method='POST', body={'code': '1234567', 'submit': '1'}) + self.assertStatus(200) + self.getPage("/mfa/", method='POST', body={'code': '1234567', 'submit': '1'}) + # Then an error get displayed to the user + self.assertStatus(200) + self.assertInBody("Invalid verification code.") + self.assertInBody("A new verification code has been sent to your email.") + + def test_resend_code(self): + # Given an authenticated user With MFA enabled with an existing code + self._get_code() + # When user request a new code + self.getPage("/mfa/", method='POST', body={'resend_code': '1'}) + # Then a new code is sent to the user by email + self.assertInBody("A new verification code has been sent to your email.") + + def test_redirect_to_original_url(self): + # When querying a page that required mfa + self.getPage('/prefs/general') + # Then user is redirected to mfa page + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/mfa/') + # When providing verification code + code = self._get_code() + self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'}) + # Then user is redirected to original url + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/prefs/general') + + def test_login_persistent(self): + # Given a user authenticated with MFA with "login_persistent" + code = self._get_code() + self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1', 'persistent': '1'}) + self.assertStatus(303) + self.getPage("/") + self.assertStatus(200) + session = DbSession(id=self.session_id) + session.load() + self.assertTrue(session['login_persistent']) + # When the login_time expired (after 1 days) + session['login_time'] = session.now() - datetime.timedelta(days=1, seconds=1) + session.save() + # Then next query redirect user to root page (by mfa) + self.getPage("/prefs/general") + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/prefs/general') + self.getPage("/prefs/general") + # Then user is redirected to /login/ page (by auth_form) + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/login/') + # When user enter valid username password + self.getPage("/login/", method='POST', body={'login': self.USERNAME, 'password': self.PASSWORD}) + # Then user is redirected to original url + self.assertStatus(303) + self.assertHeaderItemValue('Location', self.baseurl + '/prefs/general') + self.getPage("/") + self.assertStatus(200) + self.assertInBody('Repositories') + + +class MfaPageWithWelcomeMsgTest(rdiffweb.test.WebCase): + + login = True + + default_config = {'welcomemsg': 'default message', 'welcomemsg[fr]': 'french message'} + + def setUp(self): + super().setUp() + # Enabled MFA for all test cases + userobj = UserObject.get_user(self.USERNAME) + userobj.mfa = UserObject.ENABLED_MFA + userobj.email = 'admin@example.com' + userobj.add() + + def test_getpage_default(self): + # Given a user with MFA enabled + # When querying the mfa page + self.getPage('/mfa/', headers=[("Accept-Language", "it")]) + # Then page is return without error with the custom welcome message + self.assertStatus('200 OK') + self.assertInBody('default message') + + def test_getpage_french(self): + # Given a user with MFA enabled + # When querying the mfa page in french + self.getPage('/mfa/', headers=[("Accept-Language", "fr")]) + # Then page is return without error with the custom welcome message in french + self.assertStatus('200 OK') + self.assertInBody('french message') diff --git a/rdiffweb/controller/tests/test_page_prefs_mfa.py b/rdiffweb/controller/tests/test_page_prefs_mfa.py new file mode 100644 index 00000000..248e53bd --- /dev/null +++ b/rdiffweb/controller/tests/test_page_prefs_mfa.py @@ -0,0 +1,160 @@ +# -*- 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 . + +from unittest.mock import MagicMock + +import cherrypy +from parameterized import parameterized + +import rdiffweb.test +from rdiffweb.core.model import UserObject + + +class PagePrefMfaTest(rdiffweb.test.WebCase): + + login = True + + def setUp(self): + super().setUp() + # Define email for all test + userobj = UserObject.get_user(self.USERNAME) + userobj.email = 'admin@example.com' + userobj.add() + + def _set_mfa(self, mfa): + # Define mfa for user + userobj = UserObject.get_user(self.USERNAME) + userobj.mfa = mfa + userobj.add() + if mfa == UserObject.DISABLED_MFA: + return + # Generate a code for login if required + self.listener = MagicMock() + cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50) + try: + self.getPage("/mfa/") + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + # Extract code from email between and + self.listener.queue_email.assert_called_once() + message = self.listener.queue_email.call_args[1]['message'] + code = message.split('', 1)[1].split('')[0] + # Login to MFA + self.getPage("/mfa/", method='POST', body={'code': code, 'submit': '1'}) + self.assertStatus(303) + finally: + cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email) + + def _get_code(self, action): + assert action in ['enable_mfa', 'disable_mfa', 'resend_code'] + # Register an email listeer to capture email send + self.listener = MagicMock() + cherrypy.engine.subscribe('queue_mail', self.listener.queue_email, priority=50) + # Query MFA page to generate a code + try: + self.getPage("/prefs/mfa", method='POST', body={action: '1'}) + self.assertStatus(200) + self.assertInBody("A new verification code has been sent to your email.") + # Extract code from email between and + self.listener.queue_email.assert_called_once() + message = self.listener.queue_email.call_args[1]['message'] + return message.split('', 1)[1].split('')[0] + finally: + cherrypy.engine.unsubscribe('queue_mail', self.listener.queue_email) + + def test_get(self): + # When getting the page + self.getPage("/prefs/mfa") + # Then the page is return without error + self.assertStatus(200) + + @parameterized.expand( + [ + ('enable_mfa', UserObject.DISABLED_MFA, UserObject.ENABLED_MFA), + ('disable_mfa', UserObject.ENABLED_MFA, UserObject.DISABLED_MFA), + ] + ) + def test_with_valid_code(self, action, initial_mfa, expected_mfa): + # Define mfa for user + self._set_mfa(initial_mfa) + # Given a user with email requesting a code + code = self._get_code(action=action) + # When sending a valid code + self.getPage("/prefs/mfa", method='POST', body={action: '1', 'code': code}) + # Then mfa get enabled or disable accordingly + self.assertStatus(200) + userobj = UserObject.get_user(self.USERNAME) + self.assertEqual(userobj.mfa, expected_mfa) + # Then no email get sent + self.assertNotInBody("A new verification code has been sent to your email.") + # Then next page request is still working. + self.getPage('/') + self.assertStatus(200) + + @parameterized.expand( + [ + ('enable_mfa', UserObject.DISABLED_MFA, UserObject.DISABLED_MFA), + ('disable_mfa', UserObject.ENABLED_MFA, UserObject.ENABLED_MFA), + ] + ) + def test_with_invalid_code(self, action, initial_mfa, expected_mfa): + # Define mfa for user + self._set_mfa(initial_mfa) + # Given a user with email requesting a code + self._get_code(action=action) + # When sending an invalid code + self.getPage("/prefs/mfa", method='POST', body={action: '1', 'code': '1234567'}) + # Then mfa get enabled or disable accordingly + self.assertStatus(200) + userobj = UserObject.get_user(self.USERNAME) + self.assertEqual(userobj.mfa, expected_mfa) + # Then next page request is still working. + self.getPage('/') + self.assertStatus(200) + + @parameterized.expand( + [ + ('enable_mfa', UserObject.DISABLED_MFA), + ('disable_mfa', UserObject.ENABLED_MFA), + ] + ) + def test_without_email(self, action, initial_mfa): + # Define mfa for user + self._set_mfa(initial_mfa) + # Given a user without email requesting a code + userobj = UserObject.get_user(self.USERNAME) + userobj.email = '' + userobj.add() + # When trying to enable or disable mfa + self.getPage("/prefs/mfa", method='POST', body={action: '1'}) + # Then an error is return to the user + self.assertStatus(200) + self.assertInBody("To continue, you must set up an email address for your account.") + + @parameterized.expand( + [ + (UserObject.DISABLED_MFA,), + (UserObject.ENABLED_MFA,), + ] + ) + def test_resend_code(self, initial_mfa): + # Define mfa for user + self._set_mfa(initial_mfa) + # When requesting a new code. + self.getPage("/prefs/mfa", method='POST', body={'resend_code': '1'}) + # Then a new code get sent. + self.assertInBody("A new verification code has been sent to your email.") diff --git a/rdiffweb/controller/tests/test_page_settings_remove_older.py b/rdiffweb/controller/tests/test_page_settings_remove_older.py index fc43abe6..f677c96c 100644 --- a/rdiffweb/controller/tests/test_page_settings_remove_older.py +++ b/rdiffweb/controller/tests/test_page_settings_remove_older.py @@ -35,18 +35,6 @@ def _settings(self, user, repo): def _remove_older(self, user, repo, value): self.getPage("/settings/" + user + "/" + repo + "/", method="POST", body={'keepdays': value}) - def test_page_api_set_remove_older(self): - """ - Check if /api/remove-older/ is still working. - """ - self.getPage( - "/api/remove-older/" + self.USERNAME + "/" + self.REPO + "/", method="POST", body={'keepdays': '4'} - ) - self.assertStatus(200) - # Check results - repo = RepoObject.query.filter(RepoObject.repopath == self.REPO).first() - self.assertEqual(4, repo.keepdays) - def test_page_set_keepdays(self): self._remove_older(self.USERNAME, self.REPO, '1') self.assertStatus(200) diff --git a/rdiffweb/controller/tests/test_page_settings_set_encoding.py b/rdiffweb/controller/tests/test_page_settings_set_encoding.py index df8de193..b190b530 100644 --- a/rdiffweb/controller/tests/test_page_settings_set_encoding.py +++ b/rdiffweb/controller/tests/test_page_settings_set_encoding.py @@ -41,16 +41,6 @@ def test_check_default_encoding(self): self.assertInBody("Character encoding") self.assertInBody('selected value="%s"' % RepoObject.DEFAULT_REPO_ENCODING) - def test_api_set_encoding(self): - """ - Check if /api/set-encoding/ is still working. - """ - self.getPage("/api/set-encoding/admin/testcases/", method="POST", body={'new_encoding': 'cp1252'}) - self.assertStatus(200) - # Check results - repo = RepoObject.query.filter(RepoObject.repopath == self.REPO).first() - self.assertEqual('cp1252', repo.encoding) - def test_set_encoding(self): """ Check to update the encoding with cp1252. diff --git a/rdiffweb/core/config.py b/rdiffweb/core/config.py index 3c6b8807..d16b8e49 100644 --- a/rdiffweb/core/config.py +++ b/rdiffweb/core/config.py @@ -386,11 +386,25 @@ def get_parser(): help='location where to store rate-limit information. When undefined, the data is kept in memory. `--session-dir` are deprecated and kept for backward compatibility.', ) + parser.add( + '--session-timeout', + metavar='MINUTES', + help='The session will be purged after this period of inactivity. Default 30 days or 43200 minutes.', + default=43200, + ) + + parser.add( + '--login-timeout', + metavar='MINUTES', + help='Remember user session Keep user login Number of minutes of inactivities before purging sessions data. Default 1 day or 1440 minutes.', + default=1440, + ) + parser.add( '--rate-limit', metavar='LIMIT', type=int, - default=10, + default=30, help='maximum number of requests per minute that can be made by an IP address for an unauthenticated connection. When this limit is reached, an HTTP 429 message is returned to the user. This security measure is used to limit brute force attacks on the login page and the RESTful API.', ) diff --git a/rdiffweb/core/login.py b/rdiffweb/core/login.py index c74ffcf4..a3c43519 100644 --- a/rdiffweb/core/login.py +++ b/rdiffweb/core/login.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import datetime import logging import cherrypy @@ -23,7 +22,6 @@ from rdiffweb.core.model import UserObject from rdiffweb.core.passwd import check_password -from rdiffweb.tools.auth_form import SESSION_KEY logger = logging.getLogger(__name__) @@ -105,11 +103,6 @@ def login(self, username, password): dirty = True if dirty: userobj.add() - - # Save username in session if session is enabled. - if cherrypy.request.config and cherrypy.request.config.get('tools.sessions.on', False): - cherrypy.session[SESSION_KEY] = userobj.username - cherrypy.session['login_time'] = datetime.datetime.now() self.bus.publish('user_login', userobj) return userobj diff --git a/rdiffweb/core/model/__init__.py b/rdiffweb/core/model/__init__.py index ff8c821b..30d8ca8c 100644 --- a/rdiffweb/core/model/__init__.py +++ b/rdiffweb/core/model/__init__.py @@ -66,9 +66,12 @@ def add_column(column): add_column(UserObject.__table__.c.role) UserObject.query.filter(UserObject._is_admin == 1).update({UserObject.role: UserObject.ADMIN_ROLE}) - # Add user's fullname + # Add user's fullname column add_column(UserObject.__table__.c.fullname) + # Add user's mfa column + add_column(UserObject.__table__.c.mfa) + # Re-create session table if Number column is missing if not exists(SessionObject.__table__.c.Number): SessionObject.__table__.drop() diff --git a/rdiffweb/core/model/_session.py b/rdiffweb/core/model/_session.py index d528d6a2..6bfdec03 100644 --- a/rdiffweb/core/model/_session.py +++ b/rdiffweb/core/model/_session.py @@ -23,6 +23,8 @@ from sqlalchemy import Column, DateTime, Integer, PickleType, String from sqlalchemy.orm import validates +SESSION_KEY = '_cp_username' + Base = cherrypy.tools.db.get_base() logger = logging.getLogger(__name__) @@ -42,7 +44,7 @@ class SessionObject(Base): def validate_encoding(self, key, value): if value: self.access_time = value.get('access_time') - self.username = value.get('_cp_username') + self.username = value.get(SESSION_KEY) return value diff --git a/rdiffweb/core/model/_user.py b/rdiffweb/core/model/_user.py index 44f4bdf6..ffd396cf 100644 --- a/rdiffweb/core/model/_user.py +++ b/rdiffweb/core/model/_user.py @@ -58,28 +58,35 @@ class UserObject(Base): 'maintainer': MAINTAINER_ROLE, 'user': USER_ROLE, } + DISABLED_MFA = 0 + ENABLED_MFA = 1 userid = Column('UserID', Integer, primary_key=True) _username = Column('Username', String, nullable=False, unique=True) hash_password = Column('Password', String, nullable=False, default="") _user_root = Column('UserRoot', String, nullable=False, default="") - _is_admin = deferred(Column( - 'IsAdmin', - SmallInteger, - nullable=False, - server_default="0", - doc="DEPRECATED This column is replaced by 'role'", - )) + _is_admin = deferred( + Column( + 'IsAdmin', + SmallInteger, + nullable=False, + server_default="0", + doc="DEPRECATED This column is replaced by 'role'", + ) + ) _email = Column('UserEmail', String, nullable=False, default="") - restore_format = deferred(Column( - 'RestoreFormat', - SmallInteger, - nullable=False, - server_default="1", - doc="DEPRECATED This column is not used anymore", - )) + restore_format = deferred( + Column( + 'RestoreFormat', + SmallInteger, + nullable=False, + server_default="1", + doc="DEPRECATED This column is not used anymore", + ) + ) _role = Column('role', SmallInteger, nullable=False, server_default=str(USER_ROLE)) fullname = Column('fullname', String, nullable=False, default="") + mfa = Column('mfa', SmallInteger, nullable=False, default=DISABLED_MFA) repo_objs = relationship( 'RepoObject', foreign_keys='UserObject.userid', diff --git a/rdiffweb/locales/fr/LC_MESSAGES/messages.po b/rdiffweb/locales/fr/LC_MESSAGES/messages.po index 1eabbd72..a08d8a69 100644 --- a/rdiffweb/locales/fr/LC_MESSAGES/messages.po +++ b/rdiffweb/locales/fr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: rdiffweb 0.6.5\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-08-05 09:35-0400\n" +"POT-Creation-Date: 2022-09-07 11:51-0400\n" "PO-Revision-Date: 2014-11-14 10:00-0500\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -87,7 +87,7 @@ msgid "Group" msgstr "Groupe" #: rdiffweb/controller/page_admin_sysinfo.py:83 -#: rdiffweb/controller/page_admin_users.py:86 +#: rdiffweb/controller/page_admin_users.py:99 #: rdiffweb/templates/admin_session.html:22 #: rdiffweb/templates/admin_users.html:22 #: rdiffweb/templates/admin_users.html:49 @@ -151,26 +151,26 @@ msgid "UserID" msgstr "Identifiant utilisateur" #: rdiffweb/controller/page_admin_users.py:69 -#: rdiffweb/controller/page_admin_users.py:144 -#: rdiffweb/controller/page_login.py:35 rdiffweb/controller/page_login.py:38 -#: rdiffweb/controller/page_pref_general.py:41 +#: rdiffweb/controller/page_admin_users.py:167 +#: rdiffweb/controller/page_login.py:37 rdiffweb/controller/page_login.py:41 +#: rdiffweb/controller/page_pref_general.py:42 msgid "Username" msgstr "Nom d'utilisateur" #: rdiffweb/controller/page_admin_users.py:70 -#: rdiffweb/controller/page_pref_general.py:42 +#: rdiffweb/controller/page_pref_general.py:43 msgid "Fullname" msgstr "Nom complet" #: rdiffweb/controller/page_admin_users.py:71 -#: rdiffweb/controller/page_pref_general.py:43 +#: rdiffweb/controller/page_pref_general.py:44 #: rdiffweb/templates/admin_users.html:23 msgid "Email" msgstr "Courriel" #: rdiffweb/controller/page_admin_users.py:73 -#: rdiffweb/controller/page_login.py:45 -#: rdiffweb/templates/prefs_general.html:37 +#: rdiffweb/controller/page_login.py:48 +#: rdiffweb/templates/prefs_general.html:15 msgid "Password" msgstr "Mot de passe" @@ -179,30 +179,53 @@ msgid "To create an LDAP user, you must leave the password empty." msgstr "Pour créer un utilisateur LDAP, vous devez laisser le mot de passe vide." #: rdiffweb/controller/page_admin_users.py:77 +msgid "Two-Factor Authentication (2FA)" +msgstr "Authentification à deux facteurs (2FA)" + +#: rdiffweb/controller/page_admin_users.py:80 +#: rdiffweb/controller/page_pref_mfa.py:47 +msgid "Disabled" +msgstr "Désactivé" + +#: rdiffweb/controller/page_admin_users.py:81 +#: rdiffweb/controller/page_pref_mfa.py:48 +msgid "Enabled" +msgstr "Activé" + +#: rdiffweb/controller/page_admin_users.py:84 +msgid "" +"When Two-Factor Authentication (2FA) is enabled for a user, a " +"verification code get sent by email when user login from a new location." +msgstr "" +"Lorsque l'authentification à deux facteurs (2FA) est activée pour un " +"utilisateur, un code de vérification est envoyé par courriel lorsque " +"l'utilisateur se connecte depuis un nouveau lieu." + +#: rdiffweb/controller/page_admin_users.py:90 #: rdiffweb/templates/admin_users.html:25 msgid "Root directory" msgstr "Répertoire racine" -#: rdiffweb/controller/page_admin_users.py:78 +#: rdiffweb/controller/page_admin_users.py:91 msgid "Absolute path defining the location of the repositories for this user." msgstr "Chemin absolu définissant l'emplacement des dépôts pour cet utilisateur." -#: rdiffweb/controller/page_admin_users.py:81 +#: rdiffweb/controller/page_admin_users.py:94 #: rdiffweb/templates/admin_users.html:24 msgid "User Role" msgstr "Rôle d'utilisateur" -#: rdiffweb/controller/page_admin_users.py:84 +#: rdiffweb/controller/page_admin_users.py:97 #: rdiffweb/templates/admin_users.html:45 msgid "Admin" msgstr "Administrateur" -#: rdiffweb/controller/page_admin_users.py:85 +#: rdiffweb/controller/page_admin_users.py:98 #: rdiffweb/templates/admin_users.html:47 msgid "Maintainer" msgstr "Mainteneur" -#: rdiffweb/controller/page_admin_users.py:89 +#: rdiffweb/controller/page_admin_users.py:102 msgid "" "Admin: may browse and delete everything. Maintainer: may browse and " "delete their own repo. User: may only browser their own repo." @@ -211,58 +234,70 @@ msgstr "" "parcourir et supprimer son propre depôt. Utilisateur: ne peut naviguer " "que sur son propre depôt." -#: rdiffweb/controller/page_admin_users.py:94 +#: rdiffweb/controller/page_admin_users.py:107 msgid "Disk space" msgstr "Espace disque" -#: rdiffweb/controller/page_admin_users.py:96 +#: rdiffweb/controller/page_admin_users.py:109 msgid "Users disk spaces (in bytes). Set to 0 to remove quota (unlimited)." msgstr "" "Espace disque des utilisateurs (en octets). Mettre à 0 pour supprimer le " "quota (illimité)." -#: rdiffweb/controller/page_admin_users.py:99 +#: rdiffweb/controller/page_admin_users.py:112 msgid "Quota Used" msgstr "Quota utilisé" -#: rdiffweb/controller/page_admin_users.py:101 +#: rdiffweb/controller/page_admin_users.py:114 msgid "Disk spaces (in bytes) used by this user." msgstr "Espace disque (en octets) utilisé par cet utilisateur." -#: rdiffweb/controller/page_admin_users.py:109 +#: rdiffweb/controller/page_admin_users.py:122 msgid "Cannot edit your own role." msgstr "Vous ne pouvez pas modifier votre propre rôle." -#: rdiffweb/controller/page_admin_users.py:120 +#: rdiffweb/controller/page_admin_users.py:128 +msgid "Cannot change your own two-factor authentication settings." +msgstr "" +"Vous ne pouvez pas modifier vos propres paramètres d'authentification à " +"deux facteurs." + +#: rdiffweb/controller/page_admin_users.py:139 +msgid "User email is required to enabled Two-Factor Authentication" +msgstr "" +"Un courriel est nécessaire pour activer l'authentification à deux " +"facteurs." + +#: rdiffweb/controller/page_admin_users.py:143 #, python-format msgid "User's root directory %s is not accessible!" msgstr "Le répertoire racine de l'utilisateur %s n'est pas accessible !" -#: rdiffweb/controller/page_admin_users.py:132 +#: rdiffweb/controller/page_admin_users.py:155 msgid "Setting user's quota is not supported" msgstr "La définition du quota de l'utilisateur n'est pas prise en charge" -#: rdiffweb/controller/page_admin_users.py:159 +#: rdiffweb/controller/page_admin_users.py:182 msgid "You cannot remove your own account!" msgstr "Vous ne pouvez pas supprimer votre propre compte!" -#: rdiffweb/controller/page_admin_users.py:165 +#: rdiffweb/controller/page_admin_users.py:188 msgid "User account removed." msgstr "Le compte utilisateur a été effacé." -#: rdiffweb/controller/page_admin_users.py:167 +#: rdiffweb/controller/page_admin_users.py:190 msgid "User doesn't exists!" msgstr "L'utilisateur n'existe pas!" -#: rdiffweb/controller/page_admin_users.py:181 +#: rdiffweb/controller/page_admin_users.py:204 msgid "User added successfully." msgstr "Utilisateur ajouté avec succès." -#: rdiffweb/controller/page_admin_users.py:193 +#: rdiffweb/controller/page_admin_users.py:216 msgid "User information modified successfully." msgstr "Information utilisateur modifié avec succès." -#: rdiffweb/controller/page_admin_users.py:199 +#: rdiffweb/controller/page_admin_users.py:222 #, python-format msgid "Cannot edit user `%s`: user doesn't exists" msgstr "Impossible de modifier l'utilisateur `%s` : l'utilisateur n'existe pas." @@ -323,75 +358,186 @@ msgstr "Nombre d'erreurs" msgid "The displayed data may be inconsistent." msgstr "Les données affichées peuvent ne pas correspondre." -#: rdiffweb/controller/page_login.py:72 -msgid "Fail to validate user credential." -msgstr "Impossible de valider l'information d'identification de l'utilisateur." +#: rdiffweb/controller/page_login.py:50 rdiffweb/controller/page_mfa.py:44 +msgid "Remember me" +msgstr "Se souvenir de cet appareil" + +#: rdiffweb/controller/page_login.py:54 rdiffweb/controller/page_mfa.py:48 +#: rdiffweb/templates/login.html:4 +msgid "Sign in" +msgstr "Se connecter" + +#: rdiffweb/controller/page_login.py:81 +msgid "Failed to validate user credentials." +msgstr "Échec de la validation des informations d'identification de l'utilisateur." -#: rdiffweb/controller/page_login.py:77 +#: rdiffweb/controller/page_login.py:87 msgid "Invalid username or password." msgstr "Nom d'utilisateur ou mot de passe invalide." -#: rdiffweb/controller/page_pref_general.py:43 +#: rdiffweb/controller/page_mfa.py:33 rdiffweb/controller/page_pref_mfa.py:58 +msgid "Verification code" +msgstr "Code de vérification" + +#: rdiffweb/controller/page_mfa.py:34 +msgid "Enter the code to verify your identity." +msgstr "Entrez le code pour vérifier votre identité." + +#: rdiffweb/controller/page_mfa.py:37 rdiffweb/controller/page_pref_mfa.py:60 +msgid "Enter verification code here" +msgstr "Entrez le code de vérification ici" + +#: rdiffweb/controller/page_mfa.py:52 rdiffweb/controller/page_pref_mfa.py:69 +msgid "Resend code to my email" +msgstr "Renvoyer un code à mon email" + +#: rdiffweb/controller/page_mfa.py:60 rdiffweb/controller/page_mfa.py:63 +#: rdiffweb/controller/page_pref_mfa.py:93 +msgid "Invalid verification code." +msgstr "Code de vérification non valide." + +#: rdiffweb/controller/page_mfa.py:67 rdiffweb/controller/page_pref_mfa.py:97 +msgid "Invalid operation" +msgstr "Opération non valide" + +#: rdiffweb/controller/page_mfa.py:95 +msgid "" +"Multi-factor authentication is enabled for your account, but your account" +" does not have a valid email address to send the verification code to. " +"Check your account settings with your administrator." +msgstr "" +"L'authentification à deux facteurs est activée pour votre compte, mais " +"votre compte ne dispose pas d'une adresse courriel valide à laquelle " +"envoyer le code de vérification. Vérifiez les paramètres de votre compte " +"auprès de votre administrateur." + +#: rdiffweb/controller/page_mfa.py:104 rdiffweb/controller/page_pref_mfa.py:131 +msgid "Your verification code" +msgstr "Votre code de vérification" + +#: rdiffweb/controller/page_mfa.py:105 rdiffweb/controller/page_pref_mfa.py:132 +msgid "A new verification code has been sent to your email." +msgstr "Un nouveau code de vérification a été envoyé à votre addresse courriel." + +#: rdiffweb/controller/page_pref_general.py:44 msgid "Invalid email." msgstr "Adresse courriel invalide." -#: rdiffweb/controller/page_pref_general.py:53 +#: rdiffweb/controller/page_pref_general.py:45 +#: rdiffweb/templates/admin_users.html:110 +#: rdiffweb/templates/prefs_notification.html:32 +#: rdiffweb/templates/settings.html:32 rdiffweb/templates/settings.html:88 +#: rdiffweb/templates/settings.html:111 +msgid "Save changes" +msgstr "Enregistrer modifications" + +#: rdiffweb/controller/page_pref_general.py:60 msgid "Current password" msgstr "Mot de passe actuel" -#: rdiffweb/controller/page_pref_general.py:54 +#: rdiffweb/controller/page_pref_general.py:61 msgid "Current password is missing." msgstr "Le mot de passe actuel est vide." -#: rdiffweb/controller/page_pref_general.py:55 +#: rdiffweb/controller/page_pref_general.py:62 msgid "You must provide your current password in order to change it." msgstr "Vous devez fournir votre mot de passe actuel pour pouvoir le modifier." -#: rdiffweb/controller/page_pref_general.py:58 +#: rdiffweb/controller/page_pref_general.py:65 msgid "New password" msgstr "Nouveau mot de passe" -#: rdiffweb/controller/page_pref_general.py:60 +#: rdiffweb/controller/page_pref_general.py:67 msgid "New password is missing." msgstr "Le nouveau mot de passe est vide." -#: rdiffweb/controller/page_pref_general.py:61 +#: rdiffweb/controller/page_pref_general.py:68 msgid "The new password and its confirmation do not match." msgstr "Le nouveau mot de passe et sa confirmation ne correspondent pas." -#: rdiffweb/controller/page_pref_general.py:65 +#: rdiffweb/controller/page_pref_general.py:72 msgid "Confirm new password" msgstr "Confirmer mot de passe" -#: rdiffweb/controller/page_pref_general.py:65 +#: rdiffweb/controller/page_pref_general.py:72 msgid "Confirmation password is missing." msgstr "Le mot de passe de confirmation est vide." -#: rdiffweb/controller/page_pref_general.py:88 +#: rdiffweb/controller/page_pref_general.py:74 +msgid "Update password" +msgstr "Nouveau mot de passe" + +#: rdiffweb/controller/page_pref_general.py:83 msgid "Password updated successfully." msgstr "Mot de passe mis à jour avec succès." -#: rdiffweb/controller/page_pref_general.py:100 -msgid "Profile updated successfully." -msgstr "Profil mis à jour avec succès." +#: rdiffweb/controller/page_pref_general.py:91 +msgid "Refresh repositories" +msgstr "Rafraîchir les dépôts" -#: rdiffweb/controller/page_pref_general.py:105 +#: rdiffweb/controller/page_pref_general.py:92 +msgid "" +"Refresh the list of repositories associated to your account. If you " +"recently add a new repository and it doesn't show, you may try to refresh" +" the list." +msgstr "" +"Rafraîchissez la liste des dépôts associés à votre compte. Si vous avez " +"récemment ajouté un nouveau dépôt et qu'il n'apparaît pas, vous pouvez " +"essayer de rafraîchir la liste." + +#: rdiffweb/controller/page_pref_general.py:104 msgid "Repositories successfully updated" msgstr "Dépôts mis à jour avec succès" +#: rdiffweb/controller/page_pref_general.py:123 +msgid "Profile updated successfully." +msgstr "Profil mis à jour avec succès." + +#: rdiffweb/controller/page_pref_mfa.py:44 +msgid "Two-Factor Authentication (2FA) Status" +msgstr "Statut de l'authentification à deux facteurs (2FA)" + +#: rdiffweb/controller/page_pref_mfa.py:52 +#: rdiffweb/controller/page_pref_mfa.py:66 +msgid "Enable Two-Factor Authentication" +msgstr "Activer l'authentification à deux facteurs" + +#: rdiffweb/controller/page_pref_mfa.py:53 +#: rdiffweb/controller/page_pref_mfa.py:67 +msgid "Disable Two-Factor Authentication" +msgstr "Désactiver l'authentification à deux facteurs" + +#: rdiffweb/controller/page_pref_mfa.py:81 +msgid "Two-Factor authentication enabled successfully." +msgstr "L'authentification à deux facteurs a été activée avec succès." + +#: rdiffweb/controller/page_pref_mfa.py:84 +msgid "Two-Factor authentication disabled successfully." +msgstr "L'authentification à deux facteurs a été désactivée avec succès." + +#: rdiffweb/controller/page_pref_mfa.py:90 +msgid "Enter the verification code to continue." +msgstr "Entrez le code de vérification pour continuer." + +#: rdiffweb/controller/page_pref_mfa.py:125 +msgid "To continue, you must set up an email address for your account." +msgstr "" +"Pour continuer, vous devez configurer une adresse courriel pour votre " +"compte." + #: rdiffweb/controller/page_pref_notification.py:48 msgid "Notification settings updated successfully." msgstr "Les paramètres de notification ont été mis à jour avec succès." -#: rdiffweb/controller/page_pref_sshkeys.py:47 +#: rdiffweb/controller/page_pref_sshkeys.py:48 msgid "Invalid SSH key." msgstr "Clé SSH invalide." -#: rdiffweb/controller/page_pref_sshkeys.py:52 +#: rdiffweb/controller/page_pref_sshkeys.py:53 msgid "Title" msgstr "Titre" -#: rdiffweb/controller/page_pref_sshkeys.py:53 +#: rdiffweb/controller/page_pref_sshkeys.py:54 msgid "" "The title is an optional description to identify the key. e.g.: " "bob@thinkpad-t530" @@ -399,11 +545,11 @@ msgstr "" "Le titre est une description facultative pour identifier la clé. P.ex.: " "bob@thinkpad-t530" -#: rdiffweb/controller/page_pref_sshkeys.py:57 +#: rdiffweb/controller/page_pref_sshkeys.py:58 msgid "Key" msgstr "Clé" -#: rdiffweb/controller/page_pref_sshkeys.py:59 +#: rdiffweb/controller/page_pref_sshkeys.py:60 msgid "" "Enter a SSH public key. It should start with 'ssh-dss', 'ssh-ed25519', " "'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384' or 'ecdsa-" @@ -413,15 +559,15 @@ msgstr "" "ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384' ou " "'ecdsa-sha2-nistp521'." -#: rdiffweb/controller/page_pref_sshkeys.py:88 +#: rdiffweb/controller/page_pref_sshkeys.py:72 msgid "Unknown error while adding the SSH Key" msgstr "Erreur inconnue lors de l'ajout de la clé SSH" -#: rdiffweb/controller/page_pref_sshkeys.py:103 +#: rdiffweb/controller/page_pref_sshkeys.py:84 msgid "Unknown error while removing the SSH Key" msgstr "Erreur inconnue lors de la suppression de la clé SSH" -#: rdiffweb/controller/page_pref_sshkeys.py:126 +#: rdiffweb/controller/page_pref_sshkeys.py:119 msgid "Failed to get SSH keys" msgstr "Échec de la récupération des clés SSH" @@ -443,19 +589,19 @@ msgstr "Sauvegarde réussie" msgid "Backup with errors" msgstr "Sauvegarde avec des erreurs" -#: rdiffweb/core/librdiff.py:1202 +#: rdiffweb/core/librdiff.py:1198 msgid "A backup is currently in progress to this repository." msgstr "Une sauvegarde est actuellement en cours dans ce dépôt." -#: rdiffweb/core/librdiff.py:1210 +#: rdiffweb/core/librdiff.py:1206 msgid "The previous backup seams to have failed." msgstr "La sauvegarde précédente a échoué." -#: rdiffweb/core/librdiff.py:1213 +#: rdiffweb/core/librdiff.py:1209 msgid "The repository cannot be found or is badly damaged." msgstr "Le dépôt ne peut pas être trouvé ou est gravement endommagé" -#: rdiffweb/core/librdiff.py:1217 +#: rdiffweb/core/librdiff.py:1213 msgid "" "Permissions denied. Contact administrator to check repository's " "permissions." @@ -516,32 +662,32 @@ msgstr "Il y a %d secondes" msgid "invalid encoding %s" msgstr "encodage %s invalide" -#: rdiffweb/core/model/_user.py:120 +#: rdiffweb/core/model/_user.py:127 #, python-format msgid "User %s already exists." msgstr "L'utilisateur %s existe déjà." -#: rdiffweb/core/model/_user.py:153 +#: rdiffweb/core/model/_user.py:160 msgid "SSH key already exists" msgstr "La clé SSH existe déjà" -#: rdiffweb/core/model/_user.py:164 +#: rdiffweb/core/model/_user.py:171 msgid "Duplicate key. This key already exists or is associated to another user." msgstr "" "Clé en double. Cette clé existe déjà ou est associée à un autre " "utilisateur." -#: rdiffweb/core/model/_user.py:180 +#: rdiffweb/core/model/_user.py:187 msgid "can't delete admin user" msgstr "impossible de supprimer l'utilisateur admin" -#: rdiffweb/core/model/_user.py:317 +#: rdiffweb/core/model/_user.py:324 msgid "can't update admin-password defined in configuration file" msgstr "" "Impossible de mettre à jour le mot de passe administrateur défini dans le" " fichier de configuration" -#: rdiffweb/core/model/_user.py:320 +#: rdiffweb/core/model/_user.py:327 msgid "Wrong password" msgstr "Mot de passe invalide" @@ -574,7 +720,7 @@ msgid "Logs" msgstr "Logs" #: rdiffweb/templates/admin.html:14 rdiffweb/templates/admin_session.html:7 -#: rdiffweb/templates/prefs.html:17 rdiffweb/templates/prefs_session.html:8 +#: rdiffweb/templates/prefs.html:18 rdiffweb/templates/prefs_session.html:8 msgid "Active Sessions" msgstr "Sessions actives" @@ -812,14 +958,6 @@ msgstr "disponible" msgid "Edit user %(name)s" msgstr "Modifier l'utilisateur %(name)s" -#: rdiffweb/templates/admin_users.html:110 -#: rdiffweb/templates/prefs_general.html:31 -#: rdiffweb/templates/prefs_notification.html:32 -#: rdiffweb/templates/settings.html:32 rdiffweb/templates/settings.html:88 -#: rdiffweb/templates/settings.html:111 -msgid "Save changes" -msgstr "Enregistrer modifications" - #: rdiffweb/templates/admin_users.html:118 msgid "Delete User" msgstr "Supprimer l'utilisateur" @@ -850,7 +988,7 @@ msgstr "Version(s)" msgid "Show more..." msgstr "Afficher plus..." -#: rdiffweb/templates/email_changed.html:4 +#: rdiffweb/templates/email_changed.html:4 rdiffweb/templates/email_mfa.html:4 #: rdiffweb/templates/email_notification.html:4 #: rdiffweb/templates/password_changed.html:4 #, python-format @@ -863,7 +1001,7 @@ msgid "" "You recently changed the email address associated with your " "%(header_name)s account." msgstr "" -"Vous avez récemment modifié l'adresse e-mail associée à votre compte " +"Vous avez récemment modifié l'adresse courriel associée à votre compte " "%(header_name)s." #: rdiffweb/templates/email_changed.html:7 @@ -875,6 +1013,32 @@ msgstr "" "Si vous n'avez pas effectué ce changement et que votre compte a été " "compromis, veuillez contacter votre administrateur." +#: rdiffweb/templates/email_mfa.html:6 +msgid "" +"To help us make sure it's really you, here's the verification code you'll" +" need to log in:" +msgstr "" +"Pour nous aider à nous assurer qu'il s'agit bien de vous, voici le code " +"de vérification dont vous aurez besoin pour vous connecter :" + +#: rdiffweb/templates/email_mfa.html:12 +msgid "" +"If this wasn't you logging in, and you use a password to log in, please " +"reset your password." +msgstr "" +"Si ce n'est pas vous qui vous êtes connecté, et que vous utilisez un mot " +"de passe pour vous connecter, veuillez réinitialiser votre mot de passe." + +#: rdiffweb/templates/email_mfa.html:15 +msgid "" +"This code will expire in 1 hour. Once the code expires, you will need to " +"request a new verification code by going through the login procedure " +"again." +msgstr "" +"Ce code expirera dans 1 heure. Une fois le code expiré, vous devrez " +"demander un nouveau code de vérification en suivant à nouveau la " +"procédure de connexion." + #: rdiffweb/templates/email_notification.html:6 msgid "" "You are receiving this email to notify you about your backups. The\n" @@ -1024,12 +1188,7 @@ msgstr "Utilisation" msgid "total" msgstr "total" -#: rdiffweb/templates/login.html:3 rdiffweb/templates/login.html:40 -#: rdiffweb/templates/login.html:44 -msgid "Sign in" -msgstr "Se connecter" - -#: rdiffweb/templates/login.html:25 +#: rdiffweb/templates/login.html:26 msgid "" "A simplified backup management software for quick access to your archives" " through an\n" @@ -1038,17 +1197,17 @@ msgstr "" "Un logiciel de gestion de sauvegarde simplifié pour un accès rapide à vos" " archives via une interface Web efficace." -#: rdiffweb/templates/login.html:29 +#: rdiffweb/templates/login.html:30 msgid "website" msgstr "site Internet" -#: rdiffweb/templates/login.html:30 +#: rdiffweb/templates/login.html:31 msgid "community" msgstr "communauté" #: rdiffweb/templates/login.html:42 -msgid "Enter your username and password to log in." -msgstr "Entrez votre nom d'utilisateur et mot de passe pour vous connecter." +msgid "Welcome back" +msgstr "Content de vous revoir." #: rdiffweb/templates/logs.html:7 msgid "Repository Logs" @@ -1072,6 +1231,24 @@ msgstr "Sélectionnez un fichier de log pour en afficher le contenu." msgid "Well done!" msgstr "Bravo!" +#: rdiffweb/templates/mfa.html:7 +msgid "Login Verification" +msgstr "Vérification de l'accès" + +#: rdiffweb/templates/mfa.html:10 +#, fuzzy +msgid "" +"Two-Factor Authentication is enabled for your account. To verify your " +"account, you must enter the code that was sent to your email address." +msgstr "" +"L'authentification à deux facteurs est activée pour votre compte. Pour " +"vérifier votre compte, vous devez entrer le code qui a été envoyé à votre" +" adresse courriel." + +#: rdiffweb/templates/mfa.html:16 +msgid "Login with a different account" +msgstr "Se connecter avec un autre compte" + #: rdiffweb/templates/password_changed.html:5 #, python-format msgid "" @@ -1093,39 +1270,42 @@ msgstr "Général" msgid "SSH Keys" msgstr "Clés SSH" -#: rdiffweb/templates/prefs_general.html:25 +#: rdiffweb/templates/prefs.html:17 rdiffweb/templates/prefs_mfa.html:6 +msgid "Two-Factor Authentication" +msgstr "Authentification à deux facteurs" + +#: rdiffweb/templates/prefs_general.html:7 msgid "Account settings" msgstr "Paramètres du compte" -#: rdiffweb/templates/prefs_general.html:25 +#: rdiffweb/templates/prefs_general.html:7 msgid "General information about your account." msgstr "Informations générales sur votre compte." -#: rdiffweb/templates/prefs_general.html:37 +#: rdiffweb/templates/prefs_general.html:15 msgid "Change your current password" msgstr "Changez votre mot de passe actuel" -#: rdiffweb/templates/prefs_general.html:43 -msgid "Update password" -msgstr "Nouveau mot de passe" - -#: rdiffweb/templates/prefs_general.html:49 +#: rdiffweb/templates/prefs_general.html:23 msgid "Refresh" msgstr "Rafraîchir" -#: rdiffweb/templates/prefs_general.html:54 +#: rdiffweb/templates/prefs_mfa.html:9 msgid "" -"Refresh the list of repositories associated to your account. If you " -"recently add a new repository and it doesn't show, you may try to refresh" -" the list." +"You can enhance the security of your account by enabling two-factor " +"authentication (2FA)." msgstr "" -"Rafraîchissez la liste des dépôts associés à votre compte. Si vous avez " -"récemment ajouté un nouveau dépôt et qu'il n'apparaît pas, vous pouvez " -"essayer de rafraîchir la liste." +"Vous pouvez renforcer la sécurité de votre compte en activant " +"l'authentification à deux facteurs (2FA)." -#: rdiffweb/templates/prefs_general.html:56 -msgid "Refresh repositories" -msgstr "Rafraîchir les dépôts" +#: rdiffweb/templates/prefs_mfa.html:10 +msgid "" +"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." +msgstr "" +"Lorsqu'elle est activée, un code de vérification est envoyé à votre " +"adresse courriel chaque fois que vous vous connectez à partir d'un nouvel" +" emplacement pour vérifier qu'il s'agit bien de vous." #: rdiffweb/templates/prefs_notification.html:8 msgid "Notification settings" @@ -1342,7 +1522,7 @@ msgid "" "An email notification will be sent if backup is inactive for the given " "period of time." msgstr "" -"Une notification par e-mail sera envoyée si la sauvegarde est inactive " +"Une notification par courriel sera envoyée si la sauvegarde est inactive " "pendant la période de temps donnée." #: rdiffweb/templates/settings.html:107 @@ -1427,6 +1607,20 @@ msgstr "Nombre d'erreurs :" msgid "No backup for this date." msgstr "Pas de sauvegarde pour cette date." +#: rdiffweb/templates/components/form.html:31 +#: rdiffweb/templates/components/form.html:43 +msgid "" +"This feature has not been thoroughly tested and should be used with " +"caution." +msgstr "" +"Cette fonctionnalité n'a pas été testée de manière approfondie et doit " +"être utilisée avec prudence." + +#: rdiffweb/templates/components/form.html:32 +#: rdiffweb/templates/components/form.html:44 +msgid "Beta" +msgstr "Beta" + #: rdiffweb/templates/include/chartkick.html:15 msgid "Loading..." msgstr "Chargement..." @@ -1482,9 +1676,9 @@ msgstr "" "ou fermer cette fenêtre pour annuler." #: rdiffweb/templates/include/session.html:4 -#: rdiffweb/templates/include/session.html:22 -#: rdiffweb/templates/include/session.html:27 -#: rdiffweb/templates/include/session.html:37 +#: rdiffweb/templates/include/session.html:24 +#: rdiffweb/templates/include/session.html:29 +#: rdiffweb/templates/include/session.html:39 msgid "Unknown" msgstr "Inconnu" diff --git a/rdiffweb/locales/messages.pot b/rdiffweb/locales/messages.pot index e9a71022..72ecaae1 100644 --- a/rdiffweb/locales/messages.pot +++ b/rdiffweb/locales/messages.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: rdiffweb 2.4.1.dev31+gba463c5.d20220805\n" +"Project-Id-Version: rdiffweb 2.5.0a2.dev5+g602bea01\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-08-05 09:35-0400\n" +"POT-Creation-Date: 2022-09-07 11:51-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -86,7 +86,7 @@ msgid "Group" msgstr "" #: rdiffweb/controller/page_admin_sysinfo.py:83 -#: rdiffweb/controller/page_admin_users.py:86 +#: rdiffweb/controller/page_admin_users.py:99 #: rdiffweb/templates/admin_session.html:22 rdiffweb/templates/admin_users.html:22 #: rdiffweb/templates/admin_users.html:49 msgid "User" @@ -149,24 +149,24 @@ msgid "UserID" msgstr "" #: rdiffweb/controller/page_admin_users.py:69 -#: rdiffweb/controller/page_admin_users.py:144 rdiffweb/controller/page_login.py:35 -#: rdiffweb/controller/page_login.py:38 rdiffweb/controller/page_pref_general.py:41 +#: rdiffweb/controller/page_admin_users.py:167 rdiffweb/controller/page_login.py:37 +#: rdiffweb/controller/page_login.py:41 rdiffweb/controller/page_pref_general.py:42 msgid "Username" msgstr "" #: rdiffweb/controller/page_admin_users.py:70 -#: rdiffweb/controller/page_pref_general.py:42 +#: rdiffweb/controller/page_pref_general.py:43 msgid "Fullname" msgstr "" #: rdiffweb/controller/page_admin_users.py:71 -#: rdiffweb/controller/page_pref_general.py:43 +#: rdiffweb/controller/page_pref_general.py:44 #: rdiffweb/templates/admin_users.html:23 msgid "Email" msgstr "" -#: rdiffweb/controller/page_admin_users.py:73 rdiffweb/controller/page_login.py:45 -#: rdiffweb/templates/prefs_general.html:37 +#: rdiffweb/controller/page_admin_users.py:73 rdiffweb/controller/page_login.py:48 +#: rdiffweb/templates/prefs_general.html:15 msgid "Password" msgstr "" @@ -175,85 +175,113 @@ msgid "To create an LDAP user, you must leave the password empty." msgstr "" #: rdiffweb/controller/page_admin_users.py:77 +msgid "Two-Factor Authentication (2FA)" +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:80 +#: rdiffweb/controller/page_pref_mfa.py:47 +msgid "Disabled" +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:81 +#: rdiffweb/controller/page_pref_mfa.py:48 +msgid "Enabled" +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:84 +msgid "" +"When Two-Factor Authentication (2FA) is enabled for a user, a verification " +"code get sent by email when user login from a new location." +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:90 #: rdiffweb/templates/admin_users.html:25 msgid "Root directory" msgstr "" -#: rdiffweb/controller/page_admin_users.py:78 +#: rdiffweb/controller/page_admin_users.py:91 msgid "Absolute path defining the location of the repositories for this user." msgstr "" -#: rdiffweb/controller/page_admin_users.py:81 +#: rdiffweb/controller/page_admin_users.py:94 #: rdiffweb/templates/admin_users.html:24 msgid "User Role" msgstr "" -#: rdiffweb/controller/page_admin_users.py:84 +#: rdiffweb/controller/page_admin_users.py:97 #: rdiffweb/templates/admin_users.html:45 msgid "Admin" msgstr "" -#: rdiffweb/controller/page_admin_users.py:85 +#: rdiffweb/controller/page_admin_users.py:98 #: rdiffweb/templates/admin_users.html:47 msgid "Maintainer" msgstr "" -#: rdiffweb/controller/page_admin_users.py:89 +#: rdiffweb/controller/page_admin_users.py:102 msgid "" "Admin: may browse and delete everything. Maintainer: may browse and delete " "their own repo. User: may only browser their own repo." msgstr "" -#: rdiffweb/controller/page_admin_users.py:94 +#: rdiffweb/controller/page_admin_users.py:107 msgid "Disk space" msgstr "" -#: rdiffweb/controller/page_admin_users.py:96 +#: rdiffweb/controller/page_admin_users.py:109 msgid "Users disk spaces (in bytes). Set to 0 to remove quota (unlimited)." msgstr "" -#: rdiffweb/controller/page_admin_users.py:99 +#: rdiffweb/controller/page_admin_users.py:112 msgid "Quota Used" msgstr "" -#: rdiffweb/controller/page_admin_users.py:101 +#: rdiffweb/controller/page_admin_users.py:114 msgid "Disk spaces (in bytes) used by this user." msgstr "" -#: rdiffweb/controller/page_admin_users.py:109 +#: rdiffweb/controller/page_admin_users.py:122 msgid "Cannot edit your own role." msgstr "" -#: rdiffweb/controller/page_admin_users.py:120 +#: rdiffweb/controller/page_admin_users.py:128 +msgid "Cannot change your own two-factor authentication settings." +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:139 +msgid "User email is required to enabled Two-Factor Authentication" +msgstr "" + +#: rdiffweb/controller/page_admin_users.py:143 #, python-format msgid "User's root directory %s is not accessible!" msgstr "" -#: rdiffweb/controller/page_admin_users.py:132 +#: rdiffweb/controller/page_admin_users.py:155 msgid "Setting user's quota is not supported" msgstr "" -#: rdiffweb/controller/page_admin_users.py:159 +#: rdiffweb/controller/page_admin_users.py:182 msgid "You cannot remove your own account!" msgstr "" -#: rdiffweb/controller/page_admin_users.py:165 +#: rdiffweb/controller/page_admin_users.py:188 msgid "User account removed." msgstr "" -#: rdiffweb/controller/page_admin_users.py:167 +#: rdiffweb/controller/page_admin_users.py:190 msgid "User doesn't exists!" msgstr "" -#: rdiffweb/controller/page_admin_users.py:181 +#: rdiffweb/controller/page_admin_users.py:204 msgid "User added successfully." msgstr "" -#: rdiffweb/controller/page_admin_users.py:193 +#: rdiffweb/controller/page_admin_users.py:216 msgid "User information modified successfully." msgstr "" -#: rdiffweb/controller/page_admin_users.py:199 +#: rdiffweb/controller/page_admin_users.py:222 #, python-format msgid "Cannot edit user `%s`: user doesn't exists" msgstr "" @@ -314,99 +342,198 @@ msgstr "" msgid "The displayed data may be inconsistent." msgstr "" -#: rdiffweb/controller/page_login.py:72 -msgid "Fail to validate user credential." +#: rdiffweb/controller/page_login.py:50 rdiffweb/controller/page_mfa.py:44 +msgid "Remember me" msgstr "" -#: rdiffweb/controller/page_login.py:77 +#: rdiffweb/controller/page_login.py:54 rdiffweb/controller/page_mfa.py:48 +#: rdiffweb/templates/login.html:4 +msgid "Sign in" +msgstr "" + +#: rdiffweb/controller/page_login.py:81 +msgid "Failed to validate user credentials." +msgstr "" + +#: rdiffweb/controller/page_login.py:87 msgid "Invalid username or password." msgstr "" -#: rdiffweb/controller/page_pref_general.py:43 +#: rdiffweb/controller/page_mfa.py:33 rdiffweb/controller/page_pref_mfa.py:58 +msgid "Verification code" +msgstr "" + +#: rdiffweb/controller/page_mfa.py:34 +msgid "Enter the code to verify your identity." +msgstr "" + +#: rdiffweb/controller/page_mfa.py:37 rdiffweb/controller/page_pref_mfa.py:60 +msgid "Enter verification code here" +msgstr "" + +#: rdiffweb/controller/page_mfa.py:52 rdiffweb/controller/page_pref_mfa.py:69 +msgid "Resend code to my email" +msgstr "" + +#: rdiffweb/controller/page_mfa.py:60 rdiffweb/controller/page_mfa.py:63 +#: rdiffweb/controller/page_pref_mfa.py:93 +msgid "Invalid verification code." +msgstr "" + +#: rdiffweb/controller/page_mfa.py:67 rdiffweb/controller/page_pref_mfa.py:97 +msgid "Invalid operation" +msgstr "" + +#: rdiffweb/controller/page_mfa.py:95 +msgid "" +"Multi-factor authentication is enabled for your account, but your account " +"does not have a valid email address to send the verification code to. Check " +"your account settings with your administrator." +msgstr "" + +#: rdiffweb/controller/page_mfa.py:104 rdiffweb/controller/page_pref_mfa.py:131 +msgid "Your verification code" +msgstr "" + +#: rdiffweb/controller/page_mfa.py:105 rdiffweb/controller/page_pref_mfa.py:132 +msgid "A new verification code has been sent to your email." +msgstr "" + +#: rdiffweb/controller/page_pref_general.py:44 msgid "Invalid email." msgstr "" -#: rdiffweb/controller/page_pref_general.py:53 +#: rdiffweb/controller/page_pref_general.py:45 +#: rdiffweb/templates/admin_users.html:110 +#: rdiffweb/templates/prefs_notification.html:32 +#: rdiffweb/templates/settings.html:32 rdiffweb/templates/settings.html:88 +#: rdiffweb/templates/settings.html:111 +msgid "Save changes" +msgstr "" + +#: rdiffweb/controller/page_pref_general.py:60 msgid "Current password" msgstr "" -#: rdiffweb/controller/page_pref_general.py:54 +#: rdiffweb/controller/page_pref_general.py:61 msgid "Current password is missing." msgstr "" -#: rdiffweb/controller/page_pref_general.py:55 +#: rdiffweb/controller/page_pref_general.py:62 msgid "You must provide your current password in order to change it." msgstr "" -#: rdiffweb/controller/page_pref_general.py:58 +#: rdiffweb/controller/page_pref_general.py:65 msgid "New password" msgstr "" -#: rdiffweb/controller/page_pref_general.py:60 +#: rdiffweb/controller/page_pref_general.py:67 msgid "New password is missing." msgstr "" -#: rdiffweb/controller/page_pref_general.py:61 +#: rdiffweb/controller/page_pref_general.py:68 msgid "The new password and its confirmation do not match." msgstr "" -#: rdiffweb/controller/page_pref_general.py:65 +#: rdiffweb/controller/page_pref_general.py:72 msgid "Confirm new password" msgstr "" -#: rdiffweb/controller/page_pref_general.py:65 +#: rdiffweb/controller/page_pref_general.py:72 msgid "Confirmation password is missing." msgstr "" -#: rdiffweb/controller/page_pref_general.py:88 +#: rdiffweb/controller/page_pref_general.py:74 +msgid "Update password" +msgstr "" + +#: rdiffweb/controller/page_pref_general.py:83 msgid "Password updated successfully." msgstr "" -#: rdiffweb/controller/page_pref_general.py:100 -msgid "Profile updated successfully." +#: rdiffweb/controller/page_pref_general.py:91 +msgid "Refresh repositories" msgstr "" -#: rdiffweb/controller/page_pref_general.py:105 +#: rdiffweb/controller/page_pref_general.py:92 +msgid "" +"Refresh the list of repositories associated to your account. If you recently " +"add a new repository and it doesn't show, you may try to refresh the list." +msgstr "" + +#: rdiffweb/controller/page_pref_general.py:104 msgid "Repositories successfully updated" msgstr "" +#: rdiffweb/controller/page_pref_general.py:123 +msgid "Profile updated successfully." +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:44 +msgid "Two-Factor Authentication (2FA) Status" +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:52 rdiffweb/controller/page_pref_mfa.py:66 +msgid "Enable Two-Factor Authentication" +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:53 rdiffweb/controller/page_pref_mfa.py:67 +msgid "Disable Two-Factor Authentication" +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:81 +msgid "Two-Factor authentication enabled successfully." +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:84 +msgid "Two-Factor authentication disabled successfully." +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:90 +msgid "Enter the verification code to continue." +msgstr "" + +#: rdiffweb/controller/page_pref_mfa.py:125 +msgid "To continue, you must set up an email address for your account." +msgstr "" + #: rdiffweb/controller/page_pref_notification.py:48 msgid "Notification settings updated successfully." msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:47 +#: rdiffweb/controller/page_pref_sshkeys.py:48 msgid "Invalid SSH key." msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:52 +#: rdiffweb/controller/page_pref_sshkeys.py:53 msgid "Title" msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:53 +#: rdiffweb/controller/page_pref_sshkeys.py:54 msgid "" "The title is an optional description to identify the key. e.g.: " "bob@thinkpad-t530" msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:57 +#: rdiffweb/controller/page_pref_sshkeys.py:58 msgid "Key" msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:59 +#: rdiffweb/controller/page_pref_sshkeys.py:60 msgid "" "Enter a SSH public key. It should start with 'ssh-dss', 'ssh-ed25519', 'ssh-" "rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384' or 'ecdsa-sha2-nistp521'." msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:88 +#: rdiffweb/controller/page_pref_sshkeys.py:72 msgid "Unknown error while adding the SSH Key" msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:103 +#: rdiffweb/controller/page_pref_sshkeys.py:84 msgid "Unknown error while removing the SSH Key" msgstr "" -#: rdiffweb/controller/page_pref_sshkeys.py:126 +#: rdiffweb/controller/page_pref_sshkeys.py:119 msgid "Failed to get SSH keys" msgstr "" @@ -427,19 +554,19 @@ msgstr "" msgid "Backup with errors" msgstr "" -#: rdiffweb/core/librdiff.py:1202 +#: rdiffweb/core/librdiff.py:1198 msgid "A backup is currently in progress to this repository." msgstr "" -#: rdiffweb/core/librdiff.py:1210 +#: rdiffweb/core/librdiff.py:1206 msgid "The previous backup seams to have failed." msgstr "" -#: rdiffweb/core/librdiff.py:1213 +#: rdiffweb/core/librdiff.py:1209 msgid "The repository cannot be found or is badly damaged." msgstr "" -#: rdiffweb/core/librdiff.py:1217 +#: rdiffweb/core/librdiff.py:1213 msgid "Permissions denied. Contact administrator to check repository's permissions." msgstr "" @@ -496,28 +623,28 @@ msgstr "" msgid "invalid encoding %s" msgstr "" -#: rdiffweb/core/model/_user.py:120 +#: rdiffweb/core/model/_user.py:127 #, python-format msgid "User %s already exists." msgstr "" -#: rdiffweb/core/model/_user.py:153 +#: rdiffweb/core/model/_user.py:160 msgid "SSH key already exists" msgstr "" -#: rdiffweb/core/model/_user.py:164 +#: rdiffweb/core/model/_user.py:171 msgid "Duplicate key. This key already exists or is associated to another user." msgstr "" -#: rdiffweb/core/model/_user.py:180 +#: rdiffweb/core/model/_user.py:187 msgid "can't delete admin user" msgstr "" -#: rdiffweb/core/model/_user.py:317 +#: rdiffweb/core/model/_user.py:324 msgid "can't update admin-password defined in configuration file" msgstr "" -#: rdiffweb/core/model/_user.py:320 +#: rdiffweb/core/model/_user.py:327 msgid "Wrong password" msgstr "" @@ -550,7 +677,7 @@ msgid "Logs" msgstr "" #: rdiffweb/templates/admin.html:14 rdiffweb/templates/admin_session.html:7 -#: rdiffweb/templates/prefs.html:17 rdiffweb/templates/prefs_session.html:8 +#: rdiffweb/templates/prefs.html:18 rdiffweb/templates/prefs_session.html:8 msgid "Active Sessions" msgstr "" @@ -773,13 +900,6 @@ msgstr "" msgid "Edit user %(name)s" msgstr "" -#: rdiffweb/templates/admin_users.html:110 rdiffweb/templates/prefs_general.html:31 -#: rdiffweb/templates/prefs_notification.html:32 -#: rdiffweb/templates/settings.html:32 rdiffweb/templates/settings.html:88 -#: rdiffweb/templates/settings.html:111 -msgid "Save changes" -msgstr "" - #: rdiffweb/templates/admin_users.html:118 msgid "Delete User" msgstr "" @@ -810,7 +930,7 @@ msgstr "" msgid "Show more..." msgstr "" -#: rdiffweb/templates/email_changed.html:4 +#: rdiffweb/templates/email_changed.html:4 rdiffweb/templates/email_mfa.html:4 #: rdiffweb/templates/email_notification.html:4 #: rdiffweb/templates/password_changed.html:4 #, python-format @@ -831,6 +951,24 @@ msgid "" "compromised, please contact your administrator." msgstr "" +#: rdiffweb/templates/email_mfa.html:6 +msgid "" +"To help us make sure it's really you, here's the verification code you'll " +"need to log in:" +msgstr "" + +#: rdiffweb/templates/email_mfa.html:12 +msgid "" +"If this wasn't you logging in, and you use a password to log in, please reset" +" your password." +msgstr "" + +#: rdiffweb/templates/email_mfa.html:15 +msgid "" +"This code will expire in 1 hour. Once the code expires, you will need to " +"request a new verification code by going through the login procedure again." +msgstr "" + #: rdiffweb/templates/email_notification.html:6 msgid "" "You are receiving this email to notify you about your backups. The\n" @@ -967,28 +1105,23 @@ msgstr "" msgid "total" msgstr "" -#: rdiffweb/templates/login.html:3 rdiffweb/templates/login.html:40 -#: rdiffweb/templates/login.html:44 -msgid "Sign in" -msgstr "" - -#: rdiffweb/templates/login.html:25 +#: rdiffweb/templates/login.html:26 msgid "" "A simplified backup management software for quick access to your archives " "through an\n" " efficient web interface." msgstr "" -#: rdiffweb/templates/login.html:29 +#: rdiffweb/templates/login.html:30 msgid "website" msgstr "" -#: rdiffweb/templates/login.html:30 +#: rdiffweb/templates/login.html:31 msgid "community" msgstr "" #: rdiffweb/templates/login.html:42 -msgid "Enter your username and password to log in." +msgid "Welcome back" msgstr "" #: rdiffweb/templates/logs.html:7 @@ -1011,6 +1144,20 @@ msgstr "" msgid "Well done!" msgstr "" +#: rdiffweb/templates/mfa.html:7 +msgid "Login Verification" +msgstr "" + +#: rdiffweb/templates/mfa.html:10 +msgid "" +"Two-Factor Authentication is enabled for your account. To verify your " +"account, you must enter the code that was sent to your email address." +msgstr "" + +#: rdiffweb/templates/mfa.html:16 +msgid "Login with a different account" +msgstr "" + #: rdiffweb/templates/password_changed.html:5 #, python-format msgid "" @@ -1030,34 +1177,36 @@ msgstr "" msgid "SSH Keys" msgstr "" -#: rdiffweb/templates/prefs_general.html:25 +#: rdiffweb/templates/prefs.html:17 rdiffweb/templates/prefs_mfa.html:6 +msgid "Two-Factor Authentication" +msgstr "" + +#: rdiffweb/templates/prefs_general.html:7 msgid "Account settings" msgstr "" -#: rdiffweb/templates/prefs_general.html:25 +#: rdiffweb/templates/prefs_general.html:7 msgid "General information about your account." msgstr "" -#: rdiffweb/templates/prefs_general.html:37 +#: rdiffweb/templates/prefs_general.html:15 msgid "Change your current password" msgstr "" -#: rdiffweb/templates/prefs_general.html:43 -msgid "Update password" -msgstr "" - -#: rdiffweb/templates/prefs_general.html:49 +#: rdiffweb/templates/prefs_general.html:23 msgid "Refresh" msgstr "" -#: rdiffweb/templates/prefs_general.html:54 +#: rdiffweb/templates/prefs_mfa.html:9 msgid "" -"Refresh the list of repositories associated to your account. If you recently " -"add a new repository and it doesn't show, you may try to refresh the list." +"You can enhance the security of your account by enabling two-factor " +"authentication (2FA)." msgstr "" -#: rdiffweb/templates/prefs_general.html:56 -msgid "Refresh repositories" +#: rdiffweb/templates/prefs_mfa.html:10 +msgid "" +"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." msgstr "" #: rdiffweb/templates/prefs_notification.html:8 @@ -1334,6 +1483,16 @@ msgstr "" msgid "No backup for this date." msgstr "" +#: rdiffweb/templates/components/form.html:31 +#: rdiffweb/templates/components/form.html:43 +msgid "This feature has not been thoroughly tested and should be used with caution." +msgstr "" + +#: rdiffweb/templates/components/form.html:32 +#: rdiffweb/templates/components/form.html:44 +msgid "Beta" +msgstr "" + #: rdiffweb/templates/include/chartkick.html:15 msgid "Loading..." msgstr "" @@ -1384,9 +1543,9 @@ msgid "" msgstr "" #: rdiffweb/templates/include/session.html:4 -#: rdiffweb/templates/include/session.html:22 -#: rdiffweb/templates/include/session.html:27 -#: rdiffweb/templates/include/session.html:37 +#: rdiffweb/templates/include/session.html:24 +#: rdiffweb/templates/include/session.html:29 +#: rdiffweb/templates/include/session.html:39 msgid "Unknown" msgstr "" diff --git a/rdiffweb/plugins/smtp.py b/rdiffweb/plugins/smtp.py index ce6e3930..23431b9f 100644 --- a/rdiffweb/plugins/smtp.py +++ b/rdiffweb/plugins/smtp.py @@ -163,7 +163,7 @@ def send_mail(self, subject: str, message: str, to=None, cc=None, bcc=None, repl # Record the MIME types of both parts - text/plain and text/html. msg = MIMEMultipart('alternative') - msg['Subject'] = subject + msg['Subject'] = str(subject) msg['From'] = self.email_from if to: msg['To'] = to diff --git a/rdiffweb/rdw_app.py b/rdiffweb/rdw_app.py index fc47ef70..5aef92e2 100644 --- a/rdiffweb/rdw_app.py +++ b/rdiffweb/rdw_app.py @@ -32,8 +32,8 @@ import rdiffweb.plugins.ldap import rdiffweb.plugins.scheduler import rdiffweb.plugins.smtp -import rdiffweb.tools.auth_basic import rdiffweb.tools.auth_form +import rdiffweb.tools.auth_mfa import rdiffweb.tools.currentuser import rdiffweb.tools.db import rdiffweb.tools.enrich_session @@ -51,8 +51,9 @@ from rdiffweb.controller.page_graphs import GraphsPage from rdiffweb.controller.page_history import HistoryPage from rdiffweb.controller.page_locations import LocationsPage -from rdiffweb.controller.page_login import LoginPage, LogoutPage +from rdiffweb.controller.page_login import LoginPage from rdiffweb.controller.page_logs import LogsPage +from rdiffweb.controller.page_mfa import MfaPage from rdiffweb.controller.page_prefs import PreferencesPage from rdiffweb.controller.page_restore import RestorePage from rdiffweb.controller.page_settings import SettingsPage @@ -75,6 +76,13 @@ 'log.screen': False, } + +@cherrypy.tools.auth_form(login_url='/login/') +@cherrypy.tools.auth_mfa( + mfa_url='/mfa/', + mfa_enabled=lambda username: UserObject.get_user(username).mfa == UserObject.ENABLED_MFA, +) +@cherrypy.tools.currentuser(userobj=lambda username: UserObject.get_user(username)) @cherrypy.tools.db() @cherrypy.tools.enrich_session() @cherrypy.tools.proxy() @@ -82,7 +90,7 @@ class Root(LocationsPage): def __init__(self): self.login = LoginPage() - self.logout = LogoutPage() + self.mfa = MfaPage() self.browse = BrowsePage() self.delete = DeletePage() self.restore = RestorePage() @@ -92,9 +100,6 @@ def __init__(self): self.prefs = PreferencesPage() self.settings = SettingsPage() self.api = ApiPage() - # Keep this for backward compatibility. - self.api.set_encoding = self.settings - self.api.remove_older = self.settings self.graphs = GraphsPage() self.logs = LogsPage() @@ -187,11 +192,6 @@ def __init__(self, cfg): # ISO-8859-1 encoding for URL. This avoid any conversion of the # URL into UTF-8. 'request.uri_encoding': 'ISO-8859-1', - 'tools.auth_basic.realm': 'rdiffweb', - 'tools.auth_basic.checkpassword': self._checkpassword, - 'tools.auth_form.on': True, - 'tools.currentuser.on': True, - 'tools.currentuser.userobj': lambda username: UserObject.get_user(username), 'tools.i18n.on': True, 'tools.i18n.default': 'en_US', 'tools.i18n.mo_dir': pkg_resources.resource_filename('rdiffweb', 'locales'), # @UndefinedVariable @@ -204,6 +204,8 @@ def __init__(self, cfg): 'tools.sessions.debug': cfg.debug, 'tools.sessions.storage_class': DbSession, 'tools.sessions.httponly': True, + 'tools.sessions.timeout': cfg.session_timeout, # minutes + 'tools.sessions.persistent': False, # auth_form should update this. 'tools.ratelimit.debug': cfg.debug, 'tools.ratelimit.delay': 60, 'tools.ratelimit.anonymous_limit': cfg.rate_limit, @@ -237,12 +239,6 @@ def currentuser(self): """ return getattr(cherrypy.serving.request, 'currentuser', None) - def _checkpassword(self, realm, username, password): - """ - Check basic authentication. - """ - return any(cherrypy.engine.publish('login', username, password)) - def error_page(self, **kwargs): """ Default error page shown to the user when an unexpected error occur. diff --git a/rdiffweb/templates/components/form.html b/rdiffweb/templates/components/form.html index ecda29b5..3c19e6f0 100644 --- a/rdiffweb/templates/components/form.html +++ b/rdiffweb/templates/components/form.html @@ -1,25 +1,49 @@ {% set bootstrap_class_table = { +'CheckboxInput': 'form-check-input', +'EmailInput': 'form-control', +'PasswordInput': 'form-control', 'Select': 'form-control', -'TextInput': 'form-control', +'SubmitInput': 'btn', 'TextArea': 'form-control', -'PasswordInput': 'form-control', -'EmailInput': 'form-control', -'SubmitInput': 'btn btn-secondary'} %} +'TextInput': 'form-control', +} %} {% for id, field in form._fields.items() %} {% if field.widget['input_type'] == 'hidden' %} {{ field(id=False) }} {% else %} {% set extra_label_class = field.errors and ' is-invalid' or '' %} {% set field_class = bootstrap_class_table.get(field.widget.__class__.__name__) %} + {% if field.render_kw and field.render_kw.get('class') %} + {% set field_class = field_class + ' ' + field.render_kw.get('class') %} + {% endif %} {% if field.widget.__class__.__name__ == 'SubmitInput' %}
+ {{ field(id=False, class=field_class + (' btn-primary' if 'btn-' not in field_class else '')) }} + {% if field.description %}
{{ field.description }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% elif field.widget.__class__.__name__ == 'CheckboxInput' %} +
{{ field(id=False, class=field_class) }} + {{ field.label(class="font-weight-bold" + extra_label_class) }} + {% if field.render_kw and field.render_kw.get('data-beta') %} + + {% trans %}Beta{% endtrans %} + + {% endif %} {% if field.description %}
{{ field.description }}
{% endif %} {% for error in field.errors %}
{{ error }}
{% endfor %}
{% else %}
{{ field.label(class="font-weight-bold" + extra_label_class) }} + {% if field.render_kw and field.render_kw.get('data-beta') %} + + {% trans %}Beta{% endtrans %} + + {% endif %} {{ field(id=False, class=field_class) }} {% if field.description %}
{{ field.description }}
{% endif %} {% for error in field.errors %}
{{ error }}
{% endfor %} diff --git a/rdiffweb/templates/email_changed.html b/rdiffweb/templates/email_changed.html index 17042053..4293a1a5 100644 --- a/rdiffweb/templates/email_changed.html +++ b/rdiffweb/templates/email_changed.html @@ -1,7 +1,7 @@ - {% trans username=user.username %}Hey {{ username }},{% endtrans %} + {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %}

{% trans %}You recently changed the email address associated with your {{ header_name }} account.{% endtrans %}

{% trans %}If you did not make this change and believe your account has been compromised, please contact your administrator.{% endtrans %} diff --git a/rdiffweb/templates/email_mfa.html b/rdiffweb/templates/email_mfa.html new file mode 100644 index 00000000..da796644 --- /dev/null +++ b/rdiffweb/templates/email_mfa.html @@ -0,0 +1,18 @@ + + + + {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %} +

+ {% trans %}To help us make sure it's really you, here's the verification code you'll need to log in:{% endtrans %} +

+

+ {{ code }} +

+

+ {% trans %}If this wasn't you logging in, and you use a password to log in, please reset your password.{% endtrans %} +

+

+ {% trans %}This code will expire in 1 hour. Once the code expires, you will need to request a new verification code by going through the login procedure again.{% endtrans %} +

+ + diff --git a/rdiffweb/templates/email_notification.html b/rdiffweb/templates/email_notification.html index 9760362a..2ad8270e 100644 --- a/rdiffweb/templates/email_notification.html +++ b/rdiffweb/templates/email_notification.html @@ -1,7 +1,7 @@ - {% trans username=user.username %}Hey {{ username }},{% endtrans %} + {% trans username=(user.fullname or user.username) %}Hey {{ username }},{% endtrans %}

{% trans %}You are receiving this email to notify you about your backups. The following repositories are inactive for some time. We invite you to have a look diff --git a/rdiffweb/templates/login.html b/rdiffweb/templates/login.html index c858bddf..6afc40ea 100644 --- a/rdiffweb/templates/login.html +++ b/rdiffweb/templates/login.html @@ -1,4 +1,5 @@ {% extends 'layout.html' %} +{% set username = None %} {% block title %} {% trans %}Sign in{% endtrans %} {% endblock %} @@ -33,17 +34,17 @@

-
-
-
-
-

{{ _('Sign in') }}

- {% include 'message.html' %} -

{% trans %}Enter your username and password to log in.{% endtrans %}

- {{ form }} - -
+ {% block content %} +
+
+
+
+

{% trans %}Welcome back{% endtrans %}

+ {% include 'message.html' %} + {{ form }} +
+
-
-{% 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 %} +
+
+
+
+

{% trans %}Login Verification{% endtrans %}

+ {% include 'message.html' %} +

+ {% trans %}Two-Factor Authentication is enabled for your account. To verify your account, you must enter the code that was sent to your email address.{% endtrans %} +

+ {{ form }} +
+ + {% trans %}Login with a different account{% endtrans %} + +
+
+
+{% 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.")) %}
{{ profile_form }} - -
- -
{% endcall %} - + {# Panel to change password. #} {% call panel(title=_("Password"), description=_("Change your current password")) %}
-
+ {{ password_form }} - -
- -
{% endcall %} - + {# Panel to refresh repository list. #} {% call panel(title=_("Refresh")) %}
- -

- {% trans %}Refresh the list of repositories associated to your account. If you recently add a new repository and it doesn't show, you may try to refresh the list.{% endtrans %} -

- + {{ refresh_form }}
{% 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' %} +
+
+ {{ form }} +
+
+{% 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