From 272701a8fdca20443d596fa9bf9b49961d742c37 Mon Sep 17 00:00:00 2001 From: BaiJiangJie <32935519+BaiJiangJie@users.noreply.github.com> Date: Wed, 22 Apr 2020 00:22:24 +0800 Subject: [PATCH] Dev oidc (#3930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Update] 添加django-oidc-rp支持 * [Update] 添加django-oidc-rp支持2 * [Update] 调试django-oidc-rp对keycloak的支持 * [Update] 调试django-oidc-rp对keycloak的支持2 * [Update] 修改oidc_rp创建用户/更新用户的功能 * [Update] oidc_rp添加支持password认证 * [Update] 重写oidc_rp end session view * [Update] 优化 oidc_rp view backend url 等引用关系 --- apps/authentication/backends/oidc/__init__.py | 0 apps/authentication/backends/oidc/backends.py | 181 ++++++++++++++++++ apps/authentication/backends/oidc/urls.py | 10 + apps/authentication/backends/oidc/views.py | 77 ++++++++ apps/authentication/signals_handlers.py | 23 +-- apps/authentication/urls/view_urls.py | 1 + apps/authentication/views/login.py | 10 +- apps/jumpserver/conf.py | 14 ++ apps/jumpserver/settings/auth.py | 17 ++ apps/jumpserver/settings/base.py | 3 + 10 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 apps/authentication/backends/oidc/__init__.py create mode 100644 apps/authentication/backends/oidc/backends.py create mode 100644 apps/authentication/backends/oidc/urls.py create mode 100644 apps/authentication/backends/oidc/views.py diff --git a/apps/authentication/backends/oidc/__init__.py b/apps/authentication/backends/oidc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/authentication/backends/oidc/backends.py b/apps/authentication/backends/oidc/backends.py new file mode 100644 index 000000000..2fa4a2555 --- /dev/null +++ b/apps/authentication/backends/oidc/backends.py @@ -0,0 +1,181 @@ +import requests +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.core.exceptions import SuspiciousOperation +from django.conf import settings +from django.db import transaction +from django.urls import reverse +from django.utils.module_loading import import_string + +from oidc_rp.conf import settings as oidc_rp_settings +from oidc_rp.models import OIDCUser +from oidc_rp.signals import oidc_user_created +from oidc_rp.backends import OIDCAuthBackend +from oidc_rp.utils import validate_and_return_id_token + + +__all__ = ['OIDCAuthCodeBackend', 'OIDCAuthPasswordBackend'] + + +class OIDCAuthCodeBackend(OIDCAuthBackend): + def authenticate(self, request, nonce=None, **kwargs): + """ Authenticates users in case of the OpenID Connect Authorization code flow. """ + # NOTE: the request object is mandatory to perform the authentication using an authorization + # code provided by the OIDC supplier. + if (nonce is None and oidc_rp_settings.USE_NONCE) or request is None: + 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 oidc_rp_settings.USE_STATE) or code is None: + 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. + token_payload = { + 'client_id': oidc_rp_settings.CLIENT_ID, + 'client_secret': oidc_rp_settings.CLIENT_SECRET, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': request.build_absolute_uri( + reverse(settings.OIDC_RP_LOGIN_CALLBACK_URL_NAME) + ), + } + + # Calls the token endpoint. + token_response = requests.post(oidc_rp_settings.PROVIDER_TOKEN_ENDPOINT, data=token_payload) + token_response.raise_for_status() + token_response_data = token_response.json() + + # Validates the 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: + 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. + if oidc_rp_settings.ID_TOKEN_INCLUDE_USERINFO: + userinfo_data = id_token + else: + # Fetches the user information from the userinfo endpoint provided by the OP. + userinfo_response = requests.get( + oidc_rp_settings.PROVIDER_USERINFO_ENDPOINT, + headers={'Authorization': 'Bearer {0}'.format(access_token)}) + userinfo_response.raise_for_status() + userinfo_data = userinfo_response.json() + + # Tries to retrieve a corresponding user in the local database and creates it if applicable. + try: + oidc_user = OIDCUser.objects.select_related('user').get(sub=userinfo_data.get('sub')) + except OIDCUser.DoesNotExist: + oidc_user = create_oidc_user_from_claims(userinfo_data) + oidc_user_created.send(sender=self.__class__, request=request, oidc_user=oidc_user) + else: + update_oidc_user_from_claims(oidc_user, userinfo_data) + + # Runs a custom user details handler if applicable. Such handler could be responsible for + # creating / updating whatever is necessary to manage the considered user (eg. a profile). + user_details_handler(oidc_user, userinfo_data) + + return oidc_user.user + + +class OIDCAuthPasswordBackend(ModelBackend): + + def authenticate(self, request, username=None, password=None, **kwargs): + + if username is None and password is None: + return + + # Prepares the token payload that will be used to request an authentication token to the + # token endpoint of the OIDC provider. + token_payload = { + 'client_id': oidc_rp_settings.CLIENT_ID, + 'client_secret': oidc_rp_settings.CLIENT_SECRET, + 'grant_type': 'password', + 'username': username, + 'password': password, + } + + token_response = requests.post(oidc_rp_settings.PROVIDER_TOKEN_ENDPOINT, data=token_payload) + token_response.raise_for_status() + token_response_data = token_response.json() + + access_token = token_response_data.get('access_token') + + # Fetches the user information from the userinfo endpoint provided by the OP. + userinfo_response = requests.get( + oidc_rp_settings.PROVIDER_USERINFO_ENDPOINT, + headers={'Authorization': 'Bearer {0}'.format(access_token)}) + userinfo_response.raise_for_status() + userinfo_data = userinfo_response.json() + + # Tries to retrieve a corresponding user in the local database and creates it if applicable. + try: + oidc_user = OIDCUser.objects.select_related('user').get(sub=userinfo_data.get('sub')) + except OIDCUser.DoesNotExist: + oidc_user = create_oidc_user_from_claims(userinfo_data) + oidc_user_created.send(sender=self.__class__, request=request, oidc_user=oidc_user) + else: + update_oidc_user_from_claims(oidc_user, userinfo_data) + + # Runs a custom user details handler if applicable. Such handler could be responsible for + # creating / updating whatever is necessary to manage the considered user (eg. a profile). + user_details_handler(oidc_user, userinfo_data) + + return oidc_user.user + + +def get_or_create_user(username, email): + user, created = get_user_model().objects.get_or_create(username=username) + return user + + +@transaction.atomic +def create_oidc_user_from_claims(claims): + """ + Creates an ``OIDCUser`` instance using the claims extracted + from an id_token. + """ + sub = claims['sub'] + email = claims.get('email') + username = claims.get('preferred_username') + user = get_or_create_user(username, email) + oidc_user = OIDCUser.objects.create(user=user, sub=sub, userinfo=claims) + + return oidc_user + + +@transaction.atomic +def update_oidc_user_from_claims(oidc_user, claims): + """ + Updates an ``OIDCUser`` instance using the claims extracted + from an id_token. + """ + oidc_user.userinfo = claims + oidc_user.save() + + +@transaction.atomic +def user_details_handler(oidc_user, userinfo_data): + name = userinfo_data.get('name') + username = userinfo_data.get('preferred_username') + email = userinfo_data.get('email') + oidc_user.user.name = name or username + oidc_user.user.username = username + oidc_user.user.email = email + oidc_user.user.save() diff --git a/apps/authentication/backends/oidc/urls.py b/apps/authentication/backends/oidc/urls.py new file mode 100644 index 000000000..173269d0d --- /dev/null +++ b/apps/authentication/backends/oidc/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from oidc_rp import views as oidc_rp_views +from .views import OverwriteOIDCAuthRequestView, OverwriteOIDCEndSessionView + + +urlpatterns = [ + path('login/', OverwriteOIDCAuthRequestView.as_view(), name='oidc-login'), + path('callback/', oidc_rp_views.OIDCAuthCallbackView.as_view(), name='oidc-callback'), + path('logout/', OverwriteOIDCEndSessionView.as_view(), name='oidc-logout'), +] diff --git a/apps/authentication/backends/oidc/views.py b/apps/authentication/backends/oidc/views.py new file mode 100644 index 000000000..4db0c4138 --- /dev/null +++ b/apps/authentication/backends/oidc/views.py @@ -0,0 +1,77 @@ +from django.conf import settings +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 oidc_rp.conf import settings as oidc_rp_settings +from oidc_rp.views import OIDCEndSessionView, OIDCAuthRequestView + +__all__ = ['OverwriteOIDCAuthRequestView', 'OverwriteOIDCEndSessionView'] + + +class OverwriteOIDCAuthRequestView(OIDCAuthRequestView): + def get(self, request): + """ Processes GET requests. """ + # Defines common parameters used to bootstrap the authentication request. + authentication_request_params = request.GET.dict() + authentication_request_params.update({ + 'scope': oidc_rp_settings.SCOPES, + 'response_type': 'code', + 'client_id': oidc_rp_settings.CLIENT_ID, + 'redirect_uri': request.build_absolute_uri( + reverse(settings.OIDC_RP_LOGIN_CALLBACK_URL_NAME) + ), + }) + + # States should be used! They are recommended in order to maintain state between the + # authentication request and the callback. + if oidc_rp_settings.USE_STATE: + state = get_random_string(oidc_rp_settings.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 oidc_rp_settings.USE_NONCE: + nonce = get_random_string(oidc_rp_settings.NONCE_LENGTH) + authentication_request_params.update({'nonce': nonce, }) + request.session['oidc_auth_nonce'] = nonce + + # Stores the "next" URL in the session if applicable. + 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. + query = urlencode(authentication_request_params) + redirect_url = '{url}?{query}'.format( + url=oidc_rp_settings.PROVIDER_AUTHORIZATION_ENDPOINT, query=query) + return HttpResponseRedirect(redirect_url) + + +class OverwriteOIDCEndSessionView(OIDCEndSessionView): + def post(self, request): + """ Processes POST requests. """ + logout_url = settings.LOGOUT_REDIRECT_URL or '/' + + try: + logout_url = self.provider_end_session_url \ + if oidc_rp_settings.PROVIDER_END_SESSION_ENDPOINT else logout_url + except KeyError: # pragma: no cover + logout_url = logout_url + + # Redirects the user to the appropriate URL. + return HttpResponseRedirect(logout_url) + + @property + def provider_end_session_url(self): + """ Returns the end-session URL. """ + q = QueryDict(mutable=True) + q[oidc_rp_settings.PROVIDER_END_SESSION_REDIRECT_URI_PARAMETER] = \ + self.request.build_absolute_uri(settings.LOGOUT_REDIRECT_URL or '/') + if self.request.session.get('oidc_auth_id_token'): + q[oidc_rp_settings.PROVIDER_END_SESSION_ID_TOKEN_PARAMETER] = \ + self.request.session['oidc_auth_id_token'] + return '{}?{}'.format(oidc_rp_settings.PROVIDER_END_SESSION_ENDPOINT, q.urlencode()) + diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index aac64df4c..2d73ae9b7 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -4,6 +4,7 @@ from django.dispatch import receiver from django.contrib.auth.signals import user_logged_out from django_auth_ldap.backend import populate_user +from oidc_rp.signals import oidc_user_created from users.models import User from .backends.openid import new_client from .backends.openid.signals import ( @@ -14,20 +15,17 @@ from .signals import post_auth_success @receiver(user_logged_out) def on_user_logged_out(sender, request, user, **kwargs): - if not settings.AUTH_OPENID: - return - if not settings.AUTH_OPENID_SHARE_SESSION: - return query = QueryDict('', mutable=True) query.update({ 'redirect_uri': settings.BASE_SITE_URL }) - client = new_client() - openid_logout_url = "%s?%s" % ( - client.get_url_end_session_endpoint(), - query.urlencode() - ) - request.COOKIES['next'] = openid_logout_url + # openid (keycloak) + if settings.AUTH_OPENID and settings.AUTH_OPENID_SHARE_SESSION: + client = new_client() + end_session_endpoint = client.get_url_end_session_endpoint() + openid_logout_url = "%s?%s" % (end_session_endpoint, query.urlencode()) + request.COOKIES['next'] = openid_logout_url + return @receiver(post_create_or_update_openid_user) @@ -51,4 +49,7 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs): user.save() - +@receiver(oidc_user_created) +def on_oidc_user_created(sender, request, oidc_user, **kwargs): + oidc_user.user.source = User.SOURCE_OPENID + oidc_user.user.save() diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index b9f76e731..c33ea453c 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -18,4 +18,5 @@ urlpatterns = [ # openid path('openid/', include(('authentication.backends.openid.urls', 'authentication'), namespace='openid')), path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')), + path('oidc-rp/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='oidc-rp')), ] diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index e2332cae5..4a9225f94 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -61,6 +61,8 @@ class UserLoginView(mixins.AuthMixin, FormView): redirect_url = reverse("authentication:openid:openid-login") elif settings.AUTH_CAS: redirect_url = reverse(settings.CAS_LOGIN_URL_NAME) + elif settings.AUTH_OIDC_RP: + redirect_url = reverse(settings.OIDC_RP_LOGIN_URL_NAME) if redirect_url: query_string = request.GET.urlencode() @@ -187,16 +189,22 @@ class UserLogoutView(TemplateView): def get_backend_logout_url(): # if settings.AUTH_CAS: # return settings.CAS_LOGOUT_URL_NAME - return None + + # oidc rp + if settings.AUTH_OIDC_RP: + return reverse(settings.OIDC_RP_LOGOUT_URL_NAME) def get(self, request, *args, **kwargs): auth_logout(request) + backend_logout_url = self.get_backend_logout_url() if backend_logout_url: return redirect(backend_logout_url) + next_uri = request.COOKIES.get("next") if next_uri: return redirect(next_uri) + response = super().get(request, *args, **kwargs) return response diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 75978ea41..1a6e24278 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -143,6 +143,17 @@ class Config(dict): 'AUTH_OPENID_IGNORE_SSL_VERIFICATION': True, 'AUTH_OPENID_SHARE_SESSION': True, + 'AUTH_OIDC_RP': False, + 'OIDC_RP_CLIENT_ID': 'client-id', + 'OIDC_RP_CLIENT_SECRET': 'client-secret', + 'OIDC_RP_PROVIDER_ENDPOINT': 'provider-endpoint', + 'OIDC_RP_PROVIDER_AUTHORIZATION_ENDPOINT': 'provider-authorization-endpoint', + 'OIDC_RP_PROVIDER_TOKEN_ENDPOINT': 'provider-token-endpoint', + 'OIDC_RP_PROVIDER_JWKS_ENDPOINT': 'provider-jwks-endpoint', + 'OIDC_RP_PROVIDER_USERINFO_ENDPOINT': 'provider-userinfo-endpoint', + 'OIDC_RP_PROVIDER_END_SESSION_ENDPOINT': 'end-session-endpoint', + 'OIDC_RP_ID_TOKEN_MAX_AGE': 60, + 'AUTH_RADIUS': False, 'RADIUS_SERVER': 'localhost', 'RADIUS_PORT': 1812, @@ -298,6 +309,9 @@ class DynamicConfig: if self.static_config.get('AUTH_OPENID'): backends.insert(0, 'authentication.backends.openid.backends.OpenIDAuthorizationPasswordBackend') backends.insert(0, 'authentication.backends.openid.backends.OpenIDAuthorizationCodeBackend') + if self.static_config.get('AUTH_OIDC_RP'): + backends.insert(0, 'authentication.backends.oidc.backends.OIDCAuthCodeBackend') + backends.insert(0, 'authentication.backends.oidc.backends.OIDCAuthPasswordBackend',) if self.static_config.get('AUTH_RADIUS'): backends.insert(0, 'authentication.backends.radius.RadiusBackend') return backends diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index bad698e4c..16fa3e54e 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -56,6 +56,23 @@ AUTH_OPENID_SHARE_SESSION = CONFIG.AUTH_OPENID_SHARE_SESSION AUTH_OPENID_LOGIN_URL = reverse_lazy("authentication:openid:openid-login") AUTH_OPENID_LOGIN_COMPLETE_URL = reverse_lazy("authentication:openid:openid-login-complete") +# oidc rp +# jumpserver +AUTH_OIDC_RP = CONFIG.AUTH_OIDC_RP +OIDC_RP_LOGIN_URL_NAME = "authentication:oidc-rp:oidc-login" +OIDC_RP_LOGIN_CALLBACK_URL_NAME = "authentication:oidc-rp:oidc-callback" +OIDC_RP_LOGOUT_URL_NAME = "authentication:oidc-rp:oidc-logout" +# https://django-oidc-rp.readthedocs.io/en/stable/settings.html#required-settings +OIDC_RP_CLIENT_ID = CONFIG.OIDC_RP_CLIENT_ID +OIDC_RP_CLIENT_SECRET = CONFIG.OIDC_RP_CLIENT_SECRET +OIDC_RP_PROVIDER_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_ENDPOINT +OIDC_RP_PROVIDER_AUTHORIZATION_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_AUTHORIZATION_ENDPOINT +OIDC_RP_PROVIDER_TOKEN_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_TOKEN_ENDPOINT +OIDC_RP_PROVIDER_JWKS_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_JWKS_ENDPOINT +OIDC_RP_PROVIDER_USERINFO_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_USERINFO_ENDPOINT +OIDC_RP_PROVIDER_END_SESSION_ENDPOINT = CONFIG.OIDC_RP_PROVIDER_END_SESSION_ENDPOINT +OIDC_RP_ID_TOKEN_MAX_AGE = CONFIG.OIDC_RP_ID_TOKEN_MAX_AGE + # Radius Auth AUTH_RADIUS = CONFIG.AUTH_RADIUS diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 4a2f2ca88..c2b558742 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'django_filters', 'bootstrap3', 'captcha', + 'oidc_rp', 'django_celery_beat', 'django.contrib.auth', 'django.contrib.admin', @@ -81,6 +82,7 @@ MIDDLEWARE = [ 'jumpserver.middleware.DemoMiddleware', 'jumpserver.middleware.RequestMiddleware', 'orgs.middleware.OrgMiddleware', + 'oidc_rp.middleware.OIDCRefreshIDTokenMiddleware', ] @@ -103,6 +105,7 @@ TEMPLATES = [ 'django.template.context_processors.media', 'jumpserver.context_processor.jumpserver_processor', 'orgs.context_processor.org_processor', + 'oidc_rp.context_processors.oidc', ], }, },