diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 974d37ea..26be775b 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -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 diff --git a/core/admin/mailu/sso/forms.py b/core/admin/mailu/sso/forms.py index c01ef572..f097353f 100644 --- a/core/admin/mailu/sso/forms.py +++ b/core/admin/mailu/sso/forms.py @@ -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')) diff --git a/core/admin/mailu/sso/templates/pw_change.html b/core/admin/mailu/sso/templates/pw_change.html new file mode 100644 index 00000000..83beee30 --- /dev/null +++ b/core/admin/mailu/sso/templates/pw_change.html @@ -0,0 +1,13 @@ +{%- extends "base_sso.html" %} + +{%- block content %} +{%- call macros.card() %} +
+ {{ form.hidden_tag() }} + {{ macros.form_field(form.oldpw) }} + {{ macros.form_field(form.pw) }} + {{ macros.form_field(form.pw2) }} + {{ macros.form_field(form.submit) }} +
+{%- endcall %} +{%- endblock %} diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index f0950468..b4ac0fbe 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -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(): diff --git a/core/admin/mailu/ui/forms.py b/core/admin/mailu/ui/forms.py index 65f0b556..79b1445d 100644 --- a/core/admin/mailu/ui/forms.py +++ b/core/admin/mailu/ui/forms.py @@ -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')) diff --git a/core/admin/mailu/ui/templates/user/create.html b/core/admin/mailu/ui/templates/user/create.html index dff78f64..7701dcfa 100644 --- a/core/admin/mailu/ui/templates/user/create.html +++ b/core/admin/mailu/ui/templates/user/create.html @@ -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") %}