mirror of https://github.com/jumpserver/jumpserver
Fix rbac (#7699)
* perf: 优化 suggesstion * perf: 修改 migrations * feat: 添加OIDC认证逻辑 * perf: 修改 backend * perf: 优化认证backends * perf: 优化认证backends * perf: 优化CAS认证, 用户多域名进行访问时回调到各自域名 Co-authored-by: ibuler <ibuler@qq.com>pull/7700/head
parent
02ca473492
commit
edfca5eb24
|
@ -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
|
|
@ -1,3 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from .backends import *
|
|
||||||
|
# 保证 utils 中的模块进行初始化
|
||||||
|
from . import utils
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django_cas_ng.backends import CASBackend as _CASBackend
|
from django_cas_ng.backends import CASBackend as _CASBackend
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from ..base import JMSBaseAuthBackend
|
||||||
|
|
||||||
__all__ = ['CASBackend']
|
__all__ = ['CASBackend']
|
||||||
|
|
||||||
|
|
||||||
class CASBackend(_CASBackend):
|
class CASBackend(JMSBaseAuthBackend, _CASBackend):
|
||||||
def user_can_authenticate(self, user):
|
@staticmethod
|
||||||
return True
|
def is_enabled():
|
||||||
|
return settings.AUTH_CAS
|
||||||
|
|
|
@ -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
|
|
@ -8,13 +8,14 @@ from django.core.cache import cache
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from six import text_type
|
from six import text_type
|
||||||
from django.contrib.auth import get_user_model
|
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 HTTP_HEADER_ENCODING
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
from common.auth import signature
|
from common.auth import signature
|
||||||
|
|
||||||
from common.utils import get_object_or_none, make_signature, http_to_unixtime
|
from common.utils import get_object_or_none, make_signature, http_to_unixtime
|
||||||
from ..models import AccessKey, PrivateToken
|
from ..models import AccessKey, PrivateToken
|
||||||
|
from .base import JMSBaseAuthBackend, JMSModelBackend
|
||||||
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
@ -28,21 +29,6 @@ def get_request_date_header(request):
|
||||||
return date
|
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):
|
class AccessKeyAuthentication(authentication.BaseAuthentication):
|
||||||
"""App使用Access key进行签名认证, 目前签名算法比较简单,
|
"""App使用Access key进行签名认证, 目前签名算法比较简单,
|
||||||
app注册或者手动建立后,会生成 access_key_id 和 access_key_secret,
|
app注册或者手动建立后,会生成 access_key_id 和 access_key_secret,
|
||||||
|
@ -167,7 +153,7 @@ class AccessTokenAuthentication(authentication.BaseAuthentication):
|
||||||
return self.keyword
|
return self.keyword
|
||||||
|
|
||||||
|
|
||||||
class PrivateTokenAuthentication(authentication.TokenAuthentication):
|
class PrivateTokenAuthentication(JMSBaseAuthBackend, authentication.TokenAuthentication):
|
||||||
model = PrivateToken
|
model = PrivateToken
|
||||||
|
|
||||||
|
|
||||||
|
@ -215,46 +201,3 @@ class SignatureAuthentication(signature.SignatureAuthentication):
|
||||||
except AccessKey.DoesNotExist:
|
except AccessKey.DoesNotExist:
|
||||||
return None, None
|
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
|
|
|
@ -1,43 +1,38 @@
|
||||||
# coding:utf-8
|
# coding:utf-8
|
||||||
#
|
#
|
||||||
|
|
||||||
import warnings
|
|
||||||
import ldap
|
import ldap
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
|
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 django_auth_ldap.config import _LDAPConfig, LDAPSearch, LDAPSearchUnion
|
||||||
|
|
||||||
from users.utils import construct_user_email
|
from users.utils import construct_user_email
|
||||||
from common.const import LDAP_AD_ACCOUNT_DISABLE
|
from common.const import LDAP_AD_ACCOUNT_DISABLE
|
||||||
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
logger = _LDAPConfig.get_logger()
|
logger = _LDAPConfig.get_logger()
|
||||||
|
|
||||||
|
|
||||||
class LDAPAuthorizationBackend(LDAPBackend):
|
class LDAPAuthorizationBackend(JMSBaseAuthBackend, LDAPBackend):
|
||||||
"""
|
"""
|
||||||
Override this class to override _LDAPUser to LDAPUser
|
Override this class to override _LDAPUser to LDAPUser
|
||||||
"""
|
"""
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def user_can_authenticate(user):
|
def is_enabled():
|
||||||
"""
|
return settings.AUTH_LDAP
|
||||||
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_or_build_user(self, username, ldap_user):
|
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
|
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
|
the user's DN and ldap_user.attrs contains all of their LDAP
|
||||||
attributes.
|
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()
|
model = self.get_user_model()
|
||||||
|
|
||||||
if self.settings.USER_QUERY_FIELD:
|
if self.settings.USER_QUERY_FIELD:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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.core.exceptions import MiddlewareNotUsed
|
||||||
from django.conf import settings
|
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):
|
logger = get_logger(__file__)
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
class OIDCRefreshIDTokenMiddleware:
|
||||||
|
""" Allows to periodically refresh the ID token associated with the authenticated user. """
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
if not settings.AUTH_OPENID:
|
if not settings.AUTH_OPENID:
|
||||||
raise MiddlewareNotUsed
|
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
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
|
@ -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'),
|
||||||
|
]
|
|
@ -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
|
|
@ -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())
|
|
@ -1,4 +0,0 @@
|
||||||
"""
|
|
||||||
使用下面的工程,进行jumpserver 的 oidc 认证
|
|
||||||
https://github.com/BaiJiangJie/jumpserver-django-oidc-rp
|
|
||||||
"""
|
|
|
@ -1,13 +1,20 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
__all__ = ['PublicKeyAuthBackend']
|
__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):
|
def authenticate(self, request, username=None, public_key=None, **kwargs):
|
||||||
if not public_key:
|
if not public_key:
|
||||||
return None
|
return None
|
||||||
|
@ -22,15 +29,6 @@ class PublicKeyAuthBackend:
|
||||||
self.user_can_authenticate(user):
|
self.user_can_authenticate(user):
|
||||||
return 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):
|
def get_user(self, user_id):
|
||||||
try:
|
try:
|
||||||
user = UserModel._default_manager.get(pk=user_id)
|
user = UserModel._default_manager.get(pk=user_id)
|
||||||
|
|
|
@ -6,6 +6,8 @@ from django.contrib.auth import get_user_model
|
||||||
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
|
from radiusauth.backends import RADIUSBackend, RADIUSRealmBackend
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .base import JMSBaseAuthBackend
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -39,11 +41,17 @@ class CreateUserMixin:
|
||||||
return None
|
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):
|
def authenticate(self, request, username='', password='', **kwargs):
|
||||||
return super().authenticate(request, username=username, password=password)
|
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):
|
def authenticate(self, request, username='', password='', realm=None, **kwargs):
|
||||||
return super().authenticate(request, username=username, password=password, realm=realm)
|
return super().authenticate(request, username=username, password=password, realm=realm)
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from .backends import *
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
from django.contrib.auth import get_user_model
|
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 django.db import transaction
|
||||||
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
@ -10,16 +10,17 @@ from .signals import (
|
||||||
saml2_user_authenticated, saml2_user_authentication_failed,
|
saml2_user_authenticated, saml2_user_authentication_failed,
|
||||||
saml2_create_or_update_user
|
saml2_create_or_update_user
|
||||||
)
|
)
|
||||||
|
from ..base import JMSBaseAuthBackend
|
||||||
|
|
||||||
__all__ = ['SAML2Backend']
|
__all__ = ['SAML2Backend']
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class SAML2Backend(ModelBackend):
|
class SAML2Backend(JMSBaseAuthBackend):
|
||||||
def user_can_authenticate(self, user):
|
@staticmethod
|
||||||
is_valid = getattr(user, 'is_valid', None)
|
def is_enabled():
|
||||||
return is_valid or is_valid is None
|
return settings.AUTH_SAML2
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def get_or_create_from_saml_data(self, request, **saml_user_data):
|
def get_or_create_from_saml_data(self, request, **saml_user_data):
|
||||||
|
|
|
@ -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
|
|
@ -12,9 +12,10 @@ from django.contrib import auth
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from django.contrib.auth import (
|
from django.contrib.auth import (
|
||||||
BACKEND_SESSION_KEY, _get_backends,
|
BACKEND_SESSION_KEY, load_backend,
|
||||||
PermissionDenied, user_login_failed, _clean_credentials
|
PermissionDenied, user_login_failed, _clean_credentials,
|
||||||
)
|
)
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.shortcuts import reverse, redirect, get_object_or_404
|
from django.shortcuts import reverse, redirect, get_object_or_404
|
||||||
|
|
||||||
from common.utils import get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def check_backend_can_auth(username, backend_path, allowed_auth_backends):
|
def _get_backends(return_tuples=False):
|
||||||
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
|
backends = []
|
||||||
logger.debug('Skip user auth backend: {}, {} not in'.format(
|
for backend_path in settings.AUTHENTICATION_BACKENDS:
|
||||||
username, backend_path, ','.join(allowed_auth_backends)
|
backend = load_backend(backend_path)
|
||||||
))
|
# 检查 backend 是否启用
|
||||||
return False
|
if not backend.is_enabled():
|
||||||
return True
|
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):
|
def authenticate(request=None, **credentials):
|
||||||
"""
|
"""
|
||||||
If the given credentials are valid, return a User object.
|
If the given credentials are valid, return a User object.
|
||||||
|
之所以 hack 这个 auticate
|
||||||
"""
|
"""
|
||||||
username = credentials.get('username')
|
username = credentials.get('username')
|
||||||
allowed_auth_backends = User.get_user_allowed_auth_backends(username)
|
|
||||||
|
|
||||||
for backend, backend_path in _get_backends(return_tuples=True):
|
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
|
continue
|
||||||
|
|
||||||
|
# 原生
|
||||||
backend_signature = inspect.signature(backend.authenticate)
|
backend_signature = inspect.signature(backend.authenticate)
|
||||||
try:
|
try:
|
||||||
backend_signature.bind(request, **credentials)
|
backend_signature.bind(request, **credentials)
|
||||||
|
@ -63,21 +75,17 @@ def authenticate(request=None, **credentials):
|
||||||
break
|
break
|
||||||
if user is None:
|
if user is None:
|
||||||
continue
|
continue
|
||||||
# 如果是 None, 证明没有检查过, 需要再次检查
|
|
||||||
if allowed_auth_backends is None:
|
# 再次检查遇检查中遗漏的用户
|
||||||
# 有些 authentication 参数中不带 username, 之后还要再检查
|
if not backend.user_can_authenticate(user):
|
||||||
allowed_auth_backends = user.get_allowed_auth_backends()
|
continue
|
||||||
if not check_backend_can_auth(user.username, backend_path, allowed_auth_backends):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Annotate the user object with the path of the backend.
|
# Annotate the user object with the path of the backend.
|
||||||
user.backend = backend_path
|
user.backend = backend_path
|
||||||
return user
|
return user
|
||||||
|
|
||||||
# The credentials supplied are invalid to all backends, fire signal
|
# The credentials supplied are invalid to all backends, fire signal
|
||||||
user_login_failed.send(
|
user_login_failed.send(sender=__name__, credentials=_clean_credentials(credentials), request=request)
|
||||||
sender=__name__, credentials=_clean_credentials(credentials), request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
auth.authenticate = authenticate
|
auth.authenticate = authenticate
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.core.cache import cache
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django_cas_ng.signals import cas_user_authenticated
|
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 (
|
from authentication.backends.saml2.signals import (
|
||||||
saml2_user_authenticated, saml2_user_authentication_failed
|
saml2_user_authenticated, saml2_user_authentication_failed
|
||||||
|
|
|
@ -55,7 +55,7 @@ urlpatterns = [
|
||||||
|
|
||||||
# openid
|
# openid
|
||||||
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
|
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('saml2/', include(('authentication.backends.saml2.urls', 'authentication'), namespace='saml2')),
|
||||||
path('captcha/', include('captcha.urls')),
|
path('captcha/', include('captcha.urls')),
|
||||||
]
|
]
|
||||||
|
|
|
@ -184,7 +184,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_forgot_password_url():
|
def get_forgot_password_url():
|
||||||
forgot_password_url = reverse('authentication:forgot-password')
|
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:
|
if has_other_auth_backend and settings.FORGOT_PASSWORD_URL:
|
||||||
forgot_password_url = settings.FORGOT_PASSWORD_URL
|
forgot_password_url = settings.FORGOT_PASSWORD_URL
|
||||||
return forgot_password_url
|
return forgot_password_url
|
||||||
|
|
|
@ -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_URL_NAME = 'authentication:openid:login'
|
||||||
AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME = 'authentication:openid:login-callback'
|
AUTH_OPENID_AUTH_LOGIN_CALLBACK_URL_NAME = 'authentication:openid:login-callback'
|
||||||
AUTH_OPENID_AUTH_LOGOUT_URL_NAME = 'authentication:openid:logout'
|
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
|
# Radius Auth
|
||||||
|
@ -138,39 +145,35 @@ TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||||
OTP_IN_RADIUS = CONFIG.OTP_IN_RADIUS
|
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'
|
RBAC_BACKEND = 'rbac.backends.RBACBackend'
|
||||||
AUTH_BACKEND_PUBKEY = 'authentication.backends.pubkey.PublicKeyAuthBackend'
|
AUTH_BACKEND_PUBKEY = 'authentication.backends.pubkey.PublicKeyAuthBackend'
|
||||||
AUTH_BACKEND_LDAP = 'authentication.backends.ldap.LDAPAuthorizationBackend'
|
AUTH_BACKEND_LDAP = 'authentication.backends.ldap.LDAPAuthorizationBackend'
|
||||||
AUTH_BACKEND_OIDC_PASSWORD = 'jms_oidc_rp.backends.OIDCAuthPasswordBackend'
|
AUTH_BACKEND_OIDC_PASSWORD = 'authentication.backends.oidc.backends.OIDCAuthPasswordBackend'
|
||||||
AUTH_BACKEND_OIDC_CODE = 'jms_oidc_rp.backends.OIDCAuthCodeBackend'
|
AUTH_BACKEND_OIDC_CODE = 'authentication.backends.oidc.backends.OIDCAuthCodeBackend'
|
||||||
AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend'
|
AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend'
|
||||||
AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
|
AUTH_BACKEND_CAS = 'authentication.backends.cas.backends.CASBackend'
|
||||||
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
|
AUTH_BACKEND_SSO = 'authentication.backends.sso.SSOAuthentication'
|
||||||
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
|
AUTH_BACKEND_WECOM = 'authentication.backends.sso.WeComAuthentication'
|
||||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
AUTH_BACKEND_DINGTALK = 'authentication.backends.sso.DingTalkAuthentication'
|
||||||
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
|
AUTH_BACKEND_FEISHU = 'authentication.backends.sso.FeiShuAuthentication'
|
||||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.sso.AuthorizationTokenAuthentication'
|
||||||
AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend'
|
AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.backends.SAML2Backend'
|
||||||
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
AUTH_BACKEND_MODEL, RBAC_BACKEND, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
|
# 只做权限校验
|
||||||
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN,
|
RBAC_BACKEND,
|
||||||
AUTH_BACKEND_SSO,
|
# 密码形式
|
||||||
|
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_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH
|
||||||
ONLY_ALLOW_AUTH_FROM_SOURCE = CONFIG.ONLY_ALLOW_AUTH_FROM_SOURCE
|
ONLY_ALLOW_AUTH_FROM_SOURCE = CONFIG.ONLY_ALLOW_AUTH_FROM_SOURCE
|
||||||
|
|
||||||
|
|
|
@ -57,7 +57,6 @@ INSTALLED_APPS = [
|
||||||
'notifications.apps.NotificationsConfig',
|
'notifications.apps.NotificationsConfig',
|
||||||
'rbac.apps.RBACConfig',
|
'rbac.apps.RBACConfig',
|
||||||
'common.apps.CommonConfig',
|
'common.apps.CommonConfig',
|
||||||
'jms_oidc_rp',
|
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework_swagger',
|
'rest_framework_swagger',
|
||||||
'drf_yasg',
|
'drf_yasg',
|
||||||
|
@ -116,7 +115,6 @@ TEMPLATES = [
|
||||||
'django.template.context_processors.media',
|
'django.template.context_processors.media',
|
||||||
'jumpserver.context_processor.jumpserver_processor',
|
'jumpserver.context_processor.jumpserver_processor',
|
||||||
'orgs.context_processor.org_processor',
|
'orgs.context_processor.org_processor',
|
||||||
'jms_oidc_rp.context_processors.oidc',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -26,11 +26,11 @@ REST_FRAMEWORK = {
|
||||||
),
|
),
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
# 'rest_framework.authentication.BasicAuthentication',
|
# 'rest_framework.authentication.BasicAuthentication',
|
||||||
'authentication.backends.api.AccessKeyAuthentication',
|
'authentication.backends.drf.AccessKeyAuthentication',
|
||||||
'authentication.backends.api.AccessTokenAuthentication',
|
'authentication.backends.drf.AccessTokenAuthentication',
|
||||||
'authentication.backends.api.PrivateTokenAuthentication',
|
'authentication.backends.drf.PrivateTokenAuthentication',
|
||||||
'authentication.backends.api.SignatureAuthentication',
|
'authentication.backends.drf.SignatureAuthentication',
|
||||||
'authentication.backends.api.SessionAuthentication',
|
'authentication.backends.drf.SessionAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_FILTER_BACKENDS': (
|
'DEFAULT_FILTER_BACKENDS': (
|
||||||
'django_filters.rest_framework.DjangoFilterBackend',
|
'django_filters.rest_framework.DjangoFilterBackend',
|
||||||
|
|
|
@ -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):
|
def has_perm(self, user_obj, perm, obj=None):
|
||||||
if not user_obj.is_active:
|
if not user_obj.is_active:
|
||||||
return False
|
raise PermissionDenied()
|
||||||
|
|
||||||
has_perm = perm in user_obj.perms
|
has_perm = perm in user_obj.perms
|
||||||
|
if not has_perm:
|
||||||
|
raise PermissionDenied()
|
||||||
return has_perm
|
return has_perm
|
||||||
#
|
|
||||||
# def has_module_perms(self, user_obj, app_label):
|
# def has_module_perms(self, user_obj, app_label):
|
||||||
# return True
|
# return True
|
||||||
|
|
|
@ -16,7 +16,6 @@ exclude_permissions = (
|
||||||
('contenttypes', '*', '*', '*'),
|
('contenttypes', '*', '*', '*'),
|
||||||
('django_cas_ng', '*', '*', '*'),
|
('django_cas_ng', '*', '*', '*'),
|
||||||
('django_celery_beat', '*', '*', '*'),
|
('django_celery_beat', '*', '*', '*'),
|
||||||
('jms_oidc_rp', '*', '*', '*'),
|
|
||||||
('admin', '*', '*', '*'),
|
('admin', '*', '*', '*'),
|
||||||
('sessions', '*', '*', '*'),
|
('sessions', '*', '*', '*'),
|
||||||
('notifications', '*', '*', '*'),
|
('notifications', '*', '*', '*'),
|
||||||
|
|
|
@ -107,7 +107,7 @@ class Setting(models.Model):
|
||||||
# 添加
|
# 添加
|
||||||
if setting.cleaned_value and not has:
|
if setting.cleaned_value and not has:
|
||||||
logger.debug('Add auth backend: {}'.format(name))
|
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:
|
if not setting.cleaned_value and has:
|
||||||
|
|
|
@ -10,7 +10,7 @@ __all__ = [
|
||||||
class CASSettingSerializer(serializers.Serializer):
|
class CASSettingSerializer(serializers.Serializer):
|
||||||
AUTH_CAS = serializers.BooleanField(required=False, label=_('Enable CAS Auth'))
|
AUTH_CAS = serializers.BooleanField(required=False, label=_('Enable CAS Auth'))
|
||||||
CAS_SERVER_URL = serializers.CharField(required=False, max_length=1024, label=_('Server url'))
|
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_LOGOUT_COMPLETELY = serializers.BooleanField(required=False, label=_('Logout completely'))
|
||||||
CAS_VERSION = serializers.IntegerField(required=False, label=_('Version'), min_value=1, max_value=3)
|
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'))
|
CAS_USERNAME_ATTRIBUTE = serializers.CharField(required=False, max_length=1024, label=_('Username attr'))
|
||||||
|
|
|
@ -520,14 +520,27 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
||||||
|
|
||||||
SOURCE_BACKEND_MAPPING = {
|
SOURCE_BACKEND_MAPPING = {
|
||||||
Source.local: [
|
Source.local: [
|
||||||
settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY,
|
settings.AUTH_BACKEND_MODEL,
|
||||||
settings.AUTH_BACKEND_WECOM, settings.AUTH_BACKEND_DINGTALK,
|
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)
|
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
|
||||||
|
@ -728,7 +741,6 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_user_allowed_auth_backends(cls, username):
|
def get_user_allowed_auth_backends(cls, username):
|
||||||
if not settings.ONLY_ALLOW_AUTH_FROM_SOURCE or not username:
|
if not settings.ONLY_ALLOW_AUTH_FROM_SOURCE or not username:
|
||||||
# return settings.AUTHENTICATION_BACKENDS
|
|
||||||
return None
|
return None
|
||||||
user = cls.objects.filter(username=username).first()
|
user = cls.objects.filter(username=username).first()
|
||||||
if not user:
|
if not user:
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied
|
||||||
from django_cas_ng.signals import cas_user_authenticated
|
from django_cas_ng.signals import cas_user_authenticated
|
||||||
from django.db.models.signals import post_save
|
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 authentication.backends.saml2.signals import saml2_create_or_update_user
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
|
|
@ -96,7 +96,6 @@ ipython
|
||||||
huaweicloud-sdk-python==1.0.21
|
huaweicloud-sdk-python==1.0.21
|
||||||
django-redis==4.11.0
|
django-redis==4.11.0
|
||||||
python-redis-lock==3.7.0
|
python-redis-lock==3.7.0
|
||||||
jumpserver-django-oidc-rp==0.3.7.8
|
|
||||||
django-mysql==3.9.0
|
django-mysql==3.9.0
|
||||||
gmssl==3.2.1
|
gmssl==3.2.1
|
||||||
azure-mgmt-compute==4.6.2
|
azure-mgmt-compute==4.6.2
|
||||||
|
|
Loading…
Reference in New Issue