adds mail sending to admin if failure of api HIBP request to check if password is compromised

This commit is contained in:
sebastien 2024-11-05 15:43:15 +01:00
parent 093397256b
commit ca7f718353
6 changed files with 161 additions and 11 deletions

View file

@ -16,6 +16,7 @@ from canaille.app.i18n import gettext as _
from canaille.app.i18n import locale_selector from canaille.app.i18n import locale_selector
from canaille.app.i18n import timezone_selector from canaille.app.i18n import timezone_selector
from canaille.backends import Backend from canaille.backends import Backend
from canaille.core.mails import send_comprimised_password_check_failure_mail
from . import validate_uri from . import validate_uri
from .flask import request_is_htmx from .flask import request_is_htmx
@ -84,7 +85,7 @@ def password_strength_calculator(password):
return strength_score return strength_score
def pwned_password_validator(form, field): def compromised_password_validator(form, field):
try: try:
from hashlib import sha1 from hashlib import sha1
@ -94,18 +95,32 @@ def pwned_password_validator(form, field):
hashed_password = sha1(field.data.encode("utf-8")).hexdigest() hashed_password = sha1(field.data.encode("utf-8")).hexdigest()
hashed_password_splited = (hashed_password[:5].upper(), hashed_password[5:].upper()) hashed_password_splited = (hashed_password[:5].upper(), hashed_password[5:].upper())
api_url = f"https://api.pwnedpasswords.com/range/{hashed_password_splited[0]}"
try: try:
response = requests.api.get( response = requests.api.get(api_url, timeout=10)
f"https://api.pwnedpasswords.com/range/{hashed_password_splited[0]}", except Exception as e:
timeout=10,
)
except requests.exceptions.HTTPError as e:
print("Error: " + str(e)) print("Error: " + str(e))
if current_app.features.has_smtp and not request_is_htmx():
if form.user is not None:
user_name = form.user.user_name
user_email = form.user.emails[0]
else:
user_name = form["user_name"].data
user_email = form["emails"].data[0]
send_comprimised_password_check_failure_mail(
api_url, user_name, user_email, hashed_password
)
return None
decoded_response = response.content.decode("utf8").split("\r\n") decoded_response = response.content.decode("utf8").split("\r\n")
for each in decoded_response: for each in decoded_response:
if hashed_password_splited[1] == each.split(":")[0]: if hashed_password_splited[1] in each.split(":")[0]:
raise wtforms.ValidationError(_("This password is compromised.")) raise wtforms.ValidationError(_("This password is compromised."))

View file

