Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 2 factor authentication as optional feature #70

Merged
merged 11 commits into from
Apr 13, 2019
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pytest >= 3.7
pytest-asyncio
notebook==5.7.2
bcrypt
onetimepass
Binary file added docs/_static/login-two-factor-auth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/_static/signup-two-factor-auth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 19 additions & 1 deletion docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ Native Authenticator is based on username and password only. But if you need ext

c.Authenticator.ask_email_on_signup = True


Import users from FirstUse Authenticator
----------------------------------------

Expand Down Expand Up @@ -101,3 +100,22 @@ You can also remove FirstUse's database file after the importation to Native Aut

c.Authenticator.delete_firstuse_db_after_import = True


Add two factor authentication obligatory for users
--------------------------------------------------

You can increase security making two factor authentication obligatory for all users.
To do so, add the following line on the config file:

.. code-block:: python

c.Authenticator.add_two_factor_authentication = True

Users will receive a message after signup with the two factor authentication code:

.. image:: _static/signup-two-factor-auth.png

And login will now require the two factor authentication code as well:


.. image:: _static/login-two-factor-auth.png
9 changes: 8 additions & 1 deletion nativeauthenticator/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,17 @@ async def post(self):

alert, message = self.get_result_message(user)

otp_secret = ''
if user:
otp_secret = user.otp_secret

html = self.render_template(
'signup.html',
ask_email=self.authenticator.ask_email_on_signup,
result_message=message,
alert=alert
alert=alert,
two_factor_auth=self.authenticator.add_two_factor_authentication,
two_factor_auth_value=otp_secret,
)
self.finish(html)

Expand Down Expand Up @@ -135,6 +141,7 @@ def _render(self, login_error=None, username=None):
login_error=login_error,
custom_html=self.authenticator.custom_html,
login_url=self.settings['login_url'],
two_factor_auth=self.authenticator.add_two_factor_authentication,
authenticator_login_url=url_concat(
self.authenticator.login_url(self.hub.base_url),
{'next': self.get_argument('next', '')},
Expand Down
18 changes: 15 additions & 3 deletions nativeauthenticator/nativeauthenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ class NativeAuthenticator(Authenticator):
"the system without needing admin authorization")
)
ask_email_on_signup = Bool(
False,
config=True,
default_value=False,
help="Asks for email on signup"
)
import_from_firstuse = Bool(
False,
config=True,
default_value=False,
help="Import users from FirstUse Authenticator database"
)
firstuse_db_path = Unicode(
Expand All @@ -69,6 +69,11 @@ class NativeAuthenticator(Authenticator):
default_value=False,
help="Deletes FirstUse Authenticator database after the import"
)
add_two_factor_authentication = Bool(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we split this into two parts:

  1. Allow users to have 2FA
  2. Require users to have 2FA

In the former, some users might choose to protect their own accounts with 2FA, but isn't enforced. (2) is more complicated - what happens to people who already had created accounts without 2FA? How can they log in?

So I think one PR should be to allow_two_factor_authentication (or just allow_2fa), and it makes it optional for users to set up and use 2FA. There should probably also be a button in the admin screen that shows admins if this user has 2fa enabled, and optionally allows them to reset it.

After that, we can figure out how to make it required.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed this makes sense. I'll refactor this PR to make the 2fa an optional character within user and then make another one to make it required and how to deal with people that signed in without 2fa, ok?

False,
config=True,
help="Requires users to have a second factor authentication"
)

def __init__(self, add_new_table=True, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -131,7 +136,14 @@ def authenticate(self, handler, data):
if self.is_blocked(username):
return

if user.is_authorized and user.is_valid_password(password):
validations = [
user.is_authorized,
user.is_valid_password(password)
]
if self.add_two_factor_authentication:
validations.append(user.is_valid_token(data.get('2auth')))

if all(validations):
self.successful_login(username)
return username

Expand Down
12 changes: 12 additions & 0 deletions nativeauthenticator/orm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import base64
import bcrypt
import os
import re
from jupyterhub.orm import Base

import onetimepass
from sqlalchemy import Boolean, Column, Integer, String, LargeBinary
from sqlalchemy.orm import validates

Expand All @@ -13,6 +16,12 @@ class UserInfo(Base):
password = Column(LargeBinary, nullable=False)
is_authorized = Column(Boolean, default=False)
email = Column(String)
otp_secret = Column(String(10))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the '10' refer to here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the token is created with size 10, so I limited here


def __init__(self, **kwargs):
super(UserInfo, self).__init__(**kwargs)
if not self.otp_secret:
self.otp_secret = base64.b32encode(os.urandom(10)).decode('utf-8')

@classmethod
def find(cls, db, username):
Expand Down Expand Up @@ -40,3 +49,6 @@ def validate_email(self, key, address):
assert re.match(r"^[A-Za-z0-9\.\+_-]+@[A-Za-z0-9\._-]+\.[a-zA-Z]*$",
address)
return address

def is_valid_token(self, token):
return onetimepass.valid_totp(token, self.otp_secret)
7 changes: 6 additions & 1 deletion nativeauthenticator/templates/native-login.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@
</p>
{% endif %}
<label for="username_input">Username:</label>
<input id="username_input" type="text" autocapitalize="off" autocorrect="off" class="form-control" name="username" val="{{username}}" tabindex="1" autofocus="autofocus" />
<input id="username_input" type="text" autocapitalize="off" autocorrect="off" class="form-control" name="username" val="{{username}}"
tabindex="1" autofocus="autofocus" />
{% if two_factor_auth %}
<label for="2auth_input">Second auth factor:</label>
<input id="2auth_input" type="text" autocapitalize="off" autocorrect="off" class="form-control" name="2auth" tabindex="1" autofocus="autofocus" />
{% endif %}
<label for='password_input'>Password:</label>
<div class="input-group">
<input type="password" class="form-control" name="password" id="password_input" tabindex="2" />
Expand Down
10 changes: 9 additions & 1 deletion nativeauthenticator/templates/signup.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@
Already have an user? <a href="/login">Login!</a>
</div>
{% if alert %}
<div class="alert {{alert}}" style="margin-top: 15px;" role="alert">{{result_message}}</div>
<div class="alert {{alert}}" style="margin-top: 15px;" role="alert">
{{result_message}}
{% if two_factor_auth %}
<p><p/>
<strong>Attention!</strong> This system requires two factor authentication.
<br/>
Your two factor authentication code is <strong>{{ two_factor_auth_value }}</strong>. Be sure to keep it safe :)
{% endif %}
</div>
{% endif %}
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
author_email='leportella@protonmail.com',
license='3 Clause BSD',
packages=find_packages(),
install_requires=['jupyterhub>=0.8', 'bcrypt'],
install_requires=['jupyterhub>=0.8', 'bcrypt', 'onetimepass'],
include_package_data=True,
)