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
16 changes: 10 additions & 6 deletions src/nine_rtksync/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "!@#$%^&*()-_=+[]{};:,.<>?/\\|`~\"'"


Expand All @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/nine_rtksync/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/nine_rtksync/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/nine_rtksync/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/nine_rtksync/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,8 @@ def render_credentials_modal(auth_from_env: bool, lang: str) -> str:
<div class="mb-3">
<label class="form-label" for="novaSenha">{esc(translate("auth.new_password", lang))}</label>
<input type="password" class="form-control" id="novaSenha" name="password"
minlength="6" autocomplete="new-password" required
pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{{6,}}"
minlength="8" maxlength="20" autocomplete="new-password" required
pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{{8,20}}"
title="{esc(translate("password.policy", lang))}">
<div class="form-text">{esc(translate("password.policy", lang))}</div>
</div>
Expand Down
27 changes: 15 additions & 12 deletions src/nine_rtksync/sessao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)


Expand Down Expand Up @@ -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}"
)


Expand Down
51 changes: 43 additions & 8 deletions src/nine_rtksync/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
logins-pathbit marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 7 additions & 3 deletions tests/test_password_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
20 changes: 20 additions & 0 deletions tests/test_sessao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_web_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading