Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions client-v3/e2e/tests/03-system-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,50 @@ test('changing a setting enables the Submit and Reset buttons', async () => {
}
});

test('Security category is visible in the settings page', async () => {
await expect(page.locator('.card-header:has-text("Security")')).toBeVisible({ timeout: 5_000 });
});

test('Security category can be expanded to reveal JWT Token Lifetime', async () => {
// Security card is collapsed by default — click the header to expand
await page.locator('.card-header:has-text("Security")').click();
await expect(page.locator('#jwt_token_lifetime_hours-input')).toBeVisible({ timeout: 5_000 });
});

test('JWT Token Lifetime dropdown shows human-readable labels', async () => {
const select = page.locator('#jwt_token_lifetime_hours-input');
// The option text should be human-readable, not a raw number
await expect(select.locator('option').filter({ hasText: '1 day' })).toBeAttached({
timeout: 5_000,
});
await expect(select.locator('option').filter({ hasText: '1 week' })).toBeAttached();
await expect(select.locator('option').filter({ hasText: '1 month' })).toBeAttached();
});

test('can change JWT Token Lifetime and submit successfully', async () => {
const select = page.locator('#jwt_token_lifetime_hours-input');
// Change to 6 hours
await select.selectOption({ label: '6 hours' });
await expect(page.locator('button:has-text("Submit")')).toBeEnabled({ timeout: 3_000 });
await page.locator('button:has-text("Submit")').click();
// Use .v-toast__item to avoid strict-mode violation (.v-toast has two container elements)
await expect(page.locator('.v-toast__item:has-text("Saved settings")')).toBeVisible({
timeout: 5_000,
});
// Wait for Submit to re-disable — the WS SETTINGS_CHANGED broadcast resets editSettings to
// match the server, making hasChanges false. Selecting before this settles causes a race where
// the WS update overwrites our selection and Submit stays disabled.
await expect(page.locator('button:has-text("Submit")')).toBeDisabled({ timeout: 10_000 });
// Restore default (1 day = 24 hours)
await select.selectOption({ label: '1 day' });
await expect(page.locator('button:has-text("Submit")')).toBeEnabled({ timeout: 3_000 });
await page.locator('button:has-text("Submit")').click();
await page
.locator('.v-toast__item')
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(() => {});
});

// ── System tab ────────────────────────────────────────────────────────────

