mirror of
https://github.com/optim-enterprises-bv/Mailu-OIDC.git
synced 2025-10-29 17:22:20 +00:00
Initial commit
This commit is contained in:
3
.github/workflows/multiarch.yml
vendored
3
.github/workflows/multiarch.yml
vendored
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -56,3 +56,5 @@ requests
|
||||
# optional/radicale
|
||||
radicale
|
||||
|
||||
# [OIDC] OpenID Connect
|
||||
oic
|
||||
|
||||
@@ -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
524
mailu-oidc.patch
Normal 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
|
||||
Reference in New Issue
Block a user