From 003cce97a6254d5f57a9f65583d5eec04220bf28 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 19 May 2025 10:57:09 -0300 Subject: [PATCH] =?UTF-8?q?For=C3=A7a=20mudan=C3=A7a=20de=20senha=20para?= =?UTF-8?q?=20senhas=20fracas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/base/forms.py | 22 ++++--- sapl/base/views.py | 21 ++++++- sapl/middleware.py | 21 +++++++ sapl/settings.py | 1 + sapl/templates/base/alterar_senha.html | 57 ++++++++++++++++- sapl/templates/base/login.html | 51 ++++++++++++++- sapl/utils.py | 86 +++++++++++++++----------- 7 files changed, 211 insertions(+), 48 deletions(-) create mode 100644 sapl/middleware.py diff --git a/sapl/base/forms.py b/sapl/base/forms.py index 092206429..754cca79e 100644 --- a/sapl/base/forms.py +++ b/sapl/base/forms.py @@ -40,7 +40,7 @@ from sapl.utils import (autor_label, autor_modal, ChoiceWithoutValidationField, FilterOverridesMetaMixin, FileFieldCheckMixin, ImageThumbnailFileInput, qs_override_django_filter, RANGE_ANOS, YES_NO_CHOICES, choice_tipos_normas, - GoogleRecapthaMixin, parlamentares_ativos, RANGE_MESES) + GoogleRecapthaMixin, parlamentares_ativos, RANGE_MESES, is_weak_password) from .models import AppConfig, CasaLegislativa @@ -288,8 +288,13 @@ class UserAdminForm(ModelForm): ) else: if new_password1 and new_password2: + if is_weak_password(new_password1): + raise forms.ValidationError(_( + 'A senha deve ter pelo menos 8 caracteres e incluir uma combinação ' + 'de letras maiúsculas e minúsculas, números e caracteres especiais.' + )) password_validation.validate_password( - new_password2, self.instance) + new_password1, self.instance) parlamentar = data.get('parlamentar', None) if parlamentar and parlamentar.votante_set.exists() and \ @@ -926,12 +931,12 @@ class CasaLegislativaForm(FileFieldCheckMixin, ModelForm): class LoginForm(AuthenticationForm): username = forms.CharField( - label="Username", max_length=30, + label="Usuário", max_length=30, widget=forms.TextInput( attrs={ 'class': 'form-control', 'name': 'username'})) password = forms.CharField( - label="Password", max_length=30, + label="Senha", max_length=30, widget=forms.PasswordInput( attrs={ 'class': 'form-control', 'name': 'password'})) @@ -1139,12 +1144,15 @@ class AlterarSenhaForm(Form): # TODO: caracteres alfanuméricos, maiúsculas (?), # TODO: senha atual igual a senha anterior, etc - if len(new_password1) < 6: + if is_weak_password(new_password1): self.logger.warning( - 'A senha informada não tem o mínimo de 6 caracteres.' + 'A senha deve ter pelo menos 8 caracteres e incluir uma combinação ' + 'de letras maiúsculas e minúsculas, números e caracteres especiais.' ) raise ValidationError( - "A senha informada deve ter no mínimo 6 caracteres") + 'A senha deve ter pelo menos 8 caracteres e incluir uma combinação ' + 'de letras maiúsculas e minúsculas, números e caracteres especiais.' + ) username = data['username'] old_password = data['old_password'] diff --git a/sapl/base/views.py b/sapl/base/views.py index 8417c0021..ce3a0a717 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -6,7 +6,7 @@ import os from django.apps.registry import apps from django.contrib import messages -from django.contrib.auth import get_user_model, views +from django.contrib.auth import authenticate, login, get_user_model, views from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.models import Group from django.contrib.auth.tokens import default_token_generator @@ -51,7 +51,7 @@ from sapl.sessao.models import (Bancada, SessaoPlenaria) from sapl.settings import EMAIL_SEND_USER from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, - get_client_ip, sapn_is_enabled) + get_client_ip, sapn_is_enabled, is_weak_password) from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .models import AppConfig, CasaLegislativa @@ -75,6 +75,21 @@ class LoginSapl(views.LoginView): template_name = 'base/login.html' authentication_form = LoginForm + def form_valid(self, form): + """Override do comportamento padrão para verificar senha fraca""" + username = form.cleaned_data.get('username') + password = form.cleaned_data.get('password') + + user = authenticate(self.request, username=username, password=password) + if user is not None: + login(self.request, user) + if is_weak_password(password): + self.request.session['weak_password'] = True + return redirect(self.get_success_url()) + + # Fallback se falhar a autenticação (tecnicamente não devia chegar aqui) + return super().form_invalid(form) + class ConfirmarEmailView(TemplateView): template_name = "email/confirma.html" @@ -1481,6 +1496,8 @@ class AlterarSenha(FormView): user.set_password(new_password) user.save() + self.request.session.pop('weak_password', None) + return super().form_valid(form) diff --git a/sapl/middleware.py b/sapl/middleware.py new file mode 100644 index 000000000..7a8d4ff65 --- /dev/null +++ b/sapl/middleware.py @@ -0,0 +1,21 @@ +import logging + +from django.shortcuts import redirect +from django.urls import reverse + + +class CheckWeakPasswordMiddleware: + logger = logging.getLogger(__name__) + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated and \ + request.session.get('weak_password', False) and \ + request.path != reverse('sapl.base:alterar_senha') and \ + request.path != reverse('sapl.base:logout'): + logging.warning(f"Usuário {request.user.username} possui senha fraca.") + return redirect('sapl.base:alterar_senha') + + return self.get_response(request) diff --git a/sapl/settings.py b/sapl/settings.py index 462cb8de5..956cf3138 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -141,6 +141,7 @@ MIDDLEWARE = [ 'whitenoise.middleware.WhiteNoiseMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', 'waffle.middleware.WaffleMiddleware', + 'sapl.middleware.CheckWeakPasswordMiddleware', ] if DEBUG: INSTALLED_APPS += ('debug_toolbar',) diff --git a/sapl/templates/base/alterar_senha.html b/sapl/templates/base/alterar_senha.html index 20956a763..6a824628f 100644 --- a/sapl/templates/base/alterar_senha.html +++ b/sapl/templates/base/alterar_senha.html @@ -3,10 +3,65 @@ {% load crispy_forms_tags %} {% block actions %}{% endblock %} {% block detail_content %} + + + + + {%if request.session.weak_password %} + + {% endif %}

