Repository: learningequality/kolibri

Issue:
It is possible to hijack an account which was created via OSUser
## Observed behavior

Using the new `get_os_user` feature in `kolibri.plugins.app.utils.interface`, it is possible to create a desktop Kolibri app which automatically signs in as a Kolibri user that is associated with an OS user. When this happens, Kolibri creates a regular user account, and with a correct authentication token, Kolibri signs in to the user account automatically when the user is using the app.

However, if a different user chooses this account from the sign in screen, that user is asked to create a new password for the account:

![image](https://github.com/learningequality/kolibri/assets/132063/b6e95bf2-5549-406e-bf87-70ad03a81602)

Instead, if an account is associated with an `OSUser` and has no password, Kolibri should _require_ a valid authentication token.

## User-facing consequences

This affects desktop Kolibri apps deployed on multi-user systems, such as [kolibri-gnome](https://github.com/learningequality/kolibri-installer-gnome) on Endless OS. That app is the only one which meets that definition, and its Kolibri 0.16 update is not yet released. This issue is a blocker for doing so.

## Context

 * Kolibri version: Kolibri 0.16.1
 * Operating system: Fedora Linux
 * Browser: learningequality/kolibri-installer-gnome#99



Discussion:

Resolving patch:
diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py
--- a/kolibri/core/auth/api.py
+++ b/kolibri/core/auth/api.py
@@ -854,7 +854,7 @@ def post(self, request):
         except (ValueError, ObjectDoesNotExist):
             raise Http404(error_message)
 
-        if user.password != NOT_SPECIFIED:
+        if user.password != NOT_SPECIFIED or hasattr(user, "os_user"):
             raise Http404(error_message)
 
         user.set_password(password)
@@ -930,10 +930,14 @@ def create(self, request):
             unauthenticated_user = FacilityUser.objects.filter(
                 username__exact=username, facility=facility_id
             ).first()
-        if unauthenticated_user.password == NOT_SPECIFIED:
+        if unauthenticated_user.password == NOT_SPECIFIED and not hasattr(
+            unauthenticated_user, "os_user"
+        ):
             # Here - we have a Learner whose password is "NOT_SPECIFIED" because they were created
             # while the "Require learners to log in with password" setting was disabled - but now
             # it is enabled again.
+            # Alternatively, they may have been created as an OSUser for automatic login with an
+            # authentication token. If this is the case, then we do not allow for the password to be set.
             return Response(
                 [
                     {


Test patch:
diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py
--- a/kolibri/core/auth/test/test_api.py
+++ b/kolibri/core/auth/test/test_api.py
@@ -30,6 +30,7 @@
 from kolibri.core import error_constants
 from kolibri.core.auth.backends import FACILITY_CREDENTIAL_KEY
 from kolibri.core.auth.constants import demographics
+from kolibri.core.device.models import OSUser
 from kolibri.core.device.utils import set_device_settings
 
 # A weird hack because of http://bugs.python.org/issue17866
@@ -1309,6 +1310,40 @@ def test_case_sensitive_matching_usernames(self):
 
         self.assertEqual(response_user3.status_code, 401)
 
+    def test_not_specified_password(self):
+        self.user.password = demographics.NOT_SPECIFIED
+        self.user.save()
+
+        response = self.client.post(
+            reverse("kolibri:core:session-list"),
+            data={
+                "username": self.user.username,
+                "facility": self.facility.id,
+            },
+            format="json",
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.data[0]["id"], error_constants.PASSWORD_NOT_SPECIFIED)
+
+    def test_not_specified_password_os_user(self):
+        self.user.password = demographics.NOT_SPECIFIED
+        self.user.save()
+
+        OSUser.objects.create(user=self.user, os_username="os_user")
+
+        response = self.client.post(
+            reverse("kolibri:core:session-list"),
+            data={
+                "username": self.user.username,
+                "facility": self.facility.id,
+            },
+            format="json",
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.data[0]["id"], error_constants.MISSING_PASSWORD)
+
 
 class SignUpBase(object):
     @classmethod
@@ -1982,3 +2017,118 @@ def test_check_duplicate_username_with_existing_username_other_facility(self):
             format="json",
         )
         self.assertEqual(response.data, True)
+
+
+class SetNonSpecifiedPasswordViewTestCase(APITestCase):
+    def setUp(self):
+        self.url = reverse("kolibri:core:setnonspecifiedpassword")
+        self.facility = FacilityFactory.create()
+        self.user = models.FacilityUser.objects.create(
+            username="testuser",
+            facility=self.facility,
+            password=demographics.NOT_SPECIFIED,
+        )
+
+    def test_set_non_specified_password(self):
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "password": "newpassword",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 200 OK status code
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        # Refresh the user object from the database
+        self.user.refresh_from_db()
+
+        # Check that the password has been updated
+        self.assertTrue(self.user.check_password("newpassword"))
+
+    def test_set_non_specified_password_invalid_facility(self):
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "password": "newpassword",
+            "facility": uuid.uuid4().hex,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 404 Not Found status code
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_set_non_specified_password_missing_facility(self):
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "password": "newpassword",
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 400 Bad Request status code
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def test_set_non_specified_password_invalid_username(self):
+        # Make a POST request to set the password
+        data = {
+            "username": "invalidusername",
+            "password": "newpassword",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 404 Not Found status code
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_set_non_specified_password_missing_username(self):
+        # Make a POST request to set the password
+        data = {
+            "password": "newpassword",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 400 Bad Request status code
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def test_set_non_specified_password_missing_password(self):
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 400 Bad Request status code
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def test_set_non_specified_password_password_is_specified(self):
+        self.user.set_password("password")
+        self.user.save()
+
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "password": "newpassword",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 404 Not Found status code
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_set_non_specified_password_user_is_os_user(self):
+        OSUser.objects.create(user=self.user, os_username="osuser")
+
+        # Make a POST request to set the password
+        data = {
+            "username": "testuser",
+            "password": "newpassword",
+            "facility": self.facility.id,
+        }
+        response = self.client.post(self.url, data)
+
+        # Check that the response has a 400 Bad Request status code
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)