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 timezone_selector
from canaille.backends import Backend
from canaille.core.mails import send_comprimised_password_check_failure_mail
from . import validate_uri
from .flask import request_is_htmx
@ -84,7 +85,7 @@ def password_strength_calculator(password):
return strength_score
def pwned_password_validator(form, field):
def compromised_password_validator(form, field):
try:
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_splited = (hashed_password[:5].upper(), hashed_password[5:].upper())
api_url = f"https://api.pwnedpasswords.com/range/{hashed_password_splited[0]}"
try:
response = requests.api.get(
f"https://api.pwnedpasswords.com/range/{hashed_password_splited[0]}",
timeout=10,
)
except requests.exceptions.HTTPError as e:
response = requests.api.get(api_url, timeout=10)
except Exception as 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")
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."))

View file

@ -34,10 +34,10 @@ from canaille.app.flask import smtp_needed
from canaille.app.flask import user_needed
from canaille.app.forms import IDToModel
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 password_length_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_writable
from canaille.app.i18n import gettext as _
@ -317,7 +317,7 @@ def registration(data=None, hash=None):
wtforms.validators.DataRequired(),
password_length_validator,
password_too_long_validator,
pwned_password_validator,
compromised_password_validator,
]
form["password2"].validators = [
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 Form
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 is_uri
from canaille.app.forms import password_length_validator
from canaille.app.forms import password_too_long_validator
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 unique_values
from canaille.app.i18n import gettext
@ -266,7 +266,7 @@ PROFILE_FORM_FIELDS = dict(
wtforms.validators.Optional(),
password_length_validator,
password_too_long_validator,
pwned_password_validator,
compromised_password_validator,
],
render_kw={
"autocomplete": "new-password",

View file

@ -210,3 +210,43 @@ def send_registration_mail(email, registration_url):
html=html_body,
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 }}