Browse Source

HUE-8321 [oidc] Add implementation for creating a new user if not exist during login
* override user lookup by username instead of email
* allow to create as a superuser if it belongs to a superuser group
1. add the name of Hue superuser group to superuser_group in hue.ini
2. in Keycloak, go to your_realm --> your_clients --> Mappers, add a mapper
Mapper Type: Group Membership (this is predefined mapper type)
Token Claim Name: group_membership (required exact string)
* allow not to create new user, and redirect to oidc failed page

Ying Chen 7 years ago
parent
commit
84475b6717

+ 10 - 0
desktop/conf.dist/hue.ini

@@ -755,6 +755,16 @@
     # As relay party Hue URL path to redirect to after login
     # As relay party Hue URL path to redirect to after login
     ## login_redirect_url_failure=https://localhost:8888/hue/oidc_failed/
     ## login_redirect_url_failure=https://localhost:8888/hue/oidc_failed/
 
 
+    # Create a new user from OpenID Connect on login if it doesn't exist
+    ## create_users_on_login=true
+
+    # The group of users will be created and updated as superuser. To use this feature, setup in Keycloak:
+    # 1. add the name of the group here
+    # 2. in Keycloak, go to your_realm --> your_clients --> Mappers, add a mapper
+    #      Mapper Type: Group Membership (this is predefined mapper type)
+    #      Token Claim Name: group_membership (required exact string)
+    ## superuser_group=hue_superusers
+
   # Configuration options for Metrics
   # Configuration options for Metrics
   # ------------------------------------------------------------------------
   # ------------------------------------------------------------------------
   [[metrics]]
   [[metrics]]

+ 10 - 0
desktop/conf/pseudo-distributed.ini.tmpl

@@ -757,6 +757,16 @@
     # As relay party Hue URL path to redirect to after login
     # As relay party Hue URL path to redirect to after login
     ## login_redirect_url_failure=https://localhost:8888/hue/oidc_failed/
     ## login_redirect_url_failure=https://localhost:8888/hue/oidc_failed/
 
 
+    # Create a new user from OpenID Connect on login if it doesn't exist
+    ## create_users_on_login=true
+
+    # The group of users will be created and updated as superuser. To use this feature, setup in Keycloak:
+    # 1. add the name of the group here
+    # 2. in Keycloak, go to your_realm --> your_clients --> Mappers, add a mapper
+    #      Mapper Type: Group Membership (this is predefined mapper type)
+    #      Token Claim Name: group_membership (required exact string)
+    ## superuser_group=hue_superusers
+
   # Configuration options for Metrics
   # Configuration options for Metrics
   # ------------------------------------------------------------------------
   # ------------------------------------------------------------------------
   [[metrics]]
   [[metrics]]

+ 72 - 8
desktop/core/src/desktop/auth/backend.py

@@ -48,7 +48,7 @@ from django_auth_ldap.config import LDAPSearch
 import desktop.conf
 import desktop.conf
 from desktop import metrics
 from desktop import metrics
 from liboauth.metrics import oauth_authentication_time
 from liboauth.metrics import oauth_authentication_time
-from mozilla_django_oidc.auth import OIDCAuthenticationBackend
+from mozilla_django_oidc.auth import OIDCAuthenticationBackend, default_username_algo
 from mozilla_django_oidc.utils import absolutify, import_from_settings
 from mozilla_django_oidc.utils import absolutify, import_from_settings
 
 
 from useradmin import ldap_access
 from useradmin import ldap_access
@@ -626,24 +626,77 @@ class OIDCBackend(OIDCAuthenticationBackend):
     verified_id = self.verify_token(id_token, nonce=nonce)
     verified_id = self.verify_token(id_token, nonce=nonce)
 
 
     if verified_id:
     if verified_id:
+      self.save_refresh_tokens(refresh_token)
       user =  self.get_or_create_user(access_token, id_token, verified_id)
       user =  self.get_or_create_user(access_token, id_token, verified_id)
       user = rewrite_user(user)
       user = rewrite_user(user)
