mirror of
https://github.com/Mailu/Mailu.git
synced 2024-12-12 10:45:38 +02:00
Merge #2901
2901: Force pw change r=mergify[bot] a=nextgens ## What type of PR? Feature ## What does this PR do? Allow administrators to force a user to change his password. Prune web-sessions on password change. ### Related issue(s) - closes #2877 ## Prerequisites Before we can consider review and merge, please make sure the following list is done and checked. If an entry in not applicable, you can check it or remove it from the list. - [x] In case of feature or enhancement: documentation updated accordingly - [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file. Co-authored-by: Florent Daigniere <nextgens@freenetproject.org> Co-authored-by: Florent Daigniere <nextgens@users.noreply.github.com>
This commit is contained in:
commit
fb97cec238
@ -16,6 +16,7 @@ user_fields_get = api.model('UserGet', {
|
||||
'quota_bytes': fields.Integer(description='The maximum quota for the user’s email box in bytes', example='1000000000'),
|
||||
'global_admin': fields.Boolean(description='Make the user a global administrator'),
|
||||
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
|
||||
'change_pw_next_login': fields.Boolean(description='Force the user to change their password at next login'),
|
||||
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
|
||||
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
|
||||
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
|
||||
@ -40,6 +41,7 @@ user_fields_post = api.model('UserCreate', {
|
||||
'quota_bytes': fields.Integer(description='The maximum quota for the user’s email box in bytes', example='1000000000'),
|
||||
'global_admin': fields.Boolean(description='Make the user a global administrator'),
|
||||
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
|
||||
'change_pw_next_login': fields.Boolean(description='Force the user to change their password at next login'),
|
||||
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
|
||||
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
|
||||
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
|
||||
@ -63,6 +65,7 @@ user_fields_put = api.model('UserUpdate', {
|
||||
'quota_bytes': fields.Integer(description='The maximum quota for the user’s email box in bytes', example='1000000000'),
|
||||
'global_admin': fields.Boolean(description='Make the user a global administrator'),
|
||||
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
|
||||
'change_pw_next_login': fields.Boolean(description='Force the user to change their password at next login'),
|
||||
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
|
||||
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
|
||||
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
|
||||
@ -119,6 +122,8 @@ class Users(Resource):
|
||||
user_new.global_admin = data['global_admin']
|
||||
if 'enabled' in data:
|
||||
user_new.enabled = data['enabled']
|
||||
if 'change_pw_next_login' in data:
|
||||
user_new.change_pw_next_login = data['change_pw_next_login']
|
||||
if 'enable_imap' in data:
|
||||
user_new.enable_imap = data['enable_imap']
|
||||
if 'enable_pop' in data:
|
||||
@ -203,6 +208,8 @@ class User(Resource):
|
||||
user_found.global_admin = data['global_admin']
|
||||
if 'enabled' in data:
|
||||
user_found.enabled = data['enabled']
|
||||
if 'change_pw_next_login' in data:
|
||||
user_found.change_pw_next_login = data['change_pw_next_login']
|
||||
if 'enable_imap' in data:
|
||||
user_found.enable_imap = data['enable_imap']
|
||||
if 'enable_pop' in data:
|
||||
|
@ -103,7 +103,8 @@ def user(localpart, domain_name, password):
|
||||
user = models.User(
|
||||
localpart=localpart,
|
||||
domain=domain,
|
||||
global_admin=False
|
||||
global_admin=False,
|
||||
change_pw_next_login=True,
|
||||
)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
@ -122,6 +123,7 @@ def password(localpart, domain_name, password):
|
||||
user = models.User.query.get(email)
|
||||
if user:
|
||||
user.set_password(password)
|
||||
user.change_pw_next_login=True
|
||||
else:
|
||||
print(f'User {email} not found.')
|
||||
db.session.commit()
|
||||
|
@ -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
|
||||
@ -623,11 +624,15 @@ in clear-text regardless of the presence of the cache.
|
||||
self._credential_cache[self.get_id()] = (self.password.split('$')[3], passlib.hash.pbkdf2_sha256.using(rounds=1).hash(password))
|
||||
return result
|
||||
|
||||
def set_password(self, password, raw=False):
|
||||
""" Set password for user
|
||||
def set_password(self, password, raw=False, keep_sessions=None):
|
||||
""" Set password for user and destroy all web sessions except those in keep_sessions
|
||||
@password: plain text password to encrypt (or, if raw is True: the hash itself)
|
||||
@keep_sessions: True if all the sessions should be preserved, otherwise a
|
||||
set() containing the sessions to keep
|
||||
"""
|
||||
self.password = password if raw else User.get_password_context().hash(password)
|
||||
if keep_sessions is not True:
|
||||
utils.MailuSessionExtension.prune_sessions(uid=self.email, keep=keep_sessions)
|
||||
|
||||
def get_managed_domains(self):
|
||||
""" return list of domains this user can manage """
|
||||
|
@ -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'))
|
||||
|
13
core/admin/mailu/sso/templates/pw_change.html
Normal file
13
core/admin/mailu/sso/templates/pw_change.html
Normal 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 %}
|
@ -4,6 +4,7 @@ from mailu.sso import sso, forms
|
||||
from mailu.ui import access
|
||||
|
||||
from flask import current_app as app
|
||||
from flask_babel import lazy_gettext as _
|
||||
import flask
|
||||
import flask_login
|
||||
import secrets
|
||||
@ -45,15 +46,18 @@ def login():
|
||||
username = form.email.data
|
||||
if not utils.is_app_token(form.pw.data):
|
||||
if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip):
|
||||
flask.flash('Too many attempts from your IP (rate-limit)', 'error')
|
||||
flask.flash(_('Too many attempts from your IP (rate-limit)'), 'error')
|
||||
return flask.render_template('login.html', form=form, fields=fields)
|
||||
if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username):
|
||||
flask.flash('Too many attempts for this user (rate-limit)', 'error')
|
||||
flask.flash(_('Too many attempts for this user (rate-limit)'), 'error')
|
||||
return flask.render_template('login.html', form=form, fields=fields)
|
||||
user = models.User.login(username, form.pw.data)
|
||||
if user:
|
||||
flask.session.regenerate()
|
||||
flask_login.login_user(user)
|
||||
if user.change_pw_next_login:
|
||||
flask.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}')
|
||||
@ -63,9 +67,41 @@ def login():
|
||||
else:
|
||||
utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username, form.pw.data) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip, username)
|
||||
flask.current_app.logger.info(f'Login attempt for: {username}/sso/{flask.request.headers.get("X-Forwarded-Proto")} from: {client_ip}/{client_port}: failed: badauth: {utils.truncated_pw_hash(form.pw.data)}')
|
||||
flask.flash('Wrong e-mail or password', 'error')
|
||||
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, keep_sessions=set(flask.session))
|
||||
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 = flask.session.pop('redir_to', None) or app.config['WEB_ADMIN']
|
||||
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():
|
||||
@ -129,7 +165,7 @@ def _proxy():
|
||||
flask.current_app.logger.warning('Too many users for domain %s' % domain)
|
||||
return flask.abort(500, 'Too many users in (domain=%s)' % domain)
|
||||
user = models.User(localpart=localpart, domain=domain)
|
||||
user.set_password(secrets.token_urlsafe())
|
||||
user.set_password(secrets.token_urlsafe(), keep_sessions=set(flask.session))
|
||||
models.db.session.add(user)
|
||||
models.db.session.commit()
|
||||
flask.session.regenerate()
|
||||
|
@ -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'))
|
||||
|
||||
|
||||
|
@ -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") %}
|
||||
|
@ -75,7 +75,7 @@ def user_edit(user_email):
|
||||
domain=user.domain, max_quota_bytes=max_quota_bytes)
|
||||
form.populate_obj(user)
|
||||
if form.pw.data:
|
||||
user.set_password(form.pw.data)
|
||||
user.set_password(form.pw.data, keep_sessions=set(flask.session))
|
||||
models.db.session.commit()
|
||||
flask.flash('User %s updated' % user)
|
||||
return flask.redirect(
|
||||
@ -114,7 +114,7 @@ def _process_password_change(form, user_email):
|
||||
flask.flash(msg, "error")
|
||||
return flask.render_template('user/password.html', form=form, user=user)
|
||||
flask.session.regenerate()
|
||||
user.set_password(form.pw.data)
|
||||
user.set_password(form.pw.data, keep_sessions=set(flask.session))
|
||||
models.db.session.commit()
|
||||
flask.flash('Password updated for %s' % user)
|
||||
if user_email:
|
||||
@ -181,6 +181,7 @@ def user_signup(domain_name=None):
|
||||
flask.session.regenerate()
|
||||
user = models.User(domain=domain)
|
||||
form.populate_obj(user)
|
||||
user.change_pw_next_login = True
|
||||
user.set_password(form.pw.data)
|
||||
user.quota_bytes = quota_bytes
|
||||
models.db.session.add(user)
|
||||
|
22
core/admin/migrations/versions/0ba45693748d_.py
Normal file
22
core/admin/migrations/versions/0ba45693748d_.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Add user.change_pw_next_login
|
||||
|
||||
Revision ID: 0ba45693748d
|
||||
Revises: 6b8f5e8caaa9
|
||||
Create Date: 2023-08-10 09:20:50.458092
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0ba45693748d'
|
||||
down_revision = '6b8f5e8caaa9'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('user', sa.Column('change_pw_next_login', sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('user', 'change_pw_next_login')
|
2
towncrier/newsfragments/2877.feature
Normal file
2
towncrier/newsfragments/2877.feature
Normal file
@ -0,0 +1,2 @@
|
||||
Implement a feature to force users to change their password
|
||||
Prune all active sessions of users when their password is changed
|
Loading…
Reference in New Issue
Block a user