1
0
mirror of https://github.com/Mailu/Mailu.git synced 2024-12-12 10:45:38 +02:00
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:
bors[bot] 2023-08-20 21:12:55 +00:00 committed by GitHub
commit fb97cec238
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 106 additions and 9 deletions

View File

@ -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:

View File

@ -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()

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
@ -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 """

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

@ -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()

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") %}

View File

@ -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)

View 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')

View 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