Skip to content

Commit

Permalink
Disallow password login via crafted requests
Browse files Browse the repository at this point in the history
  • Loading branch information
richardebeling committed Apr 22, 2024
1 parent 0de2de4 commit 163d2a7
Show file tree
Hide file tree
Showing 6 changed files with 69 additions and 21 deletions.
6 changes: 5 additions & 1 deletion evap/evaluation/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from mozilla_django_oidc.auth import OIDCAuthenticationBackend

from evap.evaluation.models import UserProfile
from evap.evaluation.tools import clean_email
from evap.evaluation.tools import clean_email, openid_login_is_active, password_login_is_active
from evap.rewards.tools import can_reward_points_be_used_by


Expand Down Expand Up @@ -36,6 +36,7 @@ def authenticate(self, request, key): # pylint: disable=arguments-differ
class EmailAuthenticationBackend(ModelBackend):
# https://docs.djangoproject.com/en/3.1/topics/auth/customizing/#writing-an-authentication-backend
def authenticate(self, request, email=None, password=None): # pylint: disable=arguments-differ,arguments-renamed
assert password_login_is_active()
try:
user = UserProfile.objects.get(email=email)
except UserProfile.DoesNotExist:
Expand Down Expand Up @@ -133,6 +134,7 @@ def reward_user_required(user):
# see https://mozilla-django-oidc.readthedocs.io/en/stable/
class OpenIDAuthenticationBackend(OIDCAuthenticationBackend):
def filter_users_by_claims(self, claims):
assert openid_login_is_active()
email = claims.get("email")
if not email:
return []
Expand All @@ -143,13 +145,15 @@ def filter_users_by_claims(self, claims):
return []

def create_user(self, claims):
assert openid_login_is_active()
return self.UserModel.objects.create(
email=claims.get("email"),
first_name_given=claims.get("given_name", ""),
last_name=claims.get("family_name", ""),
)

def update_user(self, user, claims):
assert openid_login_is_active()
if not user.first_name_given:
user.first_name_given = claims.get("given_name", "")
user.save()
Expand Down
6 changes: 6 additions & 0 deletions evap/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
date_to_datetime,
is_external_email,
is_prefetched,
password_login_is_active,
translate,
vote_end_datetime,
)
Expand Down Expand Up @@ -1735,6 +1736,11 @@ class Meta:
def save(self, *args, **kwargs):
# This is not guaranteed to be called on every insert. For example, the importers use bulk insertion.

if self.has_usable_password and not password_login_is_active():
# We don't want this to happen, but if it happens, it shouldn't be a showstopper since password login
# isn't possible anyway -> if triggered, debug how the situation came to be, and prevent that
logger.warning("User %s has a usable password set while password login is disabled", self)

self.email = clean_email(self.email)
super().save(*args, **kwargs)

Expand Down
49 changes: 43 additions & 6 deletions evap/evaluation/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from unittest.mock import patch

from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import AnonymousUser, Group
from django.core import mail
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
Expand All @@ -20,6 +21,7 @@
@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"])
class LoginTests(WebTest):
csrf_checks = False
url = reverse("evaluation:index")

@classmethod
def setUpTestData(cls):
Expand Down Expand Up @@ -60,6 +62,7 @@ def test_login_url_works(self):
self.assertContains(page, self.external_user.full_name)

page = self.app.post(url_with_key).follow().follow()
self.assertEqual(page.context["user"], self.external_user)
self.assertContains(page, "Logout")
self.assertContains(page, self.external_user.full_name)

Expand All @@ -69,9 +72,11 @@ def test_login_key_valid_only_once(self):

url_with_key = reverse("evaluation:login_key_authentication", args=[self.external_user.login_key])
page = self.app.post(url_with_key).follow().follow()
self.assertEqual(page.context["user"], self.external_user)
self.assertContains(page, "Logout")

page = page.forms["logout-form"].submit().follow()
self.assertIsInstance(page.context["user"], AnonymousUser)
self.assertNotContains(page, "Logout")

page = self.app.get(url_with_key).follow()
Expand All @@ -87,11 +92,12 @@ def test_inactive_external_users_can_not_login(self):
reverse("evaluation:login_key_authentication", args=[self.inactive_external_user.login_key])
).follow()
self.assertContains(page, "Inactive users are not allowed to login")
self.assertIsInstance(page.context["user"], AnonymousUser)
self.assertNotContains(page, "Logout")

def test_login_key_resend_if_still_valid(self):
old_key = self.external_user.login_key
page = self.app.post("/", params={"submit_type": "new_key", "email": self.external_user.email}).follow()
page = self.app.post(self.url, params={"submit_type": "new_key", "email": self.external_user.email}).follow()
new_key = UserProfile.objects.get(id=self.external_user.id).login_key

self.assertEqual(old_key, new_key)
Expand All @@ -104,9 +110,9 @@ def test_login_key_resend_if_still_valid(self):
)
def test_oidc_login(self):
# This should send them to /oidc/authenticate
page = self.app.get("/").click("Login")
page = self.app.get(self.url).click("Login")

