Просмотр исходного кода

Login and registration improvements

- Adds protection against account enumeration timing attacks
- Adds some new settings (they still have to be implemented properly)
- Initial work on a feature that should prevent brute force login
attacks
Peter Justin 9 лет назад
Родитель
Сommit
7b1ab2e91d

+ 4 - 1
flaskbb/auth/forms.py

@@ -36,6 +36,10 @@ class LoginForm(Form):
     submit = SubmitField(_("Login"))
 
 
+class LoginRecaptchaForm(LoginForm):
+    recaptcha = RecaptchaField(_("Captcha"))
+
+
 class RegisterForm(Form):
     username = StringField(_("Username"), validators=[
         DataRequired(message=_("A Username is required.")),
@@ -51,7 +55,6 @@ class RegisterForm(Form):
 
     confirm_password = PasswordField(_('Confirm Password'))
 
-
     language = SelectField(_('Language'))
 
     accept_tos = BooleanField(_("I accept the Terms of Service"), default=True)

+ 18 - 22
flaskbb/auth/views.py

@@ -14,10 +14,12 @@ from flask_login import (current_user, login_user, login_required,
                          logout_user, confirm_login, login_fresh)
 from flask_babelplus import gettext as _
 
-from flaskbb.utils.helpers import render_template
+from flaskbb.utils.helpers import render_template, redirect_or_next
 from flaskbb.email import send_reset_token
-from flaskbb.auth.forms import (LoginForm, ReauthForm, ForgotPasswordForm,
-                                ResetPasswordForm)
+from flaskbb.exceptions import AuthenticationError
+from flaskbb.auth.forms import (LoginForm, LoginRecaptchaForm, ReauthForm,
+                                ForgotPasswordForm, ResetPasswordForm,
+                                RegisterRecaptchaForm, RegisterForm)
 from flaskbb.user.models import User
 from flaskbb.fixtures.settings import available_languages
 from flaskbb.utils.settings import flaskbb_config
@@ -27,24 +29,19 @@ auth = Blueprint("auth", __name__)
 
 @auth.route("/login", methods=["GET", "POST"])
 def login():
-    """
-    Logs the user in
-    """
-
+    """Logs the user in."""
     if current_user is not None and current_user.is_authenticated:
-        return redirect(url_for("user.profile"))
+        return redirect(current_user.url)
 
     form = LoginForm(request.form)
     if form.validate_on_submit():
-        user, authenticated = User.authenticate(form.login.data,
-                                                form.password.data)
-
-        if user and authenticated:
+        try:
+            user = User.authenticate(form.login.data, form.password.data)
             login_user(user, remember=form.remember_me.data)
-            return redirect(request.args.get("next") or
-                            url_for("forum.index"))
+            return redirect_or_next(url_for("forum.index"))
+        except AuthenticationError:
+            flash(_("Wrong Username or Password."), "danger")
 
-        flash(_("Wrong Username or Password."), "danger")
     return render_template("auth/login.html", form=form)
 
 
@@ -52,16 +49,15 @@ def login():
 @login_required
 def reauth():
     """
-    Reauthenticates a user
+    Reauthenticates a user.
     """
-
     if not login_fresh():
         form = ReauthForm(request.form)
         if form.validate_on_submit():
             if current_user.check_password(form.password.data):
                 confirm_login()
                 flash(_("Reauthenticated."), "success")
-                return redirect(request.args.get("next") or current_user.url)
+                return redirect_or_next(current_user.url)
 
             flash(_("Wrong password."), "danger")
         return render_template("auth/reauth.html", form=form)
@@ -79,18 +75,18 @@ def logout():
 @auth.route("/register", methods=["GET", "POST"])
 def register():
     """
-    Register a new user
+    Register a new user.
     """
-
     if current_user is not None and current_user.is_authenticated:
         return redirect(url_for("user.profile",
                                 username=current_user.username))
 
+    if not flaskbb_config["REGISTRATION_ENABLED"]:
+        flash(_("The registration has been disabled."), "info")
+
     if current_app.config["RECAPTCHA_ENABLED"]:
-        from flaskbb.auth.forms import RegisterRecaptchaForm
         form = RegisterRecaptchaForm(request.form)
     else:
-        from flaskbb.auth.forms import RegisterForm
         form = RegisterForm(request.form)
 
     form.language.choices = available_languages()

+ 22 - 0
flaskbb/configs/default.py

@@ -11,6 +11,7 @@
 """
 import os
 import sys
+import datetime
 
 _VERSION_STR = '{0.major}{0.minor}'.format(sys.version_info)
 
@@ -64,6 +65,27 @@ class DefaultConfig(object):
     LOGIN_MESSAGE_CATEGORY = "info"
     REFRESH_MESSAGE_CATEGORY = "info"
 
+    # The name of the cookie to store the “remember me” information in.
+    # Default: remember_token
+    #REMEMBER_COOKIE_NAME = "remember_token"
+    # The amount of time before the cookie expires, as a datetime.timedelta object.
+    # Default: 365 days (1 non-leap Gregorian year)
+    #REMEMBER_COOKIE_DURATION = datetime.timedelta(days=365)
+    # If the “Remember Me” cookie should cross domains,
+    # set the domain value here (i.e. .example.com would allow the cookie
+    # to be used on all subdomains of example.com).
+    # Default: None
+    #REMEMBER_COOKIE_DOMAIN = None
+    # Limits the “Remember Me” cookie to a certain path.
+    # Default: /
+    #REMEMBER_COOKIE_PATH = "/"
+    # Restricts the “Remember Me” cookie’s scope to secure channels (typically HTTPS).
+    # Default: None
+    #REMEMBER_COOKIE_SECURE = None
+    # Prevents the “Remember Me” cookie from being accessed by client-side scripts.
+    # Default: False
+    #REMEMBER_COOKIE_HTTPONLY = False
+
     # Caching
     CACHE_TYPE = "simple"
     CACHE_DEFAULT_TIMEOUT = 60

+ 4 - 0
flaskbb/exceptions.py

@@ -17,3 +17,7 @@ class FlaskBBError(HTTPException):
 
 class AuthorizationRequired(FlaskBBError, Forbidden):
     description = "Authorization is required to access this area."
+
+
+class AuthenticationError(FlaskBBError):
+    description = "Invalid username and password combination."

+ 45 - 0
flaskbb/fixtures/settings.py

@@ -67,6 +67,51 @@ fixture = (
             }),
         ),
     }),
+    ('auth', {
+        'name': 'Authentication Settings',
+        'description': 'Configurations for the Login and Register process.',
+        'settings': (
+            ('registration_enabled', {
+                'value':        True,
+                'value_type':   "boolean",
+                'name':         "Enable Registration",
+                'description':  "Enable or disable the registration",
+            }),
+            ('login_attempts', {
+                'value':        5,
+                'value_type':   "integer",
+                'extra':        {'min': 1},
+                'name':         "Login Attempts",
+                'description':  "Number of failed login attempts before the account will be suspended for a specified time.",
+            }),
+            ('login_timeout', {
+                'value':        15,
+                'value_type':   "integer",
+                'extra':        {'min': 0},
+                'name':         "Login Timeout",
+                'description':  "The time of how long a account will stay suspended until the user can try to login again (in minutes).",
+            }),
+            ('recaptcha_enabled', {
+                'value':        False,
+                'value_type':   "boolean",
+                'name':         "Enable reCAPTCHA",
+                'description':  ("Helps to prevent bots from creating accounts. "
+                                 "For more information visit this link: <a href=http://www.google.com/recaptcha>http://www.google.com/recaptcha</a>"),
+            }),
+            ('recaptcha_public_key', {
+                'value':        "",
+                'value_type':   "string",
+                'name':         "reCAPTCHA Site Key",
+                'description':  "Your public recaptcha key ('Site key').",
+            }),
+            ('recaptcha_private_key', {
+                'value':        "",
+                'value_type':   "string",
+                'name':         "reCAPTCHA Secret Key",
+                'description':  "The private key ('Secret key'). Keep this a secret!",
+            }),
+        ),
+    }),
     ('misc', {
         'name': "Misc Settings",
         'description': "Miscellaneous settings.",

+ 20 - 9
flaskbb/user/models.py

@@ -8,6 +8,7 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
+import os
 from datetime import datetime
 
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
@@ -18,6 +19,7 @@ from flask_login import UserMixin, AnonymousUserMixin
 
 from flaskbb._compat import max_integer
 from flaskbb.extensions import db, cache
+from flaskbb.exceptions import AuthenticationError
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.database import CRUDMixin
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
@@ -71,8 +73,7 @@ class Group(db.Model, CRUDMixin):
 
     @classmethod
     def get_guest_group(cls):
-        return cls.query.filter(cls.guest==True).first()
-
+        return cls.query.filter(cls.guest == True).first()
 
 
 class User(db.Model, UserMixin, CRUDMixin):
@@ -93,6 +94,9 @@ class User(db.Model, UserMixin, CRUDMixin):
     avatar = db.Column(db.String(200))
     notes = db.Column(db.Text)
 
+    last_failed_login = db.Column(db.DateTime)
+    login_attempts = db.Column(db.Integer, default=0)
+
     theme = db.Column(db.String(15))
     language = db.Column(db.String(15), default="en")
 
@@ -209,21 +213,28 @@ class User(db.Model, UserMixin, CRUDMixin):
     @classmethod
     def authenticate(cls, login, password):
         """A classmethod for authenticating users.
-        It returns true if the user exists and has entered a correct password
+        It returns true if the user exists and has entered a correct password.
 
         :param login: This can be either a username or a email address.
-
         :param password: The password that is connected to username and email.
         """
-
         user = cls.query.filter(db.or_(User.username == login,
                                        User.email == login)).first()
 
         if user:
-            authenticated = user.check_password(password)
-        else:
-            authenticated = False
-        return user, authenticated
+            if user.check_password(password):
+                return user
+
+            # user exists, wrong password
+            user.login_attempts += 1
+            user.last_failed_login = datetime.utcnow()
+            user.save()
+
+        # protection against account enumeration timing attacks
+        dummy_password = os.urandom(15).encode("base-64")
+        check_password_hash(dummy_password, password)
+
+        raise AuthenticationError
 
     def _make_token(self, data, timeout):
         s = Serializer(current_app.config['SECRET_KEY'], timeout)

+ 12 - 1
flaskbb/utils/helpers.py

@@ -18,7 +18,7 @@ from datetime import datetime, timedelta
 
 import requests
 import unidecode
-from flask import session, url_for, flash
+from flask import session, url_for, flash, redirect, request
 from babel.dates import format_timedelta
 from flask_babelplus import lazy_gettext as _
 from flask_themes2 import render_theme_template
@@ -48,6 +48,17 @@ def slugify(text, delim=u'-'):
     return text_type(delim.join(result))
 
 
+def redirect_or_next(endpoint, **kwargs):
+    """Redirects the user back to the page they were viewing or to a specified
+    endpoint. Wraps Flasks :func:`Flask.redirect` function.
+
+    :param endpoint: The fallback endpoint.
+    """
+    return redirect(
+        request.args.get('next') or endpoint, **kwargs
+    )
+
+
 def render_template(template, **context):  # pragma: no cover
     """A helper function that uses the `render_theme_template` function
     without needing to edit all the views

+ 3 - 1
manage.py

@@ -166,13 +166,15 @@ def insertmassdata():
     insert_mass_data()
 
 
-@manager.option('-s', '--settings', dest="settings")
 @manager.option('-f', '--force', dest="force", default=False)
+@manager.option('-s', '--settings', dest="settings")
 def update(settings=None, force=False):
     """Updates the settings via a fixture. All fixtures have to be placed
     in the `fixture`.
     Usage: python manage.py update -s your_fixture
     """
+    if settings is None:
+        settings = "settings"
 
     try:
         fixture = import_string(

+ 28 - 0
migrations/versions/10879048c708_login_attempts.py

@@ -0,0 +1,28 @@
+"""Login attempts
+
+Revision ID: 10879048c708
+Revises: 127be3fb000
+Create Date: 2016-03-07 17:27:37.159830
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '10879048c708'
+down_revision = '127be3fb000'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('last_failed_login', sa.DateTime(), nullable=True))
+    op.add_column('users', sa.Column('login_attempts', sa.Integer(), nullable=True))
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'login_attempts')
+    op.drop_column('users', 'last_failed_login')
+    ### end Alembic commands ###