forked from Github-Mirrors/canaille
Password mechanism recovery. Fixes #3
This commit is contained in:
parent
7747a64570
commit
2fc6af0fc9
10 changed files with 327 additions and 18 deletions
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."))
|
||||
|
|
40
canaille/templates/forgotten-password.html
Normal file
40
canaille/templates/forgotten-password.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
20
canaille/templates/reset-password.html
Normal file
20
canaille/templates/reset-password.html
Normal 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 %}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in a new issue