Alterar Senha

{% crispy form %}
Atenção, a mudança de senha fará com que o usuário atual seja deslogado do sistema.
Favor entrar novamente com a nova senha após a mudança com sucesso. -{% endblock detail_content %} \ No newline at end of file +{% endblock detail_content %} +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/sapl/templates/base/login.html b/sapl/templates/base/login.html index 1c9abbb9d..11f2bfcec 100644 --- a/sapl/templates/base/login.html +++ b/sapl/templates/base/login.html @@ -13,6 +13,11 @@ {% endif %} --> + + + + +
@@ -32,11 +37,11 @@ {% endif %} -

Usuário

+

{{form.username.label}}

{{ form.username }} -

Senha

+

{{form.password.label}}

{{ form.password }} @@ -67,5 +72,45 @@ {% if not user.is_authenticated %} $("#autenticacao") .css("display","none"); {% endif %} + + $(document).ready(function () { + $("input[type='password']").each(function () { + const $input = $(this); + + // Avoid re-wrapping + if ($input.parent().hasClass("password-container")) return; + + // Wrap in relative container + const $wrapper = $('
'); + $input.wrap($wrapper); + + // Style input to have padding-right for icon + $input.css("padding-right", "2.5rem"); + + // Create the eye icon + const $eyeIcon = $(` + + `); + + // Insert icon after input + $input.after($eyeIcon); + + // Toggle logic + $eyeIcon.on("click", function () { + const isPassword = $input.attr("type") === "password"; + $input.attr("type", isPassword ? "text" : "password"); + $(this).toggleClass("bi-eye bi-eye-slash"); + }); + }); + }); -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/sapl/utils.py b/sapl/utils.py index 0733d8152..16b79993b 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -1,4 +1,5 @@ import csv +import string from functools import wraps import hashlib import io @@ -24,7 +25,7 @@ from django.contrib.contenttypes.fields import (GenericForeignKey, GenericRel, GenericRelation) from django.core.exceptions import ValidationError from django.core.files.storage import FileSystemStorage -from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile,\ +from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile, \ TemporaryUploadedFile from django.core.mail import get_connection from django.db import models @@ -47,7 +48,6 @@ from sapl.crispy_layout_mixin import (form_actions, SaplFormHelper, SaplFormLayout, to_row) from sapl.settings import MAX_DOC_UPLOAD_SIZE - # (26/10/2018): O separador foi mudador de '/' para 'K' # por conta dos leitores de códigos de barra, que trocavam # a '/' por '&' ou ';' @@ -55,6 +55,28 @@ SEPARADOR_HASH_PROPOSICAO = 'K' TIME_PATTERN = '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$' +MIN_PASSWORD_LENGTH = 8 + + +def is_weak_password(password): + pwd_has_lowercase = False + pwd_has_uppercase = False + pwd_has_number = False + pwd_has_special_char = False + + for c in password: + if c.isdigit(): + pwd_has_number = True + elif c.islower(): + pwd_has_lowercase = True + elif c.isupper(): + pwd_has_uppercase = True + elif c in list(string.punctuation): + pwd_has_special_char = True + + return len(password) < MIN_PASSWORD_LENGTH or not (pwd_has_lowercase and pwd_has_uppercase + and pwd_has_number and pwd_has_special_char) + def groups_remove_user(user, groups_name): from django.contrib.auth.models import Group @@ -92,9 +114,11 @@ def num_materias_por_tipo(qs, attr_tipo='tipo'): qtdes = {} if attr_tipo == 'tipo': - def sort_function(m): return m.tipo + def sort_function(m): + return m.tipo else: - def sort_function(m): return m.materia.tipo + def sort_function(m): + return m.materia.tipo # select_related eh importante por questoes de desempenho, pois caso # contrario ele realizara uma consulta ao banco para cada iteracao, @@ -113,12 +137,12 @@ def validar_arquivo(arquivo, nome_campo): raise ValidationError( "Certifique-se de que o nome do arquivo no " "campo '" + nome_campo + "' tenha no máximo 200 caracteres " - "(ele possui {})".format(len(arquivo.name)) + "(ele possui {})".format(len(arquivo.name)) ) if arquivo.size > MAX_DOC_UPLOAD_SIZE: raise ValidationError( "O arquivo " + nome_campo + " deve ser menor que " - "{0:.1f} mb, o tamanho atual desse arquivo é {1:.1f} mb".format( + "{0:.1f} mb, o tamanho atual desse arquivo é {1:.1f} mb".format( (MAX_DOC_UPLOAD_SIZE / 1024) / 1024, (arquivo.size / 1024) / 1024 ) @@ -148,7 +172,6 @@ def dont_break_out(value, max_part=50): def clear_thumbnails_cache(queryset, field): - for r in queryset: assert hasattr(r, field), _( 'Objeto da listagem não possui o campo informado') @@ -213,6 +236,7 @@ def montar_row_autor(name): return autor_row + # TODO: Esta função é utilizada? @@ -284,7 +308,6 @@ class SaplGenericRelation(GenericRelation): """ def __init__(self, to, fields_search=(), **kwargs): - assert 'related_query_name' in kwargs, _( 'SaplGenericRelation não pode ser instanciada sem ' 'related_query_name.') @@ -337,8 +360,8 @@ class RangeWidgetOverride(forms.MultiWidget): ) ) - html = '
%s
%s
'\ - % tuple(rendered_widgets) + html = '
%s
%s
' \ + % tuple(rendered_widgets) return '
%s
' % html @@ -355,8 +378,8 @@ class CustomSplitDateTimeWidget(SplitDateTimeWidget): ) ) - html = '
%s
%s
'\ - % tuple(rendered_widgets) + html = '
%s
%s
' \ + % tuple(rendered_widgets) return '
%s
' % html @@ -417,7 +440,6 @@ YES_NO_CHOICES = [(True, _('Sim')), (False, _('Não'))] def listify(function): - @wraps(function) def f(*args, **kwargs): return list(function(*args, **kwargs)) @@ -613,7 +635,6 @@ TIPOS_IMG_PERMITIDOS = ( def fabrica_validador_de_tipos_de_arquivo(lista, nome): - def restringe_tipos_de_arquivo(value): filename = value.name if type(value) in ( @@ -649,7 +670,6 @@ def intervalos_tem_intersecao(a_inicio, a_fim, b_inicio, b_fim): class MateriaPesquisaOrderingFilter(django_filters.OrderingFilter): - choices = ( ('', 'Selecione'), ('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'), @@ -675,7 +695,6 @@ class MateriaPesquisaOrderingFilter(django_filters.OrderingFilter): class NormaPesquisaOrderingFilter(django_filters.OrderingFilter): - choices = ( ('', 'Selecione'), ('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'), @@ -733,7 +752,6 @@ class FileFieldCheckMixin(BaseForm): class AnoNumeroOrderingFilter(django_filters.OrderingFilter): - choices = (('DEC', 'Ordem Decrescente'), ('CRE', 'Ordem Crescente'),) order_by_mapping = { @@ -774,8 +792,8 @@ def models_with_gr_for_model(model): lambda x: x.related_model, filter( lambda obj: obj.is_relation and - hasattr(obj, 'field') and - isinstance(obj, GenericRel), + hasattr(obj, 'field') and + isinstance(obj, GenericRel), model._meta.get_fields(include_hidden=True)) )) @@ -802,9 +820,9 @@ def generic_relations_for_model(model): lambda x: (x, list(filter( lambda field: ( - isinstance( - field, SaplGenericRelation) and - field.related_model == model), + isinstance( + field, SaplGenericRelation) and + field.related_model == model), x._meta.get_fields(include_hidden=True)))), models_with_gr_for_model(model) )) @@ -852,13 +870,13 @@ def texto_upload_path(instance, filename, subpath='', pk_first=False): subpath = '_' path = str_path % \ - { - 'prefix': prefix, - 'model_name': instance._meta.model_name, - 'pk': instance.pk, - 'subpath': subpath, - 'filename': filename - } + { + 'prefix': prefix, + 'model_name': instance._meta.model_name, + 'pk': instance.pk, + 'subpath': subpath, + 'filename': filename + } return path @@ -1040,7 +1058,6 @@ def remover_acentos(string): def mail_service_configured(request=None): - logger = logging.getLogger(__name__) if settings.EMAIL_RUNNING is None: @@ -1079,6 +1096,7 @@ def timing(f): logger.info('funcao:%r args:[%r, %r] took: %2.4f sec' % (f.__name__, args, kw, te - ts)) return result + return wrap @@ -1150,7 +1168,6 @@ def get_tempfile_dir(): class GoogleRecapthaMixin: - logger = logging.getLogger(__name__) def __init__(self, *args, **kwargs): @@ -1163,9 +1180,9 @@ class GoogleRecapthaMixin: row1 = to_row( [ (Div( - css_class="g-recaptcha float-right", # if not settings.DEBUG else '', - data_sitekey=AppConfig.attr('google_recaptcha_site_key') - ), 5), + css_class="g-recaptcha float-right", # if not settings.DEBUG else '', + data_sitekey=AppConfig.attr('google_recaptcha_site_key') + ), 5), ('email', 7), ] @@ -1296,7 +1313,6 @@ def get_path_to_name_report_map(): class MultiFormatOutputMixin: - formats_impl = 'csv', 'xlsx', 'json' queryset_values_for_formats = True