-      self.save_refresh_tokens(refresh_token)
       return user
       return user
 
 
     return None
     return None
 
 
+  def filter_users_by_claims(self, claims):
+    username = claims.get('preferred_username')
+    if not username:
+      return self.UserModel.objects.none()
+    # iexact is equals to SQL 'like', replace % and _ for wildcards
+    username = username.replace('%', '').replace('_', '')
+    return self.UserModel.objects.filter(username__iexact=username)
+
   def save_refresh_tokens(self, refresh_token):
   def save_refresh_tokens(self, refresh_token):
     session = self.request.session
     session = self.request.session
 
 
     if import_from_settings('OIDC_STORE_REFRESH_TOKEN', False):
     if import_from_settings('OIDC_STORE_REFRESH_TOKEN', False):
       session['oidc_refresh_token'] = refresh_token
       session['oidc_refresh_token'] = refresh_token
 
 
+  def create_user(self, claims):
+    """Return object for a newly created user account."""
+    # Overriding lib's logic, use preferred_username from oidc as username
+
+    username = claims.get('preferred_username', '')
+    email = claims.get('email', '')
+    first_name = claims.get('given_name', '')
+    last_name = claims.get('family_name', '')
+
+    if not username:
+      if not email:
+        LOG.debug("OpenID Connect no username and email while creating new user")
+        return None
+      username = default_username_algo(email)
+
+    return self.UserModel.objects.create_user(username=username, email=email,
+                                              first_name=first_name, last_name=last_name,
+                                              is_superuser=self.is_hue_superuser(claims))
+
+  def get_or_create_user(self, access_token, id_token, verified_id):
+    user = super(OIDCBackend, self).get_or_create_user(access_token, id_token, verified_id)
+    if not user and not import_from_settings('OIDC_CREATE_USER', True):
+      # in this case, user is login from Keycloak, but not allow create
+      self.logout(self.request, next_page=import_from_settings('LOGIN_REDIRECT_URL_FAILURE', '/'))
+    return user
+
   def get_user(self, user_id):
   def get_user(self, user_id):
     user = super(OIDCBackend, self).get_user(user_id)
     user = super(OIDCBackend, self).get_user(user_id)
     user = rewrite_user(user)
     user = rewrite_user(user)
     return user
     return user
 
 
+  def update_user(self, user, claims):
+    if user.is_superuser != self.is_hue_superuser(claims):
+      user.is_superuser = self.is_hue_superuser(claims)
+      user.save()
+    return user
+
+  def is_hue_superuser(self, claims):
+    """
+    To use this feature, setup in Keycloak:
+      1. add the name of Hue superuser group to superuser_group in hue.ini
+      2. in Keycloak, go to your_realm --> your_clients --> Mappers, add a mapper
+           Mapper Type: Group Membership (this is predefined mapper type)
+           Token Claim Name: group_membership (required exact string)
+    """
+    sueruser_group = '/' + desktop.conf.OIDC.SUPERUSER_GROUP.get()
+    if sueruser_group:
+      return sueruser_group in claims.get('group_membership', [])
+    return False
+
   def logout(self, request, next_page):
   def logout(self, request, next_page):
     # https://stackoverflow.com/questions/46689034/logout-user-via-keycloak-rest-api-doesnt-work
     # https://stackoverflow.com/questions/46689034/logout-user-via-keycloak-rest-api-doesnt-work
     session = request.session
     session = request.session
@@ -667,12 +720,7 @@ class OIDCBackend(OIDCAuthenticationBackend):
       resp = requests.post(oidc_logout_url, data=form, headers=headers, verify=oidc_verify_ssl)
       resp = requests.post(oidc_logout_url, data=form, headers=headers, verify=oidc_verify_ssl)
       if resp.status_code >= 200 and resp.status_code < 300:
       if resp.status_code >= 200 and resp.status_code < 300:
         LOG.debug("OpenID Connect logout succeed!")
         LOG.debug("OpenID Connect logout succeed!")