# which should then redirect them to OIDC_OP_AUTHORIZTATION_ENDPOINT
# which should then redirect them to OIDC_OP_AUTHORIZATION_ENDPOINT
location = page.headers["location"]
self.assertIn(settings.OIDC_OP_AUTHORIZATION_ENDPOINT, location)

Expand All @@ -128,15 +134,46 @@ def test_oidc_login(self):
# Thus, at this point, the user should be logged in and be redirected back to the start page.
location = page.headers["location"]
parse_result = urllib.parse.urlparse(location)
self.assertEqual(parse_result.path, "/")
self.assertEqual(parse_result.path, self.url)

page = self.app.get(location)
# A GET here should then redirect to the users real start page.
# This should be a 403 since the user is external and has no course participation
page = page.follow(status=403)

# user should see the Logout button then.
self.assertIn("Logout", page.body.decode())
self.assertEqual(page.context["user"], user)

@override_settings(INSTITUTION_EMAIL_DOMAINS=["example.com"])
def test_passworduser_login(self):
"""Tests whether a user can login with an incorrect and a correct password."""
user = baker.make(UserProfile, email="user@example.com", password=make_password("evap"))

# wrong password
with override_settings(ACTIVATE_OPEN_ID_LOGIN=False):
page = self.app.get(self.url)
password_form = page.forms["email-login-form"]
password_form["email"] = user.email
password_form["password"] = "asd" # nosec
response = password_form.submit()
self.assertIsInstance(response.context["user"], AnonymousUser)
self.assertNotContains(response, "Logout")

# correct password while password login is disabled
with override_settings(ACTIVATE_OPEN_ID_LOGIN=True):
self.assertFalse(auth.password_login_is_active())

password_form["password"] = "evap" # nosec
response = password_form.submit(status=400)
self.assertFalse(response.context["user"])

# correct password while password login is enabled
with override_settings(ACTIVATE_OPEN_ID_LOGIN=False):
self.assertTrue(auth.password_login_is_active())

response = password_form.submit(status=302).follow().follow()
self.assertEqual(response.context["user"], user)
self.assertContains(response, "Logout")


@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"])
Expand Down
12 changes: 0 additions & 12 deletions evap/evaluation/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,6 @@ def render_pages(self):
class TestIndexView(WebTest):
url = "/"

@override_settings(ACTIVATE_OPEN_ID_LOGIN=False)
def test_passworduser_login(self):
"""Tests whether a user can login with an incorrect and a correct password."""
baker.make(UserProfile, email="password.user", password=make_password("evap"))
response = self.app.get(self.url)
password_form = response.forms["email-login-form"]
password_form["email"] = "password.user"
password_form["password"] = "asd" # nosec
password_form.submit()
password_form["password"] = "evap" # nosec
password_form.submit(status=302)

@override_settings(ACTIVATE_OPEN_ID_LOGIN=False)
def test_login_for_staff_users_correctly_redirects(self):
"""Regression test for #1523: Access denied on manager login"""
Expand Down
8 changes: 8 additions & 0 deletions evap/evaluation/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
CV = TypeVar("CV", bound=CellValue)


def openid_login_is_active() -> bool:
return settings.ACTIVATE_OPEN_ID_LOGIN


def password_login_is_active() -> bool:
return not openid_login_is_active()


def unordered_groupby(key_value_pairs: Iterable[tuple[Key, Value]]) -> dict[Key, list[Value]]:
"""
We need this in several places: Take list of (key, value) pairs and make
Expand Down
9 changes: 7 additions & 2 deletions evap/evaluation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from evap.evaluation.forms import LoginEmailForm, NewKeyForm, NotebookForm, ProfileForm
from evap.evaluation.models import EmailTemplate, FaqSection, Semester, UserProfile
from evap.evaluation.tools import HttpResponseNoContent
from evap.evaluation.tools import HttpResponseNoContent, openid_login_is_active, password_login_is_active
from evap.middleware import no_login_required

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,6 +61,10 @@ def index(request):
# parse the form data into the respective form
submit_type = request.POST.get("submit_type", "no_submit")
new_key_form = NewKeyForm(request.POST if submit_type == "new_key" else None)

if submit_type == "login_email" and not password_login_is_active():
raise SuspiciousOperation

login_email_form = LoginEmailForm(request, request.POST if submit_type == "login_email" else None)

# process form data
Expand All @@ -77,6 +81,7 @@ def index(request):
return redirect("evaluation:index")

if login_email_form.is_valid():
assert password_login_is_active()
# user would like to login with email and password and passed password test
auth.login(request, login_email_form.get_user())

Expand All @@ -97,7 +102,7 @@ def index(request):
template_data = {
"new_key_form": new_key_form,
"login_email_form": login_email_form,
"openid_active": settings.ACTIVATE_OPEN_ID_LOGIN,
"openid_active": openid_login_is_active(),
}
return render(request, "index.html", template_data)

Expand Down

0 comments on commit 163d2a7

Please sign in to comment.