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:
{esc(translate("password.policy", lang))}
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()