* [Update] oidc_rp获取token添加headers base64编码

* [Update] 移除对oidc_rp的支持

* [Update] 移除对oidc_rp的支持2

* [Update] 修改OpenID配置(添加新配置项,并对旧配置项做兼容)

* [Update] 移除所有与Keycloak相关的模块

* [Update] 添加jumpserver-django-oidc-rp的使用

* [Update] 更新登录重定向地址(oidc)

* [Update] oidc添加一些配置参数;处理用户登录/创建/更新等信号

* [Update] 修改退出登录逻辑

* [Update] 添加oidc user登录成功的信号机制

* [Update] 修改mfa认证choices内容 (otp => code)

* [Update] 添加OpenID backend password 认证失败信号机制;修改引入common包问题

* [Update] 用户Token/Auth API 校验用户时,传入request参数(解决登录成功日志记录的问题)

* [Update] 添加依赖jumpserver-django-oidc-rp==0.3.7.1

* [Update] oidc认证模块说明
This commit is contained in:
BaiJiangJie
2020-04-26 20:36:17 +08:00
committed by GitHub
parent 5d433456d4
commit 7833ff6671
28 changed files with 241 additions and 906 deletions

View File

@@ -0,0 +1,4 @@
"""
使用下面的工程进行jumpserver 的 oidc 认证
https://github.com/BaiJiangJie/jumpserver-django-oidc-rp
"""

View File

@@ -1,181 +0,0 @@
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()

View File

@@ -1,10 +0,0 @@
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'),
]

View File

@@ -1,77 +0,0 @@
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())

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
#
from .backends import *
from .middleware import *
from .utils import *
from .decorator import *

View File

