Skip to content

Commit

Permalink
Imported OpenID Connect support from Heviat
Browse files Browse the repository at this point in the history
Signed-off-by: fastlorenzo <[email protected]>
  • Loading branch information
fastlorenzo committed Nov 1, 2022
1 parent 4b11435 commit 4a841ca
Show file tree
Hide file tree
Showing 11 changed files with 254 additions and 5 deletions.
1 change: 1 addition & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ Other contributors:
- [Dario Ernst](https://github.com/Nebukadneza) - [Contributions](https://github.com/Mailu/Mailu/commits?author=Nebukadneza)
- [Florent Daigniere](https://github.com/nextgens) - [Contributions](https://github.com/Mailu/Mailu/commits?author=nextgens)
- [Alexander Graf](https://github.com/ghostwheel42) - [Contributions](https://github.com/Mailu/Mailu/commits?author=ghostwheel42)
- [Sebastian Wilke](https://github.com/Encotric) - [Contributions](https://github.com/heviat/Mailu/commits?author=encotric)
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.oic_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
8 changes: 7 additions & 1 deletion core/admin/mailu/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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 @@ -152,7 +158,7 @@ def init_app(self, app):
self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2'
self.config['QUOTA_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/1'
self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3'
self.config['SESSION_COOKIE_SAMESITE'] = 'Strict'
self.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
self.config['SESSION_COOKIE_HTTPONLY'] = True
self.config['SESSION_PERMANENT'] = True
self.config['SESSION_TIMEOUT'] = int(self.config['SESSION_TIMEOUT'])
Expand Down
1 change: 1 addition & 0 deletions core/admin/mailu/internal/nginx.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from multiprocessing.spawn import is_forking
from mailu import models, utils
from flask import current_app as app
from socrate import system
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 @@ -20,8 +20,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 @@ -521,14 +522,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 OpenID checks

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

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

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

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

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

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

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

def check_password_legacy(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 @@ -625,6 +667,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 @@ -651,6 +696,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 @@ -7,5 +7,8 @@
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
</form>
{% if openId %}
<a href="{{ openIdEndpoint }}" class="btn btn-primary">{{ config["OIDC_BUTTON_NAME"] }}</a>
{% endif %}
{%- endcall %}
{%- endblock %}
37 changes: 36 additions & 1 deletion core/admin/mailu/sso/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,33 @@
import flask
import flask_login

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)

if 'code' in flask.request.args:
username, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode())
if username is not None:
user = models.User.get(username)
if user is None: # It is possible that the user never logged into Mailu with his OpenID account
user = models.User.create(username) # Create user with no password to enable OpenID-only authentication

client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
flask.session["openid_token"] = token_response
flask.session.regenerate()
flask_login.login_user(user)
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'Login failed for {username} from {client_ip}.')
flask.flash('Wrong e-mail or password', 'error')

form = forms.LoginForm()
form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin'
form.submitWebmail.label.text = form.submitWebmail.label.text + ' Webmail'
Expand Down Expand Up @@ -46,11 +70,22 @@ 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, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url())

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


def logout_legacy():
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
131 changes: 131 additions & 0 deletions core/admin/mailu/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@
import ipaddress
import redis

from flask import session as f_session

from datetime import datetime, timedelta
from flask.sessions import SessionMixin, SessionInterface
from itsdangerous.encoding import want_bytes
from werkzeug.datastructures import CallbackDict
from werkzeug.middleware.proxy_fix import ProxyFix

from oic.oic import Client
from oic.extension.client import Client as ExtensionClient
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
from oic import rndstr
from oic.oauth2.message import ROPCAccessTokenRequest, AccessTokenResponse
from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest
from oic.oauth2.grant import Token

# Login configuration
login = flask_login.LoginManager()
login.login_view = "sso.login"
Expand Down Expand Up @@ -117,6 +127,127 @@ def init_app(self, app):
proxy = PrefixMiddleware()


class OicClient:
"Redirects users to OpenID Provider if configured"

def __init__(self):
self.app = None
self.client = None
self.extension_client = None
self.registration_response = None

def init_app(self, app):
self.app = app
self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
self.client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
self.extension_client = ExtensionClient(client_authn_method=CLIENT_AUTHN_METHOD)
self.extension_client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
info = {"client_id": app.config['OIDC_CLIENT_ID'], "client_secret": app.config['OIDC_CLIENT_SECRET'], "redirect_uris": [ "https://" + self.app.config['HOSTNAME'] + "/sso/login" ]}
client_reg = RegistrationResponse(**info)
self.client.store_registration_info(client_reg)
self.extension_client.store_registration_info(client_reg)

def get_redirect_url(self):
if not self.is_enabled():
return None
f_session["state"] = rndstr()
f_session["nonce"] = rndstr()
args = {
"client_id": self.client.client_id,
"response_type": ["code"],
"scope": ["openid"],
"nonce": f_session["nonce"],
"redirect_uri": "https://" + self.app.config['HOSTNAME'] + "/sso/login",
"state": f_session["state"]
}

auth_req = self.client.construct_AuthorizationRequest(request_args=args)
login_url = auth_req.request(self.client.authorization_endpoint)
return login_url

def exchange_code(self, query):
aresp = self.client.parse_response(AuthorizationResponse, info=query, sformat="urlencoded")
if not ("state" in f_session and aresp["state"] == f_session["state"]):
return None, None
args = {
"code": aresp["code"]
}
response = self.client.do_access_token_request(state=aresp["state"],
request_args=args,
authn_method="client_secret_basic")
if 'access_token' not in response or not isinstance(response, AccessTokenResponse):
return None, None
user_response = self.client.do_user_info_request(
access_token=response['access_token'])
return user_response['email'], response

def get_token(self, username, password):
args = {
"username": username,
"password": password,
"client_id": self.extension_client.client_id,
"client_secret": self.extension_client.client_secret,
"grant_type": "password"
}
url, body, ht_args, csi = self.extension_client.request_info(ROPCAccessTokenRequest,
request_args=args, method="POST")
response = self.extension_client.request_and_return(url, AccessTokenResponse, "POST", body, "json", "", ht_args)
if isinstance(response, AccessTokenResponse):
return response
return None


def get_user_info(self, token):
return self.client.do_user_info_request(
access_token=token['access_token'])

def check_validity(self, token):
try:
args = {
"client_id": self.extension_client.client_id,
"client_secret": self.extension_client.client_secret,
"token": token['access_token'],
"token_type_hint": "access_token"
}
response = self.extension_client.do_token_introspection(request_args=args)
if ('active' in response and response['active'] == False) or 'active' not in response:
return self.refresh_token(token)
except:
return self.refresh_token(token)
return token

def refresh_token(self, token):
try:
args = {
"refresh_token": token['refresh_token']
}
response = self.client.do_access_token_refresh(request_args=args, token=Token(token))
if 'access_token' in response:
return response
except Exception as e:
print(e)
return None

def logout(self):
state = rndstr()
f_session['state'] = state
args = {
"id_token": "",
"state": state,
"post_logout_redirect_uri": "https://" + app.config['HOSTNAME'] + "/sso/logout",
"client_id": self.client.client_id
}

request = self.client.construct_EndSessionRequest(request_args=args)
uri, body, h_args, cis = self.client.uri_and_body(EndSessionRequest, method="GET", request_args=args, cis=request)
return uri

def is_enabled(self):
return self.app is not None and self.app.config['OIDC_ENABLED']

oic_client = OicClient()


# Data migrate
migrate = flask_migrate.Migrate()

Expand Down
Loading

0 comments on commit 4a841ca

Please sign in to comment.