Initial commit

This commit is contained in:
Sebastian Wilke
2024-07-23 16:26:02 +02:00
parent d5bee885bf
commit c25c6df5b3
13 changed files with 837 additions and 9 deletions

View File

@@ -13,7 +13,8 @@ concurrency: ci-multiarch-${{ github.ref }}
# REQUIRED global variables
# DOCKER_ORG, docker org used for pushing images.
env:
DOCKER_ORG: ghcr.io/mailu
# [OIDC] Required for pushing images to ghcr.io
DOCKER_ORG: ghcr.io/heviat
jobs:
# This job calculates all global job variables that are required by all the subsequent jobs.

View File

@@ -53,6 +53,10 @@ def create_app_from_config(config):
utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db)
# [OIDC] Initialize the OIDC extension if enabled
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()
app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()
@@ -85,6 +89,7 @@ def create_app_from_config(config):
return dict(
signup_domains= signup_domains,
config = app.config,
oic_client = utils.oic_client,
get_locale = utils.get_locale,
)

View File

@@ -48,6 +48,16 @@ DEFAULT_CONFIG = {
'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,
'DISABLE_STATISTICS': False,
# [OIDC] 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',
'OIDC_VERIFY_SSL': True,
'OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED': True,
'OIDC_CHANGE_PASSWORD_REDIRECT_URL': None,
'OIDC_REDIRECT_URL': None,
# Mail settings
'DMARC_RUA': None,
'DMARC_RUF': None,

View File

@@ -20,8 +20,11 @@ import smtplib
import idna
import dns.resolver
import dns.exception
# [OIDC] Import Flask-Login
import flask_login
from flask import current_app as app
from flask import session
from sqlalchemy.ext import declarative
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect
@@ -532,7 +535,8 @@ class User(Base, Email):
change_pw_next_login = db.Column(db.Boolean, nullable=False, default=False)
# Flask-login attributes
is_authenticated = True
# [OIDC] See is_authenticated property below
_is_authenticated = True
is_active = True
is_anonymous = False
@@ -551,6 +555,36 @@ class User(Base, Email):
else:
return self.email
# [OIDC] is_authenticated property getter
@property
def is_authenticated(self):
if 'oidc_token' not in session:
return self._is_authenticated
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
# [OIDC] is_authenticated property setter
@is_authenticated.setter
def is_authenticated(self, value):
if 'oidc_token' not in session:
self._is_authenticated = value
# [OIDC] is_oidc_user property getter
@property
def is_oidc_user(self):
""" check if the user is registered through OpenID Connect """
return self.password is None or self.password == 'openid'
# [OIDC] OpenID Connect token
@property
def oidc_token(self):
return session['oidc_token']
@property
def reply_active(self):
""" returns status of autoreply function """
@@ -595,6 +629,15 @@ class User(Base, Email):
"""
if password == '':
return False
# [OIDC] Check if the user is an OIDC user
if self.is_oidc_user:
return False
# [OIDC] Check if the user is authenticated with OIDC
if utils.oic_client.is_enabled() and 'openid_token' in session:
return self.is_authenticated()
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:
@@ -666,6 +709,29 @@ set() containing the sessions to keep
""" find user object for email address """
return cls.query.get(email)
# [OIDC] Create a new user
@classmethod
def create(cls, email, password='openid'):
""" create a new user """
email = email.split('@', 1)
domain = Domain.query.get(email[1])
if domain is None:
domain = Domain(name=email[1])
db.session.add(domain)
user = User(
localpart=email[0],
domain=domain,
global_admin=False
)
user.set_password(password, password == 'openid')
db.session.add(user)
db.session.commit()
return user
@classmethod
def login(cls, email, password):
""" login user when enabled and password is valid """

View File

@@ -8,5 +8,9 @@
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
</form>
<!-- [OIDC] OpenID Connect button -->
{%- if openId %}
<a href="{{ openIdEndpoint }}" class="btn btn-primary">{{ config["OIDC_BUTTON_NAME"] }}</a>
{%- endif %}
{%- endcall %}
{%- endblock %}

View File

@@ -22,6 +22,33 @@ def login():
fields = []
# [OIDC] Add the OIDC login flow
if 'code' in flask.request.args:
username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode())
if username is None:
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')
# TODO: Check if this is the correct way to handle this
return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url())
user = models.User.get(username)
if user is None:
user = models.User.create(username)
flask.session['openid_token'] = token_response
flask.session['openid_sub'] = sub
flask.session['openid_id_token'] = id_token
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
if 'url' in flask.request.args and not 'homepage' in flask.request.url:
fields.append(form.submitAdmin)
else:
@@ -67,7 +94,8 @@ def login():
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')
return flask.render_template('login.html', form=form, fields=fields)
# [OIDC] Forward the OIDC data to the login template
return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url())
@sso.route('/pw_change', methods=['GET', 'POST'])
@access.authenticated
@@ -111,6 +139,15 @@ def logout():
response.set_cookie(cookie, 'empty', expires=0)
return response
# [OIDC] Add the backchannel logout endpoint
@sso.route('/backchannel-logout', methods=['POST'])
def backchannel_logout():
if not utils.oic_client.is_enabled():
return flask.abort(404)
if not utils.oic_client.backchannel_logout(flask.request.form):
return flask.abort(400)
return {'code': 200, 'message': 'Backchannel logout successful.'}, 200
"""
Redirect to the url passed in parameter if any; Ensure that this is not an open-redirect too...
https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html

View File

@@ -29,7 +29,8 @@
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
<td><pre class="pre-config border bg-light">*******</pre></td>
<!-- [OIDC] Auth token warning -->
<td><pre class="pre-config border bg-light">{{ "USE AUTH TOKEN" if current_user.is_oidc_user else "******" }}</pre></td>
</tr>
</tbody>
{%- endcall %}
@@ -54,7 +55,8 @@
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
<td><pre class="pre-config border bg-light">*******</pre></td>
<!-- [OIDC] Auth token warning -->
<td><pre class="pre-config border bg-light">{{ "USE AUTH TOKEN" if current_user.is_oidc_user else "******" }}</pre></td>
</tr>
</tbody>
{%- endcall %}

View File

@@ -19,12 +19,15 @@
<p>{% trans %}Settings{% endtrans %}</p>
</a>
</li>
<!-- [OIDC] Custom change_password url -->
{%- if oic_client.change_password() or not current_user.is_oidc_user %}
<li class="nav-item" role="none">
<a href="{{ url_for('.user_password_change') }}" class="nav-link" role="menuitem">
<a href="{{ oic_client.change_password() if current_user.is_oidc_user else url_for('.user_password_change') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-lock"></i>
<p>{% trans %}Update password{% endtrans %}</p>
</a>
</li>
{%- endif %}
<li class="nav-item" role="none">
<a href="{{ url_for('.user_reply') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plane"></i>

View File

@@ -114,6 +114,12 @@ def user_settings(user_email):
def _process_password_change(form, user_email):
user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404)
# [OIDC] Redirect to OIDC provider if enabled
if utils.oic_client.is_enabled() and user.is_oidc_user:
url = utils.oic_client.change_password() or flask.abort(404)
return flask.redirect(url)
if form.validate_on_submit():
if form.pw.data != form.pw2.data:
flask.flash('Passwords do not match', 'error')

