Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Imported OpenID Connect support from Heviat #2

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/admin/mailu/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def create_app_from_config(config):
utils.login.user_loader(models.User.get)
utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db)
if app.config["OIDC_ENABLED"]:
utils.oidc_client.init_app(app)

app.device_cookie_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('DEVICE_COOKIE_KEY', 'utf-8'), 'sha256').digest()
app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest()
Expand Down
9 changes: 8 additions & 1 deletion core/admin/mailu/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,
'DISABLE_STATISTICS': False,
# OpenID Connect settings
'OIDC_ENABLED': False,
'OIDC_PROVIDER_INFO_URL': 'https://localhost/info',
'OIDC_CLIENT_ID': 'mailu',
'OIDC_CLIENT_SECRET': 'secret',
'OIDC_BUTTON_NAME': 'OpenID Connect',
# Mail settings
'DMARC_RUA': None,
'DMARC_RUF': None,
Expand Down Expand Up @@ -159,12 +165,13 @@ def init_app(self, app):
self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2'

self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3'
self.config['SESSION_COOKIE_SAMESITE'] = 'Strict'
self.config['SESSION_COOKIE_SAMESITE'] = 'Lax' if self.config['OIDC_ENABLED'] else 'Strict'
self.config['SESSION_COOKIE_HTTPONLY'] = True
if self.config['SESSION_COOKIE_SECURE'] is None:
self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls'
self.config['SESSION_PERMANENT'] = True
self.config['SESSION_TIMEOUT'] = int(self.config['SESSION_TIMEOUT'])
self.config['SESSION_KEY_BITS'] = int(self.config['SESSION_KEY_BITS'])
self.config['PERMANENT_SESSION_LIFETIME'] = int(self.config['PERMANENT_SESSION_LIFETIME'])
self.config['AUTH_RATELIMIT_IP_V4_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V4_MASK'])
self.config['AUTH_RATELIMIT_IP_V6_MASK'] = int(self.config['AUTH_RATELIMIT_IP_V6_MASK'])
Expand Down
71 changes: 69 additions & 2 deletions core/admin/mailu/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
import idna
import dns.resolver
import dns.exception
import flask_login

from flask import current_app as app
from flask import current_app as app, session
from sqlalchemy.ext import declarative
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect
Expand Down Expand Up @@ -526,14 +527,18 @@ class User(Base, Email):
spam_threshold = db.Column(db.Integer, nullable=False, default=lambda:int(app.config.get("DEFAULT_SPAM_THRESHOLD", 80)))

# Flask-login attributes
is_authenticated = True
is_active = True
is_anonymous = False
_authenticated = True # Flask attribute would be is_authenticated but we needed to overrride this attribute for OIDC checks

def get_id(self):
""" return users email address """
return self.email

@property
def oidc_token(self):
return session['oidc_token']

@property
def destination(self):
""" returns comma separated string of destinations """
Expand Down Expand Up @@ -561,6 +566,24 @@ def sender_limiter(self):
app.config["MESSAGE_RATELIMIT"], "sender", self.email
)

@property
def is_authenticated(self):
if 'oidc_token' not in session:
return self._authenticated
else:
token = utils.oidc_client.check_validity(self.oidc_token)
if token is None:
flask_login.logout_user()
session.destroy()
return False
session['oidc_token'] = token
return True

@is_authenticated.setter
def is_authenticated(self, value):
if 'oidc_token' not in session:
self._authenticated = value

@classmethod
def get_password_context(cls):
""" create password context for hashing and verification
Expand Down Expand Up @@ -589,6 +612,25 @@ def check_password(self, password):
"""
if password == '':
return False

if utils.oidc_client.is_enabled():
if 'oidc_token' not in session:
try:
oidc_token = utils.oidc_client.get_token(self.email, password)
if oidc_token is None:
return self.check_password_internal(password)
session['oidc_token'] = oidc_token
except:
return self.check_password_internal(password)
else:
return True
return self.is_authenticated()
else:
return self.check_password_internal(password)

