From 87edbccf5bb38a4f493e82c88dedc5da8a2c8eaf Mon Sep 17 00:00:00 2001 From: ortgit Date: Sun, 27 Apr 2014 01:39:51 -0400 Subject: [PATCH] Added support for two-step authentication. The login function will now email a 6-digit code to users if the user is part of a group with role 'web2py TWo-Step Authentication'. --- applications/welcome/views/default/user.html | 2 +- gluon/tools.py | 357 ++++++++++++------- 2 files changed, 233 insertions(+), 126 deletions(-) diff --git a/applications/welcome/views/default/user.html b/applications/welcome/views/default/user.html index 195407a3..0db230b7 100644 --- a/applications/welcome/views/default/user.html +++ b/applications/welcome/views/default/user.html @@ -3,7 +3,7 @@

{{=T( request.args(0).replace('_',' ').capitalize() )}}

{{ -if request.args(0)=='login': +if request.args(0)=='login' and not session.auth_2_factor_user: if not 'register' in auth.settings.actions_disabled: form.add_button(T('Register'),URL(args='register', vars={'_next': request.vars._next} if request.vars._next else None),_class='btn') pass diff --git a/gluon/tools.py b/gluon/tools.py index 34b92d0f..8c166581 100644 --- a/gluon/tools.py +++ b/gluon/tools.py @@ -2214,6 +2214,17 @@ class Auth(object): _code='INVALID TICKET')) raise HTTP(200, message) + def _reset_2_factor_auth(self, session): + '''When two-step authentication is enabled, this function is used to + clear the session after successfully completing second challenge + or when the maximum number of tries allowed has expired. + ''' + session.auth_2_factor_user = None + session.auth_2_factor = None + session.auth_2_factor_enabled = False + # Allow up to 4 attempts (the 1st one plus 3 more) + session.auth_2_factor_tries_left = 3 + def login( self, next=DEFAULT, @@ -2293,107 +2304,108 @@ class Auth(object): old_requires = table_user[username].requires table_user[username].requires = tmpvalidator - # do we use our own login form, or from a central source? - if settings.login_form == self: - form = SQLFORM( - table_user, - fields=[username, passfield], - hidden=dict(_next=next), - showid=settings.showid, - submit_button=self.messages.login_button, - delete_label=self.messages.delete_label, - formstyle=settings.formstyle, - separator=settings.label_separator - ) - - if settings.remember_me_form: - ## adds a new input checkbox "remember me for longer" - if settings.formstyle != 'bootstrap': - addrow(form, XML(" "), - DIV(XML(" "), - INPUT(_type='checkbox', - _class='checkbox', - _id="auth_user_remember", - _name="remember", - ), - XML("  "), - LABEL( - self.messages.label_remember_me, - _for="auth_user_remember", - )), "", - settings.formstyle, - 'auth_user_remember__row') - elif settings.formstyle == 'bootstrap': - addrow(form, - "", - LABEL( - INPUT(_type='checkbox', - _id="auth_user_remember", - _name="remember"), - self.messages.label_remember_me, - _class="checkbox"), - "", - settings.formstyle, - 'auth_user_remember__row') - - captcha = settings.login_captcha or \ - (settings.login_captcha != False and settings.captcha) - if captcha: - addrow(form, captcha.label, captcha, captcha.comment, - settings.formstyle, 'captcha__row') + # If two-factor authentication is enabled, and the maximum + # number of tries allowed is used up, reset the session to + # pre-login state with two-factor auth + if session.auth_2_factor_enabled and session.auth_2_factor_tries_left < 1: + # Exceeded maximum allowed tries for this code. Require user to enter + # username and password again. + user = None accepted_form = False + self._reset_2_factor_auth(session) + # Redirect to the default 'next' page without logging + # in. If that page requires login, user will be redirected + # back to the main login form + redirect(next, client_side=settings.client_side) - if form.accepts(request, session if self.csrf_prevention else None, - formname='login', dbio=False, - onvalidation=onvalidation, - hideerror=settings.hideerror): - - accepted_form = True - # check for username in db - entered_username = form.vars[username] - if multi_login and '@' in entered_username: - # if '@' in username check for email, not username - user = table_user(email = entered_username) - else: - user = table_user(**{username: entered_username}) - if user: - # user in db, check if registration pending or disabled - temp_user = user - if temp_user.registration_key == 'pending': - response.flash = self.messages.registration_pending - return form - elif temp_user.registration_key in ('disabled', 'blocked'): - response.flash = self.messages.login_disabled - return form - elif (not temp_user.registration_key is None - and temp_user.registration_key.strip()): - response.flash = \ - self.messages.registration_verifying - return form - # try alternate logins 1st as these have the - # current version of the password - user = None - for login_method in settings.login_methods: - if login_method != self and \ - login_method(request.vars[username], - request.vars[passfield]): - if not self in settings.login_methods: - # do not store password in db - form.vars[passfield] = None - user = self.get_or_create_user( - form.vars, settings.update_fields) - break - if not user: - # alternates have failed, maybe because service inaccessible - if settings.login_methods[0] == self: - # try logging in locally using cached credentials - if form.vars.get(passfield, '') == temp_user[passfield]: - # success - user = temp_user - else: - # user not in db - if not settings.alternate_requires_registration: - # we're allowed to auto-register users from external systems + # Before showing the default login form, check whether + # we are already on the second step of two-step authentication. + # If we are, then skip this login form and use the form for the + # second challenge instead. + # Note to devs: The code inside the if-block is unchanged from the + # previous version of this file, other than for indentation inside + # to put it inside the if-block + if session.auth_2_factor_user is None: + # do we use our own login form, or from a central source? + if settings.login_form == self: + form = SQLFORM( + table_user, + fields=[username, passfield], + hidden=dict(_next=next), + showid=settings.showid, + submit_button=self.messages.login_button, + delete_label=self.messages.delete_label, + formstyle=settings.formstyle, + separator=settings.label_separator + ) + + if settings.remember_me_form: + ## adds a new input checkbox "remember me for longer" + if settings.formstyle != 'bootstrap': + addrow(form, XML(" "), + DIV(XML(" "), + INPUT(_type='checkbox', + _class='checkbox', + _id="auth_user_remember", + _name="remember", + ), + XML("  "), + LABEL( + self.messages.label_remember_me, + _for="auth_user_remember", + )), "", + settings.formstyle, + 'auth_user_remember__row') + elif settings.formstyle == 'bootstrap': + addrow(form, + "", + LABEL( + INPUT(_type='checkbox', + _id="auth_user_remember", + _name="remember"), + self.messages.label_remember_me, + _class="checkbox"), + "", + settings.formstyle, + 'auth_user_remember__row') + + captcha = settings.login_captcha or \ + (settings.login_captcha != False and settings.captcha) + if captcha: + addrow(form, captcha.label, captcha, captcha.comment, + settings.formstyle, 'captcha__row') + accepted_form = False + + if form.accepts(request, session if self.csrf_prevention else None, + formname='login', dbio=False, + onvalidation=onvalidation, + hideerror=settings.hideerror): + + accepted_form = True + # check for username in db + entered_username = form.vars[username] + if multi_login and '@' in entered_username: + # if '@' in username check for email, not username + user = table_user(email = entered_username) + else: + user = table_user(**{username: entered_username}) + if user: + # user in db, check if registration pending or disabled + temp_user = user + if temp_user.registration_key == 'pending': + response.flash = self.messages.registration_pending + return form + elif temp_user.registration_key in ('disabled', 'blocked'): + response.flash = self.messages.login_disabled + return form + elif (not temp_user.registration_key is None + and temp_user.registration_key.strip()): + response.flash = \ + self.messages.registration_verifying + return form + # try alternate logins 1st as these have the + # current version of the password + user = None for login_method in settings.login_methods: if login_method != self and \ login_method(request.vars[username], @@ -2404,33 +2416,124 @@ class Auth(object): user = self.get_or_create_user( form.vars, settings.update_fields) break - if not user: - self.log_event(self.messages['login_failed_log'], - request.post_vars) - # invalid login - session.flash = self.messages.invalid_login - callback(onfail, None) - redirect( - self.url(args=request.args, vars=request.get_vars), - client_side=settings.client_side) + if not user: + # alternates have failed, maybe because service inaccessible + if settings.login_methods[0] == self: + # try logging in locally using cached credentials + if form.vars.get(passfield, '') == temp_user[passfield]: + # success + user = temp_user + else: + # user not in db + if not settings.alternate_requires_registration: + # we're allowed to auto-register users from external systems + for login_method in settings.login_methods: + if login_method != self and \ + login_method(request.vars[username], + request.vars[passfield]): + if not self in settings.login_methods: + # do not store password in db + form.vars[passfield] = None + user = self.get_or_create_user( + form.vars, settings.update_fields) + break + if not user: + self.log_event(self.messages['login_failed_log'], + request.post_vars) + # invalid login + session.flash = self.messages.invalid_login + callback(onfail, None) + redirect( + self.url(args=request.args, vars=request.get_vars), + client_side=settings.client_side) + + else: # use a central authentication server + cas = settings.login_form + cas_user = cas.get_user() + + if cas_user: + cas_user[passfield] = None + user = self.get_or_create_user( + table_user._filter_fields(cas_user), + settings.update_fields) + elif hasattr(cas, 'login_form'): + return cas.login_form() + else: + # we need to pass through login again before going on + next = self.url(settings.function, args='login') + redirect(cas.login_url(next), + client_side=settings.client_side) - else: - # use a central authentication server - cas = settings.login_form - cas_user = cas.get_user() - - if cas_user: - cas_user[passfield] = None - user = self.get_or_create_user( - table_user._filter_fields(cas_user), - settings.update_fields) - elif hasattr(cas, 'login_form'): - return cas.login_form() + # Extra login logic for two-factor authentication + ################################################# + # If the 'user' variable has a value, this means that the first + # authentication step was successful (i.e. user provided correct + # username and password at the first challenge). + # Check if this user is signed up for two-factor authentication + # Default rule is that the user must be part of a group that is called + # 'web2py Two-Step Authentication' + if user: + memberships = self.db(self.table_membership().user_id == user.id).select() + session.auth_2_factor_enabled = 'web2py Two-Step Authentication' in [i.group_id for i in memberships.values()] + # If user is signed up for two-factor authentication, present the second + # challenge + if session.auth_2_factor_enabled: + form = SQLFORM.factory( + Field('authentication_code', + required=True, + comment='This code was emailed to you and is required for login.'), + hidden=dict(_next=next), + formstyle=settings.formstyle, + separator=settings.label_separator + ) + # accepted_form is used by some default web2py code later in the + # function that handles running specified functions before redirect + # Set it to False until the challenge form is accepted. + accepted_form = False + # Handle the case when a user has submitted the login/password + # form successfully, and the password has been validated, but + # the two-factor form has not been displayed or validated yet. + if session.auth_2_factor_user is None and user is not None: + session.auth_2_factor_user = user # store the validated user and associate with this session + session.auth_2_factor = random.randint(100000, 999999) + session.auth_2_factor_tries_left = 3 # Allow user to try up to 4 times + # TODO: Add some error checking to handle cases where email cannot be sent + self.settings.mailer.send( + to=user.email, + subject="FDSI Login Authentication Code", + message="Your temporary login code is {0}".format(session.auth_2_factor)) + if form.accepts(request, session if self.csrf_prevention else None, + formname='login', dbio=False, + onvalidation=onvalidation, + hideerror=settings.hideerror): + accepted_form = True + if form.vars['authentication_code'] == str(session.auth_2_factor): + # Handle the case when the two-factor form has been successfully validated + # and the user was previously stored (the current user should be None because + # in this case, the previous username/password login form should not be displayed. + # This will allow the code after the 2-factor authentication block to proceed as + # normal. + if user is None or user == session.auth_2_factor_user: + user = session.auth_2_factor_user + # For security, because the username stored in the + # session somehow does not match the just validated + # user. Should not be possible without session stealing + # which is hard with SSL. + elif user != session.auth_2_factor_user: + user = None + # Either way, the user and code associated with this session should + # be removed. This handles cases where the session login may have + # expired but browser window is open, so the old session key and + # session usernamem will still exist + self._reset_2_factor_auth(session) + else: + # TODO: Limit the number of retries allowed. + response.flash = 'Incorrect code. {0} more attempt(s) remaining.'.format(session.auth_2_factor_tries_left) + session.auth_2_factor_tries_left -= 1 + return form else: - # we need to pass through login again before going on - next = self.url(settings.function, args='login') - redirect(cas.login_url(next), - client_side=settings.client_side) + return form + # End login logic for two-factor authentication # process authenticated users if user: @@ -2468,7 +2571,11 @@ class Auth(object): """ Logouts and redirects to login """ - + + # Clear out 2-step authentication information if user logs + # out. This information is also cleared on successful login. + self._reset_2_factor_auth(current.session) + if next is DEFAULT: next = self.get_vars_next() or self.settings.logout_next if onlogout is DEFAULT: