Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 9c91ee2

Browse files
authored
Clean up user's expired login sessions (#1113)
1 parent d80087a commit 9c91ee2

File tree

2 files changed

+172
-18
lines changed

2 files changed

+172
-18
lines changed

codecov_auth/tests/unit/views/test_base.py

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22
from unittest.mock import Mock, patch
33

44
import pytest
@@ -8,10 +8,15 @@
88
from django.http import HttpResponse
99
from django.test import RequestFactory, TestCase, override_settings
1010
from freezegun import freeze_time
11-
from shared.django_apps.codecov_auth.tests.factories import OwnerFactory, UserFactory
11+
from shared.django_apps.codecov_auth.tests.factories import (
12+
OwnerFactory,
13+
SessionFactory,
14+
UserFactory,
15+
)
1216
from shared.license import LicenseInformation
1317

14-
from codecov_auth.models import Owner, OwnerProfile
18+
from codecov_auth.models import DjangoSession, Owner, OwnerProfile, Session
19+
from codecov_auth.tests.factories import DjangoSessionFactory
1520
from codecov_auth.views.base import LoginMixin, StateMixin
1621

1722

@@ -729,3 +734,118 @@ def test_login_authenticated_with_claimed_owner(self):
729734
# does not re-claim owner
730735
assert owner.user is not None
731736
assert owner.user != user
737+
738+
@patch("services.refresh.RefreshService.trigger_refresh", lambda *args: None)
739+
def test_login_owner_with_expired_login_session(self):
740+
user = UserFactory()
741+
owner = OwnerFactory(service="github", user=user)
742+
743+
another_user = UserFactory()
744+
another_owner = OwnerFactory(service="github", user=another_user)
745+
746+
now = datetime.now(timezone.utc)
747+
748+
# Create a session that will be deleted
749+
to_be_deleted_1 = SessionFactory(
750+
owner=owner,
751+
type="login",
752+
name="to_be_deleted",
753+
lastseen="2021-01-01T00:00:00+00:00",
754+
login_session=DjangoSessionFactory(expire_date=now - timedelta(days=1)),
755+
)
756+
to_be_deleted_1_session_key = to_be_deleted_1.login_session.session_key
757+
758+
# Create a session that will not be deleted because its not a login session
759+
to_be_kept_1 = SessionFactory(
760+
owner=owner,
761+
type="api",
762+
name="to_be_kept",
763+
lastseen="2021-01-01T00:00:00+00:00",
764+
login_session=DjangoSessionFactory(expire_date=now + timedelta(days=1)),
765+
)
766+
767+
# Create a session that will not be deleted because it's not expired
768+
to_be_kept_2 = SessionFactory(
769+
owner=owner,
770+
type="login",
771+
name="to_be_kept",
772+
lastseen="2021-01-01T00:00:00+00:00",
773+
login_session=DjangoSessionFactory(expire_date=now + timedelta(days=1)),
774+
)
775+
776+
# Create a session that will not be deleted because it's not the owner's session
777+
to_be_kept_3 = SessionFactory(
778+
owner=another_owner,
779+
type="login",
780+
name="to_be_kept",
781+
lastseen="2021-01-01T00:00:00+00:00",
782+
login_session=DjangoSessionFactory(expire_date=now - timedelta(seconds=1)),
783+
)
784+
785+
assert (
786+
len(DjangoSession.objects.filter(session_key=to_be_deleted_1_session_key))
787+
== 1
788+
)
789+
assert (
790+
len(
791+
DjangoSession.objects.filter(
792+
session_key=to_be_kept_1.login_session.session_key
793+
)
794+
)
795+
== 1
796+
)
797+
assert (
798+
len(
799+
DjangoSession.objects.filter(
800+
session_key=to_be_kept_2.login_session.session_key
801+
)
802+
)
803+
== 1
804+
)
805+
assert (
806+
len(
807+
DjangoSession.objects.filter(
808+
session_key=to_be_kept_3.login_session.session_key
809+
)
810+
)
811+
== 1
812+
)
813+
814+
self.request.user = user
815+
self.mixin_instance.login_owner(owner, self.request, HttpResponse())
816+
owner.refresh_from_db()
817+
818+
new_login_session = Session.objects.filter(name=None)
819+
820+
assert len(new_login_session) == 1
821+
assert len(Session.objects.filter(name="to_be_deleted").all()) == 0
822+
assert len(Session.objects.filter(name="to_be_kept").all()) == 3
823+
824+
assert (
825+
len(DjangoSession.objects.filter(session_key=to_be_deleted_1_session_key))
826+
== 0
827+
)
828+
assert (
829+
len(
830+
DjangoSession.objects.filter(
831+
session_key=to_be_kept_1.login_session.session_key
832+
)
833+
)
834+
== 1
835+
)
836+
assert (
837+
len(
838+
DjangoSession.objects.filter(
839+
session_key=to_be_kept_2.login_session.session_key
840+
)
841+
)
842+
== 1
843+
)
844+
assert (
845+
len(
846+
DjangoSession.objects.filter(
847+
session_key=to_be_kept_3.login_session.session_key
848+
)
849+
)
850+
== 1
851+
)

codecov_auth/views/base.py

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
import re
33
import uuid
44
from functools import reduce
5+
from typing import Any
56
from urllib.parse import parse_qs, urlencode, urlparse
67

78
from django.conf import settings
89
from django.contrib.auth import login, logout
910
from django.contrib.sessions.models import Session as DjangoSession
1011
from django.core.exceptions import PermissionDenied
12+
from django.db import transaction
1113
from django.http.request import HttpRequest
1214
from django.http.response import HttpResponse
1315
from django.utils import timezone
16+
from django.utils.timezone import now
1417
from shared.encryption.token import encode_token
1518
from shared.license import LICENSE_ERRORS_MESSAGES, get_current_license
1619

@@ -59,7 +62,7 @@ class StateMixin(object):
5962
6063
"""
6164

62-
def __init__(self, *args, **kwargs):
65+
def __init__(self, *args: Any, **kwargs: Any) -> None:
6366
self.redis = get_redis_connection()
6467
super().__init__(*args, **kwargs)
6568

@@ -69,7 +72,7 @@ def _session_key(self) -> str:
6972
def _get_key_redis(self, state: str) -> str:
7073
return f"oauth-state-{state}"
7174

72-
def _is_matching_cors_domains(self, url_domain) -> bool:
75+
def _is_matching_cors_domains(self, url_domain: str) -> bool:
7376
# make sure the domain is part of the CORS so that's a safe domain to
7477
# redirect to.
7578
if url_domain in settings.CORS_ALLOWED_ORIGINS:
@@ -79,7 +82,7 @@ def _is_matching_cors_domains(self, url_domain) -> bool:
7982
return True
8083
return False
8184

82-
def _is_valid_redirection(self, to) -> bool:
85+
def _is_valid_redirection(self, to: str) -> bool:
8386
# make sure the redirect url is from a domain we own
8487
try:
8588
url = urlparse(to)
@@ -115,11 +118,11 @@ def generate_state(self) -> str:
115118

116119
return state
117120

118-
def verify_state(self, state) -> bool:
121+
def verify_state(self, state: str) -> bool:
119122
state_from_session = self.request.session.get(self._session_key(), None)
120123
return state_from_session and state == state_from_session
121124

122-
def get_redirection_url_from_state(self, state) -> (str, bool):
125+
def get_redirection_url_from_state(self, state: str) -> tuple[str, bool]:
123126
cached_url = self.redis.get(self._get_key_redis(state))
124127

125128
if not cached_url:
@@ -149,7 +152,7 @@ def get_redirection_url_from_state(self, state) -> (str, bool):
149152
# Return the final redirect URL to complete the login.
150153
return (cached_url.decode("utf-8"), True)
151154

152-
def remove_state(self, state, delay=0) -> None:
155+
def remove_state(self, state: str, delay: int = 0) -> None:
153156
redirection_url, _ = self.get_redirection_url_from_state(state)
154157
if delay == 0:
155158
self.redis.delete(self._get_key_redis(state))
@@ -182,15 +185,17 @@ def modify_redirection_url_based_on_default_user_org(
182185
url += f"/{owner_profile.default_org.username}"
183186
return url
184187

185-
def get_or_create_org(self, single_organization):
188+
def get_or_create_org(self, single_organization: dict) -> Owner:
186189
owner, was_created = Owner.objects.get_or_create(
187190
service=self.service,
188191
service_id=single_organization["id"],
189192
defaults={"createstamp": timezone.now()},
190193
)
191194
return owner
192195

193-
def login_owner(self, owner: Owner, request: HttpRequest, response: HttpResponse):
196+
def login_owner(
197+
self, owner: Owner, request: HttpRequest, response: HttpResponse
198+
) -> None:
194199
# if there's a currently authenticated user
195200
if request.user is not None and not request.user.is_anonymous:
196201
if owner.user is None:
@@ -253,9 +258,11 @@ def login_owner(self, owner: Owner, request: HttpRequest, response: HttpResponse
253258

254259
request.session["current_owner_id"] = owner.pk
255260
RefreshService().trigger_refresh(owner.ownerid, owner.username)
261+
262+
self.delete_expired_sessions_and_django_sessions(owner)
256263
self.store_login_session(owner)
257264

258-
def get_and_modify_owner(self, user_dict, request) -> Owner:
265+
def get_and_modify_owner(self, user_dict: dict, request: HttpRequest) -> Owner:
259266
user_orgs = user_dict["orgs"]
260267
formatted_orgs = [
261268
dict(username=org["username"], id=str(org["id"])) for org in user_orgs
@@ -298,7 +305,9 @@ def get_and_modify_owner(self, user_dict, request) -> Owner:
298305

299306
return owner
300307

301-
def _check_enterprise_organizations_membership(self, user_dict, orgs):
308+
def _check_enterprise_organizations_membership(
309+
self, user_dict: dict, orgs: list[dict]
310+
) -> None:
302311
"""Checks if a user belongs to the restricted organizations (or teams if GitHub) allowed in settings."""
303312
if settings.IS_ENTERPRISE and get_config(self.service, "organizations"):
304313
orgs_in_settings = set(get_config(self.service, "organizations"))
@@ -315,7 +324,7 @@ def _check_enterprise_organizations_membership(self, user_dict, orgs):
315324
"You must be a member of an allowed team in your organization."
316325
)
317326

318-
def _check_user_count_limitations(self, login_data):
327+
def _check_user_count_limitations(self, login_data: dict) -> None:
319328
if not settings.IS_ENTERPRISE:
320329
return
321330
license = get_current_license()
@@ -339,7 +348,7 @@ def _check_user_count_limitations(self, login_data):
339348
owners_with_activated_users = Owner.objects.exclude(
340349
plan_activated_users__len=0
341350
).exclude(plan_activated_users__isnull=True)
342-
all_distinct_actiaved_users = reduce(
351+
all_distinct_actiaved_users: set[str] = reduce(
343352
lambda acc, curr: set(curr.plan_activated_users) | acc,
344353
owners_with_activated_users,
345354
set(),
@@ -357,7 +366,9 @@ def _check_user_count_limitations(self, login_data):
357366
if users_on_service_count > license.number_allowed_users:
358367
raise PermissionDenied(LICENSE_ERRORS_MESSAGES["users-exceeded"])
359368

360-
def _get_or_create_owner(self, user_dict, request):
369+
def _get_or_create_owner(
370+
self, user_dict: dict, request: HttpRequest
371+
) -> tuple[Owner, bool]:
361372
fields_to_update = ["oauth_token", "private_access", "updatestamp"]
362373
login_data = user_dict["user"]
363374
owner, was_created = Owner.objects.get_or_create(
@@ -403,7 +414,7 @@ def _get_utm_params(self, params: dict) -> dict:
403414
# remove None values from the dict
404415
return {k: v for k, v in filtered_params.items() if v is not None}
405416

406-
def store_to_cookie_utm_tags(self, response) -> None:
417+
def store_to_cookie_utm_tags(self, response: HttpResponse) -> None:
407418
if not settings.IS_ENTERPRISE:
408419
data = urlencode(self._get_utm_params(self.request.GET))
409420
response.set_cookie(
@@ -423,7 +434,7 @@ def retrieve_marketing_tags_from_cookie(self) -> dict:
423434
else:
424435
return {}
425436

426-
def store_login_session(self, owner: Owner):
437+
def store_login_session(self, owner: Owner) -> None:
427438
# Store user's login session info after logging in
428439
http_x_forwarded_for = self.request.META.get("HTTP_X_FORWARDED_FOR")
429440
if http_x_forwarded_for:
@@ -443,3 +454,26 @@ def store_login_session(self, owner: Owner):
443454
type=Session.SessionType.LOGIN,
444455
owner=owner,
445456
)
457+
458+
def delete_expired_sessions_and_django_sessions(self, owner: Owner) -> None:
459+
"""
460+
This function deletes expired login sessions for a given owner
461+
"""
462+
with transaction.atomic():
463+
# Get the primary keys of expired DjangoSessions for the given owner
464+
expired_sessions = Session.objects.filter(
465+
owner=owner,
466+
type="login",
467+
login_session__isnull=False,
468+
login_session__expire_date__lt=now(),
469+
)
470+
471+
# Delete the rows in the Session table using sessionid
472+
Session.objects.filter(
473+
sessionid__in=[es.sessionid for es in expired_sessions]
474+
).delete()
475+
476+
# Delete the rows in the DjangoSession table using the extracted keys
477+
DjangoSession.objects.filter(
478+
session_key__in=[es.login_session for es in expired_sessions]
479+
).delete()

0 commit comments

Comments
 (0)