Commits

Peter Sagerson committed 5946508

Add otp_required(if_configured=True)

A new mode for otp_required that allows authenticated users with no confirmed OTP devices. Useful for allowing users to manage their own authentication security.

  • Participants
  • Parent commits 8fd23f3

Comments (0)

Files changed (10)

django-otp-agents/otp_agents/decorators.py

 from django_agent_trust.decorators import trusted_agent_required
 
 
-def otp_required(view=None, redirect_field_name='next', login_url=None, accept_trusted_agent=False):
+def otp_required(view=None, redirect_field_name='next', login_url=None, if_configured=False, accept_trusted_agent=False):
     """
     Similar to :func:`~django_otp.decorators.otp_required`, but with an extra
     argument.
             elif accept_trusted_agent:
                 x_required = trusted_agent_required(redirect_field_name=redirect_field_name, login_url=login_url)
             else:
-                x_required = real_otp_required(redirect_field_name=redirect_field_name, login_url=login_url)
+                x_required = real_otp_required(redirect_field_name=redirect_field_name, login_url=login_url, if_configured=if_configured)
 
             return x_required(view_func)(request, *args, **kwargs)
 

django-otp-agents/otp_agents/tests/__init__.py

 
         self.assertEquals(response.status_code, 302)
 
-    def test_otp_aythenticated(self):
+    def test_otp_authenticated(self):
         self.login()
         response = self.client.get('/otp/')
 
 
         self.assertEquals(response.status_code, 200)
 
+    def test_otp_advised_anonymous(self):
+        response = self.client.get('/otp_advised/')
+
+        self.assertEquals(response.status_code, 302)
+
+    def test_otp_advised_unconfigured(self):
+        self.alice.staticdevice_set.all().delete()
+        self.login()
+        response = self.client.get('/otp_advised/')
+
+        self.assertEquals(response.status_code, 200)
+
+    def test_otp_advised_authenticated(self):
+        self.login()
+        response = self.client.get('/otp_advised/')
+
+        self.assertEquals(response.status_code, 302)
+
+    def test_otp_advised_verified(self):
+        self.verify()
+        response = self.client.get('/otp_advised/')
+
+        self.assertEquals(response.status_code, 200)
+
     def test_agent_anonymous(self):
         response = self.client.get('/agent/')
 

django-otp-agents/otp_agents/tests/urls.py

 
     url(r'^otp/$', views.otp_view),
     url(r'^otp2/$', views.otp_view_2),
+    url(r'^otp_advised/$', views.otp_advised_view),
     url(r'^agent/$', views.agent_view),
 )

django-otp-agents/otp_agents/tests/views.py

     return HttpResponse()
 
 
+@otp_required(if_configured=True)
+def otp_advised_view(request):
+    return HttpResponse()
+
+
 @otp_required(accept_trusted_agent=True)
 def agent_view(request):
     return HttpResponse()

django-otp/django_otp/decorators.py

 from django.contrib.auth.decorators import user_passes_test
 
+from django_otp import devices_for_user
 from django_otp.conf import settings
 
 
-def otp_required(view=None, redirect_field_name='next', login_url=None):
+def otp_required(view=None, redirect_field_name='next', login_url=None, if_configured=False):
     """
     Similar to :func:`~django.contrib.auth.decorators.login_required`, but
     requires the user to be :term:`verified`. By default, this redirects users
     to :setting:`OTP_LOGIN_URL`.
+
+    :param if_configured: If ``True``, an authenticated user with no confirmed
+        OTP devices will be allowed. Default is ``False``.
+    :type if_configured: bool
     """
     if login_url is None:
         login_url = settings.OTP_LOGIN_URL
 
-    decorator = user_passes_test(
-        lambda u: u.is_verified(),
-        login_url=login_url,
-        redirect_field_name=redirect_field_name
-    )
+    if if_configured:
+        def test(user):
+            try:
+                if user.is_authenticated():
+                    next(devices_for_user(user, confirmed=True))
+            except StopIteration:
+                return True  # No devices
+            else:
+                return user.is_verified()
+    else:
+        test = lambda u: u.is_verified()
+
+    decorator = user_passes_test(test, login_url=login_url, redirect_field_name=redirect_field_name)
 
     return decorator if (view is None) else decorator(view)

django-otp/django_otp/models.py

     Abstract base model for a :term:`device` attached to a user. Plugins must
     subclass this to define their OTP models.
 
+    .. _unsaved_device_warning:
+
+    .. warning::
+
+        OTP devices are inherently stateful. For example, verifying a token is
+        logically a mutating operation on the device, which may involve
+        incrementing a counter or otherwise consuming a token. A device must be
+        committed to the database before it can be used in any way.
+
     .. attribute:: user
 
         *ForeignKey*: Foreign key to your user model, as configured by
         Verifies a token. In some cases, the token will no longer be valid if
         this returns ``True``.
 
-        .. warning::
-
-            This method is allowed to call ``self.save()`` any time it wants.
-            Some devices will update themselves on every successful
-            verification. To be safe, this method should only be called on
-            objects that have already been committed to the database.
-
         :param string token: The OTP token provided by the user.
         :rtype: bool
         """

django-otp/django_otp/plugins/__init__.py

-import sys
-from itertools import ifilter
-
-from django.contrib.auth.signals import user_logged_in
-import django.db.models
-
-from django_otp.models import Device
-
-
-DEVICE_ID_SESSION_KEY = 'otp_device_id'
-
-
-def login(request, device):
-    """
-    Persist the given OTP device in the current session. The device will be
-    rejected if it does not belong to ``request.user``.
-
-    This is called automatically any time :func:`django.contrib.auth.login` is
-    called with a user having an ``otp_device`` atribute.
-
-    :param request: The HTTP request
-    :type request: :class:`~django.http.HttpRequest`
-
-    :param device: The OTP device used to authenticate the user.
-    :type device: :class:`~django_otp.models.Device`
-    """
-    user = getattr(request, 'user', None)
-
-    if (user is not None) and (device is not None) and (device.user_id == user.id):
-        request.session[DEVICE_ID_SESSION_KEY] = device.persistent_id
-        request.user.otp_device = device
-
-
-def _handle_auth_login(sender, request, user, **kwargs):
-    """
-    Automatically persists an OTP device that was set by an OTP-aware
-    AuthenticationForm.
-    """
-    if hasattr(user, 'otp_device'):
-        login(request, user.otp_device)
-
-user_logged_in.connect(_handle_auth_login)
-
-
-def match_token(user, token):
-    """
-    Attempts to verify a :term:`token` on every device attached to the given
-    user until one of them succeeds. As much as possible, you should prefer to
-    verify tokens against specific devices.
-
-    :param user: The user supplying the token.
-    :type user: :class:`~django.contrib.auth.models.User`
-
-    :param string token: An OTP token to verify.
-
-    :returns: The device that accepted ``token``, if any.
-    :rtype: :class:`~django_otp.models.Device` or ``None``
-    """
-    matches = ifilter(lambda d: d.verify_token(token), devices_for_user(user))
-
-    return next(matches, None)
-
-
-def devices_for_user(user, confirmed=True):
-    """
-    Returns an iterable of all devices registered to the given user.
-
-    :param user:
-    :type user: :class:`~django.contrib.auth.models.User`
-
-    :param confirmed: If ``None``, all matching devices are returned.
-        Otherwise, this can be any true or false value to limit the query
-        to confirmed or unconfirmed devices, respectively.
-
-    :rtype: iterable
-    """
-    for model_cls in device_classes():
-        for device in model_cls.objects.devices_for_user(user, confirmed=confirmed):
-            yield device
-
-
-def device_classes():
-    """
-    Returns an iterable of all loaded device models.
-    """
-    return ifilter(lambda m: issubclass(m, Device), django.db.models.get_models())
-
-
-def import_class(path):
-    """
-    Imports a class based on a full Python path ('pkg.pkg.mod.Class'). This
-    does not trap any exceptions if the path is not valid.
-    """
-    module, name = path.rsplit('.', 1)
-    __import__(module)
-    mod = sys.modules[module]
-    cls = getattr(mod, name)
-
-    return cls

django-otp/django_otp/plugins/otp_static/tests.py

         data = {
             'username': 'alice',
             'password': 'password',
-            'otp_device': 'django_otp.plugins.otp_static.models.StaticDevice/10',
+            'otp_device': 'django_otp.plugins.otp_static.models.StaticDevice/2',
             'otp_token': 'bob1',
         }
         form = OTPAuthenticationForm(None, data)
         alice = form.get_user()
         self.assert_(alice.get_username() == 'alice')
         self.assert_(alice.otp_device is not None)
-
-    def _test_email_interaction(self):
-        data = {
-            'username': 'alice',
-            'password': 'password',
-            'otp_device': 'django_otp.plugins.otp_email.models.EmailDevice/1',
-            'otp_token': '',
-            'otp_challenge': '1',
-        }
-        form = OTPAuthenticationForm(None, data)
-
-        self.assert_(not form.is_valid())
-        alice = form.get_user()
-        self.assert_(alice.get_username() == 'alice')
-        self.assert_(alice.otp_device is None)
-        self.assertEqual(len(mail.outbox), 1)
-
-        data['otp_token'] = mail.outbox[0].body
-        del data['otp_challenge']
-        form = OTPAuthenticationForm(None, data)
-
-        self.assert_(form.is_valid())
-        self.assert_(isinstance(form.get_user().otp_device, EmailDevice))

django-otp/docs/source/auth.rst

 limit access to some resources to verified users only. The primary tool for this
 is otp_required:
 
-.. decorator:: django_otp.decorators.otp_required([redirect_field_name='next', login_url=None])
+.. decorator:: django_otp.decorators.otp_required([redirect_field_name='next', login_url=None, if_configured=False])
 
     Similar to :func:`~django.contrib.auth.decorators.login_required`, but
     requires the user to be :term:`verified`. By default, this redirects users
     to :setting:`OTP_LOGIN_URL`.
 
+    :param if_configured: If ``True``, an authenticated user with no confirmed
+        OTP devices will be allowed. Default is ``False``.
+    :type if_configured: bool
+
 If you need more fine-grained control over authorization decisions, you can use
 ``request.user.is_verified()`` to determine whether the user has been verified
 by an OTP device. if ``is_verified()`` is true, then ``request.user.otp_device``
 provide users a self-service API to manage devices, but this will be very
 site-specific. Fortunately, managing a user's devices is just a matter of
 managing :class:`~django_otp.models.Device`-derived model objects, so it will be
-easy to implement.
+easy to implement. Be sure to note the :ref:`warning <unsaved_device_warning>`
+about unsaved :class:`~django_otp.models.Device` objects.

django-otp/docs/source/conf.py

 )
 
 intersphinx_mapping = {
-    'python': ('http://docs.python.org/', None),
-    'django': ('http://docs.djangoproject.com/en/dev/',
-               'http://docs.djangoproject.com/en/dev/_objects/'),
+    'python': ('http://docs.python.org/2/', None),
+    'django': ('https://docs.djangoproject.com/en/1.5/',
+               'https://docs.djangoproject.com/en/1.5/_objects/'),
 }
 
 # Add any paths that contain templates here, relative to this directory.