"Remember MFA" options

64 views
Skip to first unread message

Bo Jeanes

unread,
Apr 5, 2021, 9:13:59 PM4/5/21
to Rodauth

Hi Jeremy and others,

We've been using Rodauth in production now since about November to generally great success.

Recently, we've been working on shipping 2FA/MFA to our users but our beta testing has uncovered a pretty troubling pattern. It seems that certain browsers (mobile browsers in particular, and especially those on older or lower-specced devices) evict session cookies quite aggressively -- at least when user switches away from browser but anecdotally even when they switch tabs within the browser.

This results in a constant re-auth prompt for MFA. The authentication logs show this pattern for those users:

load_memory -> two_factor_authentication -> load_memory -> two_factor_authentication -> load_memory -> two_factor_authentication -> load_memory -> two_factor_authentication -> load_memory -> two_factor_authentication

I have not observed this behaviour on desktop browsers and have not been able to replicate it on my own mobile, but analysis of the audit logs shows that it is very common on iPhones, at least one Android device, but it is not universal on all mobile devices. I have run into similar issues in Rails land where CSRF tokens are rejected because a mobile user switched away mid-form (e.g. to copy some text from another app to paste into form). That turned out to be based on memory pressure and aggressive eviction of the browser app from memory (particularly on older iOS devices).

We have already see many of our beta users un-enroll from MFA and specifically citing this annoyance so it has become an urgent issue for us.

Looking around elsewhere, Google offers a pre-checked "Don't ask again on this device" box which presumably sets a persisted non-session cookie:

Image from iOS.jpg
Looking at the `remember` feature, it seems quite complicated to get right. I'm not sure if there is a simpler way to approach this but thought it I should email hereto see if there is a community solution or an approach which can be incorporated upstream as it is likely to affect other users too, if it isn't already.

One option is to extend and depend on the `remember` feature but augment what it stores in the cookie or in the DB to restore. However, it would probably be nice to be able to vary the TTL of remember in general and remember MFA specifically. Also, there may be valid reasons to expire a regular session but persist the second factor. I know Google does something similar by occasionally re-prompting for your password but not requiring your second factor. This might support the idea of a separate MFA cookie.

Thoughts?

Cheers,
Bo

Jeremy Evans

unread,
Apr 5, 2021, 10:05:29 PM4/5/21
to rod...@googlegroups.com
One way to work around this would be to have the session cookie always set an expiration, so that it isn't treated like a session cookie by the browser.  Then you have some javascript that automatically refreshes the cookie before it would expire by making another request (so long running browser tabs stay logged in as they would for a normal session cookie with no expiration).  That approach requires no changes to Rodauth itself, and should fix issues beyond Rodauth.  After all, this problem is not in Rodauth, but in session cookies getting lost, so the fix is best made to the session cookies itself.

Thanks,
Jeremy

Bo Jeanes

unread,
Apr 5, 2021, 11:19:31 PM4/5/21
to Rodauth
Hmm, I think I agree.

I separately came to a similar conclusion while experimenting shortly after posting my original message. Now I have to investigate the implications of changing the session storage from a session cookie to a persisted cookie late in the game. I think Rodauth offers all the primitives I need with session lifetimes and session expiry to keep things the way it should be.

Anecdotally, while looking at the cookies for companies like Google, they are not using any session-lifetime cookies and I suspect due to similar reasons.

Thanks for weighing in Jeremy! I'll update here if I run into any issues getting the desired behaviour after switching away from session cookies.

Cheers,
Bo

Bo Jeanes

unread,
Apr 7, 2021, 11:36:14 PM4/7/21
to rod...@googlegroups.com
Hey Jeremy and future readers,

I'm posting the feature I wrote in case it is helpful for others and in general in the sake of posterity. I haven't deployed this so it may change further, but this is roughly where I've landed for the time being:

# frozen_string_literal: true

require 'rodauth'

