diff --git a/src/nine_rtksync/auth.py b/src/nine_rtksync/auth.py
index 42c0d51..c98bf8a 100644
--- a/src/nine_rtksync/auth.py
+++ b/src/nine_rtksync/auth.py
@@ -32,7 +32,8 @@
# Politica de senha do painel. Exigida sempre que a senha for definida ou
# trocada pela tela; o ambiente headless nao passa por aqui porque quem opera
# DASHBOARD_PASSWORD ja controla o segredo por fora.
-MIN_PASSWORD_LENGTH = 6
+MIN_PASSWORD_LENGTH = 8
+MAX_PASSWORD_LENGTH = 20
SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.<>?/\\|`~\"'"
@@ -43,15 +44,18 @@ def validate_password_strength(password: str) -> list:
evita o vaivem de corrigir um requisito por tentativa.
"""
problems = []
- if len(password or "") < MIN_PASSWORD_LENGTH:
+ pwd = password or ""
+ if len(pwd) < MIN_PASSWORD_LENGTH:
problems.append("password.too_short")
- if not any(c.isupper() for c in password or ""):
+ elif len(pwd) > MAX_PASSWORD_LENGTH:
+ problems.append("password.too_long")
+ if not any(c.isupper() for c in pwd):
problems.append("password.needs_upper")
- if not any(c.islower() for c in password or ""):
+ if not any(c.islower() for c in pwd):
problems.append("password.needs_lower")
- if not any(c.isdigit() for c in password or ""):
+ if not any(c.isdigit() for c in pwd):
problems.append("password.needs_digit")
- if not any(c in SPECIAL_CHARACTERS for c in password or ""):
+ if not any(c in SPECIAL_CHARACTERS for c in pwd):
problems.append("password.needs_special")
return problems
diff --git a/src/nine_rtksync/locales/en.json b/src/nine_rtksync/locales/en.json
index dcc1ffa..316ca3c 100644
--- a/src/nine_rtksync/locales/en.json
+++ b/src/nine_rtksync/locales/en.json
@@ -146,8 +146,9 @@
"password.needs_lower": "Password must contain a lowercase letter.",
"password.needs_special": "Password must contain a special character.",
"password.needs_upper": "Password must contain an uppercase letter.",
- "password.policy": "At least 6 characters, with uppercase, lowercase, a number and a special character.",
- "password.too_short": "Password must have at least 6 characters.",
+ "password.policy": "8 to 20 characters, with uppercase, lowercase, a number and a special character.",
+ "password.too_long": "Password must have at most 20 characters.",
+ "password.too_short": "Password must have at least 8 characters.",
"reason.api_key": "Static key: never expires, nothing to renew",
"reason.expired": "Token expired: renewal will be attempted on the next sweep",
"reason.inside_margin": "Within the {margin} min margin: will be renewed on the next sweep",
diff --git a/src/nine_rtksync/locales/es.json b/src/nine_rtksync/locales/es.json
index 6b7be5a..9d75171 100644
--- a/src/nine_rtksync/locales/es.json
+++ b/src/nine_rtksync/locales/es.json
@@ -146,8 +146,9 @@
"password.needs_lower": "La contraseña necesita una letra minúscula.",
"password.needs_special": "La contraseña necesita un carácter especial.",
"password.needs_upper": "La contraseña necesita una letra mayúscula.",
- "password.policy": "Mínimo de 6 caracteres, con mayúscula, minúscula, número y carácter especial.",
- "password.too_short": "La contraseña necesita al menos 6 caracteres.",
+ "password.policy": "De 8 a 20 caracteres, con mayúscula, minúscula, número y carácter especial.",
+ "password.too_long": "La contraseña debe tener como máximo 20 caracteres.",
+ "password.too_short": "La contraseña necesita al menos 8 caracteres.",
"reason.api_key": "Clave estática: no expira, nada que renovar",
"reason.expired": "Token expirado: se intentará renovar en el próximo barrido",
"reason.inside_margin": "Dentro del margen de {margin} min: se renovará en el próximo barrido",
diff --git a/src/nine_rtksync/locales/pt.json b/src/nine_rtksync/locales/pt.json
index ae899b1..330000b 100644
--- a/src/nine_rtksync/locales/pt.json
+++ b/src/nine_rtksync/locales/pt.json
@@ -146,8 +146,9 @@
"password.needs_lower": "A senha precisa conter uma letra minúscula.",
"password.needs_special": "A senha precisa conter um caractere especial.",
"password.needs_upper": "A senha precisa conter uma letra maiúscula.",
- "password.policy": "Mínimo de 6 caracteres, com maiúscula, minúscula, número e caractere especial.",
- "password.too_short": "A senha precisa ter ao menos 6 caracteres.",
+ "password.policy": "De 8 a 20 caracteres, com maiúscula, minúscula, número e caractere especial.",
+ "password.too_long": "A senha deve ter no máximo 20 caracteres.",
+ "password.too_short": "A senha precisa ter ao menos 8 caracteres.",
"reason.api_key": "Chave estática: não expira, nada a renovar",
"reason.expired": "Token expirado: renovação será tentada na próxima varredura",
"reason.inside_margin": "Dentro da margem de {margin} min: será renovada na próxima varredura",
diff --git a/src/nine_rtksync/render.py b/src/nine_rtksync/render.py
index c78fb09..639e4b7 100644
--- a/src/nine_rtksync/render.py
+++ b/src/nine_rtksync/render.py
@@ -657,8 +657,8 @@ def render_credentials_modal(auth_from_env: bool, lang: str) -> str:
diff --git a/src/nine_rtksync/sessao.py b/src/nine_rtksync/sessao.py
index c58f6a5..eb77a37 100644
--- a/src/nine_rtksync/sessao.py
+++ b/src/nine_rtksync/sessao.py
@@ -100,22 +100,24 @@ def usuario_da_sessao(valor: str, agora: Optional[float] = None) -> Optional[str
return usuario or None
-def cabecalho_para_gravar(valor: str) -> str:
+def cabecalho_para_gravar(valor: str, seguro: bool = False) -> str:
"""Cookie de sessão: inacessível ao script da página e presa a este site.
- Sem `Secure` de propósito: o painel é servido em HTTP no loopback, e um
- cookie `Secure` simplesmente não seria gravado ali.
+ Sem `Secure` por padrão para desenvolvimento em loopback HTTP; com
+ `seguro=True`, adiciona `; Secure` quando servido em HTTPS ou atrás de proxy.
"""
+ s = "; Secure" if seguro else ""
return (
f"{NOME_DO_COOKIE}={valor}; Path=/; HttpOnly; SameSite=Strict; "
- f"Max-Age={VALIDADE_EM_SEGUNDOS}"
+ f"Max-Age={VALIDADE_EM_SEGUNDOS}{s}"
)
-def cabecalho_para_apagar() -> str:
+def cabecalho_para_apagar(seguro: bool = False) -> str:
+ s = "; Secure" if seguro else ""
return (
f"{NOME_DO_COOKIE}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; "
- f"Max-Age=0; HttpOnly; SameSite=Strict"
+ f"Max-Age=0; HttpOnly; SameSite=Strict{s}"
)
@@ -181,26 +183,27 @@ def ler_estado_sso(valor: str, agora: Optional[float] = None) -> Optional[dict]:
return {"state": state, "nonce": nonce, "verificador": verificador}
-def cabecalho_para_gravar_estado(valor: str) -> str:
+def cabecalho_para_gravar_estado(valor: str, seguro: bool = False) -> str:
"""Cookie de estado do SSO: `Lax`, curto e restrito ao caminho do fluxo.
NÃO pode ser `SameSite=Strict` como o de sessão: a volta do provedor é uma
navegação vinda de outro site, e um cookie `Strict` simplesmente não é
enviado nela — a falha apareceria como "login que não funciona", sem erro
- nenhum na tela. Sem `Secure` pelo mesmo motivo do cookie de sessão: o painel
- é servido em HTTP no loopback.
+ nenhum na tela. Sem `Secure` no loopback, mas com `; Secure` quando em HTTPS.
"""
+ s = "; Secure" if seguro else ""
return (
f"{NOME_DO_COOKIE_DE_ESTADO}={valor}; Path=/sso/; HttpOnly; SameSite=Lax; "
- f"Max-Age={VALIDADE_DO_ESTADO_EM_SEGUNDOS}"
+ f"Max-Age={VALIDADE_DO_ESTADO_EM_SEGUNDOS}{s}"
)
-def cabecalho_para_apagar_estado() -> str:
+def cabecalho_para_apagar_estado(seguro: bool = False) -> str:
"""Consumo de uso único: o mesmo `Path` do cookie, ou o navegador não o apaga."""
+ s = "; Secure" if seguro else ""
return (
f"{NOME_DO_COOKIE_DE_ESTADO}=; Path=/sso/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; "
- f"Max-Age=0; HttpOnly; SameSite=Lax"
+ f"Max-Age=0; HttpOnly; SameSite=Lax{s}"
)
diff --git a/src/nine_rtksync/web.py b/src/nine_rtksync/web.py
index 2a97082..f282d32 100644
--- a/src/nine_rtksync/web.py
+++ b/src/nine_rtksync/web.py
@@ -579,6 +579,31 @@ def serve_login_page(self, erro: str = "", mensagem: str = "") -> None:
"""Formulário de entrada: a porta do navegador para o painel."""
self.respond_html(self.pagina_de_login(self.resolve_language(), erro, mensagem=mensagem))
+ def eh_conexao_segura(self) -> bool:
+ """Determina se a requisição veio por canal seguro (HTTPS).
+
+ Detecta via proxy reverso (cabeçalhos X-Forwarded-Proto, X-Forwarded-Scheme,
+ X-Forwarded-Ssl, Front-End-Https) ou quando o SSO foi configurado com uma
+ base_url https://.
+ """
+ proto = (self.headers.get("X-Forwarded-Proto") or "").lower().strip()
+ if proto == "https":
+ return True
+ scheme = (self.headers.get("X-Forwarded-Scheme") or "").lower().strip()
+ if scheme == "https":
+ return True
+ if (self.headers.get("X-Forwarded-Ssl") or "").lower().strip() == "on":
+ return True
+ if (self.headers.get("Front-End-Https") or "").lower().strip() == "on":
+ return True
+ try:
+ cfg = self.configuracao_sso()
+ if cfg and str(cfg.get("base_url") or "").lower().strip().startswith("https://"):
+ return True
+ except Exception:
+ pass
+ return False
+
def handle_login(self) -> None:
"""Valida a credencial do formulário e emite o cookie de sessão."""
endereco = protecao.endereco_do_cliente(self.client_address)
@@ -627,7 +652,10 @@ def handle_login(self) -> None:
protecao.limpa_apos_sucesso(endereco)
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", "/")
- self.send_header("Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir(usuario)))
+ seguro = self.eh_conexao_segura()
+ self.send_header(
+ "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir(usuario), seguro=seguro)
+ )
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.end_headers()
@@ -637,8 +665,9 @@ def handle_logout(self) -> None:
self.authenticated_user = ""
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", "/login?logout=1")
- self.send_header("Set-Cookie", sessao.cabecalho_para_apagar())
- self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado())
+ seguro = self.eh_conexao_segura()
+ self.send_header("Set-Cookie", sessao.cabecalho_para_apagar(seguro=seguro))
+ self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro))
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Content-Length", "0")
@@ -683,7 +712,8 @@ def recusa_sso(self) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(corpo)))
- self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado())
+ seguro = self.eh_conexao_segura()
+ self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.write_body(corpo)
@@ -706,11 +736,12 @@ def pousa_sessao_federada(self, email: str) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(corpo)))
+ seguro = self.eh_conexao_segura()
self.send_header(
- "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir("sso:" + email))
+ "Set-Cookie", sessao.cabecalho_para_gravar(sessao.emitir("sso:" + email), seguro=seguro)
)
# O cookie de ida já cumpriu o papel: uso único.
- self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado())
+ self.send_header("Set-Cookie", sessao.cabecalho_para_apagar_estado(seguro=seguro))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.write_body(corpo)
@@ -801,10 +832,12 @@ def inicia_oidc(self) -> None:
self.send_header(
"Location", sso.url_de_autorizacao(cfg_obj, documento, state, nonce, verificador)
)
+ seguro = self.eh_conexao_segura()
self.send_header(
"Set-Cookie",
sessao.cabecalho_para_gravar_estado(
- sessao.emitir_estado_sso(state, nonce, verificador)
+ sessao.emitir_estado_sso(state, nonce, verificador),
+ seguro=seguro,
),
)
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
@@ -1203,9 +1236,11 @@ def handle_language(self, campos: Dict[str, List[str]]) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", destino)
+ seguro = self.eh_conexao_segura()
+ s = "; Secure" if seguro else ""
self.send_header(
"Set-Cookie",
- f"rtksync_lang={escolhido}; Path=/; Max-Age=31536000; SameSite=Lax",
+ f"rtksync_lang={escolhido}; Path=/; Max-Age=31536000; SameSite=Lax{s}",
)
self.send_header("Content-Length", "0")
self.end_headers()
diff --git a/tests/test_password_policy.py b/tests/test_password_policy.py
index 7cc5ea4..a435ca4 100644
--- a/tests/test_password_policy.py
+++ b/tests/test_password_policy.py
@@ -27,9 +27,13 @@ def test_reports_every_broken_rule_at_once(self):
self.assertIn("password.needs_digit", problems)
self.assertIn("password.needs_special", problems)
- def test_six_characters_is_the_floor(self):
- self.assertIn("password.too_short", validate_password_strength("Ab1!c"))
- self.assertEqual(validate_password_strength("Ab1!cd"), [])
+ def test_eight_characters_is_the_floor(self):
+ self.assertIn("password.too_short", validate_password_strength("Ab1!cde"))
+ self.assertEqual(validate_password_strength("Ab1!cdef"), [])
+
+ def test_twenty_characters_is_the_ceiling(self):
+ self.assertEqual(validate_password_strength("Ab1!" + "x" * 16), [])
+ self.assertIn("password.too_long", validate_password_strength("Ab1!" + "x" * 17))
def test_each_class_is_required(self):
self.assertEqual(validate_password_strength("ABC123!@"), ["password.needs_lower"])
diff --git a/tests/test_sessao.py b/tests/test_sessao.py
index 7cd4f26..1873759 100644
--- a/tests/test_sessao.py
+++ b/tests/test_sessao.py
@@ -73,6 +73,26 @@ def test_sair_apaga_cookie_de_estado_com_data_no_passado(self):
self.assertIn("Max-Age=0", cabecalho)
self.assertIn("Expires=Thu, 01 Jan 1970 00:00:00 GMT", cabecalho)
+ def test_cabecalho_seguro_inclui_secure(self):
+ cabecalho = sessao.cabecalho_para_gravar("qualquer", seguro=True)
+ self.assertIn("; Secure", cabecalho)
+ cabecalho_inseguro = sessao.cabecalho_para_gravar("qualquer", seguro=False)
+ self.assertNotIn("Secure", cabecalho_inseguro)
+
+ def test_apagar_seguro_inclui_secure(self):
+ cabecalho = sessao.cabecalho_para_apagar(seguro=True)
+ self.assertIn("; Secure", cabecalho)
+ cabecalho_inseguro = sessao.cabecalho_para_apagar(seguro=False)
+ self.assertNotIn("Secure", cabecalho_inseguro)
+
+ def test_estado_seguro_inclui_secure(self):
+ cabecalho = sessao.cabecalho_para_gravar_estado("qualquer", seguro=True)
+ self.assertIn("; Secure", cabecalho)
+ cabecalho_apagar = sessao.cabecalho_para_apagar_estado(seguro=True)
+ self.assertIn("; Secure", cabecalho_apagar)
+ cabecalho_inseguro = sessao.cabecalho_para_gravar_estado("qualquer", seguro=False)
+ self.assertNotIn("Secure", cabecalho_inseguro)
+
def test_assinatura_vinculada_ao_nome_do_cookie(self):
"""Um cookie emitido para um produto nao pode ser aceito por outro."""
import hmac
diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py
index aeef356..682a182 100644
--- a/tests/test_web_auth.py
+++ b/tests/test_web_auth.py
@@ -81,6 +81,37 @@ def test_dashboard_accepts_valid_auth(self):
self.assertEqual(data["status"], "online")
self.assertEqual(data["currentUser"], "admin")
+ def test_login_cookie_with_x_forwarded_proto_includes_secure(self):
+ """When behind an HTTPS reverse proxy (X-Forwarded-Proto: https), cookies must be Secure."""
+ import http.client
+ import urllib.parse
+ conn = http.client.HTTPConnection("127.0.0.1", self.settings.web_port, timeout=3.0)
+ body = urllib.parse.urlencode({"usuario": "admin", "senha": "testpassword"})
+ conn.request("POST", "/login", body=body, headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ "X-Forwarded-Proto": "https",
+ })
+ resp = conn.getresponse()
+ self.assertEqual(resp.status, 302)
+ cookie = resp.headers.get("Set-Cookie", "")
+ self.assertIn("; Secure", cookie)
+ conn.close()
+
+ def test_login_cookie_without_x_forwarded_proto_no_secure(self):
+ """When accessed over local plain HTTP without reverse proxy, cookies omit Secure."""
+ import http.client
+ import urllib.parse
+ conn = http.client.HTTPConnection("127.0.0.1", self.settings.web_port, timeout=3.0)
+ body = urllib.parse.urlencode({"usuario": "admin", "senha": "testpassword"})
+ conn.request("POST", "/login", body=body, headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ })
+ resp = conn.getresponse()
+ self.assertEqual(resp.status, 302)
+ cookie = resp.headers.get("Set-Cookie", "")
+ self.assertNotIn("Secure", cookie)
+ conn.close()
+
if __name__ == "__main__":
unittest.main()