@@ -1,82 +0,0 @@
# coding:utf-8
#
from django.contrib.auth import get_user_model
from django.conf import settings
from common.utils import get_logger
from .utils import new_client
from .models import OIDT_ACCESS_TOKEN
UserModel = get_user_model()
logger = get_logger(__file__)
client = new_client()
__all__ = [
'OpenIDAuthorizationCodeBackend', 'OpenIDAuthorizationPasswordBackend',
]
class BaseOpenIDAuthorizationBackend(object):
@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 get_user(self, user_id):
try:
user = UserModel._default_manager.get(pk=user_id)
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None
class OpenIDAuthorizationCodeBackend(BaseOpenIDAuthorizationBackend):
def authenticate(self, request, **kwargs):
logger.info('Authentication OpenID code backend')
code = kwargs.get('code')
redirect_uri = kwargs.get('redirect_uri')
if not code or not redirect_uri:
logger.info('Authenticate failed: No code or No redirect uri')
return None
try:
oidt_profile = client.update_or_create_from_code(
code=code, redirect_uri=redirect_uri
)
except Exception as e:
logger.info('Authenticate failed: get oidt_profile: {}'.format(e))
return None
else:
# Check openid user single logout or not with access_token
request.session[OIDT_ACCESS_TOKEN] = oidt_profile.access_token
user = oidt_profile.user
logger.info('Authenticate success: user -> {}'.format(user))
return user if self.user_can_authenticate(user) else None
class OpenIDAuthorizationPasswordBackend(BaseOpenIDAuthorizationBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
logger.info('Authentication OpenID password backend')
if not username:
logger.info('Authenticate failed: Not username')
return None
try:
oidt_profile = client.update_or_create_from_password(
username=username, password=password
)
except Exception as e:
logger.error(e, exc_info=True)
logger.info('Authenticate failed: get oidt_profile: {}'.format(e))
return None
else:
user = oidt_profile.user
logger.info('Authenticate success: user -> {}'.format(user))
return user if self.user_can_authenticate(user) else None

View File

@@ -1,57 +0,0 @@
# coding: utf-8
#
import warnings
import contextlib
import requests
from urllib3.exceptions import InsecureRequestWarning
from django.conf import settings
__all__ = [
'ssl_verification',
]
old_merge_environment_settings = requests.Session.merge_environment_settings
@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

View File

@@ -1,41 +0,0 @@
# coding:utf-8
#
from django.conf import settings
from django.contrib.auth import logout
from django.utils.deprecation import MiddlewareMixin
from django.contrib.auth import BACKEND_SESSION_KEY
from common.utils import get_logger
from .utils import new_client
from .models import OIDT_ACCESS_TOKEN
BACKEND_OPENID_AUTH_CODE = 'OpenIDAuthorizationCodeBackend'
logger = get_logger(__file__)
__all__ = ['OpenIDAuthenticationMiddleware']
class OpenIDAuthenticationMiddleware(MiddlewareMixin):
"""
Check openid user single logout (with access_token)
"""
def process_request(self, request):
# Don't need openid auth if AUTH_OPENID is False
if not settings.AUTH_OPENID:
return
# Don't need openid auth if no shared session enabled
if not settings.AUTH_OPENID_SHARE_SESSION:
return
# Don't need check single logout if user not authenticated
if not request.user.is_authenticated:
return
elif not request.session[BACKEND_SESSION_KEY].endswith(
BACKEND_OPENID_AUTH_CODE):
return
# Check openid user single logout or not with access_token
try:
client = new_client()
client.get_userinfo(token=request.session.get(OIDT_ACCESS_TOKEN))
except Exception as e:
logout(request)
logger.error(e)

View File

@@ -1,185 +0,0 @@
# -*- coding: utf-8 -*-
#
from django.db import transaction
from django.contrib.auth import get_user_model
from keycloak.realm import KeycloakRealm
from keycloak.keycloak_openid import KeycloakOpenID
from users.utils import construct_user_email
from .signals import post_create_or_update_openid_user
from .decorator import ssl_verification
OIDT_ACCESS_TOKEN = 'oidt_access_token'
class Nonce(object):
"""
The openid-login is stored in cache as a temporary object, recording the
user's redirect_uri and next_pat
"""
def __init__(self, redirect_uri, next_path):
import uuid
self.state = uuid.uuid4()
self.redirect_uri = redirect_uri
self.next_path = next_path
class OpenIDTokenProfile(object):
def __init__(self, user, access_token, refresh_token):
"""
:param user: User object
:param access_token:
:param refresh_token:
"""
self.user = user
self.access_token = access_token
self.refresh_token = refresh_token
def __str__(self):
return "{}'s OpenID token profile".format(self.user.username)
class Client(object):
def __init__(self, server_url, realm_name, client_id, client_secret):
self.server_url = server_url
self.realm_name = realm_name
self.client_id = client_id
self.client_secret = client_secret
self._openid_client = None
self._realm = None
self._openid_connect_client = None
@property
def realm(self):
if self._realm is None:
self._realm = KeycloakRealm(
server_url=self.server_url,
realm_name=self.realm_name,
headers={}
)
return self._realm
@property
def openid_connect_client(self):
"""
:rtype: keycloak.openid_connect.KeycloakOpenidConnect
"""
if self._openid_connect_client is None:
self._openid_connect_client = self.realm.open_id_connect(
client_id=self.client_id,
client_secret=self.client_secret
)
return self._openid_connect_client
@property
def openid_client(self):
"""
:rtype: keycloak.keycloak_openid.KeycloakOpenID
"""
if self._openid_client is None:
self._openid_client = KeycloakOpenID(
server_url='%sauth/' % self.server_url,
realm_name=self.realm_name,
client_id=self.client_id,
client_secret_key=self.client_secret,
)
return self._openid_client
@ssl_verification
def get_url(self, name):
return self.openid_connect_client.get_url(name=name)
def get_url_end_session_endpoint(self):
return self.get_url(name='end_session_endpoint')
@ssl_verification
def get_authorization_url(self, redirect_uri, scope, state):
url = self.openid_connect_client.authorization_url(
redirect_uri=redirect_uri, scope=scope, state=state
)
return url
@ssl_verification
def get_userinfo(self, token):
user_info = self.openid_connect_client.userinfo(token=token)
return user_info
@ssl_verification
def authorization_code(self, code, redirect_uri):
token_response = self.openid_connect_client.authorization_code(
code=code, redirect_uri=redirect_uri
)
return token_response
@ssl_verification
def authorization_password(self, username, password):
token_response = self.openid_client.token(
username=username, password=password
)
return token_response
def update_or_create_from_code(self, code, redirect_uri):
"""
Update or create an user based on an authentication code.
Response as specified in:
https://tools.ietf.org/html/rfc6749#section-4.1.4
:param str code: authentication code
:param str redirect_uri:
:rtype: OpenIDTokenProfile
"""
token_response = self.authorization_code(code, redirect_uri)
return self._update_or_create(token_response=token_response)
def update_or_create_from_password(self, username, password):
"""
Update or create an user based on an authentication username and password.
:param str username: authentication username
:param str password: authentication password
:return: OpenIDTokenProfile
"""
token_response = self.authorization_password(username, password)
return self._update_or_create(token_response=token_response)
def _update_or_create(self, token_response):
"""
Update or create an user based on a token response.
`token_response` contains the items returned by the OpenIDConnect Token API
end-point:
- id_token
- access_token
- expires_in
- refresh_token
- refresh_expires_in
:param dict token_response:
:rtype: OpenIDTokenProfile
"""
userinfo = self.get_userinfo(token=token_response['access_token'])
with transaction.atomic():
name = userinfo.get('name', '')
username = userinfo.get('preferred_username', '')
email = userinfo.get('email', '')
email = construct_user_email(username, email)
user, created = get_user_model().objects.update_or_create(
username=username,
defaults={
'name': name, 'email': email,
'first_name': userinfo.get('given_name', ''),
'last_name': userinfo.get('family_name', ''),
}
)
oidt_profile = OpenIDTokenProfile(
user=user,
access_token=token_response['access_token'],
refresh_token=token_response['refresh_token'],
)
if user:
post_create_or_update_openid_user.send(
sender=user.__class__, user=user, created=created
)
return oidt_profile
def __str__(self):
return self.client_id

View File

@@ -1,5 +0,0 @@
from django.dispatch import Signal
post_create_or_update_openid_user = Signal(providing_args=('user',))
post_openid_login_success = Signal(providing_args=('user', 'request'))

View File

@@ -1,11 +0,0 @@
# -*- coding: utf-8 -*-
#
from django.urls import path
from . import views
urlpatterns = [
path('login/', views.OpenIDLoginView.as_view(), name='openid-login'),
path('login/complete/', views.OpenIDLoginCompleteView.as_view(),
name='openid-login-complete'),
]

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
#
from django.conf import settings
from .models import Client
__all__ = ['new_client']
def new_client():
"""
:return: authentication.models.Client
"""
return Client(
server_url=settings.AUTH_OPENID_SERVER_URL,
realm_name=settings.AUTH_OPENID_REALM_NAME,
client_id=settings.AUTH_OPENID_CLIENT_ID,
client_secret=settings.AUTH_OPENID_CLIENT_SECRET
)

View File

@@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
#
import logging
from django.conf import settings
from django.core.cache import cache
from django.views.generic.base import RedirectView
from django.contrib.auth import authenticate, login
from django.http.response import (
HttpResponseBadRequest,
HttpResponseServerError,
HttpResponseRedirect
)
from .utils import new_client
from .models import Nonce
from .signals import post_openid_login_success
logger = logging.getLogger(__name__)
client = new_client()
__all__ = ['OpenIDLoginView', 'OpenIDLoginCompleteView']
class OpenIDLoginView(RedirectView):
def get_redirect_url(self, *args, **kwargs):
redirect_uri = settings.BASE_SITE_URL + \
str(settings.AUTH_OPENID_LOGIN_COMPLETE_URL)
nonce = Nonce(
redirect_uri=redirect_uri,
next_path=self.request.GET.get('next')
)
cache.set(str(nonce.state), nonce, 24*3600)
self.request.session['openid_state'] = str(nonce.state)
authorization_url = client.get_authorization_url(
redirect_uri=nonce.redirect_uri,
scope='code',
state=str(nonce.state)
)
return authorization_url
class OpenIDLoginCompleteView(RedirectView):
def get(self, request, *args, **kwargs):
if 'error' in request.GET:
return HttpResponseServerError(self.request.GET['error'])
if 'code' not in self.request.GET and 'state' not in self.request.GET:
return HttpResponseBadRequest(content='Code or State is empty')
if self.request.GET['state'] != self.request.session['openid_state']:
return HttpResponseBadRequest(content='State invalid')
nonce = cache.get(self.request.GET['state'])
if not nonce:
return HttpResponseBadRequest(content='State failure')
user = authenticate(
request=self.request,
code=self.request.GET['code'],
redirect_uri=nonce.redirect_uri
)
cache.delete(str(nonce.state))
if not user:
return HttpResponseBadRequest(content='Authenticate user failed')
login(self.request, user)
post_openid_login_success.send(
sender=self.__class__, user=user, request=self.request
)
return HttpResponseRedirect(nonce.next_path or '/')