Repository: Screenly/Anthias
Pull request #2291: api: update `PATCH /api/v2/device_settings` to handle auth
Author: login: nicomiguelino
Labels: name: api
description: Changes to REST API
Created: 2025-05-21T00:54:50Z | Merged: 2025-05-21T04:30:18Z

### Description

- Updated `PATCH /api/v2/device_settings`

### Checklist

- [x] I have performed a self-review of my own code.
- [ ] New and existing unit tests pass locally and on CI with my changes.
- [x] I have done an end-to-end test for Raspberry Pi devices.
- [x] I have tested my changes for x86 devices.
- [x] I added a documentation for the changes I have made (when necessary).



Diff:
diff --git a/api/serializers/v2.py b/api/serializers/v2.py
index 5f325b1d3..7bdd06bc6 100644
--- a/api/serializers/v2.py
+++ b/api/serializers/v2.py
@@ -97,6 +97,11 @@ class UpdateDeviceSettingsSerializerV2(Serializer):
     shuffle_playlist = BooleanField(required=False)
     use_24_hour_clock = BooleanField(required=False)
     debug_logging = BooleanField(required=False)
+    username = CharField(required=False, allow_blank=True)
+    password = CharField(required=False, allow_blank=True)
+    password_2 = CharField(required=False, allow_blank=True)
+    auth_backend = CharField(required=False, allow_blank=True)
+    current_password = CharField(required=False)
 
 
 class IntegrationsSerializerV2(Serializer):
diff --git a/api/tests/test_v2_endpoints.py b/api/tests/test_v2_endpoints.py
index d50541fa5..756e8c608 100644
--- a/api/tests/test_v2_endpoints.py
+++ b/api/tests/test_v2_endpoints.py
@@ -1,6 +1,7 @@
 """
 Tests for V2 API endpoints.
 """
+import hashlib
 from unittest import mock
 from unittest.mock import patch
 