View File

@@ -35,6 +35,17 @@ from itsdangerous.encoding import want_bytes
from werkzeug.datastructures import CallbackDict
from werkzeug.middleware.proxy_fix import ProxyFix
# [OIDC] Import the OIDC related modules
from oic.oic import Client
from oic.extension.client import Client as ExtensionClient
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
from oic.utils.settings import OicClientSettings
from oic import rndstr
from oic.exception import MessageException, NotForMe
from oic.oauth2.message import ROPCAccessTokenRequest, AccessTokenResponse
from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest, BackChannelLogoutRequest
from oic.oauth2.grant import Token
# Login configuration
login = flask_login.LoginManager()
login.login_view = "sso.login"
@@ -126,6 +137,157 @@ class PrefixMiddleware(object):
proxy = PrefixMiddleware()
# [OIDC] Client class
class OicClient:
"Redirects user to OpenID Provider if configured"
def __init__(self):
self.app = None
self.client = None
self.extension_client = None
self.registration_response = None
self.change_password_redirect_enabled = True,
self.change_password_url = None
def init_app(self, app):
self.app = app
settings = OicClientSettings()
settings.verify_ssl = app.config['OIDC_VERIFY_SSL']
self.change_password_redirect_enabled = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED']
self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings)
self.client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
self.extension_client = ExtensionClient(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings)
self.extension_client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
self.change_password_url = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_URL'] or (self.client.issuer + '/.well-known/change-password')
self.redirect_url = app.config['OIDC_REDIRECT_URL'] or ("https://" + self.app.config['HOSTNAME'])
info = {"client_id": app.config['OIDC_CLIENT_ID'], "client_secret": app.config['OIDC_CLIENT_SECRET'], "redirect_uris": [ self.redirect_url + "/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
flask.session["state"] = rndstr()
flask.session["nonce"] = rndstr()
args = {
"client_id": self.client.client_id,
"response_type": ["code"],
"scope": ["openid", "email"],
"nonce": flask.session["nonce"],
"redirect_uri": self.redirect_url + "/sso/login",
"state": flask.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 flask.session and aresp["state"] == flask.session["state"]):
return None, None, 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 "id_token" not in response or response["id_token"]["nonce"] != flask.session["nonce"]:
return None, None, None, None
if 'access_token' not in response or not isinstance(response, AccessTokenResponse):
return None, None, None, None
user_response = self.client.do_user_info_request(
access_token=response['access_token'])
return user_response['email'], user_response['sub'], response["id_token"], 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):
if 'exp' in token['id_token'] and token['id_token']['exp'] > time.time():
return token
else:
return self.refresh_token(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 isinstance(response, AccessTokenResponse):
return response
else:
return None
except Exception as e:
print(e)
return None
def logout(self, id_token):
state = rndstr()
flask.session['state'] = state
args = {
"state": state,
"id_token_hint": id_token,
"post_logout_redirect_uri": self.redirect_url + "/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 backchannel_logout(self, body):
req = BackChannelLogoutRequest().from_dict(body)
kwargs = {"aud": self.client.client_id, "iss": self.client.issuer, "keyjar": self.client.keyjar}
try:
req.verify(**kwargs)
except (MessageException, ValueError, NotForMe) as err:
self.app.logger.error(err)
return False
sub = req["logout_token"]["sub"]
if sub is not None and sub != '':
MailuSessionExtension.prune_sessions(None, None, self.app, sub)
return True
def is_enabled(self):
return self.app is not None and self.app.config['OIDC_ENABLED']
def change_password(self):
return self.change_password_url if self.change_password_redirect_enabled else None
oic_client = OicClient()
# Data migrate
migrate = flask_migrate.Migrate()
@@ -449,8 +611,9 @@ class MailuSessionExtension:
return count
# [OIDC] Prune sessions by user id
@staticmethod
def prune_sessions(uid=None, keep=None, app=None):
def prune_sessions(uid=None, keep=None, app=None, sub=None):
""" Remove sessions
uid: remove all sessions (NONE) or sessions belonging to a specific user
keep: keep listed sessions
@@ -464,8 +627,11 @@ class MailuSessionExtension:
count = 0
for key in app.session_store.list(prefix):
if key not in keep and not key.startswith(b'token-'):
app.session_store.delete(key)
count += 1
# [OIDC] Prune sessions by sub
session = MailuSession(key, app) if sub is not None else None
if sub is None or ('openid_sub' in session and session['openid_sub'] == sub):
app.session_store.delete(key)
count += 1
return count

View File

@@ -56,3 +56,5 @@ requests
# optional/radicale
radicale
# [OIDC] OpenID Connect
oic

View File

@@ -48,6 +48,8 @@ marshmallow-sqlalchemy==1.0.0
msoffcrypto-tool==5.4.0
multidict==6.0.5
mysql-connector-python==8.4.0
# [OIDC] pyoidc: https://github.com/CZ-NIC/pyoidc/
oic==1.7.0
olefile==0.47
oletools==0.60.1
packaging==24.0

524
mailu-oidc.patch Normal file
View File

@@ -0,0 +1,524 @@
diff --git a/.github/workflows/multiarch.yml b/.github/workflows/multiarch.yml
index d387bc10..88585e19 100644
--- a/.github/workflows/multiarch.yml
+++ b/.github/workflows/multiarch.yml
@@ -13,7 +13,8 @@ concurrency: ci-multiarch-${{ github.ref }}
# REQUIRED global variables
# DOCKER_ORG, docker org used for pushing images.
env:
- DOCKER_ORG: ghcr.io/mailu
+ # [OIDC] Required for pushing images to ghcr.io
+ DOCKER_ORG: ghcr.io/heviat
jobs:
# This job calculates all global job variables that are required by all the subsequent jobs.
diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py
index dc9dc97d..ef5b1512 100644
--- a/core/admin/mailu/__init__.py
+++ b/core/admin/mailu/__init__.py
@@ -53,6 +53,10 @@ def create_app_from_config(config):
utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db)
+ # [OIDC] Initialize the OIDC extension if enabled
+ if app.config['OIDC_ENABLED']:
+ utils.oidc.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()
app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest()
diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py
index e568deb9..99fec9a0 100644
--- a/core/admin/mailu/configuration.py
+++ b/core/admin/mailu/configuration.py
@@ -48,6 +48,16 @@ DEFAULT_CONFIG = {
'AUTH_RATELIMIT_EXEMPTION': '',
'AUTH_RATELIMIT_EXEMPTION_LENGTH': 86400,
'DISABLE_STATISTICS': False,
+ # [OIDC] 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',
+ 'OIDC_VERIFY_SSL': True,
+ 'OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED': True,
+ 'OIDC_CHANGE_PASSWORD_REDIRECT_URL': None,
+ 'OIDC_REDIRECT_URL': None,
# Mail settings
'DMARC_RUA': None,
'DMARC_RUF': None,
diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py
index f73bf0b7..2a77c663 100644
--- a/core/admin/mailu/models.py
+++ b/core/admin/mailu/models.py
@@ -20,6 +20,8 @@ import smtplib
import idna
import dns.resolver
import dns.exception
+# [OIDC] Import Flask-Login
+import flask_login
from flask import current_app as app
from sqlalchemy.ext import declarative
@@ -532,7 +534,8 @@ class User(Base, Email):
change_pw_next_login = db.Column(db.Boolean, nullable=False, default=False)
# Flask-login attributes
- is_authenticated = True
+ # [OIDC] See is_authenticated property below
+ _is_authenticated = True
is_active = True
is_anonymous = False
@@ -551,6 +554,36 @@ class User(Base, Email):
else:
return self.email
+ # [OIDC] is_authenticated property getter
+ @property
+ def is_authenticated(self):
+ if 'oidc_token' not in session:
+ return self._is_authenticated
+ 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
+
+ # [OIDC] is_authenticated property setter
+ @is_authenticated.setter
+ def is_authenticated(self, value):
+ if 'oidc_token' not in session:
+ self._is_authenticated = value
+
+ # [OIDC] is_oidc_user property getter
+ @property
+ def is_oidc_user(self):
+ """ check if the user is registered through OpenID Connect """
+ return self.password is None or self.password == 'openid'
+
+ # [OIDC] OpenID Connect token
+ @property
+ def oidc_token(self):
+ return session['oidc_token']
+
@property
def reply_active(self):
""" returns status of autoreply function """
@@ -595,6 +628,15 @@ class User(Base, Email):
"""
if password == '':
return False
+
+ # [OIDC] Check if the user is an OIDC user
+ if self.is_oidc_user:
+ return False
+
+ # [OIDC] Check if the user is authenticated with OIDC
+ if utils.oic_client.is_enabled() and 'openid_token' in session:
+ return self.is_authenticated()
+
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:
@@ -666,6 +708,29 @@ set() containing the sessions to keep
""" find user object for email address """
return cls.query.get(email)
+ # [OIDC] Create a new user
+ @classmethod
+ def create(cls, email, password='openid'):
+ """ create a new user """
+ email = email.split('@', 1)
+ domain = Domain.query.get(email[1])
+
+ if domain is None:
+ domain = Domain(name=email[1])
+ db.session.add(domain)
+
+ user = User(
+ localpart=email[0],
+ domain=domain,
+ global_admin=False
+ )
+
+ user.set_password(password, password == 'openid')
+ db.session.add(user)
+ db.session.commit()
+
+ return user
+
@classmethod
def login(cls, email, password):
""" login user when enabled and password is valid """
diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html
index d713251e..0ddc90a4 100644
--- a/core/admin/mailu/sso/templates/form_sso.html
+++ b/core/admin/mailu/sso/templates/form_sso.html
@@ -8,5 +8,9 @@
{{ macros.form_field(form.pw) }}
{{ macros.form_fields(fields, label=False, class="btn btn-default") }}
</form>
+<!-- [OIDC] OpenID Connect button -->
+{%- if openId %}
+<a href="{{ openIdEndpoint }}" class="btn btn-primary">{{ config["OIDC_BUTTON_NAME"] }}</a>
+{%- endif %}
{%- endcall %}
{%- endblock %}
diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py
index 59c4ef04..e10605c1 100644
--- a/core/admin/mailu/sso/views/base.py
+++ b/core/admin/mailu/sso/views/base.py
@@ -22,6 +22,33 @@ def login():
fields = []
+ # [OIDC] Add the OIDC login flow
+ if 'code' in flask.request.args:
+ username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode())
+
+ if username is None:
+ 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')
+ # TODO: Check if this is the correct way to handle this
+ return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url())
+
+ user = models.User.get(username)
+ if user is None:
+ user = models.User.create(username)
+
+ flask.session['openid_token'] = token_response
+ flask.session['openid_sub'] = sub
+ flask.session['openid_id_token'] = id_token
+ 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
+
if 'url' in flask.request.args and not 'homepage' in flask.request.url:
fields.append(form.submitAdmin)
else:
@@ -67,7 +94,8 @@ def login():
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')
- return flask.render_template('login.html', form=form, fields=fields)
+ # [OIDC] Forward the OIDC data to the login template
+ return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url())
@sso.route('/pw_change', methods=['GET', 'POST'])
@access.authenticated
@@ -111,6 +139,15 @@ def logout():
response.set_cookie(cookie, 'empty', expires=0)
return response
+# [OIDC] Add the backchannel logout endpoint
+@sso.route('/backchannel-logout', methods=['POST'])
+def backchannel_logout():
+ if not utils.oic_client.is_enabled():
+ return flask.abort(404)
+ if not utils.oic_client.backchannel_logout(flask.request.form):
+ return flask.abort(400)
+ return {'code': 200, 'message': 'Backchannel logout successful.'}, 200
+
"""
Redirect to the url passed in parameter if any; Ensure that this is not an open-redirect too...
https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
diff --git a/core/admin/mailu/ui/templates/client.html b/core/admin/mailu/ui/templates/client.html
index 304de179..ea8bdc32 100644
--- a/core/admin/mailu/ui/templates/client.html
+++ b/core/admin/mailu/ui/templates/client.html
@@ -29,7 +29,8 @@
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
- <td><pre class="pre-config border bg-light">*******</pre></td>
+ <!-- [OIDC] Auth token warning -->
+ <td><pre class="pre-config border bg-light">{{ "USE AUTH TOKEN" if current_user.is_oidc_user else "******" }}</pre></td>
</tr>
</tbody>
{%- endcall %}
@@ -54,7 +55,8 @@
</tr>
<tr>
<th>{% trans %}Password{% endtrans %}</th>
- <td><pre class="pre-config border bg-light">*******</pre></td>
+ <!-- [OIDC] Auth token warning -->
+ <td><pre class="pre-config border bg-light">{{ "USE AUTH TOKEN" if current_user.is_oidc_user else "******" }}</pre></td>
</tr>
</tbody>
{%- endcall %}
diff --git a/core/admin/mailu/ui/templates/sidebar.html b/core/admin/mailu/ui/templates/sidebar.html
index 3fd22f0e..089b7cfe 100644
--- a/core/admin/mailu/ui/templates/sidebar.html
+++ b/core/admin/mailu/ui/templates/sidebar.html
@@ -19,12 +19,15 @@
<p>{% trans %}Settings{% endtrans %}</p>
</a>
</li>
+ <!-- [OIDC] Custom change_password url -->
+ {%- if oic_client.change_password() or not current_user.is_oidc_user %}
<li class="nav-item" role="none">
- <a href="{{ url_for('.user_password_change') }}" class="nav-link" role="menuitem">
+ <a href="{{ oic_client.change_password() if current_user.is_oidc_user else url_for('.user_password_change') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-lock"></i>
<p>{% trans %}Update password{% endtrans %}</p>
</a>
</li>
+ {%- endif %}
<li class="nav-item" role="none">
<a href="{{ url_for('.user_reply') }}" class="nav-link" role="menuitem">
<i class="nav-icon fa fa-plane"></i>
diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py
index d3816c4a..569730c8 100644
--- a/core/admin/mailu/ui/views/users.py
+++ b/core/admin/mailu/ui/views/users.py
@@ -114,6 +114,12 @@ def user_settings(user_email):
def _process_password_change(form, user_email):
user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404)
+
+ # [OIDC] Redirect to OIDC provider if enabled
+ if utils.oic_client.is_enabled() and user.is_oidc_user:
+ url = utils.oic_client.change_password() or flask.abort(404)
+ return flask.redirect(url)
+
if form.validate_on_submit():
if form.pw.data != form.pw2.data:
flask.flash('Passwords do not match', 'error')
diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py
index 13522d79..91d14ff5 100644
--- a/core/admin/mailu/utils.py
+++ b/core/admin/mailu/utils.py
@@ -35,6 +35,17 @@ from itsdangerous.encoding import want_bytes
from werkzeug.datastructures import CallbackDict
from werkzeug.middleware.proxy_fix import ProxyFix
+# [OIDC] Import the OIDC related modules
+from oic.oic import Client
+from oic.extension.client import Client as ExtensionClient
+from oic.utils.authn.client import CLIENT_AUTHN_METHOD
+from oic.utils.settings import OicClientSettings
+from oic import rndstr
+from oic.exception import MessageException, NotForMe
+from oic.oauth2.message import ROPCAccessTokenRequest, AccessTokenResponse
+from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest, BackChannelLogoutRequest
+from oic.oauth2.grant import Token
+
# Login configuration
login = flask_login.LoginManager()
login.login_view = "sso.login"
@@ -126,6 +137,157 @@ class PrefixMiddleware(object):
proxy = PrefixMiddleware()
+# [OIDC] Client class
+class OicClient:
+ "Redirects user to OpenID Provider if configured"
+
+ def __init__(self):
+ self.app = None
+ self.client = None
+ self.extension_client = None
+ self.registration_response = None
+ self.change_password_redirect_enabled = True,
+ self.change_password_url = None
+
+ def init_app(self, app):
+ self.app = app
+
+ settings = OicClientSettings()
+
+ settings.verify_ssl = app.config['OIDC_VERIFY_SSL']
+
+ self.change_password_redirect_enabled = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED']
+
+ self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings)
+ self.client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
+
+ self.extension_client = ExtensionClient(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings)
+ self.extension_client.provider_config(app.config['OIDC_PROVIDER_INFO_URL'])
+ self.change_password_url = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_URL'] or (self.client.issuer + '/.well-known/change-password')
+ self.redirect_url = app.config['OIDC_REDIRECT_URL'] or ("https://" + self.app.config['HOSTNAME'])
+ info = {"client_id": app.config['OIDC_CLIENT_ID'], "client_secret": app.config['OIDC_CLIENT_SECRET'], "redirect_uris": [ self.redirect_url + "/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", "email"],
+ "nonce": f_session["nonce"],
+ "redirect_uri": self.redirect_url + "/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, 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 "id_token" not in response or response["id_token"]["nonce"] != f_session["nonce"]:
+ return None, None, None, None
+ if 'access_token' not in response or not isinstance(response, AccessTokenResponse):
+ return None, None, None, None
+ user_response = self.client.do_user_info_request(
+ access_token=response['access_token'])
+ return user_response['email'], user_response['sub'], response["id_token"], 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):
+ if 'exp' in token['id_token'] and token['id_token']['exp'] > time.time():
+ return token
+ else:
+ return self.refresh_token(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 isinstance(response, AccessTokenResponse):
+ return response
+ else:
+ return None
+ except Exception as e:
+ print(e)
+ return None
+
+ def logout(self, id_token):
+ state = rndstr()
+ f_session['state'] = state
+
+ args = {
+ "state": state,
+ "id_token_hint": id_token,
+ "post_logout_redirect_uri": self.redirect_url + "/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 backchannel_logout(self, body):
+ req = BackChannelLogoutRequest().from_dict(body)
+
+ kwargs = {"aud": self.client.client_id, "iss": self.client.issuer, "keyjar": self.client.keyjar}
+
+ try:
+ req.verify(**kwargs)
+ except (MessageException, ValueError, NotForMe) as err:
+ self.app.logger.error(err)
+ return False
+
+ sub = req["logout_token"]["sub"]
+
+ if sub is not None and sub != '':
+ MailuSessionExtension.prune_sessions(None, None, self.app, sub)
+
+ return True
+
+ def is_enabled(self):
+ return self.app is not None and self.app.config['OIDC_ENABLED']
+
+ def change_password(self):
+ return self.change_password_url if self.change_password_redirect_enabled else None
+
+oic_client = OicClient()
# Data migrate
migrate = flask_migrate.Migrate()
@@ -449,8 +611,9 @@ class MailuSessionExtension:
return count
+ # [OIDC] Prune sessions by user id
@staticmethod
- def prune_sessions(uid=None, keep=None, app=None):
+ def prune_sessions(uid=None, keep=None, app=None, sub=None):
""" Remove sessions
uid: remove all sessions (NONE) or sessions belonging to a specific user
keep: keep listed sessions
@@ -464,8 +627,11 @@ class MailuSessionExtension:
count = 0
for key in app.session_store.list(prefix):
if key not in keep and not key.startswith(b'token-'):
- app.session_store.delete(key)
- count += 1
+ # [OIDC] Prune sessions by sub
+ session = MailuSession(key, app) if sub is not None else None
+ if sub is None or ('openid_sub' in session and session['openid_sub'] == sub):
+ app.session_store.delete(key)
+ count += 1
return count
diff --git a/core/base/requirements-dev.txt b/core/base/requirements-dev.txt
index 6ac3daac..94e3f552 100644
--- a/core/base/requirements-dev.txt
+++ b/core/base/requirements-dev.txt
@@ -56,3 +56,5 @@ requests
# optional/radicale
radicale
+# [OIDC] OpenID Connect
+oic
diff --git a/core/base/requirements-prod.txt b/core/base/requirements-prod.txt
index 6152a45a..175a1787 100644
--- a/core/base/requirements-prod.txt
+++ b/core/base/requirements-prod.txt
@@ -48,6 +48,8 @@ marshmallow-sqlalchemy==1.0.0
msoffcrypto-tool==5.4.0
multidict==6.0.5
mysql-connector-python==8.4.0
+# [OIDC] pyoidc: https://github.com/CZ-NIC/pyoidc/
+oic==1.7.0
olefile==0.47
oletools==0.60.1
packaging==24.0