# This feature changes how `remember` feature works so that the whole session cookie is persisted instead of a
# remember token being persisted which builds a new session.
#
# Fundamentally, this is because mobile browsers (particularly on iOS and particularly older iOS devices) will
# aggressively remove the browser from memory and clear session cookies.
#
# The consequence is a many-fold:
#
# * CSRF tokens are lost resulting in form errors or us disabling CSRF on critical forms
# * Users with 2FA enabled will get re-prompted whenever they switch away from browser and back
#
# Instead of outright replacing the `remember` feature, this extends it so that we can carry over existing sessions
# using remember tokens, but in the future we could unload the upstream `remember` feature.
module Rodauth
  Feature.define(:remember2, :Remember2) do
    depends :remember

    # Replace the upstream mechanism (i.e. don't call `super`) and set `session_options` instead of a new cookie.
    def remember_login
      set_session_expiry(remember_deadline_interval)
    end

    # If this is being called, then an old-style remember token exists and it has just created a fresh session. In this
    # case, we want to remove that cookie and turn our session cookie into a persistent one.
    def after_load_memory
      super
      disable_remember_login # removes key from DB
      forget_login           # via its `super` will remove original remember cookie altogether
      remember_login         # configure current session cookie to be persistent
    end

    def forget_login
      super
      set_session_expiry(nil)
    end

    # We override `remember`'s `load_memory` so we can refresh the current login or port the old login by way of
    # triggering `after_load_memory`.
    def load_memory
      # If a remember cookie _exists_, pretend we are restoring the session so that the remember key is validated. If
      # it is valid, it will create a new session, `after_load_memory` will run, and the session will be ported.
      #
      # This will manifest as one final "re-prompt MFA" or CSRF expiry, but then they will be in new land. We could
      # restore original session instead, but it gets messy.
      if request.cookies.key?(remember_cookie_key)
        session.clear
        super()
      end

      refresh_remembered_login
    end

    # Non-auth requests will need to call this to make sure the token continues to be refreshed
    def refresh_remembered_login
      if extend_remember_deadline?
        set_session_expiry(session[:remember_for])
      end
    end

    def set_session_expiry(expiry)
      expiry = _to_duration(expiry)
      set_session_option(:expire_after, expiry)

      if expiry.nil?
        set_session_option(:renew, true) # force it to be re-set
        remove_session_value(:remember_for)
      else
        set_session_value(:remember_for, expiry.to_i) unless session.blank?
      end
    end

    def set_session_option(key, value)
      request.env['rack.session.options'][key.to_sym] = value
    end

    private

    # upstream remember feature defaults to time as `{days: 14}` which is akin to `ActiveSupport::Duration`'s `parts`,
    # but Rodauth/Sequel does not offer a way to turn this conveniently into seconds. I happen to be in context of
    # a Rails app, so I can use ActiveSupport::Duration, but others may need to do their own logic here to turn it to
    # something Rack understands.
    def _to_duration(data)
      case data
      when Hash
        secs = ActiveSupport::Duration.send(:calculate_total_seconds, data)
        ActiveSupport::Duration.new(secs, data.dup)
      when ActiveSupport::Duration
        data
      when nil, 0
        nil
      when Integer # secs
        ActiveSupport::Duration.build(data.dup)
      else
        raise "unexpected duration value: #{data.inspect}"
      end
    end
  end
end

Clearly, if I didn't care about porting existing remember cookies, this could be simpler, but there is value in extending the existing `remember` feature, in that the `/remember` route works mostly as intended (will switch between persisted and session cookies).

From here, I may want to build upon password grace period feature to re-auth with password, something like every 2 weeks. This would be a different TTL than the extant grace period, which I'd still want to be short to require passwords on forms like password or login change.

Cheers,
Bo
--
You received this message because you are subscribed to a topic in the Google Groups "Rodauth" group.
To unsubscribe from this group and all its topics, send an email to rodauth+u...@googlegroups.com.

Reply all
Reply to author
Forward
0 new messages