def check_password_internal(self, password):
if self.password is None or self.password == "openid":
return False
cache_result = self._credential_cache.get(self.get_id())
current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None
if cache_result and current_salt:
Expand Down Expand Up @@ -630,6 +672,9 @@ def set_password(self, password, raw=False):
"""
self.password = password if raw else User.get_password_context().hash(password)

def set_display_name(self, display_name):
self.displayed_name = display_name

def get_managed_domains(self):
""" return list of domains this user can manage """
if self.global_admin:
Expand All @@ -656,6 +701,28 @@ def get(cls, email):
""" find user object for email address """
return cls.query.get(email)

@classmethod
def create(cls, email, password='openid'):
email = email.split('@', 1)
domain = Domain.query.get(email[1])
if not domain:
domain = Domain(name=email[1])
db.session.add(domain)
user = User(
localpart=email[0],
domain=domain,
global_admin=False
)

if password == 'openid':
user.set_password(password, True)
else:
user.set_password(password)

db.session.add(user)
db.session.commit()
return user

@classmethod
def login(cls, email, password):
""" login user when enabled and password is valid """
Expand Down
3 changes: 3 additions & 0 deletions core/admin/mailu/sso/templates/form_sso.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
</form>
{% if oidc_enabled %}
<a href="{{ oidc_redirect_url }}" class="btn btn-primary">{{ config["OIDC_BUTTON_NAME"] }}</a>
{% endif %}
{%- endcall %}
{%- endblock %}
66 changes: 63 additions & 3 deletions core/admin/mailu/sso/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
import secrets
import ipaddress

from oic import rndstr

@sso.route('/login', methods=['GET', 'POST'])
def login():
device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit'))
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)

form = forms.LoginForm()
form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail'
Expand All @@ -32,10 +36,10 @@ def login():
username = form.email.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')
return flask.render_template('login.html', form=form, fields=fields)
return flask.render_template('login.html', form=form, fields=fields, oidc_enabled=app.config['OIDC_ENABLED'], oidc_redirect_url=utils.oidc_client.get_redirect_url())
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')
return flask.render_template('login.html', form=form, fields=fields)
return flask.render_template('login.html', form=form, fields=fields, oidc_enabled=app.config['OIDC_ENABLED'], oidc_redirect_url=utils.oidc_client.get_redirect_url())
user = models.User.login(username, form.pw.data)
if user:
flask.session.regenerate()
Expand All @@ -50,11 +54,67 @@ def login():
utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip)
flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.')
flask.flash('Wrong e-mail or password', 'error')
return flask.render_template('login.html', form=form, fields=fields)
return flask.render_template('login.html', form=form, fields=fields, oidc_enabled=app.config['OIDC_ENABLED'], oidc_redirect_url=utils.oidc_client.get_redirect_url())

@sso.route('/login/oidc', methods=['GET', 'POST'])
def login_oidc():
# Redirect to /login if OIDC is disabled
if not app.config['OIDC_ENABLED']:
return redirect('/login')

device_cookie, device_cookie_username = utils.limiter.parse_device_cookie(flask.request.cookies.get('rate_limit'))
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)

if 'code' in flask.request.args:
user_data, token_response = utils.oidc_client.exchange_code(flask.request.query_string.decode())
username = user_data['email']
user_display_name = user_data['name']
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')
return redirect('/login')
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')
return redirect('/login')
if username is not None:
user = models.User.get(username)
# If the user does not exist, create it with an empty password
if user is None:
user = models.User.create(username)

if user.displayed_name != user_display_name:
# Update the display name if it has changed
user.set_display_name(user_display_name)
models.db.session.commit()

flask.session["oidc_token"] = token_response
flask.session.regenerate()
flask_login.login_user(user)
# Redirect to the admin interface by default
response = redirect(app.config['WEB_ADMIN'])
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 succeeded for {username} from {client_ip}.')
return response
else:
utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip)
flask.current_app.logger.warn(f'OIDC login failed for {username} from {client_ip}: exchanged code didn\'t return any username.')

# No code was provided, redirect to the OIDC provider
return redirect(utils.oidc_client.get_redirect_url())

@sso.route('/logout', methods=['GET'])
@access.authenticated
def logout():
if utils.oidc_client.is_enabled():
if 'oidc_token' not in flask.session:
return logout_internal()
if 'state' in flask.request.args and 'state' in flask.session:
if flask.args.get('state') == flask.session['state']:
logout_internal()
return redirect(utils.oidc_client.logout())
return logout_internal()


def logout_internal():
flask_login.logout_user()
flask.session.destroy()
return flask.redirect(flask.url_for('.login'))
Expand Down
2 changes: 1 addition & 1 deletion core/admin/mailu/ui/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def owner(args, kwargs, model, key):
def authenticated(args, kwargs):
""" The view is only available to logged in users.
"""
return True
return flask_login.current_user.is_authenticated



Expand Down
Loading