From f6cdaa33e7d046a1682aefd4b0d998e15283470f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Thu, 25 Jul 2024 17:52:33 +0200 Subject: [PATCH 1/3] wip --- bid_main/admin.py | 5 ++ bid_main/sessions.py | 5 ++ .../templates/bid_main/active_sessions.html | 48 +++++++++++++++++++ bid_main/templates/bid_main/index.html | 5 ++ bid_main/urls.py | 6 +++ bid_main/views/normal_pages.py | 31 +++++++++++- blenderid/settings.py | 9 +++- requirements.txt | 2 + 8 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 bid_main/sessions.py create mode 100644 bid_main/templates/bid_main/active_sessions.html 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/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/templates/bid_main/active_sessions.html b/bid_main/templates/bid_main/active_sessions.html new file mode 100644 index 0000000..1037e1e --- /dev/null +++ b/bid_main/templates/bid_main/active_sessions.html @@ -0,0 +1,48 @@ +{% extends 'layout.html' %} +{% load humanize pipeline static %} +{% block page_title %} +Active Sessions +{% endblock %} + +{% block body %} +
+

Active Sessions

+ + + + + + + + + + + {% for session in sessions %} + + + + + + + {% endfor %} + +
CreatedLocationDevice
{{ session.created_at|naturaltime }} + {% if session.location %} + {{ session.location }} + {% else %} + Not detected + {% endif %} + ({{ session.ip }}) + {{ session.device }} + {% if session.is_current %} + Current Session + {% else %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+{% endblock %} 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/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" -- 2.30.2 From 8b835ab87011fdc883103580ab7f73add2c5af4c Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 26 Jul 2024 12:42:10 +0200 Subject: [PATCH 2/3] add test --- .../templates/bid_main/active_sessions.html | 3 +- bid_main/tests/test_sessions.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 bid_main/tests/test_sessions.py diff --git a/bid_main/templates/bid_main/active_sessions.html b/bid_main/templates/bid_main/active_sessions.html index 1037e1e..639d2fa 100644 --- a/bid_main/templates/bid_main/active_sessions.html +++ b/bid_main/templates/bid_main/active_sessions.html @@ -21,7 +21,8 @@ Active Sessions {{ session.created_at|naturaltime }} - {% if session.location %} + {# 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 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) -- 2.30.2 From b52564fb363d61d1bdc71b36c3bd8ecc09afb4d1 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 26 Jul 2024 15:38:57 +0200 Subject: [PATCH 3/3] new_user_session email when logging in from a new IP --- bid_main/email.py | 39 +++++++++++++++++++ bid_main/signals.py | 12 +++--- .../bid_main/emails/new_user_session.txt | 16 ++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 bid_main/templates/bid_main/emails/new_user_session.txt 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/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/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 %} -- 2.30.2