WIP: active-sessions #93586

Closed
Oleg-Komarov wants to merge 3 commits from active-sessions into main

When changing the target branch, be careful to rebase the branch in your fork to match. See documentation.
12 changed files with 199 additions and 9 deletions

View File

@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from qsessions.models import Session
import bid_main.file_utils import bid_main.file_utils
@ -408,3 +409,7 @@ class AccessTokenAdmin(admin.ModelAdmin):
list_filter = ("application", "scope") list_filter = ("application", "scope")
raw_id_fields = ("user", "source_refresh_token", "id_token") raw_id_fields = ("user", "source_refresh_token", "id_token")
search_fields = ("scope",) search_fields = ("scope",)
# avoid exposing session_key values in admin
admin.site.unregister(Session)

View File

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

5
bid_main/sessions.py Normal file
View File

@ -0,0 +1,5 @@
import hashlib
def get_hashed_key(session):
return hashlib.sha256(session.session_key.encode('utf-8')).hexdigest()

View File

@ -6,7 +6,7 @@ from django.db.models import F
from django.db.models.signals import m2m_changed, post_delete from django.db.models.signals import m2m_changed, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from . import models from . import email, models
import bid_main.utils as utils import bid_main.utils as utils
import bid_main.file_utils import bid_main.file_utils
@ -19,8 +19,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 upon login. Sends an 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.
""" """
@ -33,9 +33,11 @@ def update_user_for_login(sender, request, user, **kwargs):
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"})
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) user.save(update_fields=fields)

View File

@ -0,0 +1,49 @@
{% 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>Location</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>
{# 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 }})
</td>
<td>{{ session.device }}</td>
<td class="text-center">
{% if session.is_current %}
Current Session
{% else %}
<form action="{% url 'bid_main:terminate_session' %}" method="post">
{% csrf_token %}
<input type="hidden" name="session_key_hashed" value="{{ session.session_key_hashed }}" />
<button type="submit" class="btn-danger">Terminate Session</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

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

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

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/',
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
@ -31,6 +32,7 @@ from .. import forms, email
from . import mixins from . import mixins
from bid_main.email import send_verify_address from bid_main.email import send_verify_address
import bid_main.file_utils import bid_main.file_utils
import bid_main.sessions
User = get_user_model() User = get_user_model()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -429,3 +431,28 @@ 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):
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")

View File

@ -43,16 +43,17 @@ INSTALLED_APPS = [
"django.contrib.admindocs", "django.contrib.admindocs",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites", "django.contrib.sites",
"django.contrib.flatpages", "django.contrib.flatpages",
"django.contrib.humanize",
"oauth2_provider", "oauth2_provider",
"pipeline", "pipeline",
"sorl.thumbnail", "sorl.thumbnail",
"django_admin_select2", "django_admin_select2",
"loginas", "loginas",
"qsessions",
"bid_main", "bid_main",
"bid_api", "bid_api",
"bid_addon_support", "bid_addon_support",
@ -61,7 +62,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "qsessions.middleware.SessionMiddleware",
"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",
@ -75,6 +76,10 @@ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
] ]
SESSION_ENGINE = "qsessions.backends.db"
GEOIP_PATH = os.getenv('GEOIP_PATH')
ROOT_URLCONF = "blenderid.urls" ROOT_URLCONF = "blenderid.urls"
TEMPLATES = [ TEMPLATES = [

View File

@ -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-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-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-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==4.2.13 ; python_version >= "3.8" and python_version < "4"
django[bcrypt]==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" 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" htmlmin==0.1.12 ; python_version >= "3.8" and python_version < "4"
idna==2.8 ; 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" importlib-metadata==3.6.0 ; python_version >= "3.8" and python_version < "4"