From e09f3ca4fd1f7937fe381725d5043793f7e2fc10 Mon Sep 17 00:00:00 2001 From: BaiJiangJie <32935519+BaiJiangJie@users.noreply.github.com> Date: Fri, 9 Nov 2018 14:54:38 +0800 Subject: [PATCH] =?UTF-8?q?[Update]=20=E6=94=AF=E6=8C=81=20OpenID=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=20(#2008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Update] core支持openid登录,coco还不支持 * [Update] coco支持openid登录 * [Update] 修改注释 * [Update] 修改 OpenID Auth Code Backend 用户认证失败返回None, 不是Anonymoususer * [Update] 修改OpenID Code用户认证异常捕获 * [Update] 修改OpenID Auth Middleware, check用户是否单点退出的异常捕获 * [Update] 修改config_example Auth OpenID 配置 * [Update] 登录页面添加 更多登录方式 * [Update] 重构OpenID认证架构 * [Update] 修改小细节 * [Update] OpenID用户认证成功后,更新用户来源 * [update] 添加OpenID用户登录成功日志 --- apps/authentication/__init__.py | 0 apps/authentication/admin.py | 1 + apps/authentication/apps.py | 10 ++ apps/authentication/models.py | 1 + apps/authentication/openid/__init__.py | 20 +++ apps/authentication/openid/backends.py | 90 +++++++++++++ apps/authentication/openid/middleware.py | 42 ++++++ apps/authentication/openid/models.py | 159 +++++++++++++++++++++++ apps/authentication/openid/tests.py | 0 apps/authentication/openid/views.py | 102 +++++++++++++++ apps/authentication/signals.py | 4 + apps/authentication/signals_handlers.py | 33 +++++ apps/authentication/tests.py | 1 + apps/authentication/urls/api_urls.py | 1 + apps/authentication/urls/view_urls.py | 16 +++ apps/authentication/views.py | 1 + apps/jumpserver/settings.py | 20 +++ apps/jumpserver/urls.py | 1 + apps/locale/zh/LC_MESSAGES/django.mo | Bin 55705 -> 55767 bytes apps/locale/zh/LC_MESSAGES/django.po | 54 ++++---- apps/users/models/user.py | 2 + apps/users/templates/users/login.html | 20 ++- apps/users/views/login.py | 4 + config_example.py | 8 ++ 24 files changed, 564 insertions(+), 26 deletions(-) create mode 100644 apps/authentication/__init__.py create mode 100644 apps/authentication/admin.py create mode 100644 apps/authentication/apps.py create mode 100644 apps/authentication/models.py create mode 100644 apps/authentication/openid/__init__.py create mode 100644 apps/authentication/openid/backends.py create mode 100644 apps/authentication/openid/middleware.py create mode 100644 apps/authentication/openid/models.py create mode 100644 apps/authentication/openid/tests.py create mode 100644 apps/authentication/openid/views.py create mode 100644 apps/authentication/signals.py create mode 100644 apps/authentication/signals_handlers.py create mode 100644 apps/authentication/tests.py create mode 100644 apps/authentication/urls/api_urls.py create mode 100644 apps/authentication/urls/view_urls.py create mode 100644 apps/authentication/views.py diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/authentication/admin.py b/apps/authentication/admin.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/authentication/admin.py @@ -0,0 +1 @@ + diff --git a/apps/authentication/apps.py b/apps/authentication/apps.py new file mode 100644 index 000000000..0869b64fd --- /dev/null +++ b/apps/authentication/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + name = 'authentication' + + def ready(self): + from . import signals_handlers + super().ready() + diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1 @@ + diff --git a/apps/authentication/openid/__init__.py b/apps/authentication/openid/__init__.py new file mode 100644 index 000000000..bc4c753ca --- /dev/null +++ b/apps/authentication/openid/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# + +from django.conf import settings +from .models import 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 + ) + + +client = new_client() diff --git a/apps/authentication/openid/backends.py b/apps/authentication/openid/backends.py new file mode 100644 index 000000000..15a758acc --- /dev/null +++ b/apps/authentication/openid/backends.py @@ -0,0 +1,90 @@ +# coding:utf-8 +# + +from django.contrib.auth import get_user_model +from django.conf import settings + +from . import client +from common.utils import get_logger +from authentication.openid.models import OIDT_ACCESS_TOKEN + +UserModel = get_user_model() + +logger = get_logger(__file__) + +BACKEND_OPENID_AUTH_CODE = \ + 'authentication.openid.backends.OpenIDAuthorizationCodeBackend' + + +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_active = getattr(user, 'is_active', None) + return is_active or is_active is None + + def get_user(self, user_id): + try: + user = UserModel._default_manager.get(pk=user_id) + 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('1.openid code backend') + + code = kwargs.get('code') + redirect_uri = kwargs.get('redirect_uri') + + if not code or not redirect_uri: + return None + + try: + oidt_profile = client.update_or_create_from_code( + code=code, + redirect_uri=redirect_uri + ) + + except Exception as e: + logger.error(e) + + else: + # Check openid user single logout or not with access_token + request.session[OIDT_ACCESS_TOKEN] = oidt_profile.access_token + + user = oidt_profile.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('2.openid password backend') + + if not settings.AUTH_OPENID: + return None + + elif not username: + return None + + try: + oidt_profile = client.update_or_create_from_password( + username=username, password=password + ) + + except Exception as e: + logger.error(e) + + else: + user = oidt_profile.user + return user if self.user_can_authenticate(user) else None + diff --git a/apps/authentication/openid/middleware.py b/apps/authentication/openid/middleware.py new file mode 100644 index 000000000..128b20984 --- /dev/null +++ b/apps/authentication/openid/middleware.py @@ -0,0 +1,42 @@ +# 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 . import client +from common.utils import get_logger +from .backends import BACKEND_OPENID_AUTH_CODE +from authentication.openid.models import OIDT_ACCESS_TOKEN + +logger = get_logger(__file__) + + +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 check single logout if user not authenticated + if not request.user.is_authenticated: + return + + elif request.session[BACKEND_SESSION_KEY] != BACKEND_OPENID_AUTH_CODE: + return + + # Check openid user single logout or not with access_token + try: + client.openid_connect_client.userinfo( + token=request.session.get(OIDT_ACCESS_TOKEN)) + + except Exception as e: + logout(request) + logger.error(e) diff --git a/apps/authentication/openid/models.py b/apps/authentication/openid/models.py new file mode 100644 index 000000000..e3c0a4842 --- /dev/null +++ b/apps/authentication/openid/models.py @@ -0,0 +1,159 @@ +# -*- 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 ..signals import post_create_openid_user + +OIDT_ACCESS_TOKEN = 'oidt_access_token' + + +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.realm = self.new_realm() + self.openid_client = self.new_openid_client() + self.openid_connect_client = self.new_openid_connect_client() + + def new_realm(self): + """ + :param authentication.openid.models.Realm realm: + :return keycloak.realm.Realm: + """ + return KeycloakRealm( + server_url=self.server_url, + realm_name=self.realm_name, + headers={} + ) + + def new_openid_connect_client(self): + """ + :rtype: keycloak.openid_connect.KeycloakOpenidConnect + """ + openid_connect = self.realm.open_id_connect( + client_id=self.client_id, + client_secret=self.client_secret + ) + return openid_connect + + def new_openid_client(self): + """ + :rtype: keycloak.keycloak_openid.KeycloakOpenID + """ + + return KeycloakOpenID( + server_url='%sauth/' % self.server_url, + realm_name=self.realm_name, + client_id=self.client_id, + client_secret_key=self.client_secret, + ) + + 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: authentication.models.OpenIDTokenProfile + """ + token_response = self.openid_client.token( + username=username, password=password + ) + + return self._update_or_create(token_response=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: authentication.models.OpenIDTokenProfile + """ + + token_response = self.openid_connect_client.authorization_code( + code=code, redirect_uri=redirect_uri) + + 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: authentication.openid.models.OpenIDTokenProfile + """ + + userinfo = self.openid_connect_client.userinfo( + token=token_response['access_token']) + + with transaction.atomic(): + user, _ = get_user_model().objects.update_or_create( + username=userinfo.get('preferred_username', ''), + defaults={ + 'email': userinfo.get('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_openid_user.send(sender=user.__class__, user=user) + + return oidt_profile + + def __str__(self): + return self.client_id + + +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 + diff --git a/apps/authentication/openid/tests.py b/apps/authentication/openid/tests.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/authentication/openid/views.py b/apps/authentication/openid/views.py new file mode 100644 index 000000000..9aeb0bf7b --- /dev/null +++ b/apps/authentication/openid/views.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# + +import logging + +from django.urls import reverse +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 . import client +from .models import Nonce +from users.models import LoginLog +from users.tasks import write_login_log_async +from common.utils import get_request_ip + +logger = logging.getLogger(__name__) + + +def get_base_site_url(): + return settings.BASE_SITE_URL + + +class LoginView(RedirectView): + + def get_redirect_url(self, *args, **kwargs): + nonce = Nonce( + redirect_uri=get_base_site_url() + reverse( + "authentication:openid-login-complete"), + + 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.openid_connect_client.\ + authorization_url( + redirect_uri=nonce.redirect_uri, scope='code', + state=str(nonce.state) + ) + + return authorization_url + + +class LoginCompleteView(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() + + if self.request.GET['state'] != self.request.session['openid_state']: + return HttpResponseBadRequest() + + nonce = cache.get(self.request.GET['state']) + + if not nonce: + return HttpResponseBadRequest() + + 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() + + login(self.request, user) + + data = { + 'username': user.username, + 'mfa': int(user.otp_enabled), + 'reason': LoginLog.REASON_NOTHING, + 'status': True + } + self.write_login_log(data) + + return HttpResponseRedirect(nonce.next_path or '/') + + def write_login_log(self, data): + login_ip = get_request_ip(self.request) + user_agent = self.request.META.get('HTTP_USER_AGENT', '') + tmp_data = { + 'ip': login_ip, + 'type': 'W', + 'user_agent': user_agent + } + data.update(tmp_data) + write_login_log_async.delay(**data) diff --git a/apps/authentication/signals.py b/apps/authentication/signals.py new file mode 100644 index 000000000..f33a3b821 --- /dev/null +++ b/apps/authentication/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + + +post_create_openid_user = Signal(providing_args=('user',)) diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py new file mode 100644 index 000000000..7cf240386 --- /dev/null +++ b/apps/authentication/signals_handlers.py @@ -0,0 +1,33 @@ +from django.http.request import QueryDict +from django.contrib.auth.signals import user_logged_out +from django.dispatch import receiver +from django.conf import settings +from .openid import client +from .signals import post_create_openid_user + + +@receiver(user_logged_out) +def on_user_logged_out(sender, request, user, **kwargs): + if not settings.AUTH_OPENID: + return + + query = QueryDict('', mutable=True) + query.update({ + 'redirect_uri': settings.BASE_SITE_URL + }) + + openid_logout_url = "%s?%s" % ( + client.openid_connect_client.get_url( + name='end_session_endpoint'), + query.urlencode() + ) + + request.COOKIES['next'] = openid_logout_url + + +@receiver(post_create_openid_user) +def on_post_create_openid_user(sender, user=None, **kwargs): + if user and user.username != 'admin': + user.source = user.SOURCE_OPENID + user.save() + diff --git a/apps/authentication/tests.py b/apps/authentication/tests.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/authentication/tests.py @@ -0,0 +1 @@ + diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/authentication/urls/api_urls.py @@ -0,0 +1 @@ + diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py new file mode 100644 index 000000000..4d4e6753a --- /dev/null +++ b/apps/authentication/urls/view_urls.py @@ -0,0 +1,16 @@ +# coding:utf-8 +# + +from django.urls import path +from authentication.openid import views + +app_name = 'authentication' + +urlpatterns = [ + # openid + path('openid/login/', views.LoginView.as_view(), name='openid-login'), + path('openid/login/complete/', views.LoginCompleteView.as_view(), + name='openid-login-complete'), + + # other +] diff --git a/apps/authentication/views.py b/apps/authentication/views.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/apps/authentication/views.py @@ -0,0 +1 @@ + diff --git a/apps/jumpserver/settings.py b/apps/jumpserver/settings.py index 6635b35e6..212878386 100644 --- a/apps/jumpserver/settings.py +++ b/apps/jumpserver/settings.py @@ -64,6 +64,7 @@ INSTALLED_APPS = [ 'common.apps.CommonConfig', 'terminal.apps.TerminalConfig', 'audits.apps.AuditsConfig', + 'authentication.apps.AuthenticationConfig', # authentication 'rest_framework', 'rest_framework_swagger', 'drf_yasg', @@ -94,6 +95,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'authentication.openid.middleware.OpenIDAuthenticationMiddleware', # openid 'jumpserver.middleware.TimezoneMiddleware', 'jumpserver.middleware.DemoMiddleware', 'jumpserver.middleware.RequestMiddleware', @@ -389,6 +391,24 @@ AUTH_LDAP_BACKEND = 'django_auth_ldap.backend.LDAPBackend' if AUTH_LDAP: AUTHENTICATION_BACKENDS.insert(0, AUTH_LDAP_BACKEND) +# openid +# Auth OpenID settings +BASE_SITE_URL = CONFIG.BASE_SITE_URL +AUTH_OPENID = CONFIG.AUTH_OPENID +AUTH_OPENID_SERVER_URL = CONFIG.AUTH_OPENID_SERVER_URL +AUTH_OPENID_REALM_NAME = CONFIG.AUTH_OPENID_REALM_NAME +AUTH_OPENID_CLIENT_ID = CONFIG.AUTH_OPENID_CLIENT_ID +AUTH_OPENID_CLIENT_SECRET = CONFIG.AUTH_OPENID_CLIENT_SECRET +AUTH_OPENID_BACKENDS = [ + 'authentication.openid.backends.OpenIDAuthorizationPasswordBackend', + 'authentication.openid.backends.OpenIDAuthorizationCodeBackend', +] + +if AUTH_OPENID: + LOGIN_URL = reverse_lazy("authentication:openid-login") + AUTHENTICATION_BACKENDS.insert(0, AUTH_OPENID_BACKENDS[0]) + AUTHENTICATION_BACKENDS.insert(0, AUTH_OPENID_BACKENDS[1]) + # Celery using redis as broker CELERY_BROKER_URL = 'redis://:%(password)s@%(host)s:%(port)s/%(db)s' % { 'password': CONFIG.REDIS_PASSWORD if CONFIG.REDIS_PASSWORD else '', diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 22351e509..2319d81e5 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -75,6 +75,7 @@ app_view_patterns = [ path('ops/', include('ops.urls.view_urls', namespace='ops')), path('audits/', include('audits.urls.view_urls', namespace='audits')), path('orgs/', include('orgs.urls.views_urls', namespace='orgs')), + path('auth/', include('authentication.urls.view_urls'), name='auth'), ] if settings.XPACK_ENABLED: diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 9f41c1d67939c0a96cc3a11a618c509fd2a70610..bb3a0102676cdcc8f6fff601e97337378be1ec37 100644 GIT binary patch delta 17218 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 Nw@=*gR?;ic{{t7z1SkLi delta 17158 zcmZA82b4}%+s5(3FxrgKMw!t@Z_!H_(M1VDbV1b7dkex*f=osyqE7@7z1M_9@4btV zLsXv(^XsSJEW|4X?ZO7tiOHv{;l|&@G{lK(dR{nY#9%Cpp;!u2 zU{y@-dA|2Pm24zpko$ULu{g$IdE9}8@j14~0<~BmPQ-k;!TcGo5C_+GH@J>zi65XA zkoY~fzz|GFoF9{Lf3E_SlqBB86xbL;u&vn>HPKMiM3c-Js2$D2FkFrq@H^D_)5xKE zzhE-_9d*KYQ74q5j^`!i{$5rpk(d`#U{y8X`f@s#desiF6XZubf=OBhhTnOfW7cA_QU9Up7#N+#)g>iePe&xR^&5qHbg`)WOvKbU$ETtNti-Ii88zWKEQ&X*Jp<>i ziK0;L`B3BD#=sGyc3KnFuZh_T)xQI3$NftcGvN)?33-k9j6-VFxB{qgh0V9kDrOzC3G$NrUK=W!s0)_GA*dT}K&|jQ)DCu| zJ{6}?H@J*?nQmYa3~%gqRvmRBwNXc1A2m-avm?Gs+yhhU^S^;gIEmeu7B8Z9^cU*J z!A;ym=}~b`)Iv(2CN7U@uo~(SG_`z3)RFfc-E_q#wEk zg!@!uh&z8`vEZ5)sH@jPlL2U@!ECs7MHhg$H@sQGVWVBx6# zDO!164fNAdDL|zqs$m@J<1-octY=_;T#lOXEb0c=P)GhJYN5|jI}L8_=1FO0K%G=} z)VxJe3n`7v<9k)8=$X|*Eua~yV_VdOolrOIg<8Ns)KPwl*>Q@+>rmr=K=nU>8h;#h z!}FHEhI;A$4CHzK2|jWiLa`(bnNT;Vhq~d1sCT3bY9~Wc6UU+YO+!7puP`$%Mm@q^ zs0AHCJ%Y2C0dJu`4GG#X&Zm-&N)gP2x=}6Eia$h6*a>xmPf-)cT09MPgPEv>EI^&y zDpbD>mV{)c3!R2r zF&>9t^Y)&X1<#-s^boayCl^k z{idK6G6(fCF0ptW22Q~8*D(?8_faqJ-yJyr6;y&ddR|XlkD4e)jN4&h)DEIi6IaGy ztbXD2_eKU?ny)$2<`mIB4@F3>E3qBPc<#W`HlXh~y-KIm`xEyNY zN~retFb_7h{BYC>#G-CI&EmP%{tasWwHEKdVB&+Qh4?3^L{YhlL71Sk`(ct8GZ9Cj zUdm|9h;=b3#$bNzg?bdTPzzg$db`hHZu|pvqtGty$TOoB@Rrl}N>b6zqES1pfl06~ z>gXDyUZ#$y1&lW5qQ0OuqK^6yY9YU%J~fXqE2i%1ezz=Y*2iSzdt)NrKW_*XEx<<| zRa~Hfj}@xpJWPs9P$#j*+IL}c;{B+FpF-{I0;=Ct)JygNBQRk%_c6?Z+F*7}uFroy zD!OqA)X`N!9a%%v4%?wlVi2m|I4ptFQ4<}&6nFx)(95Vtc^%b$3pMWp%O~pY7M>J+ z4MKkuA>LjjOd>?f} zNqcbq`WQs?a36{ITHs66kq7s5JCDMO#3eB= z_QzZ}w^aFqAk+FL%V@s2fM27M35?zpPmW_0GJ9$+0npVH+%t zy-^!mh`QfKpNgLGHq?raV>Wz@A(*bWGYYlS!l)aRL_LxUs82<8)WTY0DvUv$z#!DP zsi^)lQT^gA_LopeNn#bI!0o7y(?KkPS1ljb$MuWEeB=wDCTxf;u!Xg6H20uxd>Xaj zi>QrWL%qCDko)>xp}y{`F&Yce@B!-c?puBy>WDX>9>sRljSrirP)B(Yb;B#DxBqu* zzll1@d#HuHKy5U*pWY3ge;O)k$ZZxu-Kea^HOvN<{}8pHE@od0BOZ!6$vD&kXJd9; zfT?j8rouC*Z`SJ=!u`G1RMKKde>XvP)B=j2UZQs_u8EqU32FzOP&?~~1#kpv0n1VI zZA0}thH3B;s{dc86MKffUY^%fs$s?f?qk*p^|>94TF4X(yfmmAEHO8scD@(W;aSW7 ziCVx@)JcU7bmP;TSyA~s137;^vmzw4(o&cgE2ECE3u=MAQ6~{=`RS+$=2*PQT#j1k zI@H3qS$qJs@DrF1ucA&M=^)Nu9YY7XBZ@Q&qdq>BF$1DB@7hb}nkc zt57@Nh~an;^)g>YZSWSS-BPU@cd1og-g3~~MZ)KoMA5yff^qHbx#29_oEQ7_k4)Jg0_y_EZq|9L0*V-q$U<#wEWH17{_CCrGUFayp(oz!|v!Tr5M zs^B^EH`LpD7j>hTm=F_w>b}_$qxvU9y>#KIg=I#)1G!M+i=p~eLiMYIT5v{SDwelIL{tK`puC{#mXKupmsPTnR8;QoW z*b6m&H0skf5w-AmpNd|NwWysPLapo)Y6mw^H@uGp@fB*q05sunPLDYb9s0CI<^=pX1*c_8!8&v0V}B$~&$XOm*Q8;}ZhgDhAWb72%V#9BB2QzhW< z`&fzi#02+oOFhy3s?`v6GGkHwm!VGNF{Z?@Nt&PMpOs2+61lN3HbXt5amXjnn~55b zc{0b$4GSXQqTb*symAczGUwq+S;tez0d}mRQ<_0Fhrx^JBzoMd} zO8%vLR%tOCac)$;Iu^is=3vwhl|?uLcVTs`I@A5lC)WH1kCNYwAvk50b3W>1mt$S6 zXq`3ewT4p`Uo>x+k4$g2n#A_LEZ7G>CJI*5;Nr-o~V`*PC|RmW)?ziwX9hO^#T3})qgMM!sD0^ zA7fL@GM85y2Vg}!XQrR$;wI({EUWf;yir45&B?%V9puitSOKlToOJ&ail) zxf%7rKa3iG(fq~y12yih`P@wE$Gam8M|H?;adFg6t608{+1!jV`(al4jj%W#GZL>r zo#0-JFIf8n)FXRkv7hoQx8g_)q9F%rA$d?cDPeIr)PyxGZh=w6oh*(;Eo262+(z?z z)O@=wK4G4BdEdKYiQA|JJai4-KW5MZ_hp<4_4$fKy^Oica;OhzebkK_qfV|JYQf!6 zC*oT?)8geAq;HfxR5aj#dCI(O-Z1Z@cJ|ET1Yf&=@fb<|Yt;B1sGT3S_=N~RE#E`W${@IY!J1;TZ=e< zRbE&_*kad^8Fhmqs2i3=?eKkT?}mXJnsMe#YhP^f7IUxVPnp*(|70=euZa?W<6e?9 zWNG1UA&`Bu4W4GEUGfho;M zGam*spp?bsEv|+7;;D~XU~9|wL@i)|Im(=5&PC1VFSW`V`N zqHZ+U;#g~+Y|cYHs%7Rr)K~3q$d3Up^HMkOW@J9!+v6(UVQV;ry1`Y8?^*i`)K_xQ zGWQFA1=NCK%pR!Uo%&-Gjzyiox7NN9^^3yym<*2w@;v|ZRMhcGAi?j;7C*H3wHdtJ z-7pl@Kb^%N|Du00j%1hZKD7HZyd7FV{o9{L4nXiOzJj~^813((PO6FJyP(GP_N_9^8a~IIG)%GlM$7L)O>hw7@G>^S z&gZP5ALAV1`;V$zu z>gD?#b@X>J6knl!?1pS~{i|Yb?(a3S#1Pa<<4_CQfN60j26kp%w)Sfl-#7m?U!fM7 zc#|`uSrE0L3T8bF*XO@A6&-zlbAtJe`2%V}7f=hiZt)$|PM?}Vn_a(9GXgblUevfs zmakzp!obh}wp6s@&ZwgtYVml~jpNM~=2mkbYTRkm&Td%zkF_V;;>M@PinQlMEu_8W zyKdq9wSz&H7-=0QqP~E>uz0PtZ!!0pr_8IUiSL+?%x7kTt!|-7QJ=0z47_t&Ie%5^ zScm4Qopi^H_zCJA_`>o_Ex#4R$)B?LrnM*7=H^R{+CWyb9BP40ti6@RU42XRK}|T^ z;_0aG_64Y~;De~I+?$vSlYi$XEP^`0(q<*Iw%G`^!Pclp)eW_f;gdHyPy^{3P)oshUoJjy2DKvf$ESK zwc^64iQdN2SQB+)AGP4|s4t>;)C~@y9^p0AJWo;my&qg0YNj(IrB8bfOXN3;q6U^R ztCRyd+sr-YG4$1Nfr?iA8)m^}^w=4T zSUl5Qi`vj3)I!dn=KIOKY5r{{+~vCtVY{4}&HSi|N~0#KWceDHn79$@=v!bR>}c&% zQ45T>cnfO$Uh@bB5udjByl<5&=5_N9YUPhmFO|33{Vg{=Dj$hDv3zDU>c;iVPN;q( z%n7Lev(1&Lh50{PVn1rYCDcTJSnTa_aVRz=p9eM3aB~V?BVJ_jXM5c|bIfnejpi=% zIHu(O-eoF!*0)hBdTu7%=O#*KrZ;n%MKPHE?^s;TY=BYZTVpVeKz&h-!yLEY z9&sm83$>v7*b`f!KavxSN`smxlbOdXVeOSLG3_-l z2dZ+R}v|37;43_)?u2(vr#);WNtt$U=M2iNsE8C_6MkEoZz@? zk3c=Tw@{y^I;azEcicYz!%1kuICCcI*)F#DfO*#Z&Af;C>GuM4!#pS4JQYz3t7Gv; zW_JuDKNtfW^{p}ugK3zD4RH}_f+vOny4$Py}$V>>IkP>ybRTE8wMVUc@p0ue+AQE$Z0oE zHq^(n2-4s88dyVX)Wkh4{?wd~y1^pUM5|Cgq;{fic-8WEPz!x+`LHu?TxQfun8)G* zsFN;pgy*@Q4@DN>u%HwHSu7~jU%uieuK^M6yC*x z=iJ9M;yiyt)PdH2q&UW<{Hk#e^4hj=^~#1 ztjQmF5>ny1fX^vu$%RvNf3Gc-FiKbInQ8n|$^Tp@ZLm@|=(iGkQc{x7PT6kl)3CeM zQ_^p@)dx`5wScnCa@DAFn!Y!PL>@YPPMJb|7UdG9HzftRUnnoA51~wDkannR7v(i= zD=2)?d7UsHT0d???~f5o2UWt73#Mw9*k?~ zTwA^EnvWk-^nd8lHO}EJrtda#l`Nlwd?~93>rJ)mE7bdToZ?U74_(6<{2FI5AS3nt zc#m=`(8@o}kmEP)|6Q$#-&`NiZ!Ymd%QYw7PSG_3lT&`BtpFtK0+L26O!`IP_KkrF|p;F5dTUkOZ))WQ~rC6CjU8!C#atVw<)Xi z{9{SX{ZG$@#Dyrj+EAh?MJb<9!fatwy|*cahBGyHkO@wB62DI z^9Vj64pXe>zt%cmqMneXzQ;FHbfu=_D9UpBoWM6%9Ptf;eK-vJ(YA=Pka`=;hsP+L zC_5*q`S;-Z#dJE#>)I-Q+Wb6v+ zv#IN9M*UyP2eV$QtU6gwMgDjz>u3W@baU}zr z+TikwaAsDPY_2KL1{@_AL2TcJ+v(){=@o<7;=X&BSpVub)a8D%0Tk|_f-CS zC1FBcNojm@m8Fu6lIcwXOHm#$_D}U=%qr?ZlphJlko$*v59)8O4pe57{}%tCZ6@{Q z3EBTOf_Q=ilus$E>2QHuVal89A(gKwX}Qrn^5ZDIhzGvufIAp_gAzd*Z)2;ONyrta zPX^-G)HhJ?PJO!0U;kgq?j(XqOva;>%XCUd{txU)J}*UAZQ={~559x_D8Ew{)1Hi+ ze%{}*zE6qYT>38&+EK=m7-Y!~v}g87KDPlcXgF?z?ojV+9jnsL?+k&fg7vk~`-RF9 za>ppYQ3_Bx+Z;uy7oz>nrlB zDJ}og-oV;6l3QSP^-XVmDw^5Jb)~((#SirS-zB(Zi8MC&9Ptm-=TmM{pM(6vmp2iY zqprM;z<B%`-%7zZ7cCHxx*M9#Pd(gbFNQfjdg5{KhxMB zdtq$~zf*dV^m}tfF;NThdo7Wkw)d#-Aou2qqf&u>t1uRIJ*7RGl7{+U_>ND*cT^@| zc}!2k&(^s(v95P1W2mpib+mP%d~Iz5)PZY_jrp7WR^pGWJ)U!UDX=`fjep+jM zK_aE?us$6_C?As>uE{7`=v7I{O??`M(*KlAl!=_KaE!(ASj*a!Pfp*O({+51kuD`54 z%G#74O3trCB{j((DK)IqY3iBD_n|yjN8&Tq){**mlt^+VD7qe7o9a8s zO{6TQ-kws4dN4Ml&u{o44yH69U)shsroM@imZEDZE}+!0*dI>EoUYBQLOm6`D`t(= z$%j&3M%iP1?odxgzmmkMa1wED>JzBHxxOTKkTQov23$njhp4MQ^>viouD|b{bro+5 z2Gc1gj-lhwKzD9ToW<(p@fBq%{UXS(p#BdfF{KOf7pUuB>YYbrvFXicPZac zN>ciei`Vl{L{OP>N+Y>$Q*VYv0+IV8297acI^`-QlK3Y|M@n4^hvwyH+)GMd>f0&0 zu2I@jHZw+7BicWwe#V7fTRr~`loZxM`5tuAwHxbUF3K;om!jmNq$Vyvd2_9%UqSL= zB)-8nS8uCq!KRcd7O$uOZmY9@J~kxE;7Lo0=1kO+(wMe?i7QePsll$$Hx=ATxlg~n zlopKXLL6o7Wr\n" "Language-Team: Jumpserver team\n" @@ -115,7 +115,7 @@ msgid "Port" msgstr "端口" #: assets/forms/domain.py:15 assets/forms/label.py:13 -#: assets/models/asset.py:243 assets/templates/assets/admin_user_list.html:28 +#: assets/models/asset.py:253 assets/templates/assets/admin_user_list.html:28 #: assets/templates/assets/domain_detail.html:60 #: assets/templates/assets/domain_list.html:26 #: assets/templates/assets/label_list.html:16 @@ -1758,7 +1758,7 @@ msgstr "改密日志" #: audits/views.py:187 templates/_nav.html:10 users/views/group.py:28 #: users/views/group.py:44 users/views/group.py:60 users/views/group.py:76 -#: users/views/group.py:92 users/views/login.py:331 users/views/user.py:68 +#: users/views/group.py:92 users/views/login.py:334 users/views/user.py:68 #: users/views/user.py:83 users/views/user.py:111 users/views/user.py:193 #: users/views/user.py:354 users/views/user.py:404 users/views/user.py:439 msgid "Users" @@ -2438,7 +2438,7 @@ msgstr "任务列表" msgid "Task run history" msgstr "执行历史" -#: orgs/mixins.py:78 orgs/models.py:24 +#: orgs/mixins.py:77 orgs/models.py:24 msgid "Organization" msgstr "组织管理" @@ -3092,11 +3092,11 @@ msgstr "登录频繁, 稍后重试" msgid "Please carry seed value and conduct MFA secondary certification" msgstr "请携带seed值, 进行MFA二次认证" -#: users/api/auth.py:195 +#: users/api/auth.py:196 msgid "Please verify the user name and password first" msgstr "请先进行用户名和密码验证" -#: users/api/auth.py:207 +#: users/api/auth.py:208 msgid "MFA certification failed" msgstr "MFA认证失败" @@ -3448,7 +3448,7 @@ msgstr "获取更多信息" #: users/templates/users/forgot_password.html:11 #: users/templates/users/forgot_password.html:27 -#: users/templates/users/login.html:79 +#: users/templates/users/login.html:81 msgid "Forgot password" msgstr "忘记密码" @@ -3497,6 +3497,14 @@ msgstr "改变世界,从一点点开始。" msgid "Captcha invalid" msgstr "验证码错误" +#: users/templates/users/login.html:87 +msgid "More login options" +msgstr "更多登录方式" + +#: users/templates/users/login.html:91 +msgid "Keycloak" +msgstr "" + #: users/templates/users/login_otp.html:46 #: users/templates/users/user_detail.html:91 #: users/templates/users/user_profile.html:85 @@ -3995,19 +4003,19 @@ msgstr "" "
\n" " " -#: users/utils.py:148 +#: users/utils.py:162 msgid "User not exist" msgstr "用户不存在" -#: users/utils.py:150 +#: users/utils.py:164 msgid "Disabled or expired" msgstr "禁用或失效" -#: users/utils.py:163 +#: users/utils.py:177 msgid "Password or SSH public key invalid" msgstr "密码或密钥不合法" -#: users/utils.py:286 users/utils.py:296 +#: users/utils.py:300 users/utils.py:310 msgid "Bit" msgstr " 位" @@ -4027,52 +4035,52 @@ msgstr "用户组授权资产" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: users/views/login.py:179 users/views/user.py:526 users/views/user.py:551 +#: users/views/login.py:180 users/views/user.py:526 users/views/user.py:551 msgid "MFA code invalid, or ntp sync server time" msgstr "MFA验证码不正确,或者服务器端时间不对" -#: users/views/login.py:208 +#: users/views/login.py:211 msgid "Logout success" msgstr "退出登录成功" -#: users/views/login.py:209 +#: users/views/login.py:212 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: users/views/login.py:225 +#: users/views/login.py:228 msgid "Email address invalid, please input again" msgstr "邮箱地址错误,重新输入" -#: users/views/login.py:238 +#: users/views/login.py:241 msgid "Send reset password message" msgstr "发送重置密码邮件" -#: users/views/login.py:239 +#: users/views/login.py:242 msgid "Send reset password mail success, login your mail box and follow it " msgstr "" "发送重置邮件成功, 请登录邮箱查看, 按照提示操作 (如果没收到,请等待3-5分钟)" -#: users/views/login.py:252 +#: users/views/login.py:255 msgid "Reset password success" msgstr "重置密码成功" -#: users/views/login.py:253 +#: users/views/login.py:256 msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: users/views/login.py:274 users/views/login.py:287 +#: users/views/login.py:277 users/views/login.py:290 msgid "Token invalid or expired" msgstr "Token错误或失效" -#: users/views/login.py:283 +#: users/views/login.py:286 msgid "Password not same" msgstr "密码不一致" -#: users/views/login.py:293 users/views/user.py:127 users/views/user.py:422 +#: users/views/login.py:296 users/views/user.py:127 users/views/user.py:422 msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/views/login.py:331 +#: users/views/login.py:334 msgid "First login" msgstr "首次登陆" diff --git a/apps/users/models/user.py b/apps/users/models/user.py index bf2cae7f1..8192ae095 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -40,9 +40,11 @@ class User(AbstractUser): ) SOURCE_LOCAL = 'local' SOURCE_LDAP = 'ldap' + SOURCE_OPENID = 'openid' SOURCE_CHOICES = ( (SOURCE_LOCAL, 'Local'), (SOURCE_LDAP, 'LDAP/AD'), + (SOURCE_OPENID, 'OpenID'), ) id = models.UUIDField(default=uuid.uuid4, primary_key=True) username = models.CharField( diff --git a/apps/users/templates/users/login.html b/apps/users/templates/users/login.html index 93ed4e023..6582dd447 100644 --- a/apps/users/templates/users/login.html +++ b/apps/users/templates/users/login.html @@ -75,9 +75,23 @@

{% endif %} - - {% trans 'Forgot password' %}? - +
+ + + {% if AUTH_OPENID %} +
+

{% trans "More login options" %}

+
+ +
+ {% endif %}

diff --git a/apps/users/views/login.py b/apps/users/views/login.py index 4f2308d04..971b1cf2d 100644 --- a/apps/users/views/login.py +++ b/apps/users/views/login.py @@ -132,6 +132,7 @@ class UserLoginView(FormView): def get_context_data(self, **kwargs): context = { 'demo_mode': os.environ.get("DEMO_MODE"), + 'AUTH_OPENID': settings.AUTH_OPENID, } kwargs.update(context) return super().get_context_data(**kwargs) @@ -200,6 +201,9 @@ class UserLogoutView(TemplateView): def get(self, request, *args, **kwargs): auth_logout(request) + next_uri = request.COOKIES.get("next") + if next_uri: + return redirect(next_uri) response = super().get(request, *args, **kwargs) return response diff --git a/config_example.py b/config_example.py index a96f0d7c9..d0fc4bff9 100644 --- a/config_example.py +++ b/config_example.py @@ -54,6 +54,14 @@ class Config: REDIS_DB_CELERY = os.environ.get('REDIS_DB') or 3 REDIS_DB_CACHE = os.environ.get('REDIS_DB') or 4 + # Use OpenID authorization + # BASE_SITE_URL = 'http://localhost:8080' + # AUTH_OPENID = False # True or False + # AUTH_OPENID_SERVER_URL = 'https://openid-auth-server.com/' + # AUTH_OPENID_REALM_NAME = 'realm-name' + # AUTH_OPENID_CLIENT_ID = 'client-id' + # AUTH_OPENID_CLIENT_SECRET = 'client-secret' + def __init__(self): pass