Password mechanism recovery. Fixes #3

This commit is contained in:
Éloi Rivard 2020-10-22 17:37:01 +02:00
parent 7747a64570
commit 2fc6af0fc9
10 changed files with 327 additions and 18 deletions

View file

@ -1,8 +1,12 @@
import email.message
import hashlib
import smtplib
from flask import Blueprint, request, flash, url_for, current_app
from flask import render_template, redirect
from flask_babel import gettext
from flask_babel import gettext as _
from .forms import LoginForm, ProfileForm
from .forms import LoginForm, ProfileForm, PasswordResetForm, ForgottenPasswordForm
from .flaskutils import current_user, user_needed
from .models import User
@ -25,7 +29,7 @@ def login():
if not form.validate() or not User.authenticate(
form.login.data, form.password.data, True
):
flash(gettext("Login failed, please check your information"), "error")
flash(_("Login failed, please check your information"), "error")
return render_template("login.html", form=form)
return redirect(url_for("canaille.account.index"))
@ -54,7 +58,7 @@ def profile(user):
if request.form:
if not form.validate():
flash(gettext("Profile edition failed."), "error")
flash(_("Profile edition failed."), "error")
else:
for attribute in form:
@ -65,8 +69,106 @@ def profile(user):
user[model_attribute_name] = [attribute.data]
if not form.password1.data or user.set_password(form.password1.data):
flash(gettext("Profile updated successfuly."), "success")
flash(_("Profile updated successfuly."), "success")
user.save()
return render_template("profile.html", form=form, menuitem="profile")
def profile_hash(user, password):
return hashlib.sha256(
current_app.config["SECRET_KEY"].encode("utf-8")
+ user.encode("utf-8")
+ password.encode("utf-8")
).hexdigest()
@bp.route("/reset", methods=["GET", "POST"])
def forgotten():
form = ForgottenPasswordForm(request.form)
if not request.form:
return render_template("forgotten-password.html", form=form)
if not form.validate():
flash(_("Could not send the password reset link."), "error")
return render_template("forgotten-password.html", form=form)
user = User.get(form.login.data)
if not user:
flash(
_("A password reset link has been sent at your email address."), "success"
)
return render_template("forgotten-password.html", form=form)
recipient = user.mail
base_url = current_app.config.get("URL") or request.base_url
url = base_url + url_for(
"canaille.account.reset",
uid=user.uid,
hash=profile_hash(user.uid[0], user.userPassword[0]),
)
subject = _("Password reset on {website_name}").format(
website_name=current_app.config.get("NAME", url)
)
text_body = _(
"To reset your password on {website_name}, visit the following link :\n{url}"
).format(website_name=current_app.config.get("NAME", url), url=url)
msg = email.message.EmailMessage()
msg.set_content(text_body)
msg["Subject"] = subject
msg["From"] = current_app.config["SMTP"]["FROM_ADDR"]
msg["To"] = recipient
success = True
try:
with smtplib.SMTP(
host=current_app.config["SMTP"]["HOST"],
port=current_app.config["SMTP"]["PORT"],
) as smtp:
if current_app.config["SMTP"].get("TLS"):
smtp.starttls()
if current_app.config["SMTP"].get("LOGIN"):
smtp.login(
user=current_app.config["SMTP"]["LOGIN"],
password=current_app.config["SMTP"].get("PASSWORD"),
)
smtp.send_message(msg)
except smtplib.SMTPRecipientsRefused:
pass
except OSError:
flash(_("Could not reset your password"), "error")
success = False
if success:
flash(
_("A password reset link has been sent at your email address."), "success"
)
return render_template("forgotten-password.html", form=form)
@bp.route("/reset/<uid>/<hash>", methods=["GET", "POST"])
def reset(uid, hash):
form = PasswordResetForm(request.form)
user = User.get(uid)
if not user or hash != profile_hash(user.uid[0], user.userPassword[0]):
flash(
_("The password reset link that brought you here was invalid."),
"error",
)
return redirect(url_for("canaille.account.index"))
if request.form and form.validate():
user.set_password(form.password.data)
user.login()
flash(_("Your password has been updated successfuly"), "success")
return redirect(url_for("canaille.account.profile", user_id=uid))
return render_template("reset-password.html", form=form, uid=uid, hash=hash)

View file

@ -4,6 +4,9 @@ SECRET_KEY = "change me before you go in production"
# Your organization name.
NAME = "MyDomain"
# The interface on which canaille will be served
# URL = "https://auth.mydomain.tld"
# You can display a logo to be recognized on login screens
# LOGO = "https://path/to/your/organization/logo.png"
@ -59,3 +62,11 @@ FAMILY_NAME = "sn"
PREFERRED_USERNAME = "displayName"
LOCALE = "preferredLanguage"
PICTURE = "photo"
[SMTP]
HOST = "localhost"
PORT = 25
TLS = false
LOGIN = "smtp_user"
PASSWORD = "smtp_password"
FROM_ADDR = "admin@mydomain.tld"

View file

@ -8,7 +8,7 @@ class LoginForm(FlaskForm):
_("Login"),
validators=[wtforms.validators.DataRequired()],
render_kw={
"placeholder": "mdupont",
"placeholder": _("jane@doe.com"),
"spellcheck": "false",
"autocorrect": "off",
"inputmode": "email",
@ -20,6 +20,32 @@ class LoginForm(FlaskForm):
)
class ForgottenPasswordForm(FlaskForm):
login = wtforms.StringField(
_("Login"),
validators=[wtforms.validators.DataRequired()],
render_kw={
"placeholder": _("jane@doe.com"),
"spellcheck": "false",
"autocorrect": "off",
},
)
class PasswordResetForm(FlaskForm):
password = wtforms.PasswordField(
_("Password"), validators=[wtforms.validators.DataRequired()]
)
confirmation = wtforms.PasswordField(
_("Password confirmation"),
validators=[
wtforms.validators.EqualTo(
"password", _("Password and confirmation do not match.")
),
],
)
class ProfileForm(FlaskForm):
sub = wtforms.StringField(
_("Username"),
@ -73,4 +99,4 @@ class ProfileForm(FlaskForm):
def validate_password2(self, field):
if self.password1.data and self.password1.data != field.data:
raise wtforms.ValidationError(_("Password and confirmation are different."))
raise wtforms.ValidationError(_("Password and confirmation do not match."))

View file

@ -0,0 +1,40 @@
{% extends 'base.html' %}
{% import 'fomanticui.j2' as sui %}
{% block content %}
<div class="loginform">
<h3 class="ui top attached header">
{% trans %}Forgotten password{% endtrans %}
</h3>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="ui attached message {{ category }}">
{{ message }}
</div>
{% endfor %}
{% endwith %}
<div class="ui attached message">
{% trans %}
After this form is sent, if the email address or the login you provided
exists, you will receive an email containing a link that will allow you
to reset your password.
{% endtrans %}
</div>
<div class="ui attached clearing segment">
<form method="POST"
id="{{ form.id or form.__class__.__name__|lower }}"
action="{{ request.url }}"
role="form"
class="ui form"
>
{{ form.hidden_tag() if form.hidden_tag }}
{{ sui.render_field(form.login, icon="user") }}
<button type="submit" class="ui right floated primary button">{{ _("Sign in") }}</button>
<a type="button" class="ui right floated button" href="{{ url_for('canaille.account.login') }}">{{ _("Login page") }}</a>
</form>
</div>
</div>
{% endblock %}

View file

@ -36,6 +36,7 @@
{{ sui.render_field(form.password, icon="lock") }}
<button type="submit" class="ui right floated primary button">{{ _("Sign in") }}</button>
<a type="button" class="ui right floated button" href="{{ url_for('canaille.account.forgotten') }}">{{ _("Forgotten password") }}</a>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% import 'fomanticui.j2' as sui %}
{% block content %}
<div class="loginform">
<h3 class="ui top attached header">
{% trans %}Password reset{% endtrans %}
</h3>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="ui attached message {{ category }}">
{{ message }}
</div>
{% endfor %}
{% endwith %}
<div class="ui attached clearing segment">
{{ sui.render_form(form, _("Password reset"), action=url_for("canaille.account.reset", uid=uid, hash=hash)) }}
</div>
</div>
{% endblock %}

View file

@ -26,7 +26,7 @@ sn: Doe
uid: admin
mail: admin@mydomain.tld
telephoneNumber: 555-000-000
userpassword: {SSHA}7zQVLckaEc6cJEsS0ylVipvb2PAR/4tS
userPassword: {SSHA}7zQVLckaEc6cJEsS0ylVipvb2PAR/4tS
memberof: cn=admins,ou=groups,dc=mydomain,dc=tld
memberof: cn=users,ou=groups,dc=mydomain,dc=tld
@ -39,5 +39,5 @@ sn: Doe
uid: user
mail: user@mydomain.tld
telephoneNumber: 555-000-001
userpassword: {SSHA}Yr1ZxSljRsKyaTB30suY2iZ1KRTStF1X
userPassword: {SSHA}Yr1ZxSljRsKyaTB30suY2iZ1KRTStF1X
memberof: cn=users,ou=groups,dc=mydomain,dc=tld

View file

@ -53,14 +53,16 @@ commands = {envbindir}/pytest --showlocals --full-trace {posargs}
deps =
--editable .
flask-webtest
pytest
mock
pdbpp
pytest
[testenv:coverage]
skip_install = true
deps =
--editable .
flask-webtest
mock
pdbpp
pytest
pytest-coverage

View file

@ -149,6 +149,14 @@ def app(slapd_server, keypair_path):
"PICTURE": "photo",
},
},
"SMTP": {
"HOST": "localhost",
"PORT": 25,
"TLS": False,
"LOGIN": "smtp_login",
"PASSWORD": "smtp_password",
"FROM_ADDR": "admin@mydomain.tld",
},
}
)
return app
@ -224,7 +232,7 @@ def user(app, slapd_connection):
sn="Doe",
uid="user",
mail="john@doe.com",
userpassword="{SSHA}fw9DYeF/gHTHuVMepsQzVYAkffGcU8Fz",
userPassword="{SSHA}fw9DYeF/gHTHuVMepsQzVYAkffGcU8Fz",
)
u.save(slapd_connection)
return u
@ -239,7 +247,7 @@ def admin(app, slapd_connection):
sn="Doe",
uid="admin",
mail="jane@doe.com",
userpassword="{SSHA}Vmgh2jkD0idX3eZHf8RzGos31oerjGiU",
userPassword="{SSHA}Vmgh2jkD0idX3eZHf8RzGos31oerjGiU",
)
u.save(slapd_connection)
return u

View file

@ -1,4 +1,8 @@
def test_login_and_out(testclient, slapd_connection, user, client):
import mock
from canaille.account import profile_hash
def test_login_and_out(testclient, slapd_connection, user):
with testclient.session_transaction() as session:
assert session.get("user_dn") is None
@ -24,7 +28,7 @@ def test_login_and_out(testclient, slapd_connection, user, client):
assert session.get("user_dn") is None
def test_login_wrong_password(testclient, slapd_connection, user, client):
def test_login_wrong_password(testclient, slapd_connection, user):
with testclient.session_transaction() as session:
assert session.get("user_dn") is None
@ -38,7 +42,7 @@ def test_login_wrong_password(testclient, slapd_connection, user, client):
assert b"Login failed, please check your information" in res.body
def test_login_no_password(testclient, slapd_connection, user, client):
def test_login_no_password(testclient, slapd_connection, user):
with testclient.session_transaction() as session:
assert session.get("user_dn") is None
@ -52,7 +56,7 @@ def test_login_no_password(testclient, slapd_connection, user, client):
assert b"Login failed, please check your information" in res.body
def test_login_with_alternate_attribute(testclient, slapd_connection, user, client):
def test_login_with_alternate_attribute(testclient, slapd_connection, user):
res = testclient.get("/login")
assert 200 == res.status_code
@ -66,3 +70,98 @@ def test_login_with_alternate_attribute(testclient, slapd_connection, user, clie
with testclient.session_transaction() as session:
assert user.dn == session.get("user_dn")
@mock.patch("smtplib.SMTP")
def test_password_forgotten(SMTP, testclient, slapd_connection, user):
res = testclient.get("/reset")
assert 200 == res.status_code
res.form["login"] = "user"
res = res.form.submit()
assert 200 == res.status_code
assert "A password reset link has been sent at your email address." in res.text
SMTP.assert_called_once_with(host="localhost", port=25)
@mock.patch("smtplib.SMTP")
def test_password_forgotten_invalid_form(SMTP, testclient, slapd_connection, user):
res = testclient.get("/reset")
assert 200 == res.status_code
res.form["login"] = ""
res = res.form.submit()
assert 200 == res.status_code
assert "Could not send the password reset link." in res.text
SMTP.assert_not_called()
@mock.patch("smtplib.SMTP")
def test_password_forgotten_invalid(SMTP, testclient, slapd_connection, user):
res = testclient.get("/reset")
assert 200 == res.status_code
res.form["login"] = "i-dont-really-exist"
res = res.form.submit()
assert 200 == res.status_code
assert "A password reset link has been sent at your email address." in res.text
SMTP.assert_not_called()
def test_password_reset(testclient, slapd_connection, user):
user.attr_type_by_name(conn=slapd_connection)
user.reload(conn=slapd_connection)
with testclient.app.app_context():
hash = profile_hash("user", user.userPassword[0])
res = testclient.get("/reset/user/" + hash)
assert 200 == res.status_code
res.form["password"] = "foobarbaz"
res.form["confirmation"] = "foobarbaz"
res = res.form.submit()
assert 302 == res.status_code
res = res.follow()
assert 200 == res.status_code
with testclient.app.app_context():
assert user.check_password("foobarbaz")
assert "Your password has been updated successfuly" in res.text
user.set_password("correct horse battery staple", conn=slapd_connection)
res = testclient.get("/reset/user/" + hash)
res = res.follow()
res = res.follow()
assert "The password reset link that brought you here was invalid." in res.text
def test_password_reset_bad_link(testclient, slapd_connection, user):
user.attr_type_by_name(conn=slapd_connection)
user.reload(conn=slapd_connection)
res = testclient.get("/reset/user/foobarbaz")
res = res.follow()
res = res.follow()
assert "The password reset link that brought you here was invalid." in res.text
def test_password_reset_bad_password(testclient, slapd_connection, user):
user.attr_type_by_name(conn=slapd_connection)
user.reload(conn=slapd_connection)
with testclient.app.app_context():
hash = profile_hash("user", user.userPassword[0])
res = testclient.get("/reset/user/" + hash)
assert 200 == res.status_code
res.form["password"] = "foobarbaz"
res.form["confirmation"] = "typo"
res = res.form.submit()
assert 200 == res.status_code
with testclient.app.app_context():
assert user.check_password("correct horse battery staple")