Compare commits
6 Commits
01925fd3f0
...
c2b5fa7b2e
| Author | SHA1 | Date | |
|---|---|---|---|
| c2b5fa7b2e | |||
| b4c1ec0bf3 | |||
| a51650dd6e | |||
| 5eb8aeeba6 | |||
| ac9c7ba280 | |||
| bd2e71cd62 |
@ -163,10 +163,21 @@ def _groups_from_claims(claims: dict) -> set[str]:
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _captcha_enabled() -> bool:
|
||||||
|
"""Return True when the login captcha is enabled in SystemSettings."""
|
||||||
|
try:
|
||||||
|
s = SystemSettings.query.first()
|
||||||
|
if s is None:
|
||||||
|
return True # default on for new installs
|
||||||
|
return bool(getattr(s, "login_captcha_enabled", True))
|
||||||
|
except Exception:
|
||||||
|
return True # fail-safe: keep captcha on if DB unreachable
|
||||||
|
|
||||||
|
|
||||||
def captcha_required(func):
|
def captcha_required(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if request.method == "POST":
|
if request.method == "POST" and _captcha_enabled():
|
||||||
expected = session.get("captcha_answer")
|
expected = session.get("captcha_answer")
|
||||||
provided = (request.form.get("captcha") or "").strip()
|
provided = (request.form.get("captcha") or "").strip()
|
||||||
if not expected or provided != expected:
|
if not expected or provided != expected:
|
||||||
@ -184,6 +195,7 @@ def captcha_required(func):
|
|||||||
return render_template(
|
return render_template(
|
||||||
"auth/login.html",
|
"auth/login.html",
|
||||||
captcha_question=question,
|
captcha_question=question,
|
||||||
|
captcha_enabled=True,
|
||||||
username=request.form.get("username", ""),
|
username=request.form.get("username", ""),
|
||||||
entra_sso_enabled=entra_ready,
|
entra_sso_enabled=entra_ready,
|
||||||
)
|
)
|
||||||
@ -195,6 +207,8 @@ def captcha_required(func):
|
|||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
@captcha_required
|
@captcha_required
|
||||||
def login():
|
def login():
|
||||||
|
captcha_on = _captcha_enabled()
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
if not users_exist():
|
if not users_exist():
|
||||||
return redirect(url_for("auth.initial_setup"))
|
return redirect(url_for("auth.initial_setup"))
|
||||||
@ -211,6 +225,7 @@ def login():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"auth/login.html",
|
"auth/login.html",
|
||||||
captcha_question=question,
|
captcha_question=question,
|
||||||
|
captcha_enabled=captcha_on,
|
||||||
entra_sso_enabled=entra_ready,
|
entra_sso_enabled=entra_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -233,6 +248,7 @@ def login():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"auth/login.html",
|
"auth/login.html",
|
||||||
captcha_question=question,
|
captcha_question=question,
|
||||||
|
captcha_enabled=captcha_on,
|
||||||
username=username,
|
username=username,
|
||||||
entra_sso_enabled=entra_ready,
|
entra_sso_enabled=entra_ready,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -741,6 +741,7 @@ def settings():
|
|||||||
old_ui_timezone = settings.ui_timezone
|
old_ui_timezone = settings.ui_timezone
|
||||||
old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit
|
old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit
|
||||||
old_is_sandbox_environment = settings.is_sandbox_environment
|
old_is_sandbox_environment = settings.is_sandbox_environment
|
||||||
|
old_login_captcha_enabled = getattr(settings, "login_captcha_enabled", True)
|
||||||
old_graph_tenant_id = settings.graph_tenant_id
|
old_graph_tenant_id = settings.graph_tenant_id
|
||||||
old_graph_client_id = settings.graph_client_id
|
old_graph_client_id = settings.graph_client_id
|
||||||
old_graph_mailbox = settings.graph_mailbox
|
old_graph_mailbox = settings.graph_mailbox
|
||||||
@ -787,6 +788,9 @@ def settings():
|
|||||||
# Checkbox: present in form = checked, absent = unchecked.
|
# Checkbox: present in form = checked, absent = unchecked.
|
||||||
settings.is_sandbox_environment = bool(request.form.get("is_sandbox_environment"))
|
settings.is_sandbox_environment = bool(request.form.get("is_sandbox_environment"))
|
||||||
|
|
||||||
|
# Login captcha toggle — same form (General tab).
|
||||||
|
settings.login_captcha_enabled = bool(request.form.get("login_captcha_enabled"))
|
||||||
|
|
||||||
# Autotask integration
|
# Autotask integration
|
||||||
if "autotask_enabled" in request.form:
|
if "autotask_enabled" in request.form:
|
||||||
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
|
settings.autotask_enabled = bool(request.form.get("autotask_enabled"))
|
||||||
@ -981,6 +985,8 @@ def settings():
|
|||||||
changes_general["require_daily_dashboard_visit"] = {"old": old_require_daily_dashboard_visit, "new": settings.require_daily_dashboard_visit}
|
changes_general["require_daily_dashboard_visit"] = {"old": old_require_daily_dashboard_visit, "new": settings.require_daily_dashboard_visit}
|
||||||
if old_is_sandbox_environment != settings.is_sandbox_environment:
|
if old_is_sandbox_environment != settings.is_sandbox_environment:
|
||||||
changes_general["is_sandbox_environment"] = {"old": old_is_sandbox_environment, "new": settings.is_sandbox_environment}
|
changes_general["is_sandbox_environment"] = {"old": old_is_sandbox_environment, "new": settings.is_sandbox_environment}
|
||||||
|
if old_login_captcha_enabled != settings.login_captcha_enabled:
|
||||||
|
changes_general["login_captcha_enabled"] = {"old": old_login_captcha_enabled, "new": settings.login_captcha_enabled}
|
||||||
|
|
||||||
if changes_general:
|
if changes_general:
|
||||||
_log_admin_event(
|
_log_admin_event(
|
||||||
|
|||||||
@ -1447,6 +1447,7 @@ def run_migrations() -> None:
|
|||||||
migrate_cc_accounts_repo_unique_key()
|
migrate_cc_accounts_repo_unique_key()
|
||||||
migrate_cc_remove_synthetic_missed_runs()
|
migrate_cc_remove_synthetic_missed_runs()
|
||||||
migrate_entra_sso_settings()
|
migrate_entra_sso_settings()
|
||||||
|
migrate_system_settings_login_captcha()
|
||||||
print("[migrations] All migrations completed.")
|
print("[migrations] All migrations completed.")
|
||||||
|
|
||||||
|
|
||||||
@ -1554,6 +1555,37 @@ def migrate_system_settings_sandbox_environment() -> None:
|
|||||||
print(f"[migrations] Failed to migrate system_settings.is_sandbox_environment: {exc}")
|
print(f"[migrations] Failed to migrate system_settings.is_sandbox_environment: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_system_settings_login_captcha() -> None:
|
||||||
|
"""Add login_captcha_enabled column to system_settings if missing.
|
||||||
|
|
||||||
|
Default TRUE so existing installs keep captcha enabled after the upgrade.
|
||||||
|
"""
|
||||||
|
table = "system_settings"
|
||||||
|
column = "login_captcha_enabled"
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for login_captcha_enabled migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if _column_exists(table, column):
|
||||||
|
print("[migrations] system_settings.login_captcha_enabled already exists.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
f'ALTER TABLE "{table}" ADD COLUMN {column} BOOLEAN NOT NULL DEFAULT TRUE'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[migrations] migrate_system_settings_login_captcha completed.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Failed to migrate system_settings.login_captcha_enabled: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def migrate_performance_indexes() -> None:
|
def migrate_performance_indexes() -> None:
|
||||||
"""Add performance indexes for frequently queried foreign key columns.
|
"""Add performance indexes for frequently queried foreign key columns.
|
||||||
|
|
||||||
|
|||||||
@ -123,6 +123,9 @@ class SystemSettings(db.Model):
|
|||||||
# this is not a production environment.
|
# this is not a production environment.
|
||||||
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
|
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Login page captcha (simple math question). Default True for new installs.
|
||||||
|
login_captcha_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
# Cove Data Protection integration settings
|
# Cove Data Protection integration settings
|
||||||
cove_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
cove_enabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
cove_api_url = db.Column(db.String(255), nullable=True) # default: https://api.backup.management/jsonapi
|
cove_api_url = db.Column(db.String(255), nullable=True) # default: https://api.backup.management/jsonapi
|
||||||
|
|||||||
@ -348,10 +348,13 @@ body.bc-body {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
.bc-main-auth .bc-content {
|
.bc-main-auth .bc-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32px 16px;
|
padding: 32px 16px;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
@ -8,13 +8,13 @@
|
|||||||
top: 30px;
|
top: 30px;
|
||||||
left: -60px;
|
left: -60px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
background-color: #dc3545;
|
background-color: rgba(220, 53, 69, 0.45);
|
||||||
color: white;
|
color: rgba(255, 255, 255, 0.9);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none; /* Banner itself is not clickable, elements behind it are */
|
pointer-events: none; /* Banner itself is not clickable, elements behind it are */
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sandbox-banner-text {
|
.sandbox-banner-text {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
{% extends "layout/base.html" %}
|
{% extends "layout/base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div>
|
||||||
<div class="col-md-4">
|
|
||||||
<h2 class="mb-3">Login</h2>
|
<h2 class="mb-3">Login</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -25,6 +24,7 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{% if captcha_enabled %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Captcha: {{ captcha_question }}</label>
|
<label class="form-label">Captcha: {{ captcha_question }}</label>
|
||||||
<input
|
<input
|
||||||
@ -34,6 +34,7 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center">
|
||||||
<a class="btn btn-link" href="{{ url_for('auth.password_reset_request') }}">Forgot password?</a>
|
<a class="btn btn-link" href="{{ url_for('auth.password_reset_request') }}">Forgot password?</a>
|
||||||
@ -46,5 +47,4 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -161,6 +161,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Security</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="login_captcha_enabled" name="login_captcha_enabled" {% if settings.login_captcha_enabled %}checked{% endif %} />
|
||||||
|
<label class="form-check-label" for="login_captcha_enabled">Enable login captcha</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">When enabled, users must solve a simple math question before logging in. Enabled by default.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mt-3">
|
<div class="d-flex justify-content-end mt-3">
|
||||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
This file documents all changes made to this project via Claude Code.
|
This file documents all changes made to this project via Claude Code.
|
||||||
|
|
||||||
|
## [2026-03-20] (4)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Settings → General: "Security" card with login captcha toggle (`login_captcha_enabled`):
|
||||||
|
- When disabled, the math captcha is hidden on the login page and not validated
|
||||||
|
- Default `TRUE` for new installs and existing installs after migration
|
||||||
|
- Migration `migrate_system_settings_login_captcha()` adds the column with `DEFAULT TRUE`
|
||||||
|
- Audit-logged when changed (same pattern as other General settings)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Login page layout broken when flash messages were present (e.g. "You have been logged out"):
|
||||||
|
- `bc-main-auth .bc-content` now uses `max-width: 480px; margin: 0 auto` as a centered column instead of `align-items: center` on a flex container (which caused Bootstrap `col-md-4` percentage widths to collapse)
|
||||||
|
- `login.html`: replaced `row justify-content-center / col-md-4` with a plain `<div>` — the parent CSS column handles centering
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Sandbox banner: semi-transparent (`rgba(220,53,69,0.45)`) instead of solid red
|
||||||
|
|
||||||
## [2026-03-20] (3)
|
## [2026-03-20] (3)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user