Browse Source

Força mudança de senha para senhas fracas

3.1.x
Edward Ribeiro 2 weeks ago
parent
commit
003cce97a6
  1. 22
      sapl/base/forms.py
  2. 21
      sapl/base/views.py
  3. 21
      sapl/middleware.py
  4. 1
      sapl/settings.py
  5. 57
      sapl/templates/base/alterar_senha.html
  6. 51
      sapl/templates/base/login.html
  7. 86
      sapl/utils.py

22
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']

21
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)

21
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)

1
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',)

57
sapl/templates/base/alterar_senha.html

@ -3,10 +3,65 @@
{% load crispy_forms_tags %}
{% block actions %}{% endblock %}
{% block detail_content %}
<head>
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
</head>
{%if request.session.weak_password %}
<div class="alert alert-danger" role="alert">
A senha utilizada pelo usuário <strong>{{request.user.username}}</strong> é fraca!
Uma senha forte deve ter pelo menos 8 caracteres e incluir uma combinação de letras maiúsculas
e minúsculas, números e caracteres especiais (por exemplo, !, $, #, @). Evitem reutilizar
senhas antigas ou senhas utilizadas em outros serviços.
</div>
{% endif %}
<h1>Alterar Senha</h1>
{% crispy form %}
</br>
Atenção, a mudança de senha fará com que o usuário atual seja deslogado do sistema.</br>
Favor entrar novamente com a nova senha após a mudança com sucesso.
{% endblock detail_content %}
{% endblock detail_content %}
{% block extra_js %}
<script>
$(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 = $('<div class="password-container position-relative mb-3"></div>');
$input.wrap($wrapper);
// Style input to have padding-right for icon
$input.css("padding-right", "2.5rem");
// Create the eye icon
const $eyeIcon = $(`
<i class="bi bi-eye password-toggle-icon"
style="
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
font-size: 1.2rem;
"></i>
`);
// 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");
});
});
});
</script>
{% endblock %}

51
sapl/templates/base/login.html

@ -13,6 +13,11 @@
{% endif %} -->
<head>
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
</head>
<div class="container mb-3">
<div class="row">
<div class="col-lg-4 offset-lg-4 col-8 offset-2">
@ -32,11 +37,11 @@
{% endif %}
<tr>
<p><b><center>Usuário</center></b></p>
<p><b><center>{{form.username.label}}</center></b></p>
{{ form.username }}
</tr>
<tr>
<p><b><center>Senha</center></b></p>
<p><b><center>{{form.password.label}}</center></b></p>
{{ form.password }}
</tr>
</table>
@ -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 = $('<div class="password-container position-relative mb-3"></div>');
$input.wrap($wrapper);
// Style input to have padding-right for icon
$input.css("padding-right", "2.5rem");
// Create the eye icon
const $eyeIcon = $(`
<i class="bi bi-eye password-toggle-icon"
style="
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
font-size: 1.2rem;
"></i>
`);
// 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");
});
});
});
</script>
{% endblock %}
{% endblock %}

86
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 = '<div class="col-sm-6">%s</div><div class="col-sm-6">%s</div>'\
% tuple(rendered_widgets)
html = '<div class="col-sm-6">%s</div><div class="col-sm-6">%s</div>' \
% tuple(rendered_widgets)
return '<div class="row">%s</div>' % html
@ -355,8 +378,8 @@ class CustomSplitDateTimeWidget(SplitDateTimeWidget):
)
)
html = '<div class="col-6">%s</div><div class="col-6">%s</div>'\
% tuple(rendered_widgets)
html = '<div class="col-6">%s</div><div class="col-6">%s</div>' \
% tuple(rendered_widgets)
return '<div class="row">%s</div>' % 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

Loading…
Cancel
Save