test('switches to the System tab', async () => {
Expand Down
5 changes: 4 additions & 1 deletion client-v3/src/components/config/ConfigSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ function inputType(fieldType: string): string {
function getChoiceOptions(setting: any): Array<{ value: unknown; text: string }> {
const options: Array<{ value: unknown; text: string }> = [];
if (setting._nullable) options.push({ value: null, text: 'N/A' });
setting.choice_options.forEach((opt: unknown) => options.push({ value: opt, text: String(opt) }));
setting.choice_options.forEach((opt: unknown, idx: number) => {
const label = (setting.choice_labels as string[] | null)?.[idx] ?? String(opt);
options.push({ value: opt, text: label });
});
return options;
}

Expand Down
5 changes: 3 additions & 2 deletions client/src/vue_components/config/ConfigSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ export default defineComponent({
if (setting._nullable) {
options.push({ value: null, text: 'N/A' });
}
setting.choice_options.forEach((option: unknown) => {
options.push({ value: option, text: String(option) });
setting.choice_options.forEach((option: unknown, idx: number) => {
const label = (setting.choice_labels as string[] | null)?.[idx] ?? String(option);
options.push({ value: option, text: label });
});
return options;
},
Expand Down
23 changes: 23 additions & 0 deletions docs/pages/user_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ All log entries are attributed to the logged-in user where one is present:

Use the **Username** filter field to show only entries from a specific user. This filter applies to both Server and Client sources. Combined with the **Level** and **Search** filters, you can quickly isolate activity from a particular user across the full log stream.

### Security Settings

The **Security** category in the Settings tab contains authentication-related configuration.

#### JWT Token Lifetime

Controls how long JWT access tokens remain valid after they are issued. The available options are:

| Option | Duration |
|--------|----------|
| 1 hour | 1 hour |
| 6 hours | 6 hours |
| 12 hours | 12 hours |
| 1 day *(default)* | 24 hours |
| 1 week | 7 days |
| 1 month (30 days) | 30 days |

**Reducing the lifetime** takes effect immediately — any token whose issue time is older than the new limit will be rejected on the next request, even if the token's expiry date has not passed yet. Affected users are redirected to the login page.

**Increasing the lifetime** applies to newly-issued tokens. Active users automatically receive a refreshed token within 30 minutes, at which point the longer lifetime takes effect for their session.

> **Note:** This setting applies to JWT browser session tokens only. API tokens (long-lived keys used for machine-to-machine access) are not subject to this lifetime limit.

### Backup Management

The **Backups** tab allows admin users to view and manage database backup files. DigiScript automatically creates a timestamped copy of the database file before running any database migration, ensuring you can recover data if a migration causes issues.
Expand Down
6 changes: 6 additions & 0 deletions server/controllers/ws_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ async def authenticate_with_token(self, token):
)
return False

if not self.application.jwt_service.validate_token_age(payload):
await self.write_message(
{"OP": "WS_AUTH_ERROR", "DATA": "Token expired (lifetime exceeded)"}
)
return False

with self.make_session() as session:
user = session.get(User, int(payload["user_id"]))
if not user:
Expand Down
42 changes: 42 additions & 0 deletions server/digi_server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
class SettingsObject:
ALLOWED_TYPES = [str, bool, int]

def __init__(

Check failure on line 40 in server/digi_server/settings.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ8PrZAAFFhzEQA0wtyO&open=AZ8PrZAAFFhzEQA0wtyO&pullRequest=1253
self,
key,
val_type,
Expand All @@ -49,6 +49,7 @@
help_text: str = "",
hide_from_ui: bool = False,
choice_options: Optional[list] = None,
choice_labels: Optional[list] = None,
):
if val_type not in self.ALLOWED_TYPES:
raise RuntimeError(
Expand Down Expand Up @@ -80,6 +81,19 @@
f"Default value for {key} must be one of the choice options."
)

if choice_labels is not None:
if len(choice_labels) != len(choice_options):
raise RuntimeError(
f"choice_labels for {key} must have the same length as choice_options "
f"({len(choice_labels)} vs {len(choice_options)})."
)
if any(not isinstance(label, str) for label in choice_labels):
raise RuntimeError(f"All choice_labels for {key} must be strings.")
elif choice_labels is not None:
raise RuntimeError(
f"choice_labels for {key} requires choice_options to be set."
)

self.key = key
self.val_type = val_type
self.value = None
Expand All @@ -92,6 +106,7 @@
self.help_text = help_text
self.hide_from_ui = hide_from_ui
self.choice_options = choice_options
self.choice_labels = choice_labels

def set_to_default(self):
self.value = self.default
Expand Down Expand Up @@ -141,6 +156,7 @@
"help_text": self.help_text,
"hide_from_ui": self.hide_from_ui,
"choice_options": self.choice_options,
"choice_labels": self.choice_labels,
"_nullable": self._nullable,
}

Expand Down Expand Up @@ -376,6 +392,30 @@
"Larger values use more memory. Changes take effect after restart.",
category="Client Logging",
)
self.define(
"jwt_token_lifetime_hours",
int,
24,
True,
display_name="JWT Token Lifetime",
help_text=(
"How long JWT authentication tokens remain valid after being issued. "
"Reducing this value takes effect immediately: any token older than the new "
"limit is rejected on the next request. Increasing the value applies to "
"newly-issued tokens; active users will receive a refreshed token within "
"30 minutes."
),
choice_options=[1, 6, 12, 24, 168, 720],
choice_labels=[
"1 hour",
"6 hours",
"12 hours",
"1 day",
"1 week",
"1 month (30 days)",
],
category="Security",
)

def define(
self,
Expand All @@ -389,6 +429,7 @@
help_text: str = "",
hide_from_ui: bool = False,
choice_options: Optional[list] = None,
choice_labels: Optional[list] = None,
category: str = "General",
):
if key in self.settings:
Expand All @@ -405,6 +446,7 @@
help_text,
hide_from_ui,
choice_options,
choice_labels,
)
if category not in self.categories:
self.categories[category] = [key]
Expand Down
2 changes: 2 additions & 0 deletions server/services/password_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ def generate_temporary_password(word_count: int = 3) -> str:
:rtype: str
"""
wordlist = xp.generate_wordlist(wordfile=xp.locate_wordfile())
# Exclude words containing hyphens to keep the dash-delimiter unambiguous
wordlist = [w for w in wordlist if "-" not in w]
password = xp.generate_xkcdpassword(
wordlist, numwords=word_count, delimiter="-"
)
Expand Down
28 changes: 28 additions & 0 deletions server/test/controllers/api/v1/test_settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from tornado.testing import gen_test

from digi_server.logger import get_logger
Expand Down Expand Up @@ -36,3 +37,30 @@ def test_invalid_type(self):
def test_not_nullable(self):
with self.assertRaises(RuntimeError):
yield self._app.digi_settings.set("debug_mode", None)

def test_jwt_lifetime_setting_registered(self):
setting = self._app.digi_settings.settings.get("jwt_token_lifetime_hours")
self.assertIsNotNone(setting)
self.assertEqual(setting.default, 24)
self.assertEqual(setting.val_type, int)
self.assertEqual(setting.choice_options, [1, 6, 12, 24, 168, 720])
self.assertIsNotNone(setting.choice_labels)
self.assertEqual(len(setting.choice_labels), len(setting.choice_options))
self.assertTrue(setting.can_edit)

def test_jwt_lifetime_setting_rejects_invalid_choice(self):
with pytest.raises(ValueError):
self._app.digi_settings.settings["jwt_token_lifetime_hours"].set_value(999)

def test_jwt_lifetime_setting_in_security_category(self):
categories = self._app.digi_settings.categories
self.assertIn("Security", categories)
self.assertIn("jwt_token_lifetime_hours", categories["Security"])

def test_jwt_lifetime_as_json_includes_choice_labels(self):
setting = self._app.digi_settings.settings["jwt_token_lifetime_hours"]
json_repr = setting.as_json()
self.assertIn("choice_labels", json_repr)
self.assertEqual(
len(json_repr["choice_labels"]), len(json_repr["choice_options"])
)
Loading
Loading