-        del session['oidc_access_token']
-        del session['oidc_id_token']
-        del session['oidc_id_token_expiration']
-        del session['oidc_login_next']
-        del session['oidc_refresh_token']
-        del session['oidc_state']
+        delete_oidc_session_tokens(session)
         auth.logout(request)
         auth.logout(request)
         return HttpResponseRedirect(next_page)
         return HttpResponseRedirect(next_page)
       else:
       else:
@@ -684,3 +732,19 @@ class OIDCBackend(OIDCAuthenticationBackend):
   # def filter_users_by_claims(self, claims):
   # def filter_users_by_claims(self, claims):
 
 
   # def verify_claims(self, claims):
   # def verify_claims(self, claims):
+
+def delete_oidc_session_tokens(session):
+  if session:
+    if 'oidc_access_token' in session:
+      del session['oidc_access_token']
+    if 'oidc_id_token' in session:
+      del session['oidc_id_token']
+    if 'oidc_id_token_expiration' in session:
+      del session['oidc_id_token_expiration']
+    if 'oidc_login_next' in session:
+      del session['oidc_login_next']
+    if 'oidc_refresh_token' in session:
+      del session['oidc_refresh_token']
+    if 'oidc_state' in session:
+      del session['oidc_state']
+

+ 8 - 0
desktop/core/src/desktop/auth/views.py

@@ -36,6 +36,7 @@ from django.http import HttpResponseRedirect
 from django.utils.translation import ugettext as _
 from django.utils.translation import ugettext as _
 
 
 from desktop.auth import forms as auth_forms
 from desktop.auth import forms as auth_forms
+from desktop.auth.backend import OIDCBackend
 from desktop.auth.forms import ImpersonationAuthenticationForm
 from desktop.auth.forms import ImpersonationAuthenticationForm
 from desktop.lib.django_util import render
 from desktop.lib.django_util import render
 from desktop.lib.django_util import login_notrequired
 from desktop.lib.django_util import login_notrequired
@@ -270,3 +271,10 @@ def oauth_authenticated(request):
   redirect_to = request.GET.get('next', '/')
   redirect_to = request.GET.get('next', '/')
   return HttpResponseRedirect(redirect_to)
   return HttpResponseRedirect(redirect_to)
 
 
+@login_notrequired
+def oidc_failed(request):
+  if request.user.is_authenticated():
+    return HttpResponseRedirect('/')
+  access_warn(request, "401 Unauthorized by oidc")
+  return render("oidc_failed.mako", request, dict(uri=request.build_absolute_uri()), status=401)
+

+ 13 - 0
desktop/core/src/desktop/conf.py

@@ -1353,6 +1353,19 @@ OIDC = ConfigSection(
       default="https://localhost:8888/hue/oidc_failed/"
       default="https://localhost:8888/hue/oidc_failed/"
     ),
     ),
 
 
+    CREATE_USERS_ON_LOGIN=Config(
+      key="create_users_on_login",
+      help=_("Create a new user from OpenID Connect on login if it doesn't exist."),
+      type=coerce_bool,
+      default=True
+    ),
+
+    SUPERUSER_GROUP=Config(
+      key="superuser_group",
+      help=_("The group of users will be created and updated as superuser."),
+      type=str,
+      default=""
+    ),
   )
   )
 )
 )
 
 

+ 2 - 2
desktop/core/src/desktop/middleware.py

@@ -275,7 +275,7 @@ class LoginAndPermissionMiddleware(object):
     request.view_func = view_func
     request.view_func = view_func
     access_log_level = getattr(view_func, 'access_log_level', None)
     access_log_level = getattr(view_func, 'access_log_level', None)
     # skip loop for oidc
     # skip loop for oidc
-    if request.path in ['/oidc/authenticate/', '/oidc/callback/', '/oidc/logout/']:
+    if request.path in ['/oidc/authenticate/', '/oidc/callback/', '/oidc/logout/', '/hue/oidc_failed/']:
       return None
       return None
 
 
     # First, skip views not requiring login
     # First, skip views not requiring login
@@ -663,7 +663,7 @@ class EnsureSafeRedirectURLMiddleware(object):
       if is_safe_url(location, request.get_host()):
       if is_safe_url(location, request.get_host()):
         return response
         return response
 
 
-      if request.path in ['/oidc/authenticate/', '/oidc/callback/', '/oidc/logout/']:
+      if request.path in ['/oidc/authenticate/', '/oidc/callback/', '/oidc/logout/', '/hue/oidc_failed/']:
         return response
         return response
 
 
       response = render("error.mako", request, {
       response = render("error.mako", request, {

+ 1 - 0
desktop/core/src/desktop/settings.py

@@ -482,6 +482,7 @@ if is_oidc_configured():
   OIDC_STORE_ACCESS_TOKEN = True
   OIDC_STORE_ACCESS_TOKEN = True
   OIDC_STORE_ID_TOKEN = True
   OIDC_STORE_ID_TOKEN = True
   OIDC_STORE_REFRESH_TOKEN = True
   OIDC_STORE_REFRESH_TOKEN = True
+  OIDC_CREATE_USER = desktop.conf.OIDC.CREATE_USERS_ON_LOGIN.get()
 
 
 # OAuth
 # OAuth
 OAUTH_AUTHENTICATION='liboauth.backend.OAuthBackend' in AUTHENTICATION_BACKENDS
 OAUTH_AUTHENTICATION='liboauth.backend.OAuthBackend' in AUTHENTICATION_BACKENDS

+ 47 - 0
desktop/core/src/desktop/templates/oidc_failed.mako

@@ -0,0 +1,47 @@
+## Licensed to Cloudera, Inc. under one
+## or more contributor license agreements.  See the NOTICE file
+## distributed with this work for additional information
+## regarding copyright ownership.  Cloudera, Inc. licenses this file
+## to you under the Apache License, Version 2.0 (the
+## "License"); you may not use this file except in compliance
+## with the License.  You may obtain a copy of the License at
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+
+<%!
+from django.utils.translation import ugettext as _
+from desktop.views import commonheader, commonfooter
+%>
+
+%if not is_embeddable:
+${ commonheader(_('OpenID Connect Login Failed:'), "", user, request) | n,unicode }
+%endif
+
+<link rel="stylesheet" href="${ static('desktop/css/httperrors.css') }">
+
+<div id="httperror" class="container-fluid">
+  <div class="row-fluid">
+    <div class="span12 center">
+      <div class="error-code">401</div>
+    </div>
+  </div>
+  <div class="row-fluid">
+    <div class="span6 offset3 center error-box">
+      <h1>${_('Unauthorized')}</h1>
+
+      <p>${_("Sorry, your user is not found, and settings doesn't allow to create a new user.")}</p>
+      <br/>
+      <a href="/oidc/authenticate/">${_('Login again with another user')}</a>
+    </div>
+  </div>
+</div>
+
+%if not is_embeddable:
+${ commonfooter(request, messages) | n,unicode }
+%endif

+ 1 - 0
desktop/core/src/desktop/urls.py

@@ -65,6 +65,7 @@ dynamic_patterns = [
   url(r'^profile$', desktop_auth_views.profile),
   url(r'^profile$', desktop_auth_views.profile),
   url(r'^login/oauth/?$', desktop_auth_views.oauth_login),
   url(r'^login/oauth/?$', desktop_auth_views.oauth_login),
   url(r'^login/oauth_authenticated/?$', desktop_auth_views.oauth_authenticated),
   url(r'^login/oauth_authenticated/?$', desktop_auth_views.oauth_authenticated),
+  url(r'^hue/oidc_failed', desktop_auth_views.oidc_failed),
 ]
 ]
 
 
 if USE_NEW_EDITOR.get():
 if USE_NEW_EDITOR.get():