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

Numerous Improvements for Two Factor Authentication #223

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1c8fddd
replaced onetimepass with pyotp to use otpauth uri for Google Authent…
denera Dec 8, 2022
7998a39
remove has_2fa check in userinfo
denera Dec 8, 2022
d547a3f
changed where the totp object is created
denera Dec 8, 2022
c028c07
removed invalid access to missing user field in DB
denera Dec 8, 2022
1aa3bb3
replaced os.path.expanduser() with custom function to find user home …
denera Dec 8, 2022
2f34ca4
qrcode generator for signup page
denera Dec 8, 2022
af3871d
updated prerequisites
denera Dec 8, 2022
99ada29
fixing reference before assignment
denera Dec 8, 2022
dc2d95c
correcting base64 encoding of png image
denera Dec 8, 2022
f2cd59d
trying to get QR code to work in signup HTML
denera Dec 8, 2022
e2c8596
trying to get QR code to work in signup HTML...again
denera Dec 8, 2022
78460f4
added ability to change 2FA status after signup
denera Dec 8, 2022
c2875be
finalizing 2FA change tools for already existing users
denera Dec 9, 2022
8485181
fixing typos
denera Dec 9, 2022
5ccfdb4
correcting missing attributes in rendering HTML
denera Dec 9, 2022
0574233
trying to correct missing argument token
denera Dec 9, 2022
f3b309e
missing javascript to read 2FA token
denera Dec 9, 2022
91b3509
trying to fix why change_2fa doesn't read the 2FA token
denera Dec 9, 2022
f54793a
corrected Change2FAHandler trying to read 2fa token when 2fa is not a…
denera Dec 9, 2022
a2f859b
helpful message about Google Auth PAM module setting on the server
denera Dec 9, 2022
8b60512
added new config option into the dev config file
denera Dec 9, 2022
5d9ef71
added config option to require 2fa
denera Dec 9, 2022
bd175a9
incorrect nesting in HTML
denera Dec 9, 2022
5f2f475
added 2FA field to password change
denera Dec 9, 2022
35d9e7a
added 2FA field to password change
denera Dec 9, 2022
b7e09b6
documentation update for forced/mandated 2FA
denera Dec 9, 2022
3b980ec
added 2FA require option to dev config example
denera Dec 9, 2022
84de134
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 9, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dev-jupyterhub_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
c.NativeAuthenticator.ask_email_on_signup = True

c.NativeAuthenticator.allow_2fa = True
c.NativeAuthenticator.require_2fa = True
c.NativeAuthenticator.use_google_libpam = True

c.NativeAuthenticator.tos = 'I agree to the <a href="your-url" target="_blank">TOS</a>.'

Expand Down
31 changes: 29 additions & 2 deletions docs/source/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,10 @@ You can also remove FirstUse's database file after the importation to Native Aut
c.NativeAuthenticator.delete_firstuse_db_after_import = True
```

## Add two factor authentication obligatory for users
## Allow users to enable two factor authentication

You can increase security by allowing users to activate two factor authentication.

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

```python
Expand All @@ -200,3 +201,29 @@ Users will receive a message after signup with the two factor authentication cod
And login will now require the two factor authentication code as well:

![](_static/login-two-factor-auth.png)

Users who have not previously activated 2FA during sign up can do so through the account administration page or ask the server admin to enable it for them.

## Require two factor authentication for all users

To increase security even further, you can mandate all users to activate two factor authentication.

To do so, add the following line to the config file:

```python
c.NativeAuthenticator.require_2fa = True
```

This setting overrides the `c.NativeAuthenticator.allow_2fa` option.

New users will be automatically registered with two factor authentication while existing users will receive their two factor authentication secret key the next time they sign on with their existing credentials.

## Use Google Authenticator PAM Module for 2FA

