Skip to content

Commit

Permalink
Implement Multi-Factor Authentication #201
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ikus060 committed Sep 14, 2022
1 parent a04b95b commit dade9a9
Show file tree
Hide file tree
Showing 50 changed files with 2,044 additions and 610 deletions.
3 changes: 2 additions & 1 deletion README.md
Expand Up @@ -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
Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion doc/index.rst
Expand Up @@ -14,7 +14,7 @@ Welcome to Rdiffweb's documentation!
installation
quickstart
configuration
settings
usage
networking
faq
development
28 changes: 28 additions & 0 deletions 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
12 changes: 12 additions & 0 deletions 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
1 change: 1 addition & 0 deletions rdiffweb/controller/__init__.py
Expand Up @@ -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,
}
Expand Down
20 changes: 17 additions & 3 deletions rdiffweb/controller/api.py
Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions rdiffweb/controller/dispatch.py
Expand Up @@ -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


Expand Down Expand Up @@ -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'):
Expand Down
10 changes: 2 additions & 8 deletions rdiffweb/controller/filter_authorization.py
Expand Up @@ -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")

Expand Down
15 changes: 13 additions & 2 deletions rdiffweb/controller/form.py
Expand Up @@ -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):
Expand All @@ -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('<br/>')
# 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):
"""
Expand Down
23 changes: 23 additions & 0 deletions rdiffweb/controller/page_admin_users.py
Expand Up @@ -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."),
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
64 changes: 32 additions & 32 deletions rdiffweb/controller/page_login.py
Expand Up @@ -17,22 +17,24 @@
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
logger = logging.getLogger(__name__)


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'),
Expand All @@ -43,54 +45,52 @@ 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):
"""
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)

0 comments on commit dade9a9

Please sign in to comment.