User session tracking #93587

Merged
Oleg-Komarov merged 18 commits from user-session into main 2024-08-02 16:04:09 +02:00
14 changed files with 327 additions and 6 deletions

View File

@ -257,3 +257,16 @@ def check_verification_payload(
log.debug("verification OK") log.debug("verification OK")
return VerificationResult.OK, payload 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

13
bid_main/middleware.py Normal file
View File

@ -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

View File

@ -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)),
],
),
]

View File

@ -8,6 +8,7 @@ from django import urls
from django.conf import settings from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import PermissionsMixin from django.contrib.auth.models import PermissionsMixin
from django.contrib.sessions.models import Session
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import send_mail 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 import timezone
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import oauth2_provider.models as oa2_models import oauth2_provider.models as oa2_models
import user_agents
from . import fields from . import fields
from . import hashers from . import hashers
import bid_main.file_utils import bid_main.file_utils
import bid_main.utils
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
nickname_illegal_chars = re.compile(r"[^\w.+-]") nickname_illegal_chars = re.compile(r"[^\w.+-]")
@ -492,6 +495,7 @@ class User(AbstractBaseUser, PermissionsMixin):
is_active=False, is_active=False,
avatar=None, avatar=None,
) )
self.sessions.all().delete()
@classmethod @classmethod
def generate_nickname(cls, email: Optional[str] = '', full_name: Optional[str] = '') -> str: def generate_nickname(cls, email: Optional[str] = '', full_name: Optional[str] = '') -> str:
@ -646,3 +650,40 @@ class UserNote(models.Model):
def __str__(self): def __str__(self):
return "Note" 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

View File

@ -9,6 +9,7 @@ from django.dispatch import receiver
from . import models from . import models
import bid_main.utils as utils import bid_main.utils as utils
import bid_main.file_utils import bid_main.file_utils
import bid_main.tasks
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -19,8 +20,8 @@ def log_exception(sender, **kwargs):
@receiver(user_logged_in) @receiver(user_logged_in)
def update_user_for_login(sender, request, user, **kwargs): def process_new_login(sender, request, user, **kwargs):
"""Updates user fields upon login. """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. 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 user.login_count = F("login_count") + 1
fields = {"login_count"} 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. # Only move 'current' to 'last' login IP if the IP address is different.
request_ip = utils.get_client_ip(request) request_ip = utils.get_client_ip(request)
if request_ip and user.current_login_ip != request_ip: if request_ip and user.current_login_ip != request_ip:
user.last_login_ip = F("current_login_ip") user.last_login_ip = F("current_login_ip")
user.current_login_ip = request_ip user.current_login_ip = request_ip
fields.update({"last_login_ip", "current_login_ip"}) fields.update({"last_login_ip", "current_login_ip"})
if user.has_confirmed_email:
Oleg-Komarov marked this conversation as resolved
Review

should be a call of a task

should be a call of a task
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) user.save(update_fields=fields)

28
bid_main/tasks.py Normal file
View File

@ -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],
)

View File

@ -0,0 +1,44 @@
{% extends 'layout.html' %}
{% load humanize pipeline static %}
{% block page_title %}
Active Sessions
{% endblock %}
{% block body %}
<div class="bid box">
<h2>Active Sessions</h2>
<table class="table w-100 mt-4">
<thead>
<tr>
<th>Created</th>
<th>Last Active</th>
<th>IP</th>
<th>Device</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td title="{{ session.created_at }}">{{ session.created_at|naturaltime }}</td>
<td title="{{ session.last_active_at }}">
{% if session.is_current %}
Current Session
{% else %}
{{ session.last_active_at|naturaltime }}
{% endif %}
</td>
<td>{{ session.ip }}</td>
<td>{{ session.device }}</td>
<td class="text-center">
<form action="{% url 'bid_main:terminate_session' session.pk %}" method="post">
{% csrf_token %}
<button type="submit" class="btn-danger" title="Terminate Session"><i class="i-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -135,6 +135,11 @@ Profile
<div class="bid-roles container bid box mt-3"> <div class="bid-roles container bid box mt-3">
<h2>Account</h2> <h2>Account</h2>
<div class="btn-row-fluid mt-3">
<a class="btn" href="{% url 'bid_main:active_sessions' %}">
<span>Active Sessions</span>
</a>
</div>
<div class="btn-row-fluid mt-3"> <div class="btn-row-fluid mt-3">
<a class="btn" href="{% url 'bid_main:password_change' %}"> <a class="btn" href="{% url 'bid_main:password_change' %}">
<span>Change Password</span> <span>Change Password</span>

View File

@ -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)

View File

@ -139,6 +139,12 @@ urlpatterns = [
registration_email.ConfirmEmailPollView.as_view(), registration_email.ConfirmEmailPollView.as_view(),
name="confirm-email-poll", name="confirm-email-poll",
), ),
path('active-sessions/', normal_pages.ActiveSessionsView.as_view(), name='active_sessions'),
path(
'terminate-session/<int:pk>/',
normal_pages.TerminateSessionView.as_view(),
name='terminate_session',
),
] ]
# Only enable this on a dev server: # Only enable this on a dev server:

View File

@ -14,8 +14,8 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import Count from django.db.models import Count
from django.http import HttpResponseRedirect from django.http import HttpResponseNotFound, HttpResponseRedirect
from django.shortcuts import resolve_url, render from django.shortcuts import redirect, resolve_url, render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator 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.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import TemplateView, FormView from django.views.generic import TemplateView, FormView
from django.views.generic.base import View
from django.views.generic.edit import UpdateView from django.views.generic.edit import UpdateView
import loginas.utils import loginas.utils
import oauth2_provider.models as oauth2_models import oauth2_provider.models as oauth2_models
@ -429,3 +430,27 @@ class DeleteUserView(
if not ok: if not ok:
log.error("Failed to send an email about deletion of account %s", user.pk) 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) 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')
Oleg-Komarov marked this conversation as resolved Outdated

getting the pk (pk = kwargs['pk']: it's fine to expect it to be present at this point) and filtering the session on the separate line would make this more readable.

getting the `pk` (`pk = kwargs['pk']`: it's fine to expect it to be present at this point) and filtering the session on the separate line would make this more readable.
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")

View File

@ -50,6 +50,7 @@ INSTALLED_APPS = [
"django.contrib.admindocs", "django.contrib.admindocs",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.humanize",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
@ -72,6 +73,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"bid_main.middleware.user_session_middleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware",

View File

@ -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" sqlparse==0.5.0 ; python_version >= "3.8" and python_version < "4"
tornado==6.0.3 ; 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" urllib3==1.25.11 ; python_version >= "3.8" and python_version < "4"
user-agents==2.2.0
uwsgi==2.0.23 uwsgi==2.0.23
wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4" wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4"
zipp==0.6.0 ; python_version >= "3.8" and python_version < "4" zipp==0.6.0 ; python_version >= "3.8" and python_version < "4"