@@ -69,7 +70,7 @@ def test_patch_device_settings_success(
             'default_duration': '10',
             'default_streaming_duration': '50',
             'date_format': 'DD-MM-YYYY',
-            'auth_backend': 'none',
+            'auth_backend': '',
             'show_splash': False,
             'default_assets': [],
             'shuffle_playlist': True,
@@ -107,7 +108,7 @@ def test_patch_device_settings_success(
         settings_mock.load.assert_called_once()
         settings_mock.save.assert_called_once()
         self.assertEqual(
-            settings_mock.__setitem__.call_count, 4
+            settings_mock.__setitem__.call_count, 5
         )  # One for each field in data
 
         # Verify publisher was called
@@ -136,6 +137,149 @@ def test_patch_device_settings_validation_error(self, settings_mock):
         settings_mock.load.assert_not_called()
         settings_mock.save.assert_not_called()
 
+    @mock.patch('api.views.v2.settings')
+    @mock.patch('api.views.v2.ZmqPublisher')
+    def test_enable_basic_auth(self, publisher_mock, settings_mock):
+        # Mock settings methods
+        settings_mock.load = mock.MagicMock()
+        settings_mock.save = mock.MagicMock()
+        settings_mock.__getitem__.side_effect = lambda key: {
+            'player_name': 'Test Player',
+            'auth_backend': '',
+            'user': '',
+            'password': '',
+            'audio_output': 'local',
+            'default_duration': '10',
+            'default_streaming_duration': '50',
+            'date_format': 'DD-MM-YYYY',
+            'show_splash': False,
+            'default_assets': [],
+            'shuffle_playlist': True,
+            'use_24_hour_clock': False,
+            'debug_logging': True,
+        }[key]
+        settings_mock.__setitem__ = mock.MagicMock()
+
+        # Mock auth backends
+        auth_basic_mock = mock.MagicMock()
+        auth_basic_mock.name = 'auth_basic'
+        auth_basic_mock.check_password.return_value = True
+
+        auth_none_mock = mock.MagicMock()
+        auth_none_mock.name = ''
+        auth_none_mock.check_password.return_value = True
+
+        settings_mock.auth_backends = {
+            'auth_basic': auth_basic_mock,
+            '': auth_none_mock,
+        }
+        settings_mock.auth = auth_none_mock
+
+        # Mock publisher
+        publisher_instance = mock.MagicMock()
+        publisher_mock.get_instance.return_value = publisher_instance
+
+        # Test data - enable basic auth
+        data = {
+            'auth_backend': 'auth_basic',
+            'username': 'testuser',
+            'password': 'testpass',
+            'password_2': 'testpass',
+        }
+
+        # Calculate expected hashed password
+        expected_hashed_password = hashlib.sha256(
+            'testpass'.encode('utf-8')).hexdigest()
+
+        response = self.client.patch(
+            self.device_settings_url,
+            data=data,
+            format='json'
+        )
+
+        # Check response
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data['message'],
+            'Settings were successfully saved.'
+        )
+
+        # Verify settings were updated
+        settings_mock.load.assert_called_once()
+        settings_mock.save.assert_called_once()
+        settings_mock.__setitem__.assert_any_call('auth_backend', 'auth_basic')
+        settings_mock.__setitem__.assert_any_call('user', 'testuser')
+        settings_mock.__setitem__.assert_any_call(
+            'password', expected_hashed_password)
+
+        # Verify publisher was called
+        publisher_instance.send_to_viewer.assert_called_once_with('reload')
+
+    @mock.patch('api.views.v2.settings')
+    @mock.patch('api.views.v2.ZmqPublisher')
+    def test_disable_basic_auth(self, publisher_mock, settings_mock):
+        # Mock settings methods
+        settings_mock.load = mock.MagicMock()
+        settings_mock.save = mock.MagicMock()
+        settings_mock.__getitem__.side_effect = lambda key: {
+            'player_name': 'Test Player',
+            'auth_backend': 'auth_basic',
+            'user': 'testuser',
+            'password': 'testpass',
+            'audio_output': 'hdmi',
+            'default_duration': '15',
+            'default_streaming_duration': '100',
+            'date_format': 'YYYY-MM-DD',
+            'show_splash': True,
+            'default_assets': [],
+            'shuffle_playlist': False,
+            'use_24_hour_clock': True,
+            'debug_logging': False,
+        }[key]
+        settings_mock.__setitem__ = mock.MagicMock()
+        settings_mock.auth_backends = {
+            'auth_basic': mock.MagicMock(),
+            '': mock.MagicMock(),
+        }
+        settings_mock.auth = mock.MagicMock()
+        settings_mock.auth.check_password.return_value = True
+
+        # Mock publisher
+        publisher_instance = mock.MagicMock()
+        publisher_mock.get_instance.return_value = publisher_instance
+
+        # Test data - disable auth
+        data = {
+            'auth_backend': '',
+            'current_password': 'testpass',
+        }
+
+        response = self.client.patch(
+            self.device_settings_url,
+            data=data,
+            format='json'
+        )
+
+        # Check response
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data['message'],
+            'Settings were successfully saved.'
+        )
+
+        # Verify settings were updated
+        settings_mock.load.assert_called_once()
+        settings_mock.save.assert_called_once()
+        settings_mock.__setitem__.assert_any_call('auth_backend', '')
+
+        # Verify publisher was called
+        publisher_instance.send_to_viewer.assert_called_once_with('reload')
+
+        # Test that authentication is now disabled
+        # Request should succeed without auth
+        response = self.client.get(self.device_settings_url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
 
 class TestIntegrationsViewV2(TestCase):
     def setUp(self):
diff --git a/api/views/v2.py b/api/views/v2.py
index 41559da0c..aa78a0321 100644
--- a/api/views/v2.py
+++ b/api/views/v2.py
@@ -1,3 +1,4 @@
+import hashlib
 from datetime import timedelta
 from os import getenv, statvfs
 from platform import machine
@@ -215,6 +216,54 @@ def get(self, request):
             'debug_logging': settings['debug_logging'],
         })
 
+    def update_auth_settings(self, data, auth_backend, current_pass_correct):
+        if auth_backend == '':
+            return
+
+        if auth_backend != 'auth_basic':
+            return
+
+        new_user = data.get('username', '')
+        new_pass = data.get('password', '').encode('utf-8')
+        new_pass2 = data.get('password_2', '').encode('utf-8')
+        new_pass = hashlib.sha256(new_pass).hexdigest() if new_pass else None
+        new_pass2 = hashlib.sha256(new_pass2).hexdigest() if new_pass else None
+
+        if settings['password']:
+            if new_user != settings['user']:
+                if current_pass_correct is None:
+                    raise ValueError(
+                        "Must supply current password to change username"
+                    )
+                if not current_pass_correct:
+                    raise ValueError("Incorrect current password.")
+
+                settings['user'] = new_user
+
+            if new_pass:
+                if current_pass_correct is None:
+                    raise ValueError(
+                        "Must supply current password to change password"
+                    )
+                if not current_pass_correct:
+                    raise ValueError("Incorrect current password.")
+
+                if new_pass2 != new_pass:
+                    raise ValueError("New passwords do not match!")
+
+                settings['password'] = new_pass
+
+        else:
+            if new_user:
+                if new_pass and new_pass != new_pass2:
+                    raise ValueError("New passwords do not match!")
+                if not new_pass:
+                    raise ValueError("Must provide password")
+                settings['user'] = new_user
+                settings['password'] = new_pass
+            else:
+                raise ValueError("Must provide username")
+
     @extend_schema(
         summary='Update device settings',
         request=UpdateDeviceSettingsSerializerV2,
@@ -241,6 +290,36 @@ def patch(self, request):
             data = serializer.validated_data
             settings.load()
 
+            current_password = data.get('current_password', '')
+            auth_backend = data.get('auth_backend', '')
+
+            if (
+                auth_backend != settings['auth_backend']
+                and settings['auth_backend']
+            ):
+                if not current_password:
+                    raise ValueError(
+                        "Must supply current password to change "
+                        "authentication method"
+                    )
+                if not settings.auth.check_password(current_password):
+                    raise ValueError("Incorrect current password.")
+
+            prev_auth_backend = settings['auth_backend']
+            if not current_password and prev_auth_backend:
+                current_pass_correct = None
+            else:
+                current_pass_correct = (
+                    settings
+                    .auth_backends[prev_auth_backend]
+                    .check_password(current_password)
+                )
+            next_auth_backend = settings.auth_backends[auth_backend]
+
+            self.update_auth_settings(
+                data, next_auth_backend.name, current_pass_correct)
+            settings['auth_backend'] = auth_backend
+
             # Update settings
             if 'player_name' in data:
                 settings['player_name'] = data['player_name']
@@ -270,9 +349,9 @@ def patch(self, request):
             publisher.send_to_viewer('reload')
 
             return Response({'message': 'Settings were successfully saved.'})
-        except Exception:
+        except Exception as e:
             return Response(
-                {'error': 'An error occurred while saving settings.'},
+                {'error': f'An error occurred while saving settings: {e}'},
                 status=400
             )
 


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-21T00:56:11Z

Review comments:
diff_hunk: @@ -215,6 +216,54 @@
             'debug_logging': settings['debug_logging'],
         })
 
+    def update_auth_settings(self, data, auth_backend, current_pass_correct):
+        if auth_backend == '':
+            return
+
+        if auth_backend != 'auth_basic':
+            return
+
+        new_user = data.get('username', '')
+        new_pass = data.get('password', '').encode('utf-8')
+        new_pass2 = data.get('password_2', '').encode('utf-8')
+        new_pass = hashlib.sha256(new_pass).hexdigest() if new_pass else None
path: api/views/v2.py
user: login: github-advanced-security[bot]
body: ## Use of a broken or weak cryptographic hashing algorithm on sensitive data

[Sensitive data (password)](1) is used in a hashing algorithm (SHA256) that is insecure for password hashing, since it is not a computationally expensive hash function.

[Show more details](https://github.com/Screenly/Anthias/security/code-scanning/49)
created_at: 2025-05-21T00:56:11Z
author_association: NONE
diff_hunk: @@ -215,6 +216,54 @@
             'debug_logging': settings['debug_logging'],
         })
 
+    def update_auth_settings(self, data, auth_backend, current_pass_correct):
+        if auth_backend == '':
+            return
+
+        if auth_backend != 'auth_basic':
+            return
+
+        new_user = data.get('username', '')
+        new_pass = data.get('password', '').encode('utf-8')
+        new_pass2 = data.get('password_2', '').encode('utf-8')
+        new_pass = hashlib.sha256(new_pass).hexdigest() if new_pass else None
+        new_pass2 = hashlib.sha256(new_pass2).hexdigest() if new_pass else None
path: api/views/v2.py
user: login: github-advanced-security[bot]
body: ## Use of a broken or weak cryptographic hashing algorithm on sensitive data

[Sensitive data (password)](1) is used in a hashing algorithm (SHA256) that is insecure for password hashing, since it is not a computationally expensive hash function.

[Show more details](https://github.com/Screenly/Anthias/security/code-scanning/50)
created_at: 2025-05-21T00:56:11Z
author_association: NONE
diff_hunk: @@ -270,9 +349,9 @@
             publisher.send_to_viewer('reload')
 
             return Response({'message': 'Settings were successfully saved.'})
-        except Exception:
+        except Exception as e:
             return Response(
-                {'error': 'An error occurred while saving settings.'},
+                {'error': f'An error occurred while saving settings: {e}'},
path: api/views/v2.py
user: login: github-advanced-security[bot]
body: ## Information exposure through an exception

[Stack trace information](1) flows to this location and may be exposed to an external user.

[Show more details](https://github.com/Screenly/Anthias/security/code-scanning/48)
created_at: 2025-05-21T00:56:11Z
author_association: NONE

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-21T04:21:32Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Screenly_screenly-ose&pullRequest=2291) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [3 New issues](https://sonarcloud.io/project/issues?id=Screenly_screenly-ose&pullRequest=2291&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted.svg '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Screenly_screenly-ose&pullRequest=2291&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Screenly_screenly-ose&pullRequest=2291&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data.svg '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed.svg '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Screenly_screenly-ose&pullRequest=2291&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Screenly_screenly-ose&pullRequest=2291)


author_association: NONE