Outreachy Report IV
This is the forth post on my internship on the Outreachy Program with Project Jupyter. The first, the second and third post are already available, if you want to understand the big picture.
On the last post I talked about how we built the complete user cycle of signing up and getting authorization of the user for actually logging in. Then we talked a bit on adding some optional password check features that admins could configure. This time we continue implementing optional features for admins to use on Native Authenticator.
Block user after failed attempts of login
On the last time, Yuvi recommended me this post about password security guidelines that helped me implement the two options I talked about on the last report: the option to block common passwords on sign up and the option of checking the password length.
One of the things the article recommended was to add a system that would block a user after a certain number of attempts of failed logins.
This was something that, as simple as sounds, took me a lot of work. Although I’ll explain the big picture that I eventually created, but the construction process was not as simple as I am showing you here ๐.
The first thing was to construct a function that would add a count everytime the user failed to make a login. It would also store the moment when the failed attempt happened. The idea was the following:
To do this, I created an empty dictionary attribute called login_attempts
. When I call the function add_login attempts
on my authenticate
method, it will check if the user is already in the dictionary. If it is, then it will increase the count and change the time. Otherwise, it will add the dictionary to the user.
from datetime import datetime
class NativeAuthenticator(Authenticator):
def __init__(self):
self.login_attempts = dict()
def add_login_attempt(self, username):
time = datetime.now()
if not self.login_attempts.get(username):
self.login_attempts[username] = {'count': 1,
'time': time}
else:
self.login_attempts[username]['count'] += 1
self.login_attempts[username]['time'] = time
Once the count was working (tests helped me on this one ๐
), I added another function to the authenticate
method, that would check if the user is blocked or not. This would allow me to exit the authenticate
method if the user is blocked, not even let it try to login:
Cool. Now I just needed to actually check if the user is blocked or not ๐ .
I had to check if the user had tried at last 3 times and if they had tried multiple times, if they waited at least 10 minutes before trying again.
So, the main flux of the system is something like this:
So, my is_blocked
function will go to my dictionary login_attemps
and get the user info (stored here in the variable logins
) if the user has no register or if the number of counts is less then 3, the function returns that the user is not blocked. If the the user is blocked then it has to check the time. Since this part is a little bit more complicated, I separated in another function called can_try_login_again
.
The can_try_again_function
, check if the timedelta between the last datetime stored in the dictionary and now. If the number of seconds is smaller then 600 (10 minutes), the user can try again and the fucntion return True
.
from datetime import datetime
class NativeAuthenticator(Authenticator):
def is_blocked(self, username):
logins = self.login_attempts.get(username)
if not logins or logins['count'] < 3:
return False
if self.can_try_to_login_again(username):
return False
return True
def can_try_to_login_again(self, username):
login_attempts = self.login_attempts.get(username)
if not login_attempts:
return True
time_last_attempt = datetime.now() - login_attempts['time']
if time_last_attempt.seconds > 10 * 60:
return True
return False
Of course, looking more close you can easily imagine that 3 atempts and a 10 minute wait are things that should be an admin’s decision! And that’s what I did once everything was working! Now the admin can add both configuration on the config
file:
c.Authenticator.allowed_failed_logins = 3
c.Authenticator.seconds_before_next_try = 1200
It may seem simple now that it is done, but thi took a lot of work and several commits, but I am really happy with the result
Option to see the password when typing
The same post that suggested to check for common passwords and block after failed attempt logins also recommended to add an option to let the user check the password while typing.
Looking around the problem I realized I would need Javascript to make it work. I look the base tamplate and realized that Javascript was already being used in <script>
tags inside the HTML.
So, the first thing was to add a button on the password input that you would click to see the password. With this ready, I could get the id
of the element, and change it through the Javascript function:
<div class="input-group">
<input
type="password"
class="form-control"
name="password"
id="pwd"
tabindex="2"
required
/>
<span class="input-group-addon">
<button style="border:0;" type="button" id="eye">
๐
</button>
</span>
</div>
I tried a couple approaches I saw online, but everytime I tried to get the button
element through the JS code, it would return empty. When I tried again on the console, it would return correctly. After a few researches I discovered that the problem was that my code was trying to get the button before the whole document was ready to go. So, in the moment my code tried to get the button, it was actually not there!
I found this answer on Stack Overflow that really helped me. I should add a function that could only run after the whole document was ready. Like this:
document.addEventListener("DOMContentLoaded", function(event) {
//do work
});
Once I added this, my simple code worked! What I did was to get my button element through its id:
var button = document.getElementById('eye');
Then I added an Event Listener to listen to clicks on the button, which means, everytime the button was clicked, it would call this function:
button.addEventListener("click", somefunction)
What the function did was to get the password input element. Then, it would change the attribute type
. If it was equals password
(secret) it would change to text
and vice versa. It also changed the simbol from an open eye (๐) to see the password to a key (๐) to hide the password.
button.addEventListener("click", function(e){
var pwd = document.getElementById("pwd"); // get input element
if(pwd.getAttribute("type")=="password"){
pwd.setAttribute("type", "text");
button.textContent = "๐";
} else {
pwd.setAttribute("type", "password");
button.textContent = "๐";
}
});
And that was it! I had an option to see the password while typing :)
Open SignUp
One option we decided to add was the possibility of an open Sign Up. If you remember the last post, but default if a user signs up it will be blocked to log in the system until an admin authorizes. We wanted to make an option to add the opposite: the user is always authorized just after the sign up until an admin blocks them.
To add an open sign up, the admin just had to add to the config file:
c.Authenticator.open_signup = True
The solution was pretty simple. On the creation of a new user, I add the authorization if they are in the admin list of users. I did the same if the variable open_signup
was True
:
infos = {'username': username, 'password': encoded_pw}
if username in self.admin_users or self.open_signup:
infos.update({'is_authorized': True})
One thing I had to change here were the result messages after the sign up. Instead of two options: wait for admin authorization or error with your password, now I needed a new option to let the user know if they can already login. To make things more clean, on my SignUpHandler
I removed the result message from the post
method and created a separate function, just to handle this and the type of banner color we need.
If the Sign Up depends on Admin authorization:
If the Sign Up had problem with the password:
If there is an Open Sign Up and no problem with the password:
Change user password
If you remember my first post, I talked about how I added an endpoint so an user could change their password when logged in. I used [that pull request]*https://github.com/jupyterhub/firstuseauthenticator/pull/8) as a base to Native Authenticator and created
a similar endopoint. Now, users logged in through Native Authenticator can acces /hub/change-password
and change their password.
Username sanitization
I also added a prohibition of commas and spaces on usernames. This is important to avoid errors of typos and copy-and-paste extra spaces. The default Authenticator
already have a validate_username
to check for other problems, I just supercribe the method and added the verification I needed.
from datetime import datetime
class NativeAuthenticator(Authenticator):
def validate_username(self, username):
invalid_chars = [',', ' ']
if any((char in username) for char in invalid_chars):
return False
return super().validate_username(username)
Then I added the validation check on the method where I create the user, along with the password verification :)
What’s next
We still have some things to do, but things are getting polish now. While writing this I realized some tests were missing, the codecov still not working and there is an issue to fix some bugs during installation.
If you are following this reports and want to test and give feedbacks on what I can improve the authenticator, feel free to leave your issue :)
โค Cheers! Letรญcia