From edfca5eb2486c2f006257723ffeda6f56b170170 Mon Sep 17 00:00:00 2001 From: "Jiangjie.Bai" <32935519+BaiJiangJie@users.noreply.github.com> Date: Fri, 25 Feb 2022 19:23:59 +0800 Subject: [PATCH] Fix rbac (#7699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 优化 suggesstion * perf: 修改 migrations * feat: 添加OIDC认证逻辑 * perf: 修改 backend * perf: 优化认证backends * perf: 优化认证backends * perf: 优化CAS认证, 用户多域名进行访问时回调到各自域名 Co-authored-by: ibuler --- apps/authentication/backends/base.py | 51 ++++ apps/authentication/backends/cas/__init__.py | 4 +- apps/authentication/backends/cas/backends.py | 9 +- apps/authentication/backends/cas/utils.py | 32 ++ .../backends/{api.py => drf.py} | 63 +--- apps/authentication/backends/ldap.py | 27 +- apps/authentication/backends/oidc/backends.py | 288 ++++++++++++++++++ .../authentication/backends/oidc/decorator.py | 60 ++++ .../backends/oidc/middleware.py | 104 ++++++- apps/authentication/backends/oidc/signals.py | 18 ++ apps/authentication/backends/oidc/urls.py | 20 ++ apps/authentication/backends/oidc/utils.py | 126 ++++++++ apps/authentication/backends/oidc/views.py | 222 ++++++++++++++ apps/authentication/backends/openid.py | 4 - apps/authentication/backends/pubkey.py | 18 +- apps/authentication/backends/radius.py | 12 +- .../authentication/backends/saml2/__init__.py | 1 - .../authentication/backends/saml2/backends.py | 11 +- apps/authentication/backends/sso.py | 63 ++++ apps/authentication/mixins.py | 48 +-- apps/authentication/signals_handlers.py | 2 +- apps/authentication/urls/view_urls.py | 2 +- apps/authentication/views/login.py | 2 +- apps/jumpserver/settings/auth.py | 51 ++-- apps/jumpserver/settings/base.py | 2 - apps/jumpserver/settings/libs.py | 10 +- apps/rbac/backends.py | 24 +- apps/rbac/const.py | 1 - apps/settings/models.py | 2 +- apps/settings/serializers/auth/cas.py | 2 +- apps/users/models/user.py | 28 +- apps/users/signals_handler.py | 2 +- requirements/requirements.txt | 1 - 33 files changed, 1132 insertions(+), 178 deletions(-) create mode 100644 apps/authentication/backends/base.py create mode 100644 apps/authentication/backends/cas/utils.py rename apps/authentication/backends/{api.py => drf.py} (84%) create mode 100644 apps/authentication/backends/oidc/backends.py create mode 100644 apps/authentication/backends/oidc/decorator.py create mode 100644 apps/authentication/backends/oidc/signals.py create mode 100644 apps/authentication/backends/oidc/urls.py create mode 100644 apps/authentication/backends/oidc/utils.py create mode 100644 apps/authentication/backends/oidc/views.py delete mode 100644 apps/authentication/backends/openid.py create mode 100644 apps/authentication/backends/sso.py diff --git a/apps/authentication/backends/base.py b/apps/authentication/backends/base.py new file mode 100644 index 000000000..6465a29b9 --- /dev/null +++ b/apps/authentication/backends/base.py @@ -0,0 +1,51 @@ +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.backends import ModelBackend + +from users.models import User +from common.utils import get_logger + + +logger = get_logger(__file__) + + +class JMSBaseAuthBackend: + + @staticmethod + def is_enabled(): + return True + + def has_perm(self, user_obj, perm, obj=None): + return False + + # can authenticate + def username_can_authenticate(self, username): + return self.allow_authenticate(username=username) + + def user_can_authenticate(self, user): + if not self.allow_authenticate(user=user): + return False + is_valid = getattr(user, 'is_valid', None) + return is_valid or is_valid is None + + @property + def backend_path(self): + return f'{self.__module__}.{self.__class__.__name__}' + + def allow_authenticate(self, user=None, username=None): + if user: + allowed_backends = user.get_allowed_auth_backends() + else: + allowed_backends = User.get_user_allowed_auth_backends(username) + if allowed_backends is None: + # 特殊值 None 表示没有限制 + return True + allow = self.backend_path in allowed_backends + if not allow: + info = 'User {} skip authentication backend {}, because it not in {}' + info = info.format(username, self.backend_path, ','.join(allowed_backends)) + logger.debug(info) + return allow + + +class JMSModelBackend(JMSBaseAuthBackend, ModelBackend): + pass diff --git a/apps/authentication/backends/cas/__init__.py b/apps/authentication/backends/cas/__init__.py index bbdbdb814..d2b30d4cc 100644 --- a/apps/authentication/backends/cas/__init__.py +++ b/apps/authentication/backends/cas/__init__.py @@ -1,3 +1,5 @@ # -*- coding: utf-8 -*- # -from .backends import * + +# 保证 utils 中的模块进行初始化 +from . import utils diff --git a/apps/authentication/backends/cas/backends.py b/apps/authentication/backends/cas/backends.py index ec56c6d4d..db137a1bd 100644 --- a/apps/authentication/backends/cas/backends.py +++ b/apps/authentication/backends/cas/backends.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- # from django_cas_ng.backends import CASBackend as _CASBackend +from django.conf import settings +from ..base import JMSBaseAuthBackend __all__ = ['CASBackend'] -class CASBackend(_CASBackend): - def user_can_authenticate(self, user): - return True +class CASBackend(JMSBaseAuthBackend, _CASBackend): + @staticmethod + def is_enabled(): + return settings.AUTH_CAS diff --git a/apps/authentication/backends/cas/utils.py b/apps/authentication/backends/cas/utils.py new file mode 100644 index 000000000..aad1764e3 --- /dev/null +++ b/apps/authentication/backends/cas/utils.py @@ -0,0 +1,32 @@ +from django_cas_ng import utils +from django_cas_ng.utils import ( + django_settings, get_protocol, + urllib_parse, REDIRECT_FIELD_NAME, get_redirect_url +) + + +def get_service_url(request, redirect_to=None): + """ + 重写 get_service url 方法, CAS_ROOT_PROXIED_AS 为空时, 支持跳转回当前访问的域名地址 + """ + """Generates application django service URL for CAS""" + if getattr(django_settings, 'CAS_ROOT_PROXIED_AS', None): + service = django_settings.CAS_ROOT_PROXIED_AS + request.path + else: + protocol = get_protocol(request) + host = request.get_host() + service = urllib_parse.urlunparse( + (protocol, host, request.path, '', '', ''), + ) + if not django_settings.CAS_STORE_NEXT: + if '?' in service: + service += '&' + else: + service += '?' + service += urllib_parse.urlencode({ + REDIRECT_FIELD_NAME: redirect_to or get_redirect_url(request) + }) + return service + + +utils.get_service_url = get_service_url diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/drf.py similarity index 84% rename from apps/authentication/backends/api.py rename to apps/authentication/backends/drf.py index 8ceecc4af..595ae35b6 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/drf.py @@ -8,13 +8,14 @@ from django.core.cache import cache from django.utils.translation import ugettext as _ from six import text_type from django.contrib.auth import get_user_model -from django.contrib.auth.backends import ModelBackend + from rest_framework import HTTP_HEADER_ENCODING from rest_framework import authentication, exceptions from common.auth import signature from common.utils import get_object_or_none, make_signature, http_to_unixtime from ..models import AccessKey, PrivateToken +from .base import JMSBaseAuthBackend, JMSModelBackend UserModel = get_user_model() @@ -28,21 +29,6 @@ def get_request_date_header(request): return date -class JMSModelBackend(ModelBackend): - def has_perm(self, user_obj, perm, obj=None): - return False - - def user_can_authenticate(self, user): - return True - - def get_user(self, user_id): - try: - user = UserModel._default_manager.get(pk=user_id) - except UserModel.DoesNotExist: - return None - return user if user.is_valid else None - - class AccessKeyAuthentication(authentication.BaseAuthentication): """App使用Access key进行签名认证, 目前签名算法比较简单, app注册或者手动建立后,会生成 access_key_id 和 access_key_secret, @@ -167,7 +153,7 @@ class AccessTokenAuthentication(authentication.BaseAuthentication): return self.keyword -class PrivateTokenAuthentication(authentication.TokenAuthentication): +class PrivateTokenAuthentication(JMSBaseAuthBackend, authentication.TokenAuthentication): model = PrivateToken @@ -215,46 +201,3 @@ class SignatureAuthentication(signature.SignatureAuthentication): except AccessKey.DoesNotExist: return None, None - -class SSOAuthentication(JMSModelBackend): - """ - 什么也不做呀😺 - """ - - def authenticate(self, request, sso_token=None, **kwargs): - pass - - -class WeComAuthentication(JMSModelBackend): - """ - 什么也不做呀😺 - """ - - def authenticate(self, request, **kwargs): - pass - - -class DingTalkAuthentication(JMSModelBackend): - """ - 什么也不做呀😺 - """ - - def authenticate(self, request, **kwargs): - pass - - -class FeiShuAuthentication(JMSModelBackend): - """ - 什么也不做呀😺 - """ - - def authenticate(self, request, **kwargs): - pass - - -class AuthorizationTokenAuthentication(JMSModelBackend): - """ - 什么也不做呀😺 - """ - def authenticate(self, request, **kwargs): - pass diff --git a/apps/authentication/backends/ldap.py b/apps/authentication/backends/ldap.py index b034a62c5..1c8a80cb1 100644 --- a/apps/authentication/backends/ldap.py +++ b/apps/authentication/backends/ldap.py @@ -1,43 +1,38 @@ # coding:utf-8 # -import warnings import ldap from django.conf import settings from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist -from django_auth_ldap.backend import _LDAPUser, LDAPBackend, LDAPSettings +from django_auth_ldap.backend import _LDAPUser, LDAPBackend from django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion from users.utils import construct_user_email from common.const import LDAP_AD_ACCOUNT_DISABLE +from .base import JMSBaseAuthBackend logger = _LDAPConfig.get_logger() -class LDAPAuthorizationBackend(LDAPBackend): +class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend): """ Override this class to override _LDAPUser to LDAPUser """ @staticmethod - def user_can_authenticate(user): - """ - Reject users with is_active=False. Custom user models that don't have - that attribute are allowed. - """ - is_valid = getattr(user, 'is_valid', None) - return is_valid or is_valid is None + def is_enabled(): + return settings.AUTH_LDAP def get_or_build_user(self, username, ldap_user): """ - This must return a (User, built) 2-tuple for the given LDAP user. + This must return a (User, built) 2-tuple for the given LDAP user. - username is the Django-friendly username of the user. ldap_user.dn is - the user's DN and ldap_user.attrs contains all of their LDAP - attributes. + username is the Django-friendly username of the user. ldap_user.dn is + the user's DN and ldap_user.attrs contains all of their LDAP + attributes. - The returned User object may be an unsaved model instance. + The returned User object may be an unsaved model instance. - """ + """ model = self.get_user_model() if self.settings.USER_QUERY_FIELD: diff --git a/apps/authentication/backends/oidc/backends.py b/apps/authentication/backends/oidc/backends.py new file mode 100644 index 000000000..b8b829277 --- /dev/null +++ b/apps/authentication/backends/oidc/backends.py @@ -0,0 +1,288 @@ +""" + OpenID Connect relying party (RP) authentication backends + ========================================================= + + This modules defines backends allowing to authenticate a user using a specific token endpoint + of an OpenID Connect provider (OP). + +""" + +import base64 +import requests +from rest_framework.exceptions import ParseError +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.core.exceptions import SuspiciousOperation +from django.db import transaction +from django.urls import reverse +from django.conf import settings + +from common.utils import get_logger + +from ..base import JMSBaseAuthBackend +from .utils import validate_and_return_id_token, build_absolute_uri +from .decorator import ssl_verification +from .signals import ( + openid_create_or_update_user, openid_user_login_failed, openid_user_login_success +) + +logger = get_logger(__file__) + + +class UserMixin: + + @transaction.atomic + def get_or_create_user_from_claims(self, request, claims): + log_prompt = "Get or Create user from claims [ActionForUser]: {}" + logger.debug(log_prompt.format('start')) + + sub = claims['sub'] + name = claims.get('name', sub) + username = claims.get('preferred_username', sub) + email = claims.get('email', "{}@{}".format(username, 'jumpserver.openid')) + logger.debug( + log_prompt.format( + "sub: {}|name: {}|username: {}|email: {}".format(sub, name, username, email) + ) + ) + + user, created = get_user_model().objects.get_or_create( + username=username, defaults={"name": name, "email": email} + ) + logger.debug(log_prompt.format("user: {}|created: {}".format(user, created))) + logger.debug(log_prompt.format("Send signal => openid create or update user")) + openid_create_or_update_user.send( + sender=self.__class__, request=request, user=user, created=created, + name=name, username=username, email=email + ) + return user, created + + +class OIDCBaseBackend(UserMixin, JMSBaseAuthBackend, ModelBackend): + + @staticmethod + def is_enabled(): + return settings.AUTH_OPENID + + +class OIDCAuthCodeBackend(OIDCBaseBackend): + """ Allows to authenticate users using an OpenID Connect Provider (OP). + + This authentication backend is able to authenticate users in the case of the OpenID Connect + Authorization Code flow. The ``authenticate`` method provided by this backend is likely to be + called when the callback URL is requested by the OpenID Connect Provider (OP). Thus it will + call the OIDC provider again in order to request a valid token using the authorization code that + should be available in the request parameters associated with the callback call. + + """ + + @ssl_verification + def authenticate(self, request, nonce=None, **kwargs): + """ Authenticates users in case of the OpenID Connect Authorization code flow. """ + log_prompt = "Process authenticate [OIDCAuthCodeBackend]: {}" + logger.debug(log_prompt.format('start')) + + # NOTE: the request object is mandatory to perform the authentication using an authorization + # code provided by the OIDC supplier. + if (nonce is None and settings.AUTH_OPENID_USE_NONCE) or request is None: + logger.debug(log_prompt.format('Request or nonce value is missing')) + return + + # Fetches required GET parameters from the HTTP request object. + state = request.GET.get('state') + code = request.GET.get('code') + + # Don't go further if the state value or the authorization code is not present in the GET + # parameters because we won't be able to get a valid token for the user in that case. + if (state is None and settings.AUTH_OPENID_USE_STATE) or code is None: + logger.debug(log_prompt.format('Authorization code or state value is missing')) + raise SuspiciousOperation('Authorization code or state value is missing') + + # Prepares the token payload that will be used to request an authentication token to the + # token endpoint of the OIDC provider. + logger.debug(log_prompt.format('Prepares token payload')) + token_payload = { + 'client_id': settings.AUTH_OPENID_CLIENT_ID, + 'client_secret': settings.AUTH_OPENID_CLIENT_SECRET, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': build_absolute_uri( + request, path=reverse(settings.AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME) + ) + } + + # Prepares the token headers that will be used to request an authentication token to the + # token endpoint of the OIDC provider. + logger.debug(log_prompt.format('Prepares token headers')) + basic_token = "{}:{}".format(settings.AUTH_OPENID_CLIENT_ID, settings.AUTH_OPENID_CLIENT_SECRET) + headers = {"Authorization": "Basic {}".format(base64.b64encode(basic_token.encode()).decode())} + + # Calls the token endpoint. + logger.debug(log_prompt.format('Call the token endpoint')) + token_response = requests.post( + settings.AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT, data=token_payload, headers=headers + ) + try: + token_response.raise_for_status() + token_response_data = token_response.json() + except Exception as e: + error = "Json token response error, token response " \ + "content is: {}, error is: {}".format(token_response.content, str(e)) + logger.debug(log_prompt.format(error)) + raise ParseError(error) + + # Validates the token. + logger.debug(log_prompt.format('Validate ID Token')) + raw_id_token = token_response_data.get('id_token') + id_token = validate_and_return_id_token(raw_id_token, nonce) + if id_token is None: + logger.debug(log_prompt.format( + 'ID Token is missing, raw id token is: {}'.format(raw_id_token)) + ) + return + + # Retrieves the access token and refresh token. + access_token = token_response_data.get('access_token') + refresh_token = token_response_data.get('refresh_token') + + # Stores the ID token, the related access token and the refresh token in the session. + request.session['oidc_auth_id_token'] = raw_id_token + request.session['oidc_auth_access_token'] = access_token + request.session['oidc_auth_refresh_token'] = refresh_token + + # If the id_token contains userinfo scopes and claims we don't have to hit the userinfo + # endpoint. + # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + if settings.AUTH_OPENID_ID_TOKEN_INCLUDE_CLAIMS: + logger.debug(log_prompt.format('ID Token in claims')) + claims = id_token + else: + # Fetches the claims (user information) from the userinfo endpoint provided by the OP. + logger.debug(log_prompt.format('Fetches the claims from the userinfo endpoint')) + claims_response = requests.get( + settings.AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT, + headers={'Authorization': 'Bearer {0}'.format(access_token)} + ) + try: + claims_response.raise_for_status() + claims = claims_response.json() + except Exception as e: + error = "Json claims response error, claims response " \ + "content is: {}, error is: {}".format(claims_response.content, str(e)) + logger.debug(log_prompt.format(error)) + raise ParseError(error) + + logger.debug(log_prompt.format('Get or create user from claims')) + user, created = self.get_or_create_user_from_claims(request, claims) + + logger.debug(log_prompt.format('Update or create oidc user')) + + if self.user_can_authenticate(user): + logger.debug(log_prompt.format('OpenID user login success')) + logger.debug(log_prompt.format('Send signal => openid user login success')) + openid_user_login_success.send(sender=self.__class__, request=request, user=user) + return user + else: + logger.debug(log_prompt.format('OpenID user login failed')) + logger.debug(log_prompt.format('Send signal => openid user login failed')) + openid_user_login_failed.send( + sender=self.__class__, request=request, username=user.username, + reason="User is invalid" + ) + return None + + +class OIDCAuthPasswordBackend(OIDCBaseBackend): + + @ssl_verification + def authenticate(self, request, username=None, password=None, **kwargs): + try: + return self._authenticate(request, username, password, **kwargs) + except Exception as e: + error = f'Authenticate exception: {e}' + logger.error(error, exc_info=True) + return + + def _authenticate(self, request, username=None, password=None, **kwargs): + """ + https://oauth.net/2/ + https://aaronparecki.com/oauth-2-simplified/#password + """ + log_prompt = "Process authenticate [OIDCAuthPasswordBackend]: {}" + logger.debug(log_prompt.format('start')) + request_timeout = 15 + + if not username or not password: + logger.debug(log_prompt.format('Username or password is missing')) + return + + # Prepares the token payload that will be used to request an authentication token to the + # token endpoint of the OIDC provider. + logger.debug(log_prompt.format('Prepares token payload')) + token_payload = { + 'client_id': settings.AUTH_OPENID_CLIENT_ID, + 'client_secret': settings.AUTH_OPENID_CLIENT_SECRET, + 'grant_type': 'password', + 'username': username, + 'password': password, + } + + # Calls the token endpoint. + logger.debug(log_prompt.format('Call the token endpoint')) + token_response = requests.post(settings.AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT, data=token_payload, timeout=request_timeout) + try: + token_response.raise_for_status() + token_response_data = token_response.json() + except Exception as e: + error = "Json token response error, token response " \ + "content is: {}, error is: {}".format(token_response.content, str(e)) + logger.debug(log_prompt.format(error)) + logger.debug(log_prompt.format('Send signal => openid user login failed')) + openid_user_login_failed.send( + sender=self.__class__, request=request, username=username, reason=error + ) + return + + # Retrieves the access token + access_token = token_response_data.get('access_token') + + # Fetches the claims (user information) from the userinfo endpoint provided by the OP. + logger.debug(log_prompt.format('Fetches the claims from the userinfo endpoint')) + claims_response = requests.get( + settings.AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT, + headers={'Authorization': 'Bearer {0}'.format(access_token)}, + timeout=request_timeout + ) + try: + claims_response.raise_for_status() + claims = claims_response.json() + except Exception as e: + error = "Json claims response error, claims response " \ + "content is: {}, error is: {}".format(claims_response.content, str(e)) + logger.debug(log_prompt.format(error)) + logger.debug(log_prompt.format('Send signal => openid user login failed')) + openid_user_login_failed.send( + sender=self.__class__, request=request, username=username, reason=error + ) + return + + logger.debug(log_prompt.format('Get or create user from claims')) + user, created = self.get_or_create_user_from_claims(request, claims) + + logger.debug(log_prompt.format('Update or create oidc user')) + + if self.user_can_authenticate(user): + logger.debug(log_prompt.format('OpenID user login success')) + logger.debug(log_prompt.format('Send signal => openid user login success')) + openid_user_login_success.send( + sender=self.__class__, request=request, user=user + ) + return user + else: + logger.debug(log_prompt.format('OpenID user login failed')) + logger.debug(log_prompt.format('Send signal => openid user login failed')) + openid_user_login_failed.send( + sender=self.__class__, request=request, username=username, reason="User is invalid" + ) + return None + diff --git a/apps/authentication/backends/oidc/decorator.py b/apps/authentication/backends/oidc/decorator.py new file mode 100644 index 000000000..e28813de8 --- /dev/null +++ b/apps/authentication/backends/oidc/decorator.py @@ -0,0 +1,60 @@ +# coding: utf-8 +# + +import warnings +import contextlib +import requests + +from django.conf import settings +from urllib3.exceptions import InsecureRequestWarning + +from .utils import get_logger + +__all__ = ['ssl_verification'] + +old_merge_environment_settings = requests.Session.merge_environment_settings + + +logger = get_logger(__file__) + + +@contextlib.contextmanager +def no_ssl_verification(): + """ + https://stackoverflow.com/questions/15445981/ + how-do-i-disable-the-security-certificate-check-in-python-requests + """ + opened_adapters = set() + + def merge_environment_settings(self, url, proxies, stream, verify, cert): + # Verification happens only once per connection so we need to close + # all the opened adapters once we're done. Otherwise, the effects of + # verify=False persist beyond the end of this context manager. + opened_adapters.add(self.get_adapter(url)) + _settings = old_merge_environment_settings( + self, url, proxies, stream, verify, cert + ) + _settings['verify'] = False + return _settings + + requests.Session.merge_environment_settings = merge_environment_settings + try: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', InsecureRequestWarning) + yield + finally: + requests.Session.merge_environment_settings = old_merge_environment_settings + for adapter in opened_adapters: + try: + adapter.close() + except: + pass + + +def ssl_verification(func): + def wrapper(*args, **kwargs): + if not settings.AUTH_OPENID_IGNORE_SSL_VERIFICATION: + return func(*args, **kwargs) + with no_ssl_verification(): + return func(*args, **kwargs) + return wrapper diff --git a/apps/authentication/backends/oidc/middleware.py b/apps/authentication/backends/oidc/middleware.py index 0e58591d4..c2ad33637 100644 --- a/apps/authentication/backends/oidc/middleware.py +++ b/apps/authentication/backends/oidc/middleware.py @@ -1,10 +1,106 @@ -from jms_oidc_rp.middleware import OIDCRefreshIDTokenMiddleware as _OIDCRefreshIDTokenMiddleware +import time +import requests +import requests.exceptions + from django.core.exceptions import MiddlewareNotUsed from django.conf import settings +from django.contrib import auth +from common.utils import get_logger + +from .utils import validate_and_return_id_token +from .decorator import ssl_verification -class OIDCRefreshIDTokenMiddleware(_OIDCRefreshIDTokenMiddleware): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) +logger = get_logger(__file__) + + +class OIDCRefreshIDTokenMiddleware: + """ Allows to periodically refresh the ID token associated with the authenticated user. """ + + def __init__(self, get_response): if not settings.AUTH_OPENID: raise MiddlewareNotUsed + + self.get_response = get_response + + def __call__(self, request): + # Refreshes tokens only in the applicable cases. + if request.method == 'GET' and not request.is_ajax() and request.user.is_authenticated and settings.AUTH_OPENID: + self.refresh_token(request) + response = self.get_response(request) + return response + + @ssl_verification + def refresh_token(self, request): + """ Refreshes the token of the current user. """ + + log_prompt = "Process refresh Token: {}" + # logger.debug(log_prompt.format('Start')) + + # NOTE: SHARE_SESSION is False means that the user does not share sessions + # with other applications + if not settings.AUTH_OPENID_SHARE_SESSION: + logger.debug(log_prompt.format('Not share session')) + return + + # NOTE: no refresh token in the session means that the user wasn't authentified using the + # OpenID Connect provider (OP). + refresh_token = request.session.get('oidc_auth_refresh_token') + if refresh_token is None: + logger.debug(log_prompt.format('Refresh token is missing')) + return + + id_token_exp_timestamp = request.session.get('oidc_auth_id_token_exp_timestamp', None) + now_timestamp = time.time() + # Returns immediately if the token is still valid. + if id_token_exp_timestamp is not None and id_token_exp_timestamp > now_timestamp: + # logger.debug(log_prompt.format('Returns immediately because token is still valid')) + return + + # Prepares the token payload that will be used to request a new token from the token + # endpoint. + refresh_token = request.session.pop('oidc_auth_refresh_token') + token_payload = { + 'client_id': settings.AUTH_OPENID_CLIENT_ID, + 'client_secret': settings.AUTH_OPENID_CLIENT_SECRET, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'scope': settings.AUTH_OPENID_SCOPES, + } + + # Calls the token endpoint. + logger.debug(log_prompt.format('Calls the token endpoint')) + token_response = requests.post(settings.AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT, data=token_payload) + try: + token_response.raise_for_status() + except requests.exceptions.HTTPError as e: + logger.debug(log_prompt.format('Request exception http error: {}'.format(str(e)))) + logger.debug(log_prompt.format('Logout')) + auth.logout(request) + return + token_response_data = token_response.json() + + # Validates the token. + logger.debug(log_prompt.format('Validate ID Token')) + raw_id_token = token_response_data.get('id_token') + id_token = validate_and_return_id_token(raw_id_token, validate_nonce=False) + + # If the token cannot be validated we have to log out the current user. + if id_token is None: + logger.debug(log_prompt.format('ID Token is None')) + auth.logout(request) + logger.debug(log_prompt.format('Logout')) + return + + # Retrieves the access token and refresh token. + access_token = token_response_data.get('access_token') + refresh_token = token_response_data.get('refresh_token') + + # Stores the ID token, the related access token and the refresh token in the session. + request.session['oidc_auth_id_token'] = raw_id_token + request.session['oidc_auth_access_token'] = access_token + request.session['oidc_auth_refresh_token'] = refresh_token + + # Saves the new expiration timestamp. + request.session['oidc_auth_id_token_exp_timestamp'] = \ + time.time() + settings.AUTH_OPENID_ID_TOKEN_MAX_AGE diff --git a/apps/authentication/backends/oidc/signals.py b/apps/authentication/backends/oidc/signals.py new file mode 100644 index 000000000..85d2dcd94 --- /dev/null +++ b/apps/authentication/backends/oidc/signals.py @@ -0,0 +1,18 @@ +""" + OpenID Connect relying party (RP) signals + ========================================= + + This modules defines Django signals that can be triggered during the OpenID Connect + authentication process. + +""" + +from django.dispatch import Signal + + +openid_create_or_update_user = Signal( + providing_args=['request', 'user', 'created', 'name', 'username', 'email'] +) +openid_user_login_success = Signal(providing_args=['request', 'user']) +openid_user_login_failed = Signal(providing_args=['request', 'username', 'reason']) + diff --git a/apps/authentication/backends/oidc/urls.py b/apps/authentication/backends/oidc/urls.py new file mode 100644 index 000000000..a79ebe0c2 --- /dev/null +++ b/apps/authentication/backends/oidc/urls.py @@ -0,0 +1,20 @@ +""" + OpenID Connect relying party (RP) URLs + ====================================== + + This modules defines the URLs allowing to perform OpenID Connect flows on a Relying Party (RP). + It defines three main endpoints: the authentication request endpoint, the authentication + callback endpoint and the end session endpoint. + +""" + +from django.urls import path + +from . import views + + +urlpatterns = [ + path('login/', views.OIDCAuthRequestView.as_view(), name='login'), + path('callback/', views.OIDCAuthCallbackView.as_view(), name='login-callback'), + path('logout/', views.OIDCEndSessionView.as_view(), name='logout'), +] diff --git a/apps/authentication/backends/oidc/utils.py b/apps/authentication/backends/oidc/utils.py new file mode 100644 index 000000000..2a0f0609e --- /dev/null +++ b/apps/authentication/backends/oidc/utils.py @@ -0,0 +1,126 @@ +""" + OpenID Connect relying party (RP) utilities + =========================================== + + This modules defines utilities allowing to manipulate ID tokens and other common helpers. + +""" + +import datetime as dt +from calendar import timegm +from urllib.parse import urlparse, urljoin + +from django.core.exceptions import SuspiciousOperation +from django.utils.encoding import force_bytes, smart_bytes +from jwkest import JWKESTException +from jwkest.jwk import KEYS +from jwkest.jws import JWS +from django.conf import settings + +from common.utils import get_logger + + +logger = get_logger(__file__) + + +def validate_and_return_id_token(jws, nonce=None, validate_nonce=True): + """ Validates the id_token according to the OpenID Connect specification. """ + log_prompt = "Validate ID Token: {}" + logger.debug(log_prompt.format('Get shared key')) + shared_key = settings.AUTH_OPENID_CLIENT_ID \ + if settings.AUTH_OPENID_PROVIDER_SIGNATURE_ALG == 'HS256' \ + else settings.AUTH_OPENID_PROVIDER_SIGNATURE_KEY # RS256 + + try: + # Decodes the JSON Web Token and raise an error if the signature is invalid. + logger.debug(log_prompt.format('Verify compact jwk')) + id_token = JWS().verify_compact(force_bytes(jws), _get_jwks_keys(shared_key)) + except JWKESTException as e: + logger.debug(log_prompt.format('Verify compact jwkest exception: {}'.format(str(e)))) + return + + # Validates the claims embedded in the id_token. + logger.debug(log_prompt.format('Validate claims')) + _validate_claims(id_token, nonce=nonce, validate_nonce=validate_nonce) + + return id_token + + +def _get_jwks_keys(shared_key): + """ Returns JWKS keys used to decrypt id_token values. """ + # The OpenID Connect Provider (OP) uses RSA keys to sign/enrypt ID tokens and generate public + # keys allowing to decrypt them. These public keys are exposed through the 'jwks_uri' and should + # be used to decrypt the JWS - JSON Web Signature. + log_prompt = "Get jwks keys: {}" + logger.debug(log_prompt.format('Start')) + jwks_keys = KEYS() + logger.debug(log_prompt.format('Load from provider jwks endpoint')) + jwks_keys.load_from_url(settings.AUTH_OPENID_PROVIDER_JWKS_ENDPOINT) + # Adds the shared key (which can correspond to the client_secret) as an oct key so it can be + # used for HMAC signatures. + logger.debug(log_prompt.format('Add key')) + jwks_keys.add({'key': smart_bytes(shared_key), 'kty': 'oct'}) + logger.debug(log_prompt.format('End')) + return jwks_keys + + +def _validate_claims(id_token, nonce=None, validate_nonce=True): + """ Validates the claims embedded in the JSON Web Token. """ + log_prompt = "Validate claims: {}" + logger.debug(log_prompt.format('Start')) + + iss_parsed_url = urlparse(id_token['iss']) + provider_parsed_url = urlparse(settings.AUTH_OPENID_PROVIDER_ENDPOINT) + if iss_parsed_url.netloc != provider_parsed_url.netloc: + logger.debug(log_prompt.format('Invalid issuer')) + raise SuspiciousOperation('Invalid issuer') + + if isinstance(id_token['aud'], str): + id_token['aud'] = [id_token['aud']] + + if settings.AUTH_OPENID_CLIENT_ID not in id_token['aud']: + logger.debug(log_prompt.format('Invalid audience')) + raise SuspiciousOperation('Invalid audience') + + if len(id_token['aud']) > 1 and 'azp' not in id_token: + logger.debug(log_prompt.format('Incorrect id_token: azp')) + raise SuspiciousOperation('Incorrect id_token: azp') + + if 'azp' in id_token and id_token['azp'] != settings.AUTH_OPENID_CLIENT_ID: + raise SuspiciousOperation('Incorrect id_token: azp') + + utc_timestamp = timegm(dt.datetime.utcnow().utctimetuple()) + if utc_timestamp > id_token['exp']: + logger.debug(log_prompt.format('Signature has expired')) + raise SuspiciousOperation('Signature has expired') + + if 'nbf' in id_token and utc_timestamp < id_token['nbf']: + logger.debug(log_prompt.format('Incorrect id_token: nbf')) + raise SuspiciousOperation('Incorrect id_token: nbf') + + # Verifies that the token was issued in the allowed timeframe. + if utc_timestamp > id_token['iat'] + settings.AUTH_OPENID_ID_TOKEN_MAX_AGE: + logger.debug(log_prompt.format('Incorrect id_token: iat')) + raise SuspiciousOperation('Incorrect id_token: iat') + + # Validate the nonce to ensure the request was not modified if applicable. + id_token_nonce = id_token.get('nonce', None) + if validate_nonce and settings.AUTH_OPENID_USE_NONCE and id_token_nonce != nonce: + logger.debug(log_prompt.format('Incorrect id_token: nonce')) + raise SuspiciousOperation('Incorrect id_token: nonce') + + logger.debug(log_prompt.format('End')) + + +def build_absolute_uri(request, path=None): + """ + Build absolute redirect uri + """ + if path is None: + path = '/' + + if settings.BASE_SITE_URL: + redirect_uri = urljoin(settings.BASE_SITE_URL, path) + else: + redirect_uri = request.build_absolute_uri(path) + return redirect_uri diff --git a/apps/authentication/backends/oidc/views.py b/apps/authentication/backends/oidc/views.py new file mode 100644 index 000000000..1c9442ef2 --- /dev/null +++ b/apps/authentication/backends/oidc/views.py @@ -0,0 +1,222 @@ +""" + OpenID Connect relying party (RP) views + ======================================= + + This modules defines views allowing to start the authorization and authentication process in + order to authenticate a specific user. The most important views are: the "login" allowing to + authenticate the users using the OP and get an authorizartion code, the callback view allowing + to retrieve a valid token for the considered user and the logout view. + +""" + +import time + +from django.conf import settings +from django.contrib import auth +from django.core.exceptions import SuspiciousOperation +from django.http import HttpResponseRedirect, QueryDict +from django.urls import reverse +from django.utils.crypto import get_random_string +from django.utils.http import is_safe_url, urlencode +from django.views.generic import View + +from .utils import get_logger, build_absolute_uri + + +logger = get_logger(__file__) + + +class OIDCAuthRequestView(View): + """ Allows to start the authorization flow in order to authenticate the end-user. + + This view acts as the main endpoint to trigger the authentication process involving the OIDC + provider (OP). It prepares an authentication request that will be sent to the authorization + server in order to authenticate the end-user. + + """ + + http_method_names = ['get', ] + + def get(self, request): + """ Processes GET requests. """ + + log_prompt = "Process GET requests [OIDCAuthRequestView]: {}" + logger.debug(log_prompt.format('Start')) + + # Defines common parameters used to bootstrap the authentication request. + logger.debug(log_prompt.format('Construct request params')) + authentication_request_params = request.GET.dict() + authentication_request_params.update({ + 'scope': settings.AUTH_OPENID_SCOPES, + 'response_type': 'code', + 'client_id': settings.AUTH_OPENID_CLIENT_ID, + 'redirect_uri': build_absolute_uri( + request, path=reverse(settings.AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME) + ) + }) + + # States should be used! They are recommended in order to maintain state between the + # authentication request and the callback. + if settings.AUTH_OPENID_USE_STATE: + logger.debug(log_prompt.format('Use state')) + state = get_random_string(settings.AUTH_OPENID_STATE_LENGTH) + authentication_request_params.update({'state': state}) + request.session['oidc_auth_state'] = state + + # Nonces should be used too! In that case the generated nonce is stored both in the + # authentication request parameters and in the user's session. + if settings.AUTH_OPENID_USE_NONCE: + logger.debug(log_prompt.format('Use nonce')) + nonce = get_random_string(settings.AUTH_OPENID_NONCE_LENGTH) + authentication_request_params.update({'nonce': nonce, }) + request.session['oidc_auth_nonce'] = nonce + + # Stores the "next" URL in the session if applicable. + logger.debug(log_prompt.format('Stores next url in the session')) + next_url = request.GET.get('next') + request.session['oidc_auth_next_url'] = next_url \ + if is_safe_url(url=next_url, allowed_hosts=(request.get_host(), )) else None + + # Redirects the user to authorization endpoint. + logger.debug(log_prompt.format('Construct redirect url')) + query = urlencode(authentication_request_params) + redirect_url = '{url}?{query}'.format( + url=settings.AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT, query=query) + + logger.debug(log_prompt.format('Redirect')) + return HttpResponseRedirect(redirect_url) + + +class OIDCAuthCallbackView(View): + """ Allows to complete the authentication process. + + This view acts as the main endpoint to complete the authentication process involving the OIDC + provider (OP). It checks the request sent by the OIDC provider in order to determine whether the + considered was successfully authentified or not and authenticates the user at the current + application level if applicable. + + """ + + http_method_names = ['get', ] + + def get(self, request): + """ Processes GET requests. """ + log_prompt = "Process GET requests [OIDCAuthCallbackView]: {}" + logger.debug(log_prompt.format('Start')) + callback_params = request.GET + + # Retrieve the state value that was previously generated. No state means that we cannot + # authenticate the user (so a failure should be returned). + state = request.session.get('oidc_auth_state', None) + + # Retrieve the nonce that was previously generated and remove it from the current session. + # If no nonce is available (while the USE_NONCE setting is set to True) this means that the + # authentication cannot be performed and so we have redirect the user to a failure URL. + nonce = request.session.pop('oidc_auth_nonce', None) + + # NOTE: a redirect to the failure page should be return if some required GET parameters are + # missing or if no state can be retrieved from the current session. + + if ( + ((nonce and settings.AUTH_OPENID_USE_NONCE) or not settings.AUTH_OPENID_USE_NONCE) + and + ( + (state and settings.AUTH_OPENID_USE_STATE and 'state' in callback_params) + or + (not settings.AUTH_OPENID_USE_STATE) + ) + and + ('code' in callback_params) + ): + # Ensures that the passed state values is the same as the one that was previously + # generated when forging the authorization request. This is necessary to mitigate + # Cross-Site Request Forgery (CSRF, XSRF). + if settings.AUTH_OPENID_USE_STATE and callback_params['state'] != state: + logger.debug(log_prompt.format('Invalid OpenID Connect callback state value')) + raise SuspiciousOperation('Invalid OpenID Connect callback state value') + + # Authenticates the end-user. + next_url = request.session.get('oidc_auth_next_url', None) + logger.debug(log_prompt.format('Process authenticate')) + user = auth.authenticate(nonce=nonce, request=request) + if user and user.is_valid: + logger.debug(log_prompt.format('Login: {}'.format(user))) + auth.login(self.request, user) + # Stores an expiration timestamp in the user's session. This value will be used if + # the project is configured to periodically refresh user's token. + self.request.session['oidc_auth_id_token_exp_timestamp'] = \ + time.time() + settings.AUTH_OPENID_ID_TOKEN_MAX_AGE + # Stores the "session_state" value that can be passed by the OpenID Connect provider + # in order to maintain a consistent session state across the OP and the related + # relying parties (RP). + self.request.session['oidc_auth_session_state'] = \ + callback_params.get('session_state', None) + + logger.debug(log_prompt.format('Redirect')) + return HttpResponseRedirect( + next_url or settings.AUTH_OPENID_AUTHENTICATION_REDIRECT_URI + ) + + if 'error' in callback_params: + logger.debug( + log_prompt.format('Error in callback params: {}'.format(callback_params['error'])) + ) + # If we receive an error in the callback GET parameters, this means that the + # authentication could not be performed at the OP level. In that case we have to logout + # the current user because we could've obtained this error after a prompt=none hit on + # OpenID Connect Provider authenticate endpoint. + logger.debug(log_prompt.format('Logout')) + auth.logout(request) + + logger.debug(log_prompt.format('Redirect')) + return HttpResponseRedirect(settings.AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI) + + +class OIDCEndSessionView(View): + """ Allows to end the session of any user authenticated using OpenID Connect. + + This view acts as the main endpoint to end the session of an end-user that was authenticated + using the OIDC provider (OP). It calls the "end-session" endpoint provided by the provider if + applicable. + + """ + + http_method_names = ['get', 'post', ] + + def get(self, request): + """ Processes GET requests. """ + log_prompt = "Process GET requests [OIDCEndSessionView]: {}" + logger.debug(log_prompt.format('Start')) + return self.post(request) + + def post(self, request): + """ Processes POST requests. """ + log_prompt = "Process POST requests [OIDCEndSessionView]: {}" + logger.debug(log_prompt.format('Start')) + + logout_url = settings.LOGOUT_REDIRECT_URL or '/' + + # Log out the current user. + if request.user.is_authenticated: + logger.debug(log_prompt.format('Current user is authenticated')) + try: + logout_url = self.provider_end_session_url \ + if settings.AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT else logout_url + except KeyError: # pragma: no cover + logout_url = logout_url + logger.debug(log_prompt.format('Log out the current user: {}'.format(request.user))) + auth.logout(request) + + # Redirects the user to the appropriate URL. + logger.debug(log_prompt.format('Redirect')) + return HttpResponseRedirect(logout_url) + + @property + def provider_end_session_url(self): + """ Returns the end-session URL. """ + q = QueryDict(mutable=True) + q[settings.AUTH_OPENID_PROVIDER_END_SESSION_REDIRECT_URI_PARAMETER] = \ + build_absolute_uri(self.request, path=settings.LOGOUT_REDIRECT_URL or '/') + q[settings.AUTH_OPENID_PROVIDER_END_SESSION_ID_TOKEN_PARAMETER] = \ + self.request.session['oidc_auth_id_token'] + return '{}?{}'.format(settings.AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT, q.urlencode()) diff --git a/apps/authentication/backends/openid.py b/apps/authentication/backends/openid.py deleted file mode 100644 index a82161b8e..000000000 --- a/apps/authentication/backends/openid.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -使用下面的工程,进行jumpserver 的 oidc 认证 -https://github.com/BaiJiangJie/jumpserver-django-oidc-rp -""" \ No newline at end of file diff --git a/apps/authentication/backends/pubkey.py b/apps/authentication/backends/pubkey.py index 3355eacaa..1494d6b2e 100644 --- a/apps/authentication/backends/pubkey.py +++ b/apps/authentication/backends/pubkey.py @@ -1,13 +1,20 @@ # -*- coding: utf-8 -*- # from django.contrib.auth import get_user_model +from django.conf import settings + +from .base import JMSBaseAuthBackend UserModel = get_user_model() __all__ = ['PublicKeyAuthBackend'] -class PublicKeyAuthBackend: +class PublicKeyAuthBackend(JMSBaseAuthBackend): + @staticmethod + def is_enabled(): + return settings.TERMINAL_PUBLIC_KEY_AUTH + def authenticate(self, request, username=None, public_key=None, **kwargs): if not public_key: return None @@ -22,15 +29,6 @@ class PublicKeyAuthBackend: self.user_can_authenticate(user): return user - @staticmethod - def user_can_authenticate(user): - """ - Reject users with is_active=False. Custom user models that don't have - that attribute are allowed. - """ - is_active = getattr(user, 'is_active', None) - return is_active or is_active is None - def get_user(self, user_id): try: user = UserModel._default_manager.get(pk=user_id) diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 1baf3d569..170534370 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend from django.conf import settings +from .base import JMSBaseAuthBackend + User = get_user_model() @@ -39,11 +41,17 @@ class CreateUserMixin: return None -class RadiusBackend(CreateUserMixin, RADIUSBackend): +class RadiusBaseBackend(CreateUserMixin, JMSBaseAuthBackend): + @staticmethod + def is_enabled(): + return settings.AUTH_RADIUS + + +class RadiusBackend(RadiusBaseBackend, RADIUSBackend): def authenticate(self, request, username='', password='', **kwargs): return super().authenticate(request, username=username, password=password) -class RadiusRealmBackend(CreateUserMixin, RADIUSRealmBackend): +class RadiusRealmBackend(RadiusBaseBackend, RADIUSRealmBackend): def authenticate(self, request, username='', password='', realm=None, **kwargs): return super().authenticate(request, username=username, password=password, realm=realm) diff --git a/apps/authentication/backends/saml2/__init__.py b/apps/authentication/backends/saml2/__init__.py index bbdbdb814..ec51c5a2b 100644 --- a/apps/authentication/backends/saml2/__init__.py +++ b/apps/authentication/backends/saml2/__init__.py @@ -1,3 +1,2 @@ # -*- coding: utf-8 -*- # -from .backends import * diff --git a/apps/authentication/backends/saml2/backends.py b/apps/authentication/backends/saml2/backends.py index 8b7cfe3d0..d31692462 100644 --- a/apps/authentication/backends/saml2/backends.py +++ b/apps/authentication/backends/saml2/backends.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # from django.contrib.auth import get_user_model -from django.contrib.auth.backends import ModelBackend +from django.conf import settings from django.db import transaction from common.utils import get_logger @@ -10,16 +10,17 @@ from .signals import ( saml2_user_authenticated, saml2_user_authentication_failed, saml2_create_or_update_user ) +from ..base import JMSBaseAuthBackend __all__ = ['SAML2Backend'] logger = get_logger(__file__) -class SAML2Backend(ModelBackend): - def user_can_authenticate(self, user): - is_valid = getattr(user, 'is_valid', None) - return is_valid or is_valid is None +class SAML2Backend(JMSBaseAuthBackend): + @staticmethod + def is_enabled(): + return settings.AUTH_SAML2 @transaction.atomic def get_or_create_from_saml_data(self, request, **saml_user_data): diff --git a/apps/authentication/backends/sso.py b/apps/authentication/backends/sso.py new file mode 100644 index 000000000..86d8f76e1 --- /dev/null +++ b/apps/authentication/backends/sso.py @@ -0,0 +1,63 @@ +from django.conf import settings + +from .base import JMSBaseAuthBackend + + +class SSOAuthentication(JMSBaseAuthBackend): + """ + 什么也不做呀😺 + """ + + @staticmethod + def is_enabled(): + return settings.AUTH_SSO + + def authenticate(self, request, sso_token=None, **kwargs): + pass + + +class WeComAuthentication(JMSBaseAuthBackend): + """ + 什么也不做呀😺 + """ + + @staticmethod + def is_enabled(): + return settings.AUTH_WECOM + + def authenticate(self, request, **kwargs): + pass + + +class DingTalkAuthentication(JMSBaseAuthBackend): + """ + 什么也不做呀😺 + """ + + @staticmethod + def is_enabled(): + return settings.AUTH_DINGTALK + + def authenticate(self, request, **kwargs): + pass + + +class FeiShuAuthentication(JMSBaseAuthBackend): + """ + 什么也不做呀😺 + """ + + @staticmethod + def is_enabled(): + return settings.AUTH_FEISHU + + def authenticate(self, request, **kwargs): + pass + + +class AuthorizationTokenAuthentication(JMSBaseAuthBackend): + """ + 什么也不做呀😺 + """ + def authenticate(self, request, **kwargs): + pass diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index c74b1a5ff..f01b28796 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -12,9 +12,10 @@ from django.contrib import auth from django.utils.translation import ugettext as _ from rest_framework.request import Request from django.contrib.auth import ( - BACKEND_SESSION_KEY, _get_backends, - PermissionDenied, user_login_failed, _clean_credentials + BACKEND_SESSION_KEY, load_backend, + PermissionDenied, user_login_failed, _clean_credentials, ) +from django.core.exceptions import ImproperlyConfigured from django.shortcuts import reverse, redirect, get_object_or_404 from common.utils import get_request_ip, get_logger, bulk_get, FlashMessageUtil @@ -29,27 +30,38 @@ from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY logger = get_logger(__name__) -def check_backend_can_auth(username, backend_path, allowed_auth_backends): - if allowed_auth_backends is not None and backend_path not in allowed_auth_backends: - logger.debug('Skip user auth backend: {}, {} not in'.format( - username, backend_path, ','.join(allowed_auth_backends) - )) - return False - return True +def _get_backends(return_tuples=False): + backends = [] + for backend_path in settings.AUTHENTICATION_BACKENDS: + backend = load_backend(backend_path) + # 检查 backend 是否启用 + if not backend.is_enabled(): + continue + backends.append((backend, backend_path) if return_tuples else backend) + if not backends: + raise ImproperlyConfigured( + 'No authentication backends have been defined. Does ' + 'AUTHENTICATION_BACKENDS contain anything?' + ) + return backends + + +auth._get_backends = _get_backends def authenticate(request=None, **credentials): """ If the given credentials are valid, return a User object. + 之所以 hack 这个 auticate """ username = credentials.get('username') - allowed_auth_backends = User.get_user_allowed_auth_backends(username) for backend, backend_path in _get_backends(return_tuples=True): # 预先检查,不浪费认证时间 - if not check_backend_can_auth(username, backend_path, allowed_auth_backends): + if not backend.username_can_authenticate(username): continue + # 原生 backend_signature = inspect.signature(backend.authenticate) try: backend_signature.bind(request, **credentials) @@ -63,21 +75,17 @@ def authenticate(request=None, **credentials): break if user is None: continue - # 如果是 None, 证明没有检查过, 需要再次检查 - if allowed_auth_backends is None: - # 有些 authentication 参数中不带 username, 之后还要再检查 - allowed_auth_backends = user.get_allowed_auth_backends() - if not check_backend_can_auth(user.username, backend_path, allowed_auth_backends): - continue + + # 再次检查遇检查中遗漏的用户 + if not backend.user_can_authenticate(user): + continue # Annotate the user object with the path of the backend. user.backend = backend_path return user # The credentials supplied are invalid to all backends, fire signal - user_login_failed.send( - sender=__name__, credentials=_clean_credentials(credentials), request=request - ) + user_login_failed.send(sender=__name__, credentials=_clean_credentials(credentials), request=request) auth.authenticate = authenticate diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index a1063186a..6472f217e 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -6,7 +6,7 @@ from django.core.cache import cache from django.dispatch import receiver from django_cas_ng.signals import cas_user_authenticated -from jms_oidc_rp.signals import openid_user_login_failed, openid_user_login_success +from authentication.backends.oidc.signals import openid_user_login_failed, openid_user_login_success from authentication.backends.saml2.signals import ( saml2_user_authenticated, saml2_user_authentication_failed diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 53944f759..ce0d3c647 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -55,7 +55,7 @@ urlpatterns = [ # openid path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')), - path('openid/', include(('jms_oidc_rp.urls', 'authentication'), namespace='openid')), + path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')), path('saml2/', include(('authentication.backends.saml2.urls', 'authentication'), namespace='saml2')), path('captcha/', include('captcha.urls')), ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 545da111f..30ac80e3b 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -184,7 +184,7 @@ class UserLoginView(mixins.AuthMixin, FormView): @staticmethod def get_forgot_password_url(): forgot_password_url = reverse('authentication:forgot-password') - has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL + has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[1] != settings.AUTH_BACKEND_MODEL if has_other_auth_backend and settings.FORGOT_PASSWORD_URL: forgot_password_url = settings.FORGOT_PASSWORD_URL return forgot_password_url diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index f5e6af75f..6f80a44fc 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -74,6 +74,13 @@ AUTH_OPENID_ALWAYS_UPDATE_USER = CONFIG.AUTH_OPENID_ALWAYS_UPDATE_USER AUTH_OPENID_AUTH_LOGIN_URL_NAME = 'authentication:openid:login' AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME = 'authentication:openid:login-callback' AUTH_OPENID_AUTH_LOGOUT_URL_NAME = 'authentication:openid:logout' +# Other default +AUTH_OPENID_STATE_LENGTH = 32 +AUTH_OPENID_NONCE_LENGTH = 32 +AUTH_OPENID_AUTHENTICATION_REDIRECT_URI = '/' +AUTH_OPENID_AUTHENTICATION_FAILURE_REDIRECT_URI = '/' +AUTH_OPENID_PROVIDER_END_SESSION_REDIRECT_URI_PARAMETER = 'post_logout_redirect_uri' +AUTH_OPENID_PROVIDER_END_SESSION_ID_TOKEN_PARAMETER = 'id_token_hint' # ============================================================================== # Radius Auth @@ -138,39 +145,35 @@ TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION OTP_IN_RADIUS = CONFIG.OTP_IN_RADIUS -AUTH_BACKEND_MODEL = 'authentication.backends.api.JMSModelBackend' +AUTH_BACKEND_MODEL = 'authentication.backends.base.JMSModelBackend' RBAC_BACKEND = 'rbac.backends.RBACBackend' AUTH_BACKEND_PUBKEY = 'authentication.backends.pubkey.PublicKeyAuthBackend' AUTH_BACKEND_LDAP = 'authentication.backends.ldap.LDAPAuthorizationBackend' -AUTH_BACKEND_OIDC_PASSWORD = 'jms_oidc_rp.backends.OIDCAuthPasswordBackend' -AUTH_BACKEND_OIDC_CODE = 'jms_oidc_rp.backends.OIDCAuthCodeBackend' +AUTH_BACKEND_OIDC_PASSWORD = 'authentication.backends.oidc.backends.OIDCAuthPasswordBackend' +AUTH_BACKEND_OIDC_CODE = 'authentication.backends.oidc.backends.OIDCAuthCodeBackend' AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend' -AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend' -AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication' -AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication' -AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication' -AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication' -AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication' -AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend' +AUTH_BACKEND_CAS = 'authentication.backends.cas.backends.CASBackend' +AUTH_BACKEND_SSO = 'authentication.backends.sso.SSOAuthentication' +AUTH_BACKEND_WECOM = 'authentication.backends.sso.WeComAuthentication' +AUTH_BACKEND_DINGTALK = 'authentication.backends.sso.DingTalkAuthentication' +AUTH_BACKEND_FEISHU = 'authentication.backends.sso.FeiShuAuthentication' +AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.sso.AuthorizationTokenAuthentication' +AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.backends.SAML2Backend' AUTHENTICATION_BACKENDS = [ - AUTH_BACKEND_MODEL, RBAC_BACKEND, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM, - AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN, - AUTH_BACKEND_SSO, + # 只做权限校验 + RBAC_BACKEND, + # 密码形式 + AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_LDAP, AUTH_BACKEND_RADIUS, + # 跳转形式 + AUTH_BACKEND_CAS, AUTH_BACKEND_OIDC_PASSWORD, AUTH_BACKEND_OIDC_CODE, AUTH_BACKEND_SAML2, + # 扫码模式 + AUTH_BACKEND_WECOM, AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, + # Token模式 + AUTH_BACKEND_AUTH_TOKEN, AUTH_BACKEND_SSO, ] -if AUTH_CAS: - AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_CAS) -if AUTH_OPENID: - AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_PASSWORD) - AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_OIDC_CODE) -if AUTH_RADIUS: - AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS) -if AUTH_SAML2: - AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_SAML2) - - ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH ONLY_ALLOW_AUTH_FROM_SOURCE = CONFIG.ONLY_ALLOW_AUTH_FROM_SOURCE diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 936c99980..d5542b949 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -57,7 +57,6 @@ INSTALLED_APPS = [ 'notifications.apps.NotificationsConfig', 'rbac.apps.RBACConfig', 'common.apps.CommonConfig', - 'jms_oidc_rp', 'rest_framework', 'rest_framework_swagger', 'drf_yasg', @@ -116,7 +115,6 @@ TEMPLATES = [ 'django.template.context_processors.media', 'jumpserver.context_processor.jumpserver_processor', 'orgs.context_processor.org_processor', - 'jms_oidc_rp.context_processors.oidc', ], }, }, diff --git a/apps/jumpserver/settings/libs.py b/apps/jumpserver/settings/libs.py index ae36c437f..eb7b5a9eb 100644 --- a/apps/jumpserver/settings/libs.py +++ b/apps/jumpserver/settings/libs.py @@ -26,11 +26,11 @@ REST_FRAMEWORK = { ), 'DEFAULT_AUTHENTICATION_CLASSES': ( # 'rest_framework.authentication.BasicAuthentication', - 'authentication.backends.api.AccessKeyAuthentication', - 'authentication.backends.api.AccessTokenAuthentication', - 'authentication.backends.api.PrivateTokenAuthentication', - 'authentication.backends.api.SignatureAuthentication', - 'authentication.backends.api.SessionAuthentication', + 'authentication.backends.drf.AccessKeyAuthentication', + 'authentication.backends.drf.AccessTokenAuthentication', + 'authentication.backends.drf.PrivateTokenAuthentication', + 'authentication.backends.drf.SignatureAuthentication', + 'authentication.backends.drf.SessionAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', diff --git a/apps/rbac/backends.py b/apps/rbac/backends.py index 8796d5dfc..11c3b8004 100644 --- a/apps/rbac/backends.py +++ b/apps/rbac/backends.py @@ -1,13 +1,27 @@ -from django.contrib.auth.backends import ModelBackend +from django.core.exceptions import PermissionDenied + +from authentication.backends.base import JMSBaseAuthBackend -class RBACBackend(ModelBackend): +class RBACBackend(JMSBaseAuthBackend): + """ 只做权限校验 """ + @staticmethod + def is_enabled(): + return True + + def authenticate(self, *args, **kwargs): + return None + + def username_can_authenticate(self, username): + return False + def has_perm(self, user_obj, perm, obj=None): if not user_obj.is_active: - return False - + raise PermissionDenied() has_perm = perm in user_obj.perms + if not has_perm: + raise PermissionDenied() return has_perm - # + # def has_module_perms(self, user_obj, app_label): # return True diff --git a/apps/rbac/const.py b/apps/rbac/const.py index 995b2056d..3547a165f 100644 --- a/apps/rbac/const.py +++ b/apps/rbac/const.py @@ -16,7 +16,6 @@ exclude_permissions = ( ('contenttypes', '*', '*', '*'), ('django_cas_ng', '*', '*', '*'), ('django_celery_beat', '*', '*', '*'), - ('jms_oidc_rp', '*', '*', '*'), ('admin', '*', '*', '*'), ('sessions', '*', '*', '*'), ('notifications', '*', '*', '*'), diff --git a/apps/settings/models.py b/apps/settings/models.py index 179ceba06..26f085f57 100644 --- a/apps/settings/models.py +++ b/apps/settings/models.py @@ -107,7 +107,7 @@ class Setting(models.Model): # 添加 if setting.cleaned_value and not has: logger.debug('Add auth backend: {}'.format(name)) - settings.AUTHENTICATION_BACKENDS.insert(0, backend) + settings.AUTHENTICATION_BACKENDS.insert(1, backend) # 去掉 if not setting.cleaned_value and has: diff --git a/apps/settings/serializers/auth/cas.py b/apps/settings/serializers/auth/cas.py index a111a236e..7b6bb4373 100644 --- a/apps/settings/serializers/auth/cas.py +++ b/apps/settings/serializers/auth/cas.py @@ -10,7 +10,7 @@ __all__ = [ class CASSettingSerializer(serializers.Serializer): AUTH_CAS = serializers.BooleanField(required=False, label=_('Enable CAS Auth')) CAS_SERVER_URL = serializers.CharField(required=False, max_length=1024, label=_('Server url')) - CAS_ROOT_PROXIED_AS = serializers.CharField(required=False, max_length=1024, label=_('Proxy server url')) + CAS_ROOT_PROXIED_AS = serializers.CharField(required=False, allow_null=True, allow_blank=True, max_length=1024, label=_('Proxy server url')) CAS_LOGOUT_COMPLETELY = serializers.BooleanField(required=False, label=_('Logout completely')) CAS_VERSION = serializers.IntegerField(required=False, label=_('Version'), min_value=1, max_value=3) CAS_USERNAME_ATTRIBUTE = serializers.CharField(required=False, max_length=1024, label=_('Username attr')) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index f99434745..b23d67b2c 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -520,14 +520,27 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): SOURCE_BACKEND_MAPPING = { Source.local: [ - settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY, - settings.AUTH_BACKEND_WECOM, settings.AUTH_BACKEND_DINGTALK, + settings.AUTH_BACKEND_MODEL, + settings.AUTH_BACKEND_PUBKEY, + settings.AUTH_BACKEND_WECOM, + settings.AUTH_BACKEND_DINGTALK, + ], + Source.ldap: [ + settings.AUTH_BACKEND_LDAP + ], + Source.openid: [ + settings.AUTH_BACKEND_OIDC_PASSWORD, + settings.AUTH_BACKEND_OIDC_CODE + ], + Source.radius: [ + settings.AUTH_BACKEND_RADIUS + ], + Source.cas: [ + settings.AUTH_BACKEND_CAS + ], + Source.saml2: [ + settings.AUTH_BACKEND_SAML2 ], - Source.ldap: [settings.AUTH_BACKEND_LDAP], - Source.openid: [settings.AUTH_BACKEND_OIDC_PASSWORD, settings.AUTH_BACKEND_OIDC_CODE], - Source.radius: [settings.AUTH_BACKEND_RADIUS], - Source.cas: [settings.AUTH_BACKEND_CAS], - Source.saml2: [settings.AUTH_BACKEND_SAML2], } id = models.UUIDField(default=uuid.uuid4, primary_key=True) @@ -728,7 +741,6 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @classmethod def get_user_allowed_auth_backends(cls, username): if not settings.ONLY_ALLOW_AUTH_FROM_SOURCE or not username: - # return settings.AUTHENTICATION_BACKENDS return None user = cls.objects.filter(username=username).first() if not user: diff --git a/apps/users/signals_handler.py b/apps/users/signals_handler.py index 3e2ceb1f6..0ccd4053b 100644 --- a/apps/users/signals_handler.py +++ b/apps/users/signals_handler.py @@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied from django_cas_ng.signals import cas_user_authenticated from django.db.models.signals import post_save -from jms_oidc_rp.signals import openid_create_or_update_user +from authentication.backends.oidc.signals import openid_create_or_update_user from authentication.backends.saml2.signals import saml2_create_or_update_user from common.utils import get_logger diff --git a/requirements/requirements.txt b/requirements/requirements.txt index e28c5be42..2eff04412 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -96,7 +96,6 @@ ipython huaweicloud-sdk-python==1.0.21 django-redis==4.11.0 python-redis-lock==3.7.0 -jumpserver-django-oidc-rp==0.3.7.8 django-mysql==3.9.0 gmssl==3.2.1 azure-mgmt-compute==4.6.2