diff --git a/bid_main/email.py b/bid_main/email.py index dc9afa4..66e0305 100644 --- a/bid_main/email.py +++ b/bid_main/email.py @@ -257,3 +257,16 @@ def check_verification_payload( log.debug("verification OK") return VerificationResult.OK, payload + + +def construct_new_user_session(user, session_data): + context = { + "session_data": session_data, + "user": user, + } + email_body_txt = loader.render_to_string( + "bid_main/emails/new_user_session.txt", context + ) + subject = "Security alert: new sign-in" + + return email_body_txt, subject diff --git a/bid_main/middleware.py b/bid_main/middleware.py new file mode 100644 index 0000000..59d3968 --- /dev/null +++ b/bid_main/middleware.py @@ -0,0 +1,13 @@ +from bid_main.models import UserSession + + +def user_session_middleware(get_response): + def middleware(request): + if ( + hasattr(request, 'session') + and hasattr(request, 'user') + and request.user.is_authenticated + ): + UserSession.update_or_create_from_request(request) + return get_response(request) + return middleware diff --git a/bid_main/migrations/0046_usersession.py b/bid_main/migrations/0046_usersession.py new file mode 100644 index 0000000..9c5e2eb --- /dev/null +++ b/bid_main/migrations/0046_usersession.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.13 on 2024-07-30 13:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sessions', '0001_initial'), + ('bid_main', '0045_alter_oauth2accesstoken_user_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='UserSession', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_active_at', models.DateTimeField()), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='sessions', + to=settings.AUTH_USER_MODEL, + ), + ), + ('ip', models.GenericIPAddressField(blank=True, null=True)), + ( + 'session', + models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to='sessions.session', + ), + ), + ('user_agent', models.CharField(blank=True, max_length=255)), + ], + ), + ] diff --git a/bid_main/models.py b/bid_main/models.py index f8f6bdb..e428345 100644 --- a/bid_main/models.py +++ b/bid_main/models.py @@ -8,6 +8,7 @@ from django import urls from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.models import PermissionsMixin +from django.contrib.sessions.models import Session from django.core import validators from django.core.exceptions import ValidationError from django.core.mail import send_mail @@ -17,11 +18,13 @@ from django.templatetags.static import static from django.utils import timezone from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ - import oauth2_provider.models as oa2_models +import user_agents + from . import fields from . import hashers import bid_main.file_utils +import bid_main.utils log = logging.getLogger(__name__) nickname_illegal_chars = re.compile(r"[^\w.+-]") @@ -492,6 +495,7 @@ class User(AbstractBaseUser, PermissionsMixin): is_active=False, avatar=None, ) + self.sessions.all().delete() @classmethod def generate_nickname(cls, email: Optional[str] = '', full_name: Optional[str] = '') -> str: @@ -646,3 +650,40 @@ class UserNote(models.Model): def __str__(self): return "Note" + + +class UserSession(models.Model): + id = models.BigAutoField(primary_key=True) + created_at = models.DateTimeField(auto_now_add=True) + last_active_at = models.DateTimeField() + ip = models.GenericIPAddressField(null=True, blank=True) + session = models.OneToOneField(Session, on_delete=models.CASCADE, editable=False) + user = models.ForeignKey(User, related_name="sessions", on_delete=models.CASCADE) + user_agent = models.CharField( + max_length=255, + blank=True, + null=False, + ) + + @classmethod + def update_or_create_from_request(cls, request, user=None): + if not user: + user = request.user + return cls.objects.update_or_create( + session_id=request.session.session_key, + defaults={ + 'ip': bid_main.utils.get_client_ip(request), + 'user': user, + 'last_active_at': timezone.now(), + 'user_agent': request.headers.get("User-Agent", "")[:255], + }, + ) + + def __str__(self): + return f'UserSession pk={self.pk} for {self.user}' + + @property + def device(self): + if self.user_agent: + return user_agents.parse(self.user_agent) + return None diff --git a/bid_main/signals.py b/bid_main/signals.py index 8814566..1e9b443 100644 --- a/bid_main/signals.py +++ b/bid_main/signals.py @@ -9,6 +9,7 @@ from django.dispatch import receiver from . import models import bid_main.utils as utils import bid_main.file_utils +import bid_main.tasks log = logging.getLogger(__name__) @@ -19,8 +20,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 and creates a UserSession upon login. Sends and email if IP is new. Only saves specific fields, so that the webhook trigger knows what changed. """ @@ -28,14 +29,22 @@ def update_user_for_login(sender, request, user, **kwargs): user.login_count = F("login_count") + 1 fields = {"login_count"} + user_session, _ = models.UserSession.update_or_create_from_request(request, user) # Only move 'current' to 'last' login IP if the IP address is different. request_ip = utils.get_client_ip(request) 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"}) + if user.has_confirmed_email: + bid_main.tasks.send_new_user_session_email( + user_pk=user.pk, + session_data={ + 'device': str(user_session.device or 'Unknown'), + 'ip': user_session.ip, + }, + ) user.save(update_fields=fields) diff --git a/bid_main/tasks.py b/bid_main/tasks.py new file mode 100644 index 0000000..ebcfe17 --- /dev/null +++ b/bid_main/tasks.py @@ -0,0 +1,28 @@ +import logging + +from background_task import background +from background_task.tasks import TaskSchedule +from django.core.mail import send_mail + +from bid_main.models import User +import bid_main.email + + +log = logging.getLogger(__name__) + + +@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING}) +def send_new_user_session_email(user_pk, session_data): + user = User.objects.get(pk=user_pk) + log.info("sending a new user session email for account %s", user.pk) + + # sending only a text/plain email to reduce the room for look-alike phishing emails + email_body_txt, subject = bid_main.email.construct_new_user_session(user, session_data) + + email = user.email + send_mail( + subject=subject, + message=email_body_txt, + from_email=None, # just use the configured default From-address. + recipient_list=[email], + ) 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..4127f85 --- /dev/null +++ b/bid_main/templates/bid_main/active_sessions.html @@ -0,0 +1,44 @@ +{% extends 'layout.html' %} +{% load humanize pipeline static %} +{% block page_title %} +Active Sessions +{% endblock %} + +{% block body %} +
+

Active Sessions

+ + + + + + + + + + + + {% for session in sessions %} + + + + + + + + {% endfor %} + +
CreatedLast ActiveIPDevice
{{ session.created_at|naturaltime }} + {% if session.is_current %} + Current Session + {% else %} + {{ session.last_active_at|naturaltime }} + {% endif %} + {{ session.ip }}{{ session.device }} +
+ {% csrf_token %} + +
+
+
+{% 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..bac22b1 --- /dev/null +++ b/bid_main/templates/bid_main/emails/new_user_session.txt @@ -0,0 +1,15 @@ +{% autoescape off %} +Dear {{ user.full_name|default:user.email }}! + +There was a new sign-in to your Blender ID account {{ user.email }} + +IP address: {{ session_data.ip }} +Device: {{ session_data.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

+
+ + Active Sessions + +
Change Password diff --git a/bid_main/tests/test_user_sessions.py b/bid_main/tests/test_user_sessions.py new file mode 100644 index 0000000..d4034d4 --- /dev/null +++ b/bid_main/tests/test_user_sessions.py @@ -0,0 +1,77 @@ +from unittest.mock import patch + +from django.core import mail +from django.test.client import Client +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from bid_main.tests.factories import UserFactory +import bid_main.tasks + + +class TestUserSessions(TestCase): + def test_records_created_and_delete(self): + user = UserFactory() + client1 = Client() + client2 = Client() + client1.force_login(user) + client2.force_login(user) + self.assertEqual(user.sessions.count(), 2) + client2.logout() + self.assertEqual(user.sessions.count(), 1) + + +class TestActiveSessions(TestCase): + def test_active_sessions(self): + user = UserFactory() + client1 = Client() + client2 = Client() + client1.force_login(user) + first_session_pk = user.sessions.first().pk + client2.force_login(user) + response = client1.get(reverse('bid_main:active_sessions')) + self.assertContains(response, 'Current Session') + self.assertContains(response, 'Terminate Session') + + response = client2.post( + reverse('bid_main:terminate_session', kwargs={'pk': first_session_pk}) + ) + self.assertEqual(response.status_code, 302) + + response = client2.get(reverse('bid_main:active_sessions')) + self.assertEqual(response.status_code, 200) + + # got logged out, redirect to login + response = client1.get(reverse('bid_main:active_sessions')) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/login?next=/active-sessions/') + + +class TestNewUserSessionEmail(TestCase): + @patch( + 'bid_main.tasks.send_new_user_session_email', + new=bid_main.tasks.send_new_user_session_email.task_function, + ) + @patch( + 'django.contrib.auth.base_user.AbstractBaseUser.check_password', + new=lambda _, pwd: pwd == 'hunter2', + ) + def test_new_user_session_email(self): + user = UserFactory(confirmed_email_at=timezone.now()) + client1 = Client() + client1.force_login(user) + + # force_login doesn't set custom env for HttpRequest, so sending a real POST instead + client2 = Client(REMOTE_ADDR='127.0.1.1') + ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0' + response = client2.post( + '/login', + {'username': user.email, 'password': 'hunter2'}, + headers={'user-agent': ua}, + ) + self.assertEqual(response.status_code, 302) + sent_email = mail.outbox.pop() + self.assertEqual(sent_email.to[0], user.email) + self.assertIn('IP address: 127.0.1.1', sent_email.body) + self.assertIn('Device: PC / Linux / Firefox 128.0', sent_email.body) diff --git a/bid_main/urls.py b/bid_main/urls.py index f860894..7d5ca44 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..b836079 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 @@ -429,3 +430,27 @@ 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): + user_sessions = self.request.user.sessions.order_by('-last_active_at') + for session in user_sessions: + if session.session.session_key == self.request.session.session_key: + session.is_current = True + return { + **super().get_context_data(**kwargs), + 'sessions': user_sessions, + } + + +class TerminateSessionView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + user_session_pk = kwargs.get('pk') + if user_session := self.request.user.sessions.filter(pk=user_session_pk).first(): + user_session.session.delete() + user_session.delete() + return redirect('bid_main:active_sessions') + return HttpResponseNotFound("session not found") diff --git a/blenderid/settings.py b/blenderid/settings.py index 611f0f6..1ce1500 100644 --- a/blenderid/settings.py +++ b/blenderid/settings.py @@ -50,6 +50,7 @@ INSTALLED_APPS = [ "django.contrib.admindocs", "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.humanize", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", @@ -72,6 +73,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "bid_main.middleware.user_session_middleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware", diff --git a/requirements.txt b/requirements.txt index f8ccc8e..c972bb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,6 +50,7 @@ sorl-thumbnail==12.7.0 ; python_version >= "3.8" and python_version < "4" sqlparse==0.5.0 ; python_version >= "3.8" and python_version < "4" tornado==6.0.3 ; python_version >= "3.8" and python_version < "4" urllib3==1.25.11 ; python_version >= "3.8" and python_version < "4" +user-agents==2.2.0 uwsgi==2.0.23 wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4" zipp==0.6.0 ; python_version >= "3.8" and python_version < "4"