mirror of https://github.com/jumpserver/jumpserver
Dev oidc (#3930)
* [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 等引用关系pull/3932/head
parent
9febe488b5
commit
272701a8fd
|
@ -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()
|
|
@ -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'),
|
||||
]
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
|
@ -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')),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue