diff --git a/bid_main/admin.py b/bid_main/admin.py
index e015a65..ca3be44 100644
--- a/bid_main/admin.py
+++ b/bid_main/admin.py
@@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
+from qsessions.models import Session
import bid_main.file_utils
@@ -408,3 +409,7 @@ class AccessTokenAdmin(admin.ModelAdmin):
list_filter = ("application", "scope")
raw_id_fields = ("user", "source_refresh_token", "id_token")
search_fields = ("scope",)
+
+
+# avoid exposing session_key values in admin
+admin.site.unregister(Session)
diff --git a/bid_main/email.py b/bid_main/email.py
index dc9afa4..4aa8646 100644
--- a/bid_main/email.py
+++ b/bid_main/email.py
@@ -257,3 +257,42 @@ def check_verification_payload(
log.debug("verification OK")
return VerificationResult.OK, payload
+
+
+def send_new_user_session(session):
+ if not hasattr(session, 'user'):
+ log.error('programming error: called for a session without a user')
+ return False
+ user = session.user
+
+ # sending only a text/plain email to reduce the room for look-alike phishing emails
+ email_body_txt, subject = construct_new_user_session(session)
+
+ email = user.email
+ try:
+ send_mail(
+ subject,
+ message=email_body_txt,
+ from_email=None, # just use the configured default From-address.
+ recipient_list=[email],
+ fail_silently=False,
+ )
+ except (smtplib.SMTPException, OSError):
+ log.exception("failed to send a new user session email for account %s", user.pk)
+ return False
+ log.info("sent a new user session email for account %s", user.pk)
+ return True
+
+
+def construct_new_user_session(session):
+ context = {
+ "session": session,
+ "user": session.user,
+ "subject": "Blender ID new sign-in",
+ }
+
+ email_body_txt = loader.render_to_string(
+ "bid_main/emails/new_user_session.txt", context
+ )
+
+ return email_body_txt, context["subject"]
diff --git a/bid_main/sessions.py b/bid_main/sessions.py
new file mode 100644
index 0000000..a2c6fd8
--- /dev/null
+++ b/bid_main/sessions.py
@@ -0,0 +1,5 @@
+import hashlib
+
+
+def get_hashed_key(session):
+ return hashlib.sha256(session.session_key.encode('utf-8')).hexdigest()
diff --git a/bid_main/signals.py b/bid_main/signals.py
index 8814566..a223227 100644
--- a/bid_main/signals.py
+++ b/bid_main/signals.py
@@ -6,7 +6,7 @@ from django.db.models import F
from django.db.models.signals import m2m_changed, post_delete
from django.dispatch import receiver
-from . import models
+from . import email, models
import bid_main.utils as utils
import bid_main.file_utils
@@ -19,8 +19,8 @@ def log_exception(sender, **kwargs):
@receiver(user_logged_in)
-def update_user_for_login(sender, request, user, **kwargs):
- """Updates user fields upon login.
+def process_new_login(sender, request, user, **kwargs):
+ """Updates user fields upon login. Sends an email if IP is new.
Only saves specific fields, so that the webhook trigger knows what changed.
"""
@@ -33,9 +33,11 @@ def update_user_for_login(sender, request, user, **kwargs):
if request_ip and user.current_login_ip != request_ip:
user.last_login_ip = F("current_login_ip")
user.current_login_ip = request_ip
-
fields.update({"last_login_ip", "current_login_ip"})
-
+ try:
+ email.send_new_user_session(request.session.create_model_instance({}))
+ except Exception:
+ log.exception('failed to send a new user session email')
user.save(update_fields=fields)
diff --git a/bid_main/templates/bid_main/active_sessions.html b/bid_main/templates/bid_main/active_sessions.html
new file mode 100644
index 0000000..639d2fa
--- /dev/null
+++ b/bid_main/templates/bid_main/active_sessions.html
@@ -0,0 +1,49 @@
+{% extends 'layout.html' %}
+{% load humanize pipeline static %}
+{% block page_title %}
+Active Sessions
+{% endblock %}
+
+{% block body %}
+
+
Active Sessions
+
+
+
+ Created |
+ Location |
+ Device |
+ |
+
+
+
+ {% for session in sessions %}
+
+ {{ session.created_at|naturaltime }} |
+
+ {# check ip to avoid triggering a geoip warning #}
+ {% if session.ip and session.ip != '127.0.0.1' and session.location %}
+ {{ session.location }}
+ {% else %}
+ Not detected
+ {% endif %}
+ ({{ session.ip }})
+ |
+ {{ session.device }} |
+
+ {% if session.is_current %}
+ Current Session
+ {% else %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/bid_main/templates/bid_main/emails/new_user_session.txt b/bid_main/templates/bid_main/emails/new_user_session.txt
new file mode 100644
index 0000000..e2579cd
--- /dev/null
+++ b/bid_main/templates/bid_main/emails/new_user_session.txt
@@ -0,0 +1,16 @@
+{% autoescape off %}
+Dear {{ user.full_name|default:user.email }}!
+
+A new sign-in for your Blender ID account {{ user.email }}
+
+IP address: {{ session.ip }}
+Location: ({{ session.location }})
+Device: {{ session.device }}
+
+If this was you, you can ignore this message.
+If this wasn't you, please change or reset your password.
+
+--
+Kind regards,
+The Blender Web Team
+{% endautoescape %}
diff --git a/bid_main/templates/bid_main/index.html b/bid_main/templates/bid_main/index.html
index b49e268..4ab17a3 100644
--- a/bid_main/templates/bid_main/index.html
+++ b/bid_main/templates/bid_main/index.html
@@ -135,6 +135,11 @@ Profile
Account
+
Change Password
diff --git a/bid_main/tests/test_sessions.py b/bid_main/tests/test_sessions.py
new file mode 100644
index 0000000..017ddbd
--- /dev/null
+++ b/bid_main/tests/test_sessions.py
@@ -0,0 +1,29 @@
+import re
+
+from django.test import TestCase
+from django.test.client import Client
+from django.urls import reverse
+
+from bid_main.tests.factories import UserFactory
+
+
+class TestActiveSessions(TestCase):
+ def test_active_sessions(self):
+ user = UserFactory()
+ client1 = Client()
+ client2 = Client()
+ client1.force_login(user)
+ client2.force_login(user)
+ response = client1.get(reverse('bid_main:active_sessions'))
+ self.assertContains(response, 'Current Session')
+ self.assertContains(response, 'Terminate Session')
+
+ key = re.search(r'name="session_key_hashed" value="(\w+)"', str(response.content)).group(1)
+ response = client1.post(reverse('bid_main:terminate_session'), {'session_key_hashed': key})
+ self.assertEqual(response.status_code, 302)
+ response = client1.get(reverse('bid_main:active_sessions'))
+ self.assertNotContains(response, 'Terminate Session')
+
+ # got logged out, redirect to login
+ response = client2.get(reverse('bid_main:active_sessions'))
+ self.assertEqual(response.status_code, 302)
diff --git a/bid_main/urls.py b/bid_main/urls.py
index f860894..155c5ca 100644
--- a/bid_main/urls.py
+++ b/bid_main/urls.py
@@ -139,6 +139,12 @@ urlpatterns = [
registration_email.ConfirmEmailPollView.as_view(),
name="confirm-email-poll",
),
+ path('active-sessions/', normal_pages.ActiveSessionsView.as_view(), name='active_sessions'),
+ path(
+ 'terminate-session/',
+ normal_pages.TerminateSessionView.as_view(),
+ name='terminate_session',
+ ),
]
# Only enable this on a dev server:
diff --git a/bid_main/views/normal_pages.py b/bid_main/views/normal_pages.py
index 759698f..7c04c99 100644
--- a/bid_main/views/normal_pages.py
+++ b/bid_main/views/normal_pages.py
@@ -14,8 +14,8 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.db import transaction, IntegrityError
from django.db.models import Count
-from django.http import HttpResponseRedirect
-from django.shortcuts import resolve_url, render
+from django.http import HttpResponseNotFound, HttpResponseRedirect
+from django.shortcuts import redirect, resolve_url, render
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.decorators import method_decorator
@@ -23,6 +23,7 @@ from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import TemplateView, FormView
+from django.views.generic.base import View
from django.views.generic.edit import UpdateView
import loginas.utils
import oauth2_provider.models as oauth2_models
@@ -31,6 +32,7 @@ from .. import forms, email
from . import mixins
from bid_main.email import send_verify_address
import bid_main.file_utils
+import bid_main.sessions
User = get_user_model()
log = logging.getLogger(__name__)
@@ -429,3 +431,28 @@ class DeleteUserView(
if not ok:
log.error("Failed to send an email about deletion of account %s", user.pk)
return render(self.request, "bid_main/delete_user/confirm.html", context=ctx)
+
+
+class ActiveSessionsView(LoginRequiredMixin, TemplateView):
+ template_name = "bid_main/active_sessions.html"
+
+ def get_context_data(self, **kwargs):
+ sessions = self.request.user.session_set.order_by('-created_at')
+ for session in sessions:
+ session.session_key_hashed = bid_main.sessions.get_hashed_key(session)
+ if session.session_key == self.request.session.session_key:
+ session.is_current = True
+ return {
+ **super().get_context_data(**kwargs),
+ 'sessions': sessions,
+ }
+
+
+class TerminateSessionView(LoginRequiredMixin, View):
+ def post(self, request):
+ session_key_hashed = request.POST.get('session_key_hashed')
+ for session in self.request.user.session_set.all():
+ if session_key_hashed == bid_main.sessions.get_hashed_key(session):
+ session.delete()
+ return redirect('bid_main:active_sessions')
+ return HttpResponseNotFound("session not found")
diff --git a/blenderid/settings.py b/blenderid/settings.py
index 84f7200..2f4ca50 100644
--- a/blenderid/settings.py
+++ b/blenderid/settings.py
@@ -43,16 +43,17 @@ INSTALLED_APPS = [
"django.contrib.admindocs",
"django.contrib.auth",
"django.contrib.contenttypes",
- "django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.flatpages",
+ "django.contrib.humanize",
"oauth2_provider",
"pipeline",
"sorl.thumbnail",
"django_admin_select2",
"loginas",
+ "qsessions",
"bid_main",
"bid_api",
"bid_addon_support",
@@ -61,7 +62,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
- "django.contrib.sessions.middleware.SessionMiddleware",
+ "qsessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
@@ -75,6 +76,10 @@ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
]
+SESSION_ENGINE = "qsessions.backends.db"
+
+GEOIP_PATH = os.getenv('GEOIP_PATH')
+
ROOT_URLCONF = "blenderid.urls"
TEMPLATES = [
diff --git a/requirements.txt b/requirements.txt
index e9994ee..005ef7c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -15,9 +15,11 @@ django-compat==1.0.15 ; python_version >= "3.8" and python_version < "4"
django-loginas==0.3.11 ; python_version >= "3.8" and python_version < "4"
django-oauth-toolkit @ git+https://projects.blender.org/Oleg-Komarov/django-oauth-toolkit.git@0b056a99ca943771615b859f48aaff0e12357f22 ; python_version >= "3.8" and python_version < "4"
django-pipeline==3.1.0 ; python_version >= "3.8" and python_version < "4"
+django-qsessions==1.1.5
django==4.2.13 ; python_version >= "3.8" and python_version < "4"
django[bcrypt]==4.2.13 ; python_version >= "3.8" and python_version < "4"
docutils==0.14 ; python_version >= "3.8" and python_version < "4"
+geoip2==4.8.0
htmlmin==0.1.12 ; python_version >= "3.8" and python_version < "4"
idna==2.8 ; python_version >= "3.8" and python_version < "4"
importlib-metadata==3.6.0 ; python_version >= "3.8" and python_version < "4"