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: