Implement a 'force-password-change' feature

This commit is contained in:
Florent Daigniere
2023-08-10 12:06:15 +02:00
parent 32d1c7d899
commit 9bcbbdee02
6 changed files with 62 additions and 1 deletions

View File

@@ -523,6 +523,7 @@ class User(Base, Email):
spam_enabled = db.Column(db.Boolean, nullable=False, default=True)
spam_mark_as_read = db.Column(db.Boolean, nullable=False, default=True)
spam_threshold = db.Column(db.Integer, nullable=False, default=lambda:int(app.config.get("DEFAULT_SPAM_THRESHOLD", 80)))
change_pw_next_login = db.Column(db.Boolean, nullable=False, default=False)
# Flask-login attributes
is_authenticated = True

View File

@@ -10,3 +10,10 @@ class LoginForm(flask_wtf.FlaskForm):
pwned = fields.HiddenField(label='', default=-1)
submitWebmail = fields.SubmitField(_('Sign in'))
submitAdmin = fields.SubmitField(_('Sign in'))
class PWChangeForm(flask_wtf.FlaskForm):
oldpw = fields.PasswordField(_('Current password'), [validators.DataRequired()])
pw = fields.PasswordField(_('New password'), [validators.DataRequired()])
pw2 = fields.PasswordField(_('New password (again)'), [validators.DataRequired()])
pwned = fields.HiddenField(label='', default=-1)
submit = fields.SubmitField(_('Change password'))

View File

@@ -0,0 +1,13 @@
{%- extends "base_sso.html" %}
{%- block content %}
{%- call macros.card() %}
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{{ macros.form_field(form.oldpw) }}
{{ macros.form_field(form.pw) }}
{{ macros.form_field(form.pw2) }}
{{ macros.form_field(form.submit) }}
</form>
{%- endcall %}
{%- endblock %}

View File

@@ -3,7 +3,7 @@ from mailu import models, utils
from mailu.sso import sso, forms
from mailu.ui import access
from flask import current_app as app
from flask import current_app as app, session
import flask
import flask_login
import secrets
@@ -54,6 +54,9 @@ def login():
if user:
flask.session.regenerate()
flask_login.login_user(user)
if user.change_pw_next_login:
session['redirect_to'] = destination
destination = flask.url_for('sso.pw_change')
response = flask.redirect(destination)
response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True)
flask.current_app.logger.info(f'Login attempt for: {username}/sso/{flask.request.headers.get("X-Forwarded-Proto")} from: {client_ip}/{client_port}: success: password: {form.pwned.data}')
@@ -66,6 +69,41 @@ def login():
flask.flash('Wrong e-mail or password', 'error')
return flask.render_template('login.html', form=form, fields=fields)
@sso.route('/pw_change', methods=['GET', 'POST'])
@access.authenticated
def pw_change():
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
client_port = flask.request.headers.get('X-Real-Port', None)
form = forms.PWChangeForm()
if form.validate_on_submit():
if msg := utils.isBadOrPwned(form):
flask.flash(msg, "error")
return flask.redirect(flask.url_for('sso.pw_change'))
if form.oldpw.data == form.pw2.data:
# TODO: fuzzy match?
flask.flash("The new password can't be the same as the old password", "error")
return flask.redirect(flask.url_for('sso.pw_change'))
if form.pw.data != form.pw2.data:
flask.flash("The new passwords don't match", "error")
return flask.redirect(flask.url_for('sso.pw_change'))
user = models.User.login(flask_login.current_user.email, form.oldpw.data)
if user:
flask.session.regenerate()
flask_login.login_user(user)
user.set_password(form.pw.data)
user.change_pw_next_login = False
models.db.session.commit()
flask.current_app.logger.info(f'Forced password change by {user} from: {client_ip}/{client_port}: success: password: {form.pwned.data}')
destination = app.config['WEB_ADMIN']
if 'redir_to' in session:
destination = session['redir_to']
del session['redir_to']
return flask.redirect(destination)
flask.flash("The current password is incorrect!", "error")
return flask.render_template('pw_change.html', form=form)
@sso.route('/logout', methods=['GET'])
@access.authenticated
def logout():

View File

@@ -99,6 +99,7 @@ class UserForm(flask_wtf.FlaskForm):
displayed_name = fields.StringField(_('Displayed name'))
comment = fields.StringField(_('Comment'))
enabled = fields.BooleanField(_('Enabled'), default=True)
change_pw_next_login = fields.BooleanField(_('Force password change at next login'), default=True)
submit = fields.SubmitField(_('Save'))

View File

@@ -18,6 +18,7 @@
{{ macros.form_field(form.displayed_name) }}
{{ macros.form_field(form.comment) }}
{{ macros.form_field(form.enabled) }}
{{ macros.form_field(form.change_pw_next_login) }}
{%- endcall %}
{%- call macros.card(_("Features and quotas"), theme="success") %}