@ -34,10 +34,10 @@ from canaille.app.flask import smtp_needed
from canaille.app.flask import user_needed from canaille.app.flask import user_needed
from canaille.app.forms import IDToModel from canaille.app.forms import IDToModel
from canaille.app.forms import TableForm from canaille.app.forms import TableForm
from canaille.app.forms import compromised_password_validator
from canaille.app.forms import is_readonly from canaille.app.forms import is_readonly
from canaille.app.forms import password_length_validator from canaille.app.forms import password_length_validator
from canaille.app.forms import password_too_long_validator from canaille.app.forms import password_too_long_validator
from canaille.app.forms import pwned_password_validator
from canaille.app.forms import set_readonly from canaille.app.forms import set_readonly
from canaille.app.forms import set_writable from canaille.app.forms import set_writable
from canaille.app.i18n import gettext as _ from canaille.app.i18n import gettext as _
@ -317,7 +317,7 @@ def registration(data=None, hash=None):
wtforms.validators.DataRequired(), wtforms.validators.DataRequired(),
password_length_validator, password_length_validator,
password_too_long_validator, password_too_long_validator,
pwned_password_validator, compromised_password_validator,
] ]
form["password2"].validators = [ form["password2"].validators = [
wtforms.validators.DataRequired(), wtforms.validators.DataRequired(),

View file

@ -10,12 +10,12 @@ from canaille.app.forms import BaseForm
from canaille.app.forms import DateTimeUTCField from canaille.app.forms import DateTimeUTCField
from canaille.app.forms import Form from canaille.app.forms import Form
from canaille.app.forms import IDToModel from canaille.app.forms import IDToModel
from canaille.app.forms import compromised_password_validator
from canaille.app.forms import email_validator from canaille.app.forms import email_validator
from canaille.app.forms import is_uri from canaille.app.forms import is_uri
from canaille.app.forms import password_length_validator from canaille.app.forms import password_length_validator
from canaille.app.forms import password_too_long_validator from canaille.app.forms import password_too_long_validator
from canaille.app.forms import phone_number from canaille.app.forms import phone_number
from canaille.app.forms import pwned_password_validator
from canaille.app.forms import set_readonly from canaille.app.forms import set_readonly
from canaille.app.forms import unique_values from canaille.app.forms import unique_values
from canaille.app.i18n import gettext from canaille.app.i18n import gettext
@ -266,7 +266,7 @@ PROFILE_FORM_FIELDS = dict(
wtforms.validators.Optional(), wtforms.validators.Optional(),
password_length_validator, password_length_validator,
password_too_long_validator, password_too_long_validator,
pwned_password_validator, compromised_password_validator,
], ],
render_kw={ render_kw={
"autocomplete": "new-password", "autocomplete": "new-password",

View file

@ -210,3 +210,43 @@ def send_registration_mail(email, registration_url):
html=html_body, html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None, attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
) )
def send_comprimised_password_check_failure_mail(
check_password_url, user_name, user_email, hashed_password
):
base_url = url_for("core.account.index", _external=True)
logo_cid, logo_filename, logo_raw = logo()
subject = _("Pwned password check incident on {website_name}").format(
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/pwned-password-non-checked.txt",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
check_password_url=check_password_url,
user_name=user_name,
user_email=user_email,
hashed_password=hashed_password,
)
html_body = render_template(
"mails/pwned-password-non-checked.html",
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
check_password_url=check_password_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
title=subject,
user_name=user_name,
user_email=user_email,
hashed_password=hashed_password,
)
return send_email(
subject=subject,
# line to change with admin group mails....
recipient="sebastien@yaal.coop",
text=text_body,
html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
)

View file

@ -0,0 +1,81 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width"/>
<style type="text/css" style="font-weight: 300">@import url({{ url_for('static', filename='fonts/lato.css', _external=True) }});</style>
<title>{{ title }}</title>
</head>
<body style="color: rgba(0,0,0,.87); padding: 1em; margin: auto; width: 700px; font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; font-weight: 400; font-size: 14px;">
<table cellspacing="0" cellpadding="0" border="0" style="font-weight: 400; background: #fff; font-size: 1rem; margin-top: 0; margin-bottom: 0; width: 700px;">
<tr>
<td colspan="2">
<h3 style="font-weight: 700; line-height: 1.3em; font-size: 1.3rem; padding: .8rem 1rem; margin: 0; box-shadow: none; border: 1px solid #d4d4d5; border-radius: .3rem .3rem 0 0;">
{% if logo %}
<img src="{{ logo }}" alt="{{ site_name }}" style="font-size: 1.3rem; border-style: none; width: 50px; display: inline-block; margin-top: .14em; vertical-align: middle;">
{% endif %}
<div style="font-size: 1.3rem; display: inline-block; padding-left: .75rem; vertical-align: middle;">
{% trans %}Verification failed if password is compromised.{% endtrans %}
</div>
</h3>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}Our services were unable to verify if the {{ user_name }}'s password is compromised.{% endtrans %}</br>
</p>
<p>
{% trans %}You have to check manually if the new password of the user {{ user_name }} is compromised.{% endtrans %}</br>
{% trans %}Follow this steps : {% endtrans %}</br>
{% trans %}1. click on the link above "Check if password is pwned"{% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td colspan="2" style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="{{ check_password_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Check if password is compromised{% endtrans %}</a>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}2. in the page that will open, search the following hashed password in the page : {{ hashed_password }}{% endtrans %}</br>
{% trans %}3. if the password is in the list :{% endtrans %}</br>
{% trans %}3.1. open this link and reset user's password. {% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td colspan="2" style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="http://127.0.0.1:5000/profile/{{ user_name }}/settings" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %} Reset {{ user_name }}'s password {% endtrans %}</a>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}3.2. send an email to the user to explain the situation : {{ user_email }}{% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td style="background: #e0e1e2; width:50%; border-radius: 0 0 0 .3rem;">
<a href="{{ site_url }}" style="width: 100%; display: inline-block; vertical-align: middle; color: rgba(0,0,0,.6); padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem;margin: 0; border-left: none; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{{ site_name }}</a>
</td>
<td style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="{{ check_password_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Check if password is compromised{% endtrans %}</a>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,14 @@
# {% trans %}Verification failed if password is compromised.{% endtrans %}
{% trans %}Our services were unable to verify if the http://127.0.0.1:5000/profile/{{ user_name }}/settings's password is compromised.{% endtrans %}
{% trans %}You have to check manually if the new password of the user {{ user_name }} is compromised.{% endtrans %}
{% trans %}Follow this steps : {% endtrans %}
{% trans %}1. click on the link above "Check if password is pwned"{% endtrans %}
{% trans %}2. in the page that will open, search the following hashed password in the page : {{ hashed_password }}{% endtrans %}
{% trans %}3. if the password is in the list :{% endtrans %}
{% trans %}3.1. open this link http://127.0.0.1:5000/profile/{{ user_name }}/settings and reset user's password.{% endtrans %}
{% trans %}3.2. send an email to the user to explain the situation : {{ user_email }}.{% endtrans %}
{% trans %}Check if password is compromised{% endtrans %}: {{ check_password_url }}
{{ site_name }}: {{ site_url }}