Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The broader access model is documented in [Access and Permissions](access.md).
## Fields
- **Username**: Enter your admin username.
- **Password**: Enter your password.
- **Remember me on this browser**: Keeps the admin session after the browser is closed.

## Features
- **Validation**: Both fields are required.
Expand All @@ -17,6 +18,8 @@ The broader access model is documented in [Access and Permissions](access.md).
- Only administrators can log in to the management interface.
- Non-admin users are shown an error message and cannot access the interface.
- After successful login, users are redirected to the Dashboard.
- If **Remember me on this browser** is not checked, the login uses a normal browser session cookie.
- If **Remember me on this browser** is checked, the signed session cookie lasts for 14 days. The app does not store the password in the browser, and protected pages still check that the signed-in user is still an administrator.

---

Expand Down
8 changes: 8 additions & 0 deletions simple_safer_server/app_factory.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
from datetime import timedelta
from logging.handlers import RotatingFileHandler

from flask import (
Expand Down Expand Up @@ -53,6 +54,8 @@
UnauthorizedProblem,
)

REMEMBER_ME_SESSION_DAYS = 14


def create_app() -> Flask:
runtime = get_runtime()
Expand All @@ -61,6 +64,10 @@ def create_app() -> Flask:
# Keep the session secret stable across deploys so a restart does not
# invalidate every login cookie when the app's config directory persists.
app.secret_key = get_flask_secret_key(runtime)
# "Remember me" uses Flask's normal signed session cookie. No password or
# separate login token is stored, and protected pages still re-check admin
# status on each request in case roles change after sign-in.
app.permanent_session_lifetime = timedelta(days=REMEMBER_ME_SESSION_DAYS)
user_manager = UserManager(runtime=runtime)

system_utils = SystemUtils(runtime=runtime)
Expand Down Expand Up @@ -208,6 +215,7 @@ def login():

if user_manager.verify_user(username, password):
if user_manager.is_admin(username):
session.permanent = request.form.get("remember_me") == "on"
session["username"] = username
session.pop("skip_login_disabled", None)
if request.accept_mimetypes.best == "application/json":
Expand Down
23 changes: 23 additions & 0 deletions static/css/login.css
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,29 @@
background: var(--bg-root);
}

.login-remember-option {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: calc(-1 * var(--sp-1)) 0 var(--sp-5);
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.4;
cursor: pointer;
user-select: none;
}

.login-remember-checkbox {
width: 16px;
height: 16px;
flex: 0 0 auto;
accent-color: var(--accent);
}

.login-remember-option:focus-within {
color: var(--text-primary);
}

.login-form .btn-submit {
margin-top: var(--sp-2);
height: 48px;
Expand Down
5 changes: 5 additions & 0 deletions templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ <h1 class="login-brand-title">SimpleSaferServer</h1>
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" required class="form-control" placeholder="Enter your password" autocomplete="current-password">
</div>

<label class="login-remember-option" for="remember_me">
<input id="remember_me" name="remember_me" type="checkbox" class="login-remember-checkbox">
<span>Remember me on this browser</span>
</label>

<button type="submit" id="loginSubmitBtn" class="btn btn-primary btn-block btn-submit">
<i class="fas fa-right-to-bracket me-2"></i> Sign in
Expand Down
76 changes: 76 additions & 0 deletions tests/test_app_factory_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,82 @@ def test_login_title_uses_configured_hostname_without_auto_login():
runtime._fake_state = previous_fake_state


def test_login_form_includes_remember_me_checkbox():
previous_runtime = runtime._runtime
previous_fake_state = runtime._fake_state
try:
with TemporaryDirectory() as temp_dir:
app = _create_fake_app(temp_dir, skip_login=False)
_finish_fake_setup(app)

with app.test_client() as client:
response = client.get("/login")

page = response.get_data(as_text=True)
assert response.status_code == 200
assert 'name="remember_me"' in page
assert "Remember me on this browser" in page
finally:
runtime._runtime = previous_runtime
runtime._fake_state = previous_fake_state


def test_login_without_remember_me_uses_browser_session_cookie():
previous_runtime = runtime._runtime
previous_fake_state = runtime._fake_state
try:
with TemporaryDirectory() as temp_dir:
app = _create_fake_app(temp_dir, skip_login=False)
_finish_fake_setup(app)

with app.test_client() as client:
response = client.post(
"/login",
data={"username": "admin", "password": "password"},
)

with client.session_transaction() as session:
assert session["username"] == "admin"
assert session.permanent is False

assert response.status_code == 302
assert "Expires=" not in response.headers["Set-Cookie"]
finally:
runtime._runtime = previous_runtime
runtime._fake_state = previous_fake_state


def test_login_with_remember_me_sets_permanent_session_cookie():
previous_runtime = runtime._runtime
previous_fake_state = runtime._fake_state
try:
with TemporaryDirectory() as temp_dir:
app = _create_fake_app(temp_dir, skip_login=False)
_finish_fake_setup(app)

with app.test_client() as client:
response = client.post(
"/login",
data={
"username": "admin",
"password": "password",
"remember_me": "on",
},
headers={"Accept": "application/json"},
)

with client.session_transaction() as session:
assert session["username"] == "admin"
assert session.permanent is True

assert response.status_code == 200
assert response.get_json()["data"]["redirect"] == "/dashboard"
assert "Expires=" in response.headers["Set-Cookie"]
finally:
runtime._runtime = previous_runtime
runtime._fake_state = previous_fake_state


def test_setup_title_keeps_product_name_before_server_name_is_chosen():
previous_runtime = runtime._runtime
previous_fake_state = runtime._fake_state
Expand Down
Loading