You can configure Native Authenticator to re-use the existing OTP secret key in `/home/user/.google_authenticator`, or create a new one using the [Google Authenticator PAM module](https://github.com/google/google-authenticator-libpam) if it doesn't already exist.

```python
c.NativeAuthenticator.use_google_libpam = True
```

This is particularly useful when you want system users to log into mutliple services on the server using a single 2FA credential shared between the services through the `/home/user/.google_authenticator` file.
235 changes: 215 additions & 20 deletions nativeauthenticator/handlers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import base64
import io
import os
import socket
from datetime import date
from datetime import datetime
from datetime import timezone as tz

import qrcode
from jinja2 import ChoiceLoader
from jinja2 import FileSystemLoader
from jupyterhub.handlers import BaseHandler
Expand All @@ -28,6 +32,25 @@
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")


def generate_otp_uri(username, secret):
if secret:
hostname = socket.gethostname()
return f"otpauth://totp/{username}@{hostname}?secret={secret}&issuer={hostname}"
else:
return ""


def generate_otp_qrcode(username, secret):
if secret:
qrobj = qrcode.make(generate_otp_uri(username, secret))
with io.BytesIO() as buffer:
qrobj.save(buffer, "png")
otp_qrcode = base64.b64encode(buffer.getvalue()).decode()
return otp_qrcode
else:
return ""


class LocalBase(BaseHandler):
"""Base class that all handlers below extend."""

Expand Down Expand Up @@ -59,7 +82,8 @@ async def get(self):
html = await self.render_template(
"signup.html",
ask_email=self.authenticator.ask_email_on_signup,
two_factor_auth=self.authenticator.allow_2fa,
two_factor_auth_allow=self.authenticator.allow_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
recaptcha_key=self.authenticator.recaptcha_key,
tos=self.authenticator.tos,
)
Expand Down Expand Up @@ -172,7 +196,8 @@ async def post(self):
"username": self.get_body_argument("username", strip=False),
"password": self.get_body_argument("signup_password", strip=False),
"email": self.get_body_argument("email", "", strip=False),
"has_2fa": bool(self.get_body_argument("2fa", "", strip=False)),
"has_2fa": bool(self.get_body_argument("2fa", "", strip=False))
or self.authenticator.require_2fa,
}
username_already_taken = self.authenticator.user_exists(
user_info["username"]
Expand Down Expand Up @@ -209,9 +234,14 @@ async def post(self):
ask_email=self.authenticator.ask_email_on_signup,
result_message=message,
alert=alert,
two_factor_auth=self.authenticator.allow_2fa,
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_google=self.authenticator.use_google_libpam,
two_factor_auth_user=user_2fa,
two_factor_auth_value=otp_secret,
two_factor_auth_uri=generate_otp_uri(user.username, otp_secret),
two_factor_auth_qrcode=generate_otp_qrcode(user.username, otp_secret),
recaptcha_key=self.authenticator.recaptcha_key,
tos=self.authenticator.tos,
)
Expand Down Expand Up @@ -321,6 +351,147 @@ def validate_slug(slug, key):
return obj


class Change2FAHandler(LocalBase):
"""Responsible for rendering the /hub/change-otp page where users can add or modify
2FA for their account. Both on GET requests, when simply navigating to the site,
and on POST requests, with the data to change the 2FA setting."""

@web.authenticated
async def get(self):
"""Rendering on GET requests ("normal" visits)."""

user = await self.get_current_user()
userinfo = self.authenticator.get_user(user.name)
html = await self.render_template(
"change-2fa.html",
user_name=userinfo.username,
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_google=self.authenticator.use_google_libpam,
two_factor_auth_user=userinfo.has_2fa,
two_factor_auth_value=userinfo.otp_secret,
two_factor_auth_uri=generate_otp_uri(
userinfo.username, userinfo.otp_secret
),
two_factor_auth_qrcode=generate_otp_qrcode(
userinfo.username, userinfo.otp_secret
),
)
self.finish(html)

@web.authenticated
async def post(self):
"""Rendering on POST requests (requests with data attached)."""

user = await self.get_current_user()
userinfo = self.authenticator.get_user(user.name)
password = self.get_body_argument("password", strip=False)
token = self.get_body_argument("2fa", "", strip=False)

correct_password = userinfo.is_valid_password(password)
correct_token = userinfo.is_valid_token(token)

if not correct_password:
alert = "alert-danger"
message = "Your current password was incorrect. Please try again."
elif not correct_token:
alert = "alert-danger"
message = "Your 2FA token was invalid. Please try again."
else:
success = self.authenticator.change_2fa(user.name)
userinfo = self.authenticator.get_user(user.name)
if success:
alert = "alert-success"
action = "ENABLED" if userinfo.has_2fa else "DISABLED"
message = (
"You have successfully " + action + " two factor authentication!"
)
else:
alert = "alert-danger"
message = "Something went wrong! Please try again."

html = await self.render_template(
"change-2fa.html",
user_name=userinfo.username,
result_message=message,
alert=alert,
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_google=self.authenticator.use_google_libpam,
two_factor_auth_user=userinfo.has_2fa,
two_factor_auth_value=userinfo.otp_secret,
two_factor_auth_uri=generate_otp_uri(
userinfo.username, userinfo.otp_secret
),
two_factor_auth_qrcode=generate_otp_qrcode(
userinfo.username, userinfo.otp_secret
),
)
self.finish(html)


class Change2FAAdminHandler(LocalBase):
"""Responsible for rendering the /hub/change-otp/[someusername] page where
uadmins can modify any user's 2FA setting. Both on GET requests, when simply
navigating to the site, and on POST requests, with the data to change the
2FA setting."""

@admin_users_scope
async def get(self, user_name):
"""Rendering on GET requests ("normal" visits)."""

if not self.authenticator.user_exists(user_name):
raise web.HTTPError(404)

userinfo = self.authenticator.get_user(user_name)
html = await self.render_template(
"change-2fa-admin.html",
user_name=user_name,
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_user=userinfo.has_2fa,
)
self.finish(html)

