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
41 changes: 39 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,37 @@
logthis = logger.setup_child_logger("jawa", "app")
error_message = ""

SESSION_TIMEOUT_CHOICES = (15, 60, 240, 480)
DEFAULT_SESSION_TIMEOUT = 15


def _resolve_session_timeout(config: dict) -> int:
"""Resolve the configured session timeout (minutes) against the
allowed ladder. Any missing / malformed / off-ladder value fails
safe to the 15-minute default (never longer)."""
value = config.get("session_timeout_minutes")
if type(value) is int and value in SESSION_TIMEOUT_CHOICES:
return value
return DEFAULT_SESSION_TIMEOUT

# Initiate Flask
app = Flask(__name__)
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)


# Session heartbeat
# Session heartbeat: slide the window and apply the admin-configured
# timeout (fail-safe to 15 min) on every request, so a /setup change
# takes effect immediately with no restart.
@app.before_request
def func() -> None:
def _session_heartbeat() -> None:
from bin import data_store

minutes = _resolve_session_timeout(data_store.get_server_config())
app.permanent_session_lifetime = timedelta(minutes=minutes)
session.modified = True


Expand Down Expand Up @@ -252,6 +276,15 @@ def setup() -> Union[Response, str]:
jps_url = request.form.get("jss-lock")
jps2_check = request.form.get("alternate-jamf")
jps_url2 = request.form.get("alternate")
timeout_raw = request.form.get("session_timeout_minutes", "")
try:
timeout_val = int(timeout_raw)
except (TypeError, ValueError):
timeout_val = DEFAULT_SESSION_TIMEOUT
# Clamp to the allowed ladder; never store an off-ladder value.
session_timeout = _resolve_session_timeout(
{"session_timeout_minutes": timeout_val}
)
logthis.info(
f"{session.get('username')} made JAWA Setup Changes\n"
f"JAWA URL: {server_url}\n"
Expand All @@ -270,6 +303,7 @@ def setup() -> Union[Response, str]:
"jawa_address": server_url,
"jps_url": jps_url,
"alternate_jps": jps_url2,
"session_timeout_minutes": session_timeout,
}
json.dump(server_json, outfile)
elif os.path.isfile(server_json_file):
Expand All @@ -278,6 +312,7 @@ def setup() -> Union[Response, str]:
"jawa_address": server_url,
"jps_url": jps_url,
"alternate_jps": jps_url2,
"session_timeout_minutes": session_timeout,
}
json.dump(server_json, outfile)
with open(server_json_file, "r") as fin:
Expand Down Expand Up @@ -306,6 +341,7 @@ def setup() -> Union[Response, str]:
json.dump(server_json, outfile)
with open(server_json_file, "r") as fin:
server_json = json.load(fin)
session_timeout = _resolve_session_timeout(server_json)
jps_url2 = server_json.get("alternate_jps")
if jps_url2 == str(escape(session["url"])):
primary_jps = server_json["jps_url"]
Expand All @@ -318,6 +354,7 @@ def setup() -> Union[Response, str]:
jps_url=primary_jps,
jps_url2=jps_url2,
jawa_url=jawa_url,
session_timeout=session_timeout,
username=session.get("username"),
)

Expand Down
5 changes: 5 additions & 0 deletions bin/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ def _add_static_hash(endpoint: str, values: dict) -> None:

def inject_common_vars() -> dict:
"""Auto-inject session variables into all templates."""
from app import _resolve_session_timeout
from bin import data_store

minutes = _resolve_session_timeout(data_store.get_server_config())
return {
"username": session.get("username"),
"session_url": session.get("url"),
"session_timeout_seconds": minutes * 60,
}
3 changes: 2 additions & 1 deletion bin/data_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,10 @@ def get_server_config() -> Dict:
return {}
with open(SERVER_FILE, "r") as f:
try:
return json.load(f)
data = json.load(f)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}


def get_jawa_address() -> Optional[str]:
Expand Down
16 changes: 16 additions & 0 deletions templates/setup/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ <h2>Server Setup</h2>
title="Use https://"
value="{{ jps_url2 if jps_url2 }}">

<div class="mb-3 text-start">
<label for="session-timeout" class="form-label">Session timeout</label>
<select id="session-timeout" name="session_timeout_minutes" class="form-select" aria-describedby="session-timeout-help">
<option value="15" {% if session_timeout == 15 %}selected{% endif %}>15 minutes (default)</option>
<option value="60" {% if session_timeout == 60 %}selected{% endif %}>1 hour</option>
<option value="240" {% if session_timeout == 240 %}selected{% endif %}>4 hours (Extended)</option>
<option value="480" {% if session_timeout == 480 %}selected{% endif %}>8 hours (Extended)</option>
</select>
<small id="session-timeout-help" class="form-text text-muted">
Longer sessions are convenient for workflow testing but increase risk:
a signed-in console left unattended could be misused on-site. Extended
tiers (4h/8h) are a deliberate trade-off; the 15-minute default is the
most secure.
</small>
</div>

<div class="d-flex justify-content-center container-fluid">
<button type="submit" value="Setup" class="btn btn-jawa btn-padded">
Setup
Expand Down
2 changes: 1 addition & 1 deletion templates/shared/_layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ <h5 class="modal-title" id="sessionTimeoutLabel">Session Expiring</h5>

<script>
(function () {
var SESSION_DURATION = 15 * 60;
var SESSION_DURATION = {{ session_timeout_seconds | default(900) }};
var WARNING_BEFORE = 45;
var remaining = SESSION_DURATION;
var warningShown = false;
Expand Down
128 changes: 128 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Session timeout resolution, enforcement, and cookie hardening (J6/B4)."""

import app as jawa_app


def test_resolve_defaults_to_15_when_missing():
assert jawa_app._resolve_session_timeout({}) == 15


def test_resolve_accepts_ladder_values():
for v in (15, 60, 240, 480):
assert jawa_app._resolve_session_timeout(
{"session_timeout_minutes": v}
) == v


def test_resolve_rejects_off_ladder_values():
# Off-ladder, wrong type, and absurd values all fail safe to 15.
# Bools and ladder-equal floats must also be rejected (strict int).
for bad in (0, 5, 999999, -10, "60", None, 61, True, False, 60.0):
assert jawa_app._resolve_session_timeout(
{"session_timeout_minutes": bad}
) == 15


def test_cookie_flags_are_hardened():
assert jawa_app.app.config["SESSION_COOKIE_SECURE"] is True
assert jawa_app.app.config["SESSION_COOKIE_HTTPONLY"] is True
assert jawa_app.app.config["SESSION_COOKIE_SAMESITE"] == "Lax"


def test_login_makes_session_permanent(logged_in_client):
with logged_in_client.session_transaction() as sess:
assert sess.permanent is True


def test_before_request_applies_configured_timeout(logged_in_client, jawa_env):
import json
# Configure an extended (4h) timeout.
data = json.loads(jawa_env.server_file.read_text())
data["session_timeout_minutes"] = 240
jawa_env.server_file.write_text(json.dumps(data))
# Any authed request runs before_request, which sets the lifetime.
logged_in_client.get("/dashboard")
from datetime import timedelta
assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=240)


def test_before_request_failsafe_on_bad_config(logged_in_client, jawa_env):
import json
data = json.loads(jawa_env.server_file.read_text())
data["session_timeout_minutes"] = 999999
jawa_env.server_file.write_text(json.dumps(data))
logged_in_client.get("/dashboard")
from datetime import timedelta
assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=15)


def test_setup_persists_valid_timeout(logged_in_client, jawa_env):
import json
logged_in_client.post(
"/setup",
data={
"address": "https://jawa.example.test",
"jss-lock": "https://jamf.example.test",
"alternate": "",
"session_timeout_minutes": "240",
},
)
data = json.loads(jawa_env.server_file.read_text())
assert data["session_timeout_minutes"] == 240


def test_setup_rejects_off_ladder_timeout(logged_in_client, jawa_env):
import json
logged_in_client.post(
"/setup",
data={
"address": "https://jawa.example.test",
"jss-lock": "https://jamf.example.test",
"alternate": "",
"session_timeout_minutes": "999999",
},
)
data = json.loads(jawa_env.server_file.read_text())
# Off-ladder input is clamped to the safe default, never stored raw.
assert data["session_timeout_minutes"] == 15


def test_setup_form_shows_timeout_control(logged_in_client, jawa_env):
import json
data = json.loads(jawa_env.server_file.read_text())
data["session_timeout_minutes"] = 240
jawa_env.server_file.write_text(json.dumps(data))
resp = logged_in_client.get("/setup")
body = resp.data.decode()
assert 'name="session_timeout_minutes"' in body
# Current value preselected.
assert 'value="240" selected' in body
# Extended tiers carry a security-trade-off note.
assert "Extended" in body


def test_layout_injects_effective_timeout(logged_in_client, jawa_env):
import json
data = json.loads(jawa_env.server_file.read_text())
data["session_timeout_minutes"] = 60
jawa_env.server_file.write_text(json.dumps(data))
resp = logged_in_client.get("/dashboard")
body = resp.data.decode()
# 60 min -> 3600 s injected for the modal to count down against.
assert "3600" in body


def test_non_dict_server_config_fails_safe(logged_in_client, jawa_env):
# A hand-edited server.json that isn't a JSON object must not 500
# every route; it must fall back to the 15-min default.
jawa_env.server_file.write_text("[]")
resp = logged_in_client.get("/dashboard")
assert resp.status_code == 200
from datetime import timedelta
assert jawa_app.app.permanent_session_lifetime == timedelta(minutes=15)


def test_get_server_config_returns_dict_for_non_object(jawa_env):
from bin import data_store
jawa_env.server_file.write_text("42")
assert data_store.get_server_config() == {}
1 change: 1 addition & 0 deletions views/home_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def login() -> Response:
+ str(escape(session["username"]))
)

session.permanent = True
return redirect(url_for(DASHBOARD_ENDPOINT))

if "username" not in session:
Expand Down
Loading