WIP: active-sessions #93586
@ -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)
|
||||||
|
@ -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
5
bid_main/sessions.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def get_hashed_key(session):
|
||||||
|
return hashlib.sha256(session.session_key.encode('utf-8')).hexdigest()
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
49
bid_main/templates/bid_main/active_sessions.html
Normal file
49
bid_main/templates/bid_main/active_sessions.html
Normal 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 %}
|
16
bid_main/templates/bid_main/emails/new_user_session.txt
Normal file
16
bid_main/templates/bid_main/emails/new_user_session.txt
Normal 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 %}
|
@ -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>
|
||||||
|
29
bid_main/tests/test_sessions.py
Normal file
29
bid_main/tests/test_sessions.py
Normal 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)
|
@ -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:
|
||||||
|
@ -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")
|
||||||
|
@ -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 = [
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user