@admin_users_scope
async def post(self, user_name):
"""Rendering on POST requests (requests with data attached)."""
success = self.authenticator.change_2fa(user_name)
userinfo = self.authenticator.get_user(user_name)
if success:
alert = "alert-success"
action = "ENABLED" if userinfo.has_2fa else "DISABLED"
message = (
f"You have successfully "
+ action
+ " two factor authentication for "
+ user_name
+ "!"
)
else:
alert = "action-danger"
message = "Something went wrong! Please try again."

html = await self.render_template(
"change-2fa-admin.html",
user_name=user_name,
result_message=message,
alert=alert,
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_google=self.authenticator.use_google_libpam,
two_factor_auth_user=userinfo.has_2fa,
two_factor_auth_secret=userinfo.otp_secret,
two_factor_auth_uri=generate_otp_uri(user_name, userinfo.otp_secret),
two_factor_auth_qrcode=generate_otp_qrcode(user_name, userinfo.otp_secret),
)
self.finish(html)


class ChangePasswordHandler(LocalBase):
"""Responsible for rendering the /hub/change-password page where users can change
their own password. Both on GET requests, when simply navigating to the site,
Expand All @@ -331,9 +502,11 @@ async def get(self):
"""Rendering on GET requests ("normal" visits)."""

user = await self.get_current_user()
userinfo = self.authenticator.get_user(user.name)
html = await self.render_template(
"change-password.html",
user_name=user.name,
two_factor_auth_user=userinfo.has_2fa,
)
self.finish(html)

Expand All @@ -342,19 +515,23 @@ async def post(self):
"""Rendering on POST requests (requests with data attached)."""

user = await self.get_current_user()
userinfo = self.authenticator.get_user(user.name)
old_password = self.get_body_argument("old_password", strip=False)
new_password = self.get_body_argument("new_password", strip=False)
confirmation = self.get_body_argument("new_password_confirmation", strip=False)
token = self.get_body_argument("2fa", "", strip=False)

correct_password_provided = self.authenticator.get_user(
user.name
).is_valid_password(old_password)
correct_password = userinfo.is_valid_password(old_password)
correct_token = userinfo.is_valid_token(token)

new_password_matches_confirmation = new_password == confirmation

if not correct_password_provided:
if not correct_password and correct_token:
alert = "alert-danger"
message = "Your current password was incorrect. Please try again."
elif correct_password and not correct_token:
alert = "alert-danger"
message = "Your 2FA code is invalid. Please try again."
elif not new_password_matches_confirmation:
alert = "alert-danger"
message = (
Expand Down Expand Up @@ -388,6 +565,7 @@ async def post(self):
user_name=user.name,
result_message=message,
alert=alert,
two_factor_auth_user=userinfo.has_2fa,
)
self.finish(html)

Expand Down Expand Up @@ -459,7 +637,7 @@ async def post(self, user_name):
class LoginHandler(LoginHandler, LocalBase):
"""Responsible for rendering the /hub/login page."""

def _render(self, login_error=None, username=None):
def _render(self, login_error=None, username=None, otp_secret=""):
"""For 'normal' rendering."""

return self.render_template(
Expand All @@ -470,11 +648,17 @@ def _render(self, login_error=None, username=None):
custom_html=self.authenticator.custom_html,
login_url=self.settings["login_url"],
enable_signup=self.authenticator.enable_signup,
two_factor_auth=self.authenticator.allow_2fa,
authenticator_login_url=url_concat(
self.authenticator.login_url(self.hub.base_url),
{"next": self.get_argument("next", "")},
),
two_factor_auth_allow=self.authenticator.allow_2fa
or self.authenticator.require_2fa,
two_factor_auth_require=self.authenticator.require_2fa,
two_factor_auth_google=self.authenticator.use_google_libpam,
two_factor_auth_value=otp_secret,
two_factor_auth_uri=generate_otp_uri(username, otp_secret),
two_factor_auth_qrcode=generate_otp_qrcode(username, otp_secret),
)

async def post(self):
Expand All @@ -495,21 +679,32 @@ async def post(self):
self._jupyterhub_user = user
self.redirect(self.get_next_url(user))
else:
# default error mesage on unsuccessful login
error = "Invalid username or password."

# check is user exists and has correct password,
# and is just not authorised
# identify specific reason for authentication failure
username = data["username"]
user = self.authenticator.get_user(username)
otp_secret = user.otp_secret
if user is not None:
if user.is_valid_password(data["password"]) and not user.is_authorized:
error = (
f"User {username} has not been authorized "
"by an administrator yet."
)
if user.is_valid_password(data["password"]):
if not user.is_authorized:
error = (
f"User {username} has not been authorized "
"by an administrator yet."
)
elif self.authenticator.require_2fa and not user.has_2fa:
success = self.authenticator.change_2fa(username)
user = self.authenticator.get_user(username)
otp_secret = user.otp_secret if success else ""
error = "This server requires two factor authentication."
else:
error = "Invalid 2FA token. Please try again."
else:
error = "Invalid password. Please try again."
else:
error = f"User {username} does not exist. Please try again."

html = await self._render(login_error=error, username=username)
html = await self._render(
login_error=error, username=username, otp_secret=otp_secret
)
self.finish(html)


Expand Down
Loading