mirror of
https://github.com/optim-enterprises-bv/Mailu-OIDC.git
synced 2025-11-01 18:47:47 +00:00
Merge branch 'oidc' of https://github.com/heviat/Mailu-OIDC into oidc
This commit is contained in:
@@ -27,7 +27,7 @@ def api_token_authorization(func):
|
|||||||
abort(401, 'A valid Authorization header is mandatory')
|
abort(401, 'A valid Authorization header is mandatory')
|
||||||
if len(v1.api_token) < 4 or not hmac.compare_digest(request.headers.get('Authorization').removeprefix('Bearer '), v1.api_token):
|
if len(v1.api_token) < 4 or not hmac.compare_digest(request.headers.get('Authorization').removeprefix('Bearer '), v1.api_token):
|
||||||
utils.limiter.rate_limit_ip(client_ip)
|
utils.limiter.rate_limit_ip(client_ip)
|
||||||
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
|
flask.current_app.logger.warning(f'Invalid API token provided by {client_ip}.')
|
||||||
abort(403, 'Invalid API token')
|
abort(403, 'Invalid API token')
|
||||||
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
|
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
|
||||||
return func(*args, **kwds)
|
return func(*args, **kwds)
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ def handle_authentication(headers):
|
|||||||
password = urllib.parse.unquote(headers["Auth-Pass"])
|
password = urllib.parse.unquote(headers["Auth-Pass"])
|
||||||
ip = urllib.parse.unquote(headers["Client-Ip"])
|
ip = urllib.parse.unquote(headers["Client-Ip"])
|
||||||
except:
|
except:
|
||||||
app.logger.warn(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}')
|
app.logger.warning(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}')
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user = models.User.query.get(user_email) if '@' in user_email else None
|
user = models.User.query.get(user_email) if '@' in user_email else None
|
||||||
except sqlalchemy.exc.StatementError as exc:
|
except sqlalchemy.exc.StatementError as exc:
|
||||||
exc = str(exc).split('\n', 1)[0]
|
exc = str(exc).split('\n', 1)[0]
|
||||||
app.logger.warn(f'Invalid user {user_email!r}: {exc}')
|
app.logger.warning(f'Invalid user {user_email!r}: {exc}')
|
||||||
else:
|
else:
|
||||||
is_valid_user = user is not None
|
is_valid_user = user is not None
|
||||||
ip = urllib.parse.unquote(headers["Client-Ip"])
|
ip = urllib.parse.unquote(headers["Client-Ip"])
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ def basic_authentication():
|
|||||||
user = models.User.query.get(user_email) if '@' in user_email else None
|
user = models.User.query.get(user_email) if '@' in user_email else None
|
||||||
except sqlalchemy.exc.StatementError as exc:
|
except sqlalchemy.exc.StatementError as exc:
|
||||||
exc = str(exc).split('\n', 1)[0]
|
exc = str(exc).split('\n', 1)[0]
|
||||||
app.logger.warn(f'Invalid user {user_email!r}: {exc}')
|
app.logger.warning(f'Invalid user {user_email!r}: {exc}')
|
||||||
else:
|
else:
|
||||||
if user is not None and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web", flask.request.headers.get('X-Real-Port', None), user_email):
|
if user is not None and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web", flask.request.headers.get('X-Real-Port', None), user_email):
|
||||||
response = flask.Response()
|
response = flask.Response()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class LimitWraperFactory(object):
|
|||||||
client_network = utils.extract_network_from_ip(ip)
|
client_network = utils.extract_network_from_ip(ip)
|
||||||
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network)
|
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network)
|
||||||
if is_rate_limited:
|
if is_rate_limited:
|
||||||
app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.')
|
app.logger.warning(f'Authentication attempt from {ip} has been rate-limited.')
|
||||||
return is_rate_limited
|
return is_rate_limited
|
||||||
|
|
||||||
def rate_limit_ip(self, ip, username=None):
|
def rate_limit_ip(self, ip, username=None):
|
||||||
@@ -65,7 +65,7 @@ class LimitWraperFactory(object):
|
|||||||
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user')
|
limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user')
|
||||||
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username)
|
is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username)
|
||||||
if is_rate_limited:
|
if is_rate_limited:
|
||||||
app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.')
|
app.logger.warning(f'Authentication attempt from {ip} for {username} has been rate-limited.')
|
||||||
return is_rate_limited
|
return is_rate_limited
|
||||||
|
|
||||||
def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''):
|
def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''):
|
||||||
@@ -78,10 +78,10 @@ class LimitWraperFactory(object):
|
|||||||
limiter.hit(device_cookie if device_cookie_name == username else username)
|
limiter.hit(device_cookie if device_cookie_name == username else username)
|
||||||
self.rate_limit_ip(ip, username)
|
self.rate_limit_ip(ip, username)
|
||||||
|
|
||||||
""" Device cookies as described on:
|
def parse_device_cookie(self, cookie: str):
|
||||||
https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
|
""" Device cookies as described on:
|
||||||
"""
|
https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
|
||||||
def parse_device_cookie(self, cookie):
|
"""
|
||||||
try:
|
try:
|
||||||
login, nonce, _ = cookie.split('$')
|
login, nonce, _ = cookie.split('$')
|
||||||
if hmac.compare_digest(cookie, self.device_cookie(login, nonce)):
|
if hmac.compare_digest(cookie, self.device_cookie(login, nonce)):
|
||||||
@@ -90,11 +90,11 @@ class LimitWraperFactory(object):
|
|||||||
pass
|
pass
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
""" Device cookies don't require strong crypto:
|
|
||||||
72bits of nonce, 96bits of signature is more than enough
|
|
||||||
and these values avoid padding in most cases
|
|
||||||
"""
|
|
||||||
def device_cookie(self, username, nonce=None):
|
def device_cookie(self, username, nonce=None):
|
||||||
|
""" Device cookies don't require strong crypto:
|
||||||
|
72bits of nonce, 96bits of signature is more than enough
|
||||||
|
and these values avoid padding in most cases
|
||||||
|
"""
|
||||||
if not nonce:
|
if not nonce:
|
||||||
nonce = secrets.token_urlsafe(9)
|
nonce = secrets.token_urlsafe(9)
|
||||||
sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8')
|
sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8')
|
||||||
|
|||||||
310
core/admin/mailu/oidc.py
Normal file
310
core/admin/mailu/oidc.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""OIDC Client"""
|
||||||
|
|
||||||
|
from time import time
|
||||||
|
from typing import Optional
|
||||||
|
import flask
|
||||||
|
|
||||||
|
# [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,
|
||||||
|
ErrorResponse,
|
||||||
|
)
|
||||||
|
from oic.oic.message import (
|
||||||
|
AuthorizationResponse,
|
||||||
|
RegistrationResponse,
|
||||||
|
EndSessionRequest,
|
||||||
|
BackChannelLogoutRequest,
|
||||||
|
OpenIDSchema,
|
||||||
|
UserInfoErrorResponse,
|
||||||
|
)
|
||||||
|
from oic.oauth2.grant import Token
|
||||||
|
|
||||||
|
|
||||||
|
# [OIDC] Client class
|
||||||
|
class OicClient:
|
||||||
|
"""OpenID Connect Client"""
|
||||||
|
|
||||||
|
app: Optional[flask.Flask] = None
|
||||||
|
client: Optional[Client] = None
|
||||||
|
extension_client: Optional[ExtensionClient] = None
|
||||||
|
registration_response: Optional[RegistrationResponse] = None
|
||||||
|
enable_change_password_redirect: bool = True
|
||||||
|
change_password_url: Optional[str] = None
|
||||||
|
redirect_url: Optional[str] = None
|
||||||
|
|
||||||
|
def init_app(self, app: flask.Flask):
|
||||||
|
"""Initialize OIDC client"""
|
||||||
|
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
settings = OicClientSettings(verify_ssl=app.config["OIDC_VERIFY_SSL"])
|
||||||
|
|
||||||
|
self.enable_change_password_redirect = (
|
||||||
|
app.config["OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED"] or False
|
||||||
|
)
|
||||||
|
|
||||||
|
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://" + app.config["HOSTNAME"]
|
||||||
|
)
|
||||||
|
|
||||||
|
client_reg = RegistrationResponse(
|
||||||
|
client_id=app.config["OIDC_CLIENT_ID"],
|
||||||
|
client_secret=app.config["OIDC_CLIENT_SECRET"],
|
||||||
|
redirect_uris=[f"{self.redirect_url}/sso/login"],
|
||||||
|
)
|
||||||
|
self.client.store_registration_info(client_reg)
|
||||||
|
self.extension_client.store_registration_info(client_reg)
|
||||||
|
|
||||||
|
def get_redirect_url(self) -> Optional[str]:
|
||||||
|
"""Get the redirect URL"""
|
||||||
|
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 _get_authorization_code(self, query: str) -> Optional[AuthorizationResponse]:
|
||||||
|
"""
|
||||||
|
Get the authorization response and check if it is valid
|
||||||
|
|
||||||
|
:param query: The query string
|
||||||
|
:return: The authorization code if valid, None otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
auth_response = self.client.parse_response(
|
||||||
|
AuthorizationResponse, info=query, sformat="urlencoded"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(auth_response, AuthorizationResponse):
|
||||||
|
# If this line is reached, auth_response is an ErrorResponse
|
||||||
|
# TODO: Decide what to do with the error response
|
||||||
|
|
||||||
|
self.app.logger.debug(f"[OIDC] Error response in authorization: {auth_response}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "state" not in flask.session:
|
||||||
|
self.app.logger.warning("[OIDC] No state in session")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if flask.session["state"] != auth_response["state"]:
|
||||||
|
self.app.logger.warning(
|
||||||
|
f"[OIDC] State mismatch: expected {flask.session['state']}, got {auth_response['state']}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return auth_response["code"]
|
||||||
|
|
||||||
|
def _get_id_and_access_tokens(self, auth_response_code: str):
|
||||||
|
"""
|
||||||
|
Get the id and access tokens
|
||||||
|
|
||||||
|
:param auth_response_code: The authorization response code
|
||||||
|
:return: The token response if valid, None otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
token_response = self.client.do_access_token_request(
|
||||||
|
state=flask.session["state"],
|
||||||
|
request_args={"code": auth_response_code},
|
||||||
|
authn_method="client_secret_basic",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(token_response, AccessTokenResponse):
|
||||||
|
self.app.logger.warning(
|
||||||
|
f"[OIDC] No access token or invalid response: {token_response}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "id_token" not in token_response:
|
||||||
|
self.app.logger.warning("[OIDC] No id token in response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if token_response["id_token"]["nonce"] != flask.session["nonce"]:
|
||||||
|
self.app.logger.warning("[OIDC] Nonce mismatch")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "access_token" not in token_response:
|
||||||
|
self.app.logger.warning("[OIDC] No access token or invalid response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token_response
|
||||||
|
|
||||||
|
def exchange_code(
|
||||||
|
self, query: str
|
||||||
|
) -> tuple[str, str, str, AccessTokenResponse] | tuple[None, None, None, None]:
|
||||||
|
"""Exchange the code for the token"""
|
||||||
|
|
||||||
|
auth_response_code = self._get_authorization_code(query)
|
||||||
|
if not auth_response_code:
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
token_response = self._get_id_and_access_tokens(auth_response_code)
|
||||||
|
if not token_response:
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
user_info_response = self.get_user_info(token_response)
|
||||||
|
if not isinstance(user_info_response, OpenIDSchema):
|
||||||
|
# If this line is reached, user_info_response is an ErrorResponse
|
||||||
|
# TODO: Decide what to do with the error response
|
||||||
|
|
||||||
|
self.app.logger.debug("[OIDC] Error response in user info")
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
return (
|
||||||
|
user_info_response["email"],
|
||||||
|
user_info_response["sub"],
|
||||||
|
token_response["id_token"],
|
||||||
|
token_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_token(self, username: str, password: str) -> Optional[AccessTokenResponse]:
|
||||||
|
"""Get the token from the username and 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, _ = 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
|
||||||
|
|
||||||
|
# If this line is reached, response is an ErrorResponse
|
||||||
|
# TODO: Decide what to do with the error response
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_info(
|
||||||
|
self, token: AccessTokenResponse
|
||||||
|
) -> OpenIDSchema | UserInfoErrorResponse | ErrorResponse:
|
||||||
|
"""Get user info from the token"""
|
||||||
|
return self.client.do_user_info_request(access_token=token["access_token"])
|
||||||
|
|
||||||
|
def check_validity(
|
||||||
|
self, token: AccessTokenResponse
|
||||||
|
) -> Optional[AccessTokenResponse]:
|
||||||
|
"""Check if the token is still valid"""
|
||||||
|
|
||||||
|
# Assume the token is valid if it has an expiration time and it has not expired
|
||||||
|
if "exp" in token["id_token"] and token["id_token"]["exp"] > time():
|
||||||
|
return token
|
||||||
|
return self.refresh_token(token)
|
||||||
|
|
||||||
|
def refresh_token(
|
||||||
|
self, token: AccessTokenResponse
|
||||||
|
) -> Optional[AccessTokenResponse]:
|
||||||
|
"""Refresh the 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
|
||||||
|
|
||||||
|
# If this line is reached, response is an ErrorResponse
|
||||||
|
# TODO: Decide what to do with the error response
|
||||||
|
except Exception as e:
|
||||||
|
flask.current_app.logger.error(f"Error refreshing token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def logout(self, id_token: str) -> str:
|
||||||
|
"""
|
||||||
|
Logout the user
|
||||||
|
|
||||||
|
:param id_token: The id token to be used for logout
|
||||||
|
:return: The logout URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
|
# TODO: Check if identical: uri = request.request(self.client.end_session_endpoint)
|
||||||
|
uri, _, _, _ = self.client.uri_and_body(EndSessionRequest, request, "GET", args)
|
||||||
|
return uri
|
||||||
|
|
||||||
|
def backchannel_logout(self, body):
|
||||||
|
"""
|
||||||
|
Backchannel logout. See https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Finish backchannel logout implementation
|
||||||
|
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 None
|
||||||
|
|
||||||
|
sub = req["logout_token"]["sub"]
|
||||||
|
|
||||||
|
if sub is not None and sub != "":
|
||||||
|
return sub
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_enabled(self):
|
||||||
|
"""Check if OIDC is enabled"""
|
||||||
|
|
||||||
|
if self.app is not None and self.app.config["OIDC_ENABLED"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def change_password(self) -> Optional[str]:
|
||||||
|
"""Get the URL for changing password if the redirect is enabled"""
|
||||||
|
|
||||||
|
if self.enable_change_password_redirect:
|
||||||
|
return self.change_password_url
|
||||||
|
return None
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<li class="nav-item" role="none">
|
<li class="nav-item" role="none">
|
||||||
<a href="{{ oic_client.change_password() if current_user.is_oidc_user else 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>
|
<i class="nav-icon fa fa-lock"></i>
|
||||||
<p>{% trans %}Update password{% endtrans %}</p>
|
<p>{% trans %}Update password{% endtrans %}{% if current_user.is_oidc_user %} <i class="fas fa-external-link-alt text-xs"></i>{% endif %}</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
""" Mailu admin app utilities
|
""" Mailu admin app utilities
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
from datetime import datetime, timedelta
|
||||||
import cPickle as pickle
|
import hmac
|
||||||
except ImportError:
|
import ipaddress
|
||||||
import pickle
|
from multiprocessing import Value
|
||||||
|
import pickle
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
import dns.exception
|
import dns.exception
|
||||||
@@ -13,38 +17,20 @@ import dns.rdtypes
|
|||||||
import dns.rdatatype
|
import dns.rdatatype
|
||||||
import dns.rdataclass
|
import dns.rdataclass
|
||||||
|
|
||||||
import hmac
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
|
|
||||||
from multiprocessing import Value
|
|
||||||
from mailu import limiter
|
|
||||||
from flask import current_app as app
|
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask.sessions import SessionMixin, SessionInterface
|
||||||
import flask_login
|
import flask_login
|
||||||
import flask_migrate
|
import flask_migrate
|
||||||
import flask_babel
|
import flask_babel
|
||||||
import ipaddress
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from flask.sessions import SessionMixin, SessionInterface
|
|
||||||
from itsdangerous.encoding import want_bytes
|
from itsdangerous.encoding import want_bytes
|
||||||
from werkzeug.datastructures import CallbackDict
|
from werkzeug.datastructures import CallbackDict
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
# [OIDC] Import the OIDC related modules
|
from mailu import limiter, oidc
|
||||||
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, ErrorResponse
|
|
||||||
from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest, BackChannelLogoutRequest
|
|
||||||
from oic.oauth2.grant import Token
|
|
||||||
|
|
||||||
# Login configuration
|
# Login configuration
|
||||||
login = flask_login.LoginManager()
|
login = flask_login.LoginManager()
|
||||||
@@ -137,152 +123,8 @@ class PrefixMiddleware(object):
|
|||||||
|
|
||||||
proxy = PrefixMiddleware()
|
proxy = PrefixMiddleware()
|
||||||
|
|
||||||
# [OIDC] Client class
|
# OIDC client
|
||||||
class OicClient:
|
oic_client = oidc.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 isinstance(aresp, AuthorizationResponse):
|
|
||||||
flask.current_app.logger.warning('[OIDC] No authorization response or invalid response')
|
|
||||||
raise Exception('No authorization response or invalid response')
|
|
||||||
|
|
||||||
has_state = "state" in flask.session
|
|
||||||
is_state_valid = "state" in aresp and aresp["state"] == flask.session["state"] if has_state else None
|
|
||||||
|
|
||||||
if not ("state" in flask.session and aresp["state"] == flask.session["state"]):
|
|
||||||
flask.current_app.logger.warning(f'[OIDC] has_state: {has_state}, is_state_valid: {is_state_valid}')
|
|
||||||
raise Exception('Invalid state')
|
|
||||||
|
|
||||||
response = self.client.do_access_token_request(
|
|
||||||
state = aresp["state"],
|
|
||||||
request_args = {
|
|
||||||
"code": aresp["code"]
|
|
||||||
},
|
|
||||||
authn_method = "client_secret_basic"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(response, AccessTokenResponse):
|
|
||||||
flask.current_app.logger.warning(f'[OIDC] No access token or invalid response: {response}')
|
|
||||||
raise Exception('No access token or invalid response')
|
|
||||||
|
|
||||||
has_id_token = "id_token" in response
|
|
||||||
has_nonce = "nonce" in flask.session
|
|
||||||
is_id_token_valid = "nonce" in response["id_token"] and response["id_token"]["nonce"] == flask.session["nonce"] if has_id_token and has_nonce else None
|
|
||||||
|
|
||||||
if "id_token" not in response or response["id_token"]["nonce"] != flask.session["nonce"]:
|
|
||||||
flask.current_app.logger.warning(f'[OIDC] has_id_token: {has_id_token}, has_nonce: {has_nonce}, is_id_token_valid: {is_id_token_valid}')
|
|
||||||
raise Exception('Invalid id token')
|
|
||||||
|
|
||||||
if 'access_token' not in response or not isinstance(response, AccessTokenResponse):
|
|
||||||
flask.current_app.logger.warning('[OIDC] No access token or invalid response')
|
|
||||||
raise Exception('No access token or invalid response')
|
|
||||||
|
|
||||||
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_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 err:
|
|
||||||
self.app.logger.error(err)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def backchannel_logout(self, body):
|
|
||||||
# TODO: Finish backchannel logout implementation
|
|
||||||
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
|
# Data migrate
|
||||||
migrate = flask_migrate.Migrate()
|
migrate = flask_migrate.Migrate()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def resolve_hostname(hostname):
|
|||||||
try:
|
try:
|
||||||
return sorted(socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE), key=lambda s:s[0])[0][4][0]
|
return sorted(socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE), key=lambda s:s[0])[0][4][0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warn("Unable to lookup '%s': %s",hostname,e)
|
log.warning("Unable to lookup '%s': %s",hostname,e)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def _coerce_value(value):
|
def _coerce_value(value):
|
||||||
|
|||||||
Reference in New Issue
Block a user