Repository: getsentry/sentry

Issue:
Detect when session has expired
In a long running browser session, at some point, a user's session can be expired causing them to be logged out. Whether from the cookie itself expiring, or another window that explicitly logged them out.

We should detect this and say, "Hey, log back in." or something akin to that.

The two best approaches that we quickly thought of were:
- Periodically ping a `/session` endpoint to check the current status of their session. This isn't perfect since there's still a margin of error between pings, depending on our frequency.
- Detect the first 401 response for not being logged in, and prompt at the time of failure. This isn't perfect either since it requires an action to be made before the user knows.

I don't think there's a way we can do this in 100% realtime, so we'll need to pick which works best for us.



Discussion:
![image](https://cloud.githubusercontent.com/assets/1090754/11497074/cabfffa8-9816-11e5-8aac-555888406a05.png)

I think that for people having Sentry open in a background tab the /session ping would be soon enough, and people that are working (don't time out) are doing actions and causing a 401 maybe. To make it even a bit better you could store a session expire time (which is conveyed to the front-end via header) and ping after that time has passed to, this could make it a bit more reliable.

You could also postpone the ping if the last action was less then the ping time ago maybe even... (to prevent lot's of ping/pong actions)

Just my 2 cents :)


Resolving patch:
diff --git a/src/sentry/constants.py b/src/sentry/constants.py
--- a/src/sentry/constants.py
+++ b/src/sentry/constants.py
@@ -189,6 +189,8 @@ def get_all_languages():
 
 OK_PLUGIN_SAVED = _('Configuration for the {name} integration has been saved.')
 
+WARN_SESSION_EXPIRED = 'Your session has expired.'  # TODO: translate this
+
 # Key to use when ordering a list of events manually
 EVENT_ORDERING_KEY = attrgetter('datetime', 'id')
 
diff --git a/src/sentry/web/frontend/auth_login.py b/src/sentry/web/frontend/auth_login.py
--- a/src/sentry/web/frontend/auth_login.py
+++ b/src/sentry/web/frontend/auth_login.py
@@ -9,6 +9,7 @@
 from django.views.decorators.cache import never_cache
 
 from sentry import features
+from sentry.constants import WARN_SESSION_EXPIRED
 from sentry.models import AuthProvider, Organization, OrganizationStatus
 from sentry.web.forms.accounts import AuthenticationForm, RegistrationForm
 from sentry.web.frontend.base import BaseView
@@ -150,4 +151,14 @@ def handle(self, request):
                 messages.add_message(request, messages.ERROR, ERR_NO_SSO)
 
             return HttpResponseRedirect(next_uri)
-        return self.handle_basic_auth(request)
+
+        session_expired = 'session_expired' in request.COOKIES
+        if session_expired:
+            messages.add_message(request, messages.WARNING, WARN_SESSION_EXPIRED)
+
+        response = self.handle_basic_auth(request)
+
+        if session_expired:
+            response.delete_cookie('session_expired')
+
+        return response
diff --git a/src/sentry/web/frontend/auth_organization_login.py b/src/sentry/web/frontend/auth_organization_login.py
--- a/src/sentry/web/frontend/auth_organization_login.py
+++ b/src/sentry/web/frontend/auth_organization_login.py
@@ -5,9 +5,11 @@
 from django.core.urlresolvers import reverse
 from django.db import transaction
 from django.views.decorators.cache import never_cache
+from django.contrib import messages
 
 from sentry import features
 from sentry.auth.helper import AuthHelper
+from sentry.constants import WARN_SESSION_EXPIRED
 from sentry.models import AuthProvider, Organization, OrganizationStatus
 from sentry.utils import auth
 from sentry.web.forms.accounts import AuthenticationForm, RegistrationForm
@@ -136,6 +138,16 @@ def handle(self, request, organization_slug):
         except AuthProvider.DoesNotExist:
             auth_provider = None
 
+        session_expired = 'session_expired' in request.COOKIES
+        if session_expired:
+            messages.add_message(request, messages.WARNING, WARN_SESSION_EXPIRED)
+
         if not auth_provider:
-            return self.handle_basic_auth(request, organization)
-        return self.handle_sso(request, organization, auth_provider)
+            response = self.handle_basic_auth(request, organization)
+        else:
+            response = self.handle_sso(request, organization, auth_provider)
+
+        if session_expired:
+            response.delete_cookie('session_expired')
+
+        return response


Test patch:
diff --git a/tests/sentry/web/frontend/test_auth_login.py b/tests/sentry/web/frontend/test_auth_login.py
--- a/tests/sentry/web/frontend/test_auth_login.py
+++ b/tests/sentry/web/frontend/test_auth_login.py
@@ -19,6 +19,14 @@ def test_renders_correct_template(self):
         assert resp.status_code == 200
         self.assertTemplateUsed('sentry/login.html')
 
+    def test_renders_session_expire_message(self):
+        self.client.cookies['session_expired'] = '1'
+        resp = self.client.get(self.path)
+
+        assert resp.status_code == 200
+        self.assertTemplateUsed(resp, 'sentry/login.html')
+        assert len(resp.context['messages']) == 1
+
     def test_login_invalid_password(self):
         # load it once for test cookie
         self.client.get(self.path)
diff --git a/tests/sentry/web/frontend/test_auth_organization_login.py b/tests/sentry/web/frontend/test_auth_organization_login.py
--- a/tests/sentry/web/frontend/test_auth_organization_login.py
+++ b/tests/sentry/web/frontend/test_auth_organization_login.py
@@ -25,6 +25,17 @@ def test_renders_basic_login_form(self):
         assert 'provider_key' not in resp.context
         assert resp.context['CAN_REGISTER']
 
+    def test_renders_session_expire_message(self):
+        organization = self.create_organization(name='foo', owner=self.user)
+        path = reverse('sentry-auth-organization', args=[organization.slug])
+
+        self.client.cookies['session_expired'] = '1'
+        resp = self.client.get(path)
+
+        assert resp.status_code == 200
+        self.assertTemplateUsed(resp, 'sentry/organization-login.html')
+        assert len(resp.context['messages']) == 1
+
     def test_flow_as_anonymous(self):
         organization = self.create_organization(name='foo', owner=self.user)
         auth_provider = AuthProvider.objects.create(