-
Notifications
You must be signed in to change notification settings - Fork 4
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
TN-977 Lockout users if they fail password verification 5 times #2567
Changes from 5 commits
5f2f218
996c545
1d1cb82
927f9e3
b17de1c
be24ace
98c36a6
aa48a30
5ef418f
8943c2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
from alembic import op | ||
import sqlalchemy as sa | ||
|
||
|
||
"""empty message | ||
|
||
Revision ID: e503ae1c261a | ||
Revises: a031e26dc1bd | ||
Create Date: 2018-08-14 16:05:28.104848 | ||
|
||
""" | ||
|
||
# revision identifiers, used by Alembic. | ||
revision = 'e503ae1c261a' | ||
down_revision = 'a031e26dc1bd' | ||
|
||
|
||
def upgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.add_column( | ||
'users', | ||
sa.Column( | ||
'last_password_verification_failure', | ||
sa.DateTime(), | ||
nullable=True | ||
) | ||
) | ||
|
||
op.add_column( | ||
'users', | ||
sa.Column( | ||
'password_verification_failures', | ||
sa.Integer(), | ||
nullable=False, | ||
server_default=sa.text('0') | ||
) | ||
) | ||
# ### end Alembic commands ### | ||
|
||
|
||
def downgrade(): | ||
# ### commands auto generated by Alembic - please adjust! ### | ||
op.drop_column('users', 'password_verification_failures') | ||
op.drop_column('users', 'last_password_verification_failure') | ||
# ### end Alembic commands ### |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,6 +26,7 @@ | |
from flask_user.signals import ( | ||
user_changed_password, | ||
user_logged_in, | ||
user_password_failed, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's the new signal I added to flask-user |
||
user_registered, | ||
user_reset_password, | ||
) | ||
|
@@ -361,9 +362,30 @@ def base64_url_decode(s): | |
def flask_user_login_event(app, user, **extra): | ||
auditable_event("local user login", user_id=user.id, subject_id=user.id, | ||
context='login') | ||
|
||
# After a successfull login make sure lockout is reset | ||
user.reset_lockout() | ||
|
||
login_user(user, 'password_authenticated') | ||
|
||
|
||
def flask_user_password_failed_event(app, user, **extra): | ||
"""tracks when a user fails password verification | ||
|
||
If this happens too often, for security reasons, | ||
the user will be locked out of the system. | ||
""" | ||
count = user.add_password_verification_failure() | ||
auditable_event( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be a normal log instead of an auditable event because the user is not authenticated? If I should be a normal log, is it safe to use the user's id in the log? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Good point, that might make more sense
Yup yup, those aren't publicly visible anywhere |
||
'local user failed password verification. Count "{}"'.format( | ||
count | ||
), | ||
user_id=user.id, | ||
subject_id=user.id, | ||
context='login' | ||
) | ||
|
||
|
||
def flask_user_registered_event(app, user, **extra): | ||
auditable_event( | ||
"local user registered", user_id=user.id, subject_id=user.id, | ||
|
@@ -384,9 +406,10 @@ def flask_user_changed_password(app, user, **extra): | |
|
||
|
||
# Register functions to receive signals from flask_user | ||
user_changed_password.connect(flask_user_changed_password) | ||
user_logged_in.connect(flask_user_login_event) | ||
user_password_failed.connect(flask_user_password_failed_event) | ||
user_registered.connect(flask_user_registered_event) | ||
user_changed_password.connect(flask_user_changed_password) | ||
user_reset_password.connect(flask_user_changed_password) | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,11 @@ | ||
"""Module to extend or specialize flask user views for our needs""" | ||
from flask import abort, current_app, session, url_for | ||
from flask_user.forms import LoginForm | ||
from flask_user.translations import lazy_gettext as _ | ||
from flask_user.views import reset_password | ||
from flask_wtf import FlaskForm | ||
|
||
from ..audit import auditable_event | ||
from ..models.role import ROLE | ||
from ..models.user import get_user_or_abort | ||
from .portal import challenge_identity | ||
|
@@ -40,3 +44,46 @@ def reset_password_view_function(token): | |
|
||
next_url = url_for('user.reset_password', token=token) | ||
return challenge_identity(user_id=user_id, next_url=next_url) | ||
|
||
|
||
class LockoutLoginForm(LoginForm): | ||
"""adds lockout functionality to the login process""" | ||
|
||
def validate(self): | ||
"""prevent locked out users from logging in | ||
|
||
Before verifying the user's credentials | ||
check to see if the user is locked out. | ||
If the user is locked out display an error | ||
message below the password field. | ||
""" | ||
# Find user by email address (email field) | ||
user_manager = current_app.user_manager | ||
user, user_email = user_manager.find_user_by_email(self.email.data) | ||
|
||
# If the user is locked out display a message | ||
# under the password field | ||
if user.is_locked_out: | ||
# Make sure validators are run so we | ||
# can populate self.password.errors | ||
super(LoginForm, self).validate() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit odd. I'm calling the base.base class method to get things setup. I don't want to call the base method because it would add some stuff I don't want. I didn't find a better and reliable way to do this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ignoring this |
||
|
||
auditable_event( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, the user isn't authenticated so not sure if this should be an auditable event. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, keep this. When we get support calls about users not being able to log in - it'll be helpful to have the available records. |
||
'local user attempted to login after being locked out', | ||
user_id=user.id, | ||
subject_id=user.id, | ||
context='login' | ||
) | ||
|
||
error_message = _('We see you\'re having trouble - let us help. \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wish this message was longer |
||
Your account will now be locked while we give it a refresh. \ | ||
Please try again in 30 minutes. \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you replace the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oooo, good call |
||
If you\'re still having issues, please click \ | ||
"Having trouble logging in?" below.') | ||
self.password.errors.append(error_message) | ||
|
||
return False | ||
|
||
# If the user is not locked out proceed with | ||
# validating credentials | ||
return super(LockoutLoginForm, self).validate() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,7 +32,7 @@ flask-session==0.3.1 | |
flask-sqlalchemy==2.3.2 | ||
flask-swagger==0.2.13 | ||
flask-testing==0.7.1 | ||
flask-user==0.6.21 # pyup: <0.7 # pin until 1.0 is ready for prod | ||
git+https://github.com/uwcirg/Flask-User.git@v0.6.21#egg=flask-user # pyup: <0.7 # pin until 1.0 is ready for prod | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like how easy this was. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Almost :) Paul helped me repro this, but you'll need to increment the version in your fork if you want it to be installed over (ie replace) the vanilla un-forked flask-user. If you create a fresh virtual environment this will work, but won't work for a venv that already has flask-user installed since pip thinks it's already installed at the same version. You'll want to pick a version that's higher than Also, I'm not sure how picky pip is when comparing versions, or installing from git sources using a git tag. If you have any issues, I would suggest your There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Took a while to figure out, but you can force pip to use the version specified in the egg param. I ended up doing this:
where There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch BTW. This would have broken the build 😮 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah nice! I've only figured out what works for my narrow use-case, will have to try that in the future :) |
||
flask-webtest==0.0.9 | ||
flask-wtf==0.14.2 # via flask-user | ||
flask==1.0.2 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the future I would suggest using redis for storing data that could be considered ephemeral (flask-user similarly uses redis to store server-side session data).
Redis also has the ability to automatically expire keys which could slightly simplify the timeout logic
No need for any change- wouldn't be worth the effort to refactor
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for calling this out. When I started thinking about this work I didn't want to store it in a DB because it doesn't make perfect sense there, but didn't know where else to put it. Wish I had spoken with you last week! Will keep redis in mind for the future. I've used it in the past for other stuff. Would have been a great place to put it.