From 9f9963b8d9114e6bdea5a1893abec58705f05da0 Mon Sep 17 00:00:00 2001 From: natacha-beck Date: Wed, 22 May 2024 04:24:49 -0400 Subject: [PATCH 1/6] [WIP] 1st version of keycloak integration --- .../app/controllers/application_controller.rb | 1 + .../app/controllers/sessions_controller.rb | 92 +++++- .../app/controllers/users_controller.rb | 4 +- BrainPortal/app/views/sessions/new.html.erb | 21 ++ BrainPortal/app/views/users/show.html.erb | 37 ++- BrainPortal/config/routes.rb | 3 + BrainPortal/lib/keycloak_helpers.rb | 266 ++++++++++++++++++ 7 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 BrainPortal/lib/keycloak_helpers.rb diff --git a/BrainPortal/app/controllers/application_controller.rb b/BrainPortal/app/controllers/application_controller.rb index 3876b169a..8b879e00d 100644 --- a/BrainPortal/app/controllers/application_controller.rb +++ b/BrainPortal/app/controllers/application_controller.rb @@ -41,6 +41,7 @@ class ApplicationController < ActionController::Base include ExceptionHelpers include MessageHelpers include GlobusHelpers + include KeycloakHelpers helper_method :start_page_path diff --git a/BrainPortal/app/controllers/sessions_controller.rb b/BrainPortal/app/controllers/sessions_controller.rb index 8410ca194..ae1fe9dc4 100644 --- a/BrainPortal/app/controllers/sessions_controller.rb +++ b/BrainPortal/app/controllers/sessions_controller.rb @@ -44,7 +44,8 @@ def new #:nodoc: @browser_name = ua.browser_name || "(unknown browser name)" @browser_version = ua.browser_version || "(unknown browser version)" - @globus_uri = globus_login_uri(globus_url) # can be nil + @globus_uri = globus_login_uri(globus_url) # can be nil + @keycloak_uri = keycloak_login_uri(keycloak_url) # can be nil respond_to do |format| format.html @@ -205,6 +206,79 @@ def unlink_globus #:nodoc: redirect_to user_path(current_user) end + # This action receives a JSON authentication + # request from keycloak and uses it to record or verify + # a user's identity. + def keycloak + code = params[:code].presence.try(:strip) + state = params[:state].presence || 'wrong' + + # Some initial simple validations + if !code || state != keycloak_current_state() + cb_error "Keycloak session is out of sync with CBRAIN" + end + + # Query Keycloak; this returns all the info we need at the same time. + identity_struct = keycloak_fetch_token(code, keycloak_url) # keycloak_url is generated from routes + if !identity_struct + cb_error "Could not fetch your identity information from Keycloak" + end + Rails.logger.info "Keycloak identity struct:\n#{identity_struct.pretty_inspect.strip}" + + # Either record the identity... + if current_user + if ! user_can_link_to_keycloak_identity?(current_user, identity_struct) + Rails.logger.error("User #{current_user.login} attempted authentication " + + "with unallowed Keycloak identity provider " + + identity_struct['identity_provider_display_name'].to_s) + flash[:error] = "Error: your account can only authenticate with the following Keycloak providers: " + + "#{allowed_keycloak_provider_names(current_user).join(", ")}" + redirect_to user_path(current_user) + return + end + record_keycloak_identity(current_user, identity_struct) + flash[:notice] = "Your CBRAIN account is now linked to your Keycloak identity." + if user_must_link_to_keycloak?(current_user) + wipe_user_password_after_keycloak_link(current_user) + flash[:notice] += "\nImportant note: from now on you can no longer connect to CBRAIN using a password." + redirect_to start_page_path + return + end + redirect_to user_path(current_user) + return + end + + # ...or attempt login with it + user = find_user_with_keycloak_identity(identity_struct) + if user.is_a?(String) # an error occurred + flash[:error] = user # the message + redirect_to new_session_path + return + end + + login_from_keycloak_user(user, identity_struct['azp']) + + rescue CbrainException => ex + flash[:error] = "#{ex.message}" + redirect_to new_session_path + rescue => ex + clean_bt = Rails.backtrace_cleaner.clean(ex.backtrace || []) + Rails.logger.error "Keycloak auth failed: #{ex.class} #{ex.message} at #{clean_bt[0]}" + flash[:error] = 'The Keycloak authentication failed' + redirect_to new_session_path + end + + # POST /unlink_keycloak + # Removes a user's linked keycloak identity. + def unlink_keycloak #:nodoc: + redirect_to start_page_path unless current_user + + unlink_keycloak_identity(current_user) + + flash[:notice] = "Your account is no longer linked to any Keycloak identity" + redirect_to user_path(current_user) + end + ############################################### # # Private methods @@ -356,6 +430,22 @@ def login_from_globus_user(user, provider_name) redirect_to start_page_path end + def login_from_keycloak_user(user, provider_name) + # Login the user + all_ok = create_from_user(user, "CBRAIN/Keycloak/#{provider_name}") + + if ! all_ok + redirect_to new_session_path + return + end + + # Record that the user connected using the CBRAIN login page + cbrain_session[:login_page] = 'CBRAIN' + + # All's good + redirect_to start_page_path + end + # ------------------------------------ # SessionInfo fake model for API calls # ------------------------------------ diff --git a/BrainPortal/app/controllers/users_controller.rb b/BrainPortal/app/controllers/users_controller.rb index bc35092c1..e260645e6 100644 --- a/BrainPortal/app/controllers/users_controller.rb +++ b/BrainPortal/app/controllers/users_controller.rb @@ -28,6 +28,7 @@ class UsersController < ApplicationController Revision_info=CbrainFileRevision[__FILE__] #:nodoc: include GlobusHelpers + include KeycloakHelpers api_available :only => [ :index, :create, :show, :destroy, :update, :create_user_session, :push_keys] @@ -91,7 +92,8 @@ def show #:nodoc: .where( "updated_at > ?", SessionHelpers::SESSION_API_TOKEN_VALIDITY.ago ) .order(:updated_at) - @globus_uri = globus_login_uri(globus_url) + @globus_uri = globus_login_uri(globus_url) + @keycloak_uri = keycloak_login_uri(keycloak_url) respond_to do |format| format.html # show.html.erb diff --git a/BrainPortal/app/views/sessions/new.html.erb b/BrainPortal/app/views/sessions/new.html.erb index 076fb4980..5bdc2398e 100644 --- a/BrainPortal/app/views/sessions/new.html.erb +++ b/BrainPortal/app/views/sessions/new.html.erb @@ -89,6 +89,27 @@ <% end %> + <% if @keycloak_uri %> + + +   + + + + + OR + + + <%= link_to 'Sign In With Keycloak', @keycloak_uri, :class => 'button globus_button' %> +
+ (Only available if you have already linked your
+ CBRAIN account to a Keycloak identity) + + + + <% end %> + <% end -%> diff --git a/BrainPortal/app/views/users/show.html.erb b/BrainPortal/app/views/users/show.html.erb index 03546fded..b32233983 100644 --- a/BrainPortal/app/views/users/show.html.erb +++ b/BrainPortal/app/views/users/show.html.erb @@ -165,17 +165,20 @@ <%= show_table(@user, :as => :user, :header => 'Linked Identities') do |t| %> <% - prov_id = @user.meta[:globus_provider_id] - prov_name = @user.meta[:globus_provider_name] - prov_user = @user.meta[:globus_preferred_username] + globus_prov_id = @user.meta[:globus_provider_id] + globus_prov_name = @user.meta[:globus_provider_name] + globus_prov_user = @user.meta[:globus_preferred_username] + keycloak_prov_id = @user.meta[:keycloak_provider_id] + keycloak_prov_name = @user.meta[:keycloak_provider_name] + keycloak_prov_user = @user.meta[:keycloak_preferred_username] orcid_id = @user.meta[:orcid] %> <% if @globus_uri %> <% t.cell "Globus Provider", :show_width => 2 do %> - <% if prov_id %> - Provider name: <%= prov_name %>
- Provider user: <%= prov_user %>
+ <% if globus_prov_id %> + Provider name: <%= globus_prov_name %>
+ Provider user: <%= globus_prov_user %>
<% if @user.id == current_user.id # show button on user own page, hide on other users pages %> <%= link_to('Unlink this Globus identity', unlink_globus_path, :class => "button", @@ -193,6 +196,28 @@ <% end %> <% end %> + <% if @keycloak_uri %> + <% t.cell "Keycloak Provider", :show_width => 2 do %> + <% if keycloak_prov_id %> + Provider name: <%= keycloak_prov_name %>
+ Provider user: <%= keycloak_prov_user %>
+ <% if @user.id == current_user.id # show button on user own page, hide on other users pages %> + <%= link_to('Unlink this Keycloak identity', unlink_keycloak_path, + :class => "button", + :method => :post, + :data => { :confirm => "Are you sure you want to unlink your account with this Keycloak identity?" } + ) + %> + <% end %> + <% else %> + (No Keycloak identity linked to your account)
+ <% if @user.id == current_user.id # Keycloak button works only on user own account %> + <%= link_to 'Link a Keycloak identity', @keycloak_uri, :class => 'button keycloak_button' %> + <% end %> + <% end %> + <% end %> + + <% # Note: should be in a if/end block testing for the configuration of orcid_uri, like in NeuroHub %> diff --git a/BrainPortal/config/routes.rb b/BrainPortal/config/routes.rb index b4b8e34db..0320191b2 100644 --- a/BrainPortal/config/routes.rb +++ b/BrainPortal/config/routes.rb @@ -222,6 +222,9 @@ get '/globus' => 'sessions#globus' post '/unlink_globus' => 'sessions#unlink_globus' get '/mandatory_globus' => 'sessions#mandatory_globus' + # Keycloak authentication + get '/keycloak' => 'sessions#keycloak' + post '/unlink_keycloak' => 'sessions#unlink_keycloak' # Report Maker get "/report", :controller => :portal, :action => :report diff --git a/BrainPortal/lib/keycloak_helpers.rb b/BrainPortal/lib/keycloak_helpers.rb new file mode 100644 index 000000000..10fc116e3 --- /dev/null +++ b/BrainPortal/lib/keycloak_helpers.rb @@ -0,0 +1,266 @@ + +# +# NeuroHub Project +# +# Copyright (C) 2021 +# The Royal Institution for the Advancement of Learning +# McGill University +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# Helper for logging in using Keycloak identity stuff +module KeycloakHelpers + + Revision_info=CbrainFileRevision[__FILE__] #:nodoc: + + # KEYCLOAK authentication URL constants + # Maybe should be made configurable. + KEYCLOAK_AUTHORIZE_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/auth" # will be issued a GET with params + KEYCLOAK_TOKEN_URI = "keycloak:8080/realms/lasso-realm/protocol/openid-connect/token" # will be issued a POST with a single code + KEYCLOAK_LOGOUT_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/logout" # for pages that provide this link + + # Returns the URI to send users to the KEYCLOAK authentication page. + # The parameter keycloak_action_url should be the URL to the controller + # action here in CBRAIN that will received the POST response. + def keycloak_login_uri(keycloak_action_url) + return nil if api_request? + return nil unless keycloak_auth_configured? + + # Create the URI to authenticate with KEYCLOAK + keycloak_params = { + :client_id => keycloak_client_id, + :response_type => 'code', + :scope => "openid email profile", + :redirect_uri => keycloak_action_url, # generated from Rails routes + :state => keycloak_current_state, # method is below + } + KEYCLOAK_AUTHORIZE_URI + '?' + keycloak_params.to_query + end + + def keycloak_logout_uri + KEYCLOAK_LOGOUT_URI + end + + def keycloak_fetch_token(code, keycloak_action_url) + # Query Keycloak; this returns all the info we need at the same time. + auth_header = keycloak_basic_auth_header # method is below + + response = Typhoeus.post(KEYCLOAK_TOKEN_URI, + :body => { + :code => code, + :redirect_uri => keycloak_action_url, + :grant_type => 'authorization_code', + }, + :headers => { :Accept => 'application/json', + :Authorization => auth_header, + } + ) + + # Parse the response + body = response.response_body + json = JSON.parse(body) + jwt_id_token = json["id_token"] + identity_struct, _ = JWT.decode(jwt_id_token, nil, false) + + return identity_struct + rescue => ex + Rails.logger.error "KEYCLOAK token request failed: #{ex.class} #{ex.message}" + return nil + end + + # Returns the value for the Authorization header + # when doing the client authentication. + # + # "Basic 1745djfuebwifh37236djdf74.etc.etc" + def keycloak_basic_auth_header + client_id = keycloak_client_id + client_secret = keycloak_client_secret + "Basic " + Base64.strict_encode64("#{client_id}:#{client_secret}") + end + + # Returns a string that should stay constants during the entire + # keycloak negotiations. The current Rails session_id, encoded, will do + # the trick. The Rails session is maintained by a cookie already + # created and maintained, at this point. + def keycloak_current_state + Digest::MD5.hexdigest( request.session_options[:id] ) + end + + # Return the registered keycloak endpoint client ID. + # This value must be configured by the CBRAIN admin + # in the meta data of the portal. Returns nil if unset. + def keycloak_client_id + myself = RemoteResource.current_resource + myself.meta[:keycloak_client_id].presence.try(:strip) + end + + # Return the registered keycloak endpoint client secret. + # This value must be configured by the CBRAIN admin + # in the meta data of the portal. Returns nil if unset. + def keycloak_client_secret + myself = RemoteResource.current_resource + myself.meta[:keycloak_client_secret].presence.try(:strip) + end + + # Returns true if the CBRAIN system is configured for + # keycloak auth. + def keycloak_auth_configured? + myself = RemoteResource.current_resource + site_uri = myself.site_url_prefix.presence + # Three conditions: site uri, client ID, client secret. + return false if ! site_uri + return false if ! keycloak_client_id + return false if ! keycloak_client_secret + true + end + + # Record the keycloak identity for the current user. + # (This maybe should be made into a User model method) + def record_keycloak_identity(user, keycloak_identity) + + # In the case where a user must auth with a specific set of + # keycloak providers, we find the first identity that + # matches a name of that set. + identity = set_of_identities(keycloak_identity).detect do |idstruct| + user_can_link_to_keycloak_identity?(user, idstruct) + end + + provider_id = identity['aud'] || cb_error("Keycloak: No identity provider") + provider_name = identity['azp'] || cb_error("Keycloak: No identity provider name") + pref_username = identity['preferred_username'] || + identity['name'] || cb_error("Keycloak: No preferred username") + + # Special case for ORCID, because we already have fields for that provider + # We do NOT do this in the case where the user is forced to auth with keycloak. + if provider_name == 'ORCID' && ! user_must_link_to_keycloak?(user) + orcid = pref_username.sub(/@.*/, "") + user.meta['orcid'] = orcid + user.addlog("Linked to ORCID identity: '#{orcid}' through Keycloak") + return + end + + user.meta[:keycloak_provider_id] = provider_id + user.meta[:keycloak_provider_name] = provider_name # used in show page + user.meta[:keycloak_preferred_username] = pref_username + user.addlog("Linked to Keycloak identity: '#{pref_username}' on provider '#{provider_name}'") + end + + # Removes the recorded keycloak identity for +user+ + def unlink_keycloak_identity(user) + user.meta[:keycloak_provider_id] = nil + user.meta[:keycloak_provider_name] = nil + user.meta[:keycloak_preferred_username] = nil + user.addlog("Unlinked Keycloak identity") + end + + def set_of_identities(keycloak_identity) + keycloak_identity['identity_set'] || [ keycloak_identity ] + end + + def set_of_identity_provider_names(keycloak_identity) + set_of_identities(keycloak_identity).map { |s| s['azp'] } + end + + # Returns an array of allowed identity provider names. + # Returns nil if they are all allowed + def allowed_keycloak_provider_names(user) + user.meta[:allowed_keycloak_provider_names] + .presence + &.split(/\s*,\s*/) + &.map(&:strip) + end + + def user_can_link_to_keycloak_identity?(user, identity) + allowed = allowed_keycloak_provider_names(user) + return true if allowed.nil? + return true if allowed.size == 1 && allowed[0] == '*' + prov_names = set_of_identity_provider_names(identity) + return true if (allowed & prov_names).present? # if the intersection is not empty + false + end + + def user_has_link_to_keycloak?(user) + user.meta[:keycloak_provider_id].present? && + user.meta[:keycloak_provider_name].present? && + user.meta[:keycloak_preferred_username].present? + end + + def user_must_link_to_keycloak?(user) + user.meta[:allowed_keycloak_provider_names].present? + end + + + def wipe_user_password_after_keycloak_link(user) + user.update_attribute(:crypted_password, 'Wiped-By-Keycloak-Link-' + User.random_string) + user.update_attribute(:salt , 'Wiped-By-Keycloak-Link-' + User.random_string) + user.update_attribute(:password_reset , false) + end + + # Given a keycloak identity structure, find the user that matches it. + # Returns the user object if found; returns a string error message otherwise. + def find_user_with_keycloak_identity(keycloak_identity) + + provider_name = keycloak_identity['azp'] + pref_username = keycloak_identity['preferred_username'] || keycloak_identity['name'] + + id_set = set_of_identities(keycloak_identity) # a keycloak record can contain several identities + + # For each present identity, find all users that have it. + # We only allow ONE cbrain user to link to any of the identities. + users = id_set.inject([]) do |ulist, subident| + ulist |= find_users_with_specific_identity(subident) + end + + if users.size == 0 + Rails.logger.error "KEYCLOAK warning: no CBRAIN accounts found for identity '#{pref_username}' on provider '#{provider_name}'" + return "No CBRAIN user matches your Keycloak identity. Create a CBRAIN account or link your existing CBRAIN account to your Keycloak provider." + end + + if users.size > 1 + loginnames = users.map(&:login).join(", ") + Rails.logger.error "KEYCLOAK error: multiple CBRAIN accounts (#{loginnames}) found for identity '#{pref_username}' on provider '#{provider_name}'" + return "Several CBRAIN user accounts match your Keycloak identity. Please contact the CBRAIN admins." + end + + # The one lucky user + return users.first + end + + # Returns an array of all users that have linked their + # account to the +identity+ provider. The array can + # be empty (no such users) or contain more than one + # user (an account management error). + def find_users_with_specific_identity(identity) + provider_id = identity['aud'] || cb_error("Keycloak: No identity provider") + provider_name = identity['azp'] || cb_error("Keycloak: No identity provider name") + pref_username = identity['preferred_username'] || + identity['name'] || cb_error("Keycloak: No preferred username") + + # Special case for ORCID, because we already have fields for that provider + if provider_name == 'ORCID' + orcid = pref_username.sub(/@.*/, "") + users = User.find_all_by_meta_data(:orcid, orcid).to_a + return users if users.present? + # otherwise we fall through to detect users who linked with ORCID through Keycloak + end + + # All other keycloak providers + # We need a user which match both the preferred username and provider_id + users = User.find_all_by_meta_data(:keycloak_preferred_username, pref_username) + .to_a + .select { |user| user.meta[:keycloak_provider_id] == provider_id } + end + +end \ No newline at end of file From b5ce2882899ebba3db7bc2b8c35ce052131c1c20 Mon Sep 17 00:00:00 2001 From: Natacha Beck Date: Fri, 24 May 2024 22:58:04 +0000 Subject: [PATCH 2/6] [ADD] keycloak authentication --- .../app/controllers/sessions_controller.rb | 95 +------- .../app/controllers/users_controller.rb | 7 +- BrainPortal/app/views/sessions/new.html.erb | 4 +- BrainPortal/app/views/users/show.html.erb | 51 ++--- BrainPortal/lib/globus_helpers.rb | 85 +++---- ...loak_helpers.rb => keycloak_helpers.rb.bk} | 208 +++++++++--------- 6 files changed, 173 insertions(+), 277 deletions(-) rename BrainPortal/lib/{keycloak_helpers.rb => keycloak_helpers.rb.bk} (60%) diff --git a/BrainPortal/app/controllers/sessions_controller.rb b/BrainPortal/app/controllers/sessions_controller.rb index ae1fe9dc4..a12442ec1 100644 --- a/BrainPortal/app/controllers/sessions_controller.rb +++ b/BrainPortal/app/controllers/sessions_controller.rb @@ -44,9 +44,9 @@ def new #:nodoc: @browser_name = ua.browser_name || "(unknown browser name)" @browser_version = ua.browser_version || "(unknown browser version)" - @globus_uri = globus_login_uri(globus_url) # can be nil - @keycloak_uri = keycloak_login_uri(keycloak_url) # can be nil - + @globus_uri = globus_login_uri(globus_url) # can be nil + @oidc_client = (RemoteResource.current_resource.meta[:oidc_client] || "Globus").capitalize + respond_to do |format| format.html format.any { head :unauthorized } @@ -206,79 +206,6 @@ def unlink_globus #:nodoc: redirect_to user_path(current_user) end - # This action receives a JSON authentication - # request from keycloak and uses it to record or verify - # a user's identity. - def keycloak - code = params[:code].presence.try(:strip) - state = params[:state].presence || 'wrong' - - # Some initial simple validations - if !code || state != keycloak_current_state() - cb_error "Keycloak session is out of sync with CBRAIN" - end - - # Query Keycloak; this returns all the info we need at the same time. - identity_struct = keycloak_fetch_token(code, keycloak_url) # keycloak_url is generated from routes - if !identity_struct - cb_error "Could not fetch your identity information from Keycloak" - end - Rails.logger.info "Keycloak identity struct:\n#{identity_struct.pretty_inspect.strip}" - - # Either record the identity... - if current_user - if ! user_can_link_to_keycloak_identity?(current_user, identity_struct) - Rails.logger.error("User #{current_user.login} attempted authentication " + - "with unallowed Keycloak identity provider " + - identity_struct['identity_provider_display_name'].to_s) - flash[:error] = "Error: your account can only authenticate with the following Keycloak providers: " + - "#{allowed_keycloak_provider_names(current_user).join(", ")}" - redirect_to user_path(current_user) - return - end - record_keycloak_identity(current_user, identity_struct) - flash[:notice] = "Your CBRAIN account is now linked to your Keycloak identity." - if user_must_link_to_keycloak?(current_user) - wipe_user_password_after_keycloak_link(current_user) - flash[:notice] += "\nImportant note: from now on you can no longer connect to CBRAIN using a password." - redirect_to start_page_path - return - end - redirect_to user_path(current_user) - return - end - - # ...or attempt login with it - user = find_user_with_keycloak_identity(identity_struct) - if user.is_a?(String) # an error occurred - flash[:error] = user # the message - redirect_to new_session_path - return - end - - login_from_keycloak_user(user, identity_struct['azp']) - - rescue CbrainException => ex - flash[:error] = "#{ex.message}" - redirect_to new_session_path - rescue => ex - clean_bt = Rails.backtrace_cleaner.clean(ex.backtrace || []) - Rails.logger.error "Keycloak auth failed: #{ex.class} #{ex.message} at #{clean_bt[0]}" - flash[:error] = 'The Keycloak authentication failed' - redirect_to new_session_path - end - - # POST /unlink_keycloak - # Removes a user's linked keycloak identity. - def unlink_keycloak #:nodoc: - redirect_to start_page_path unless current_user - - unlink_keycloak_identity(current_user) - - flash[:notice] = "Your account is no longer linked to any Keycloak identity" - redirect_to user_path(current_user) - end - ############################################### # # Private methods @@ -430,22 +357,6 @@ def login_from_globus_user(user, provider_name) redirect_to start_page_path end - def login_from_keycloak_user(user, provider_name) - # Login the user - all_ok = create_from_user(user, "CBRAIN/Keycloak/#{provider_name}") - - if ! all_ok - redirect_to new_session_path - return - end - - # Record that the user connected using the CBRAIN login page - cbrain_session[:login_page] = 'CBRAIN' - - # All's good - redirect_to start_page_path - end - # ------------------------------------ # SessionInfo fake model for API calls # ------------------------------------ diff --git a/BrainPortal/app/controllers/users_controller.rb b/BrainPortal/app/controllers/users_controller.rb index e260645e6..d62d158f8 100644 --- a/BrainPortal/app/controllers/users_controller.rb +++ b/BrainPortal/app/controllers/users_controller.rb @@ -28,7 +28,6 @@ class UsersController < ApplicationController Revision_info=CbrainFileRevision[__FILE__] #:nodoc: include GlobusHelpers - include KeycloakHelpers api_available :only => [ :index, :create, :show, :destroy, :update, :create_user_session, :push_keys] @@ -76,7 +75,8 @@ def index #:nodoc: # GET /user/1.xml # GET /user/1.json def show #:nodoc: - @user = User.find(params[:id]) + @user = User.find(params[:id]) + @oidc_client = (RemoteResource.current_resource.meta[:oidc_client] || "Globus").capitalize cb_error "You don't have permission to view this user.", :redirect => start_page_path unless edit_permission?(@user) @@ -92,8 +92,7 @@ def show #:nodoc: .where( "updated_at > ?", SessionHelpers::SESSION_API_TOKEN_VALIDITY.ago ) .order(:updated_at) - @globus_uri = globus_login_uri(globus_url) - @keycloak_uri = keycloak_login_uri(keycloak_url) + @globus_uri = globus_login_uri(globus_url) respond_to do |format| format.html # show.html.erb diff --git a/BrainPortal/app/views/sessions/new.html.erb b/BrainPortal/app/views/sessions/new.html.erb index 5bdc2398e..870e51d36 100644 --- a/BrainPortal/app/views/sessions/new.html.erb +++ b/BrainPortal/app/views/sessions/new.html.erb @@ -79,10 +79,10 @@ OR - <%= link_to 'Sign In With Globus', @globus_uri, :class => 'button globus_button' %> + <%= link_to "Sign In With #{@oidc_client}", @globus_uri, :class => 'button globus_button' %>
(Only available if you have already linked your
- CBRAIN account to a Globus identity) + CBRAIN account to a <%= @oidc_client %> identity)
diff --git a/BrainPortal/app/views/users/show.html.erb b/BrainPortal/app/views/users/show.html.erb index b32233983..7da7138f8 100644 --- a/BrainPortal/app/views/users/show.html.erb +++ b/BrainPortal/app/views/users/show.html.erb @@ -118,10 +118,10 @@
<% end %> - <% t.edit_cell('meta[allowed_globus_provider_names]', :header => 'Forced Globus Identity Providers', :content => @user.meta['allowed_globus_provider_names'] || '') do %> + <% t.edit_cell('meta[allowed_globus_provider_names]', :header => "Forced #{@oidc_client} Identity Providers", :content => @user.meta['allowed_globus_provider_names'] || '') do %> <%= text_field_tag "meta[allowed_globus_provider_names]", @user.meta['allowed_globus_provider_names'], :size => 40 %>
- If set, must be a list of Globus identity provider names separated by commas. A single '*' is + If set, must be a list of <%= @oidc_client %> identity provider names separated by commas. A single '*' is also allowed to mean any provider name.
<% end %> @@ -165,59 +165,34 @@ <%= show_table(@user, :as => :user, :header => 'Linked Identities') do |t| %> <% - globus_prov_id = @user.meta[:globus_provider_id] - globus_prov_name = @user.meta[:globus_provider_name] - globus_prov_user = @user.meta[:globus_preferred_username] - keycloak_prov_id = @user.meta[:keycloak_provider_id] - keycloak_prov_name = @user.meta[:keycloak_provider_name] - keycloak_prov_user = @user.meta[:keycloak_preferred_username] + prov_id = @user.meta[:globus_provider_id] + prov_name = @user.meta[:globus_provider_name] + prov_user = @user.meta[:globus_preferred_username] orcid_id = @user.meta[:orcid] %> <% if @globus_uri %> - <% t.cell "Globus Provider", :show_width => 2 do %> - <% if globus_prov_id %> - Provider name: <%= globus_prov_name %>
- Provider user: <%= globus_prov_user %>
+ <% t.cell "#{@oidc_client} Provider", :show_width => 2 do %> + <% if prov_id %> + Provider name: <%= prov_name %>
+ Provider user: <%= prov_user %>
<% if @user.id == current_user.id # show button on user own page, hide on other users pages %> - <%= link_to('Unlink this Globus identity', unlink_globus_path, + <%= link_to("Unlink this #{@oidc_client} identity", unlink_globus_path, :class => "button", :method => :post, - :data => { :confirm => "Are you sure you want to unlink your account with this Globus identity?" } + :data => { :confirm => "Are you sure you want to unlink your account with this #{@oidc_client} identity?" } ) %> <% end %> <% else %> - (No Globus identity linked to your account)
+ (No <%= @oidc_client %> identity linked to your account)
<% if @user.id == current_user.id # Globus button works only on user own account %> - <%= link_to 'Link a Globus identity', @globus_uri, :class => 'button globus_button' %> + <%= link_to "Link a #{@oidc_client} identity", @globus_uri, :class => 'button globus_button' %> <% end %> <% end %> <% end %> <% end %> - <% if @keycloak_uri %> - <% t.cell "Keycloak Provider", :show_width => 2 do %> - <% if keycloak_prov_id %> - Provider name: <%= keycloak_prov_name %>
- Provider user: <%= keycloak_prov_user %>
- <% if @user.id == current_user.id # show button on user own page, hide on other users pages %> - <%= link_to('Unlink this Keycloak identity', unlink_keycloak_path, - :class => "button", - :method => :post, - :data => { :confirm => "Are you sure you want to unlink your account with this Keycloak identity?" } - ) - %> - <% end %> - <% else %> - (No Keycloak identity linked to your account)
- <% if @user.id == current_user.id # Keycloak button works only on user own account %> - <%= link_to 'Link a Keycloak identity', @keycloak_uri, :class => 'button keycloak_button' %> - <% end %> - <% end %> - <% end %> - - <% # Note: should be in a if/end block testing for the configuration of orcid_uri, like in NeuroHub %> diff --git a/BrainPortal/lib/globus_helpers.rb b/BrainPortal/lib/globus_helpers.rb index ca159df93..6632e81ba 100644 --- a/BrainPortal/lib/globus_helpers.rb +++ b/BrainPortal/lib/globus_helpers.rb @@ -25,25 +25,36 @@ module GlobusHelpers Revision_info=CbrainFileRevision[__FILE__] #:nodoc: - # GLOBUS authentication URL constants + # OIDC authentication URL constants # Maybe should be made configurable. - GLOBUS_AUTHORIZE_URI = "https://auth.globus.org/v2/oauth2/authorize" # will be issued a GET with params - GLOBUS_TOKEN_URI = "https://auth.globus.org/v2/oauth2/token" # will be issued a POST with a single code - GLOBUS_LOGOUT_URI = "https://auth.globus.org/v2/web/logout" # for pages that provide this link - # Returns the URI to send users to the GLOBUS authentication page. + # will be issued a GET with params + GLOBUS_AUTHORIZE_URI = RemoteResource.current_resource.meta[:oidc_authorize_uri] || "https://auth.globus.org/v2/oauth2/authorize" + # will be issued a POST with a single code + GLOBUS_TOKEN_URI = RemoteResource.current_resource.meta[:oidc_token_uri] || "https://auth.globus.org/v2/oauth2/token" + # for pages that provide this link + GLOBUS_LOGOUT_URI = RemoteResource.current_resource.meta[:oidc_logout_uri] || "https://auth.globus.org/v2/web/logout" + + + # Define OIDC scope + $scope,$oidc_name = !RemoteResource.current_resource.meta[:oidc_client].casecmp?("keycloak") ? + ["urn:globus:auth:scope:auth.globus.org:view_identities openid email profile", "Globus"] : + ["openid email profile", "Keycloak"] + + + # Returns the URI to send users to the OIDC authentication page. # The parameter globus_action_url should be the URL to the controller # action here in CBRAIN that will received the POST response. def globus_login_uri(globus_action_url) return nil if api_request? return nil unless globus_auth_configured? - # Create the URI to authenticate with GLOBUS + # Create the URI to authenticate with OIDC globus_params = { :client_id => globus_client_id, :response_type => 'code', - :scope => "urn:globus:auth:scope:auth.globus.org:view_identities openid email profile", - :redirect_uri => globus_action_url, # generated from Rails routes + :scope => $scope, + :redirect_uri => globus_action_url, # generated from Rails routes :state => globus_current_state, # method is below } GLOBUS_AUTHORIZE_URI + '?' + globus_params.to_query @@ -54,9 +65,9 @@ def globus_logout_uri end def globus_fetch_token(code, globus_action_url) - # Query Globus; this returns all the info we need at the same time. + # Query OIDC; this returns all the info we need at the same time. auth_header = globus_basic_auth_header # method is below - response = Typhoeus.post(GLOBUS_TOKEN_URI, + response = Typhoeus.post(GLOBUS_TOKEN_URI, :body => { :code => code, :redirect_uri => globus_action_url, @@ -75,7 +86,7 @@ def globus_fetch_token(code, globus_action_url) return identity_struct rescue => ex - Rails.logger.error "GLOBUS token request failed: #{ex.class} #{ex.message}" + Rails.logger.error "#{$oidc_name} token request failed: #{ex.class} #{ex.message}" return nil end @@ -90,14 +101,14 @@ def globus_basic_auth_header end # Returns a string that should stay constants during the entire - # globus negotiations. The current Rails session_id, encoded, will do + # OIDC negotiations. The current Rails session_id, encoded, will do # the trick. The Rails session is maintained by a cookie already # created and maintained, at this point. def globus_current_state Digest::MD5.hexdigest( request.session_options[:id] ) end - # Return the registered globus endpoint client ID. + # Return the registered OIDC endpoint client ID. # This value must be configured by the CBRAIN admin # in the meta data of the portal. Returns nil if unset. def globus_client_id @@ -105,7 +116,7 @@ def globus_client_id myself.meta[:globus_client_id].presence.try(:strip) end - # Return the registered globus endpoint client secret. + # Return the registered OIDC endpoint client secret. # This value must be configured by the CBRAIN admin # in the meta data of the portal. Returns nil if unset. def globus_client_secret @@ -114,7 +125,7 @@ def globus_client_secret end # Returns true if the CBRAIN system is configured for - # globus auth. + # OIDC auth. def globus_auth_configured? myself = RemoteResource.current_resource site_uri = myself.site_url_prefix.presence @@ -125,43 +136,43 @@ def globus_auth_configured? true end - # Record the globus identity for the current user. + # Record the OIDC identity for the current user. # (This maybe should be made into a User model method) def record_globus_identity(user, globus_identity) # In the case where a user must auth with a specific set of - # globus providers, we find the first identity that + # OIDC providers, we find the first identity that # matches a name of that set. identity = set_of_identities(globus_identity).detect do |idstruct| user_can_link_to_globus_identity?(user, idstruct) end - provider_id = identity['identity_provider'] || cb_error("Globus: No identity provider") - provider_name = identity['identity_provider_display_name'] || cb_error("Globus: No identity provider name") + provider_id = identity['identity_provider'] || identity['aud'] || cb_error("#{$oidc_name}: No identity provider") + provider_name = identity['identity_provider_display_name'] || identity['azp'] || cb_error("#{$oidc_name}: No identity provider name") pref_username = identity['preferred_username'] || - identity['username'] || cb_error("Globus: No preferred username") + identity['username'] || cb_error("#{$oidc_name}: No preferred username") # Special case for ORCID, because we already have fields for that provider - # We do NOT do this in the case where the user is forced to auth with globus. + # We do NOT do this in the case where the user is forced to auth with OIDC. if provider_name == 'ORCID' && ! user_must_link_to_globus?(user) orcid = pref_username.sub(/@.*/, "") user.meta['orcid'] = orcid - user.addlog("Linked to ORCID identity: '#{orcid}' through Globus") + user.addlog("Linked to ORCID identity: '#{orcid}' through #{$oidc_name}") return end user.meta[:globus_provider_id] = provider_id user.meta[:globus_provider_name] = provider_name # used in show page user.meta[:globus_preferred_username] = pref_username - user.addlog("Linked to Globus identity: '#{pref_username}' on provider '#{provider_name}'") + user.addlog("Linked to #{$oidc_name} identity: '#{pref_username}' on provider '#{provider_name}'") end - # Removes the recorded globus identity for +user+ + # Removes the recorded OIDC identity for +user+ def unlink_globus_identity(user) user.meta[:globus_provider_id] = nil user.meta[:globus_provider_name] = nil user.meta[:globus_preferred_username] = nil - user.addlog("Unlinked Globus identity") + user.addlog("Unlinked #{$oidc_name} identity") end def set_of_identities(globus_identity) @@ -201,19 +212,19 @@ def user_must_link_to_globus?(user) end def wipe_user_password_after_globus_link(user) - user.update_attribute(:crypted_password, 'Wiped-By-Globus-Link-' + User.random_string) - user.update_attribute(:salt , 'Wiped-By-Globus-Link-' + User.random_string) + user.update_attribute(:crypted_password, "Wiped-By-#{$oidc_name}-Link-" + User.random_string) + user.update_attribute(:salt , "Wiped-By-#{$oidc_name}-Link-" + User.random_string) user.update_attribute(:password_reset , false) end - # Given a globus identity structure, find the user that matches it. + # Given a OIDC identity structure, find the user that matches it. # Returns the user object if found; returns a string error message otherwise. def find_user_with_globus_identity(globus_identity) provider_name = globus_identity['identity_provider_display_name'] pref_username = globus_identity['preferred_username'] || globus_identity['username'] - id_set = set_of_identities(globus_identity) # a globus record can contain several identities + id_set = set_of_identities(globus_identity) # an OIDC record can contain several identities # For each present identity, find all users that have it. # We only allow ONE cbrain user to link to any of the identities. @@ -222,14 +233,14 @@ def find_user_with_globus_identity(globus_identity) end if users.size == 0 - Rails.logger.error "GLOBUS warning: no CBRAIN accounts found for identity '#{pref_username}' on provider '#{provider_name}'" - return "No CBRAIN user matches your Globus identity. Create a CBRAIN account or link your existing CBRAIN account to your Globus provider." + Rails.logger.error "#{$oidc_name.upcase} warning: no CBRAIN accounts found for identity '#{pref_username}' on provider '#{provider_name}'" + return "No CBRAIN user matches your #{$oidc_name} identity. Create a CBRAIN account or link your existing CBRAIN account to your #{$oidc_name} provider." end if users.size > 1 loginnames = users.map(&:login).join(", ") - Rails.logger.error "GLOBUS error: multiple CBRAIN accounts (#{loginnames}) found for identity '#{pref_username}' on provider '#{provider_name}'" - return "Several CBRAIN user accounts match your Globus identity. Please contact the CBRAIN admins." + Rails.logger.error "#{$oidc_name.upcase} error: multiple CBRAIN accounts (#{loginnames}) found for identity '#{pref_username}' on provider '#{provider_name}'" + return "Several CBRAIN user accounts match your #{$oidc_name} identity. Please contact the CBRAIN admins." end # The one lucky user @@ -241,17 +252,17 @@ def find_user_with_globus_identity(globus_identity) # be empty (no such users) or contain more than one # user (an account management error). def find_users_with_specific_identity(identity) - provider_id = identity['identity_provider'] || cb_error("Globus: No identity provider") - provider_name = identity['identity_provider_display_name'] || cb_error("Globus: No identity provider name") + provider_id = identity['identity_provider'] || identity['aud'] || cb_error("#{$oidc_name}: No identity provider") + provider_name = identity['identity_provider_display_name'] || identity['azp'] || cb_error("#{$oidc_name}: No identity provider name") pref_username = identity['preferred_username'] || - identity['username'] || cb_error("Globus: No preferred username") + identity['username'] || cb_error("#{$oidc_name}: No preferred username") # Special case for ORCID, because we already have fields for that provider if provider_name == 'ORCID' orcid = pref_username.sub(/@.*/, "") users = User.find_all_by_meta_data(:orcid, orcid).to_a return users if users.present? - # otherwise we fall through to detect users who linked with ORCID through Globus + # otherwise we fall through to detect users who linked with ORCID through OIDC end # All other globus providers diff --git a/BrainPortal/lib/keycloak_helpers.rb b/BrainPortal/lib/keycloak_helpers.rb.bk similarity index 60% rename from BrainPortal/lib/keycloak_helpers.rb rename to BrainPortal/lib/keycloak_helpers.rb.bk index 10fc116e3..64681984e 100644 --- a/BrainPortal/lib/keycloak_helpers.rb +++ b/BrainPortal/lib/keycloak_helpers.rb.bk @@ -23,110 +23,110 @@ # Helper for logging in using Keycloak identity stuff module KeycloakHelpers - Revision_info=CbrainFileRevision[__FILE__] #:nodoc: - - # KEYCLOAK authentication URL constants - # Maybe should be made configurable. - KEYCLOAK_AUTHORIZE_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/auth" # will be issued a GET with params - KEYCLOAK_TOKEN_URI = "keycloak:8080/realms/lasso-realm/protocol/openid-connect/token" # will be issued a POST with a single code - KEYCLOAK_LOGOUT_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/logout" # for pages that provide this link - - # Returns the URI to send users to the KEYCLOAK authentication page. - # The parameter keycloak_action_url should be the URL to the controller - # action here in CBRAIN that will received the POST response. - def keycloak_login_uri(keycloak_action_url) - return nil if api_request? - return nil unless keycloak_auth_configured? - - # Create the URI to authenticate with KEYCLOAK - keycloak_params = { - :client_id => keycloak_client_id, - :response_type => 'code', - :scope => "openid email profile", - :redirect_uri => keycloak_action_url, # generated from Rails routes - :state => keycloak_current_state, # method is below - } - KEYCLOAK_AUTHORIZE_URI + '?' + keycloak_params.to_query - end + Revision_info=CbrainFileRevision[__FILE__] #:nodoc: + + # KEYCLOAK authentication URL constants + # Maybe should be made configurable. + KEYCLOAK_AUTHORIZE_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/auth" # will be issued a GET with params + KEYCLOAK_TOKEN_URI = "keycloak:8080/realms/lasso-realm/protocol/openid-connect/token" # will be issued a POST with a single code + KEYCLOAK_LOGOUT_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/logout" # for pages that provide this link + + # Returns the URI to send users to the KEYCLOAK authentication page. + # The parameter keycloak_action_url should be the URL to the controller + # action here in CBRAIN that will received the POST response. + def keycloak_login_uri(keycloak_action_url) + return nil if api_request? + return nil unless keycloak_auth_configured? + + # Create the URI to authenticate with KEYCLOAK + keycloak_params = { + :client_id => keycloak_client_id, + :response_type => 'code', + :scope => "openid email profile", + :redirect_uri => keycloak_action_url, # generated from Rails routes + :state => keycloak_current_state, # method is below + } + KEYCLOAK_AUTHORIZE_URI + '?' + keycloak_params.to_query + end - def keycloak_logout_uri - KEYCLOAK_LOGOUT_URI - end + def keycloak_logout_uri + KEYCLOAK_LOGOUT_URI + end + + def keycloak_fetch_token(code, keycloak_action_url) + # Query Keycloak; this returns all the info we need at the same time. + auth_header = keycloak_basic_auth_header # method is below + + response = Typhoeus.post(KEYCLOAK_TOKEN_URI, + :body => { + :code => code, + :redirect_uri => keycloak_action_url, + :grant_type => 'authorization_code', + }, + :headers => { :Accept => 'application/json', + :Authorization => auth_header, + } + ) + + # Parse the response + body = response.response_body + json = JSON.parse(body) + jwt_id_token = json["id_token"] + identity_struct, _ = JWT.decode(jwt_id_token, nil, false) + + return identity_struct + rescue => ex + Rails.logger.error "KEYCLOAK token request failed: #{ex.class} #{ex.message}" + return nil + end + + # Returns the value for the Authorization header + # when doing the client authentication. + # + # "Basic 1745djfuebwifh37236djdf74.etc.etc" + def keycloak_basic_auth_header + client_id = keycloak_client_id + client_secret = keycloak_client_secret + "Basic " + Base64.strict_encode64("#{client_id}:#{client_secret}") + end + + # Returns a string that should stay constants during the entire + # keycloak negotiations. The current Rails session_id, encoded, will do + # the trick. The Rails session is maintained by a cookie already + # created and maintained, at this point. + def keycloak_current_state + Digest::MD5.hexdigest( request.session_options[:id] ) + end + + # Return the registered keycloak endpoint client ID. + # This value must be configured by the CBRAIN admin + # in the meta data of the portal. Returns nil if unset. + def keycloak_client_id + myself = RemoteResource.current_resource + myself.meta[:keycloak_client_id].presence.try(:strip) + end + + # Return the registered keycloak endpoint client secret. + # This value must be configured by the CBRAIN admin + # in the meta data of the portal. Returns nil if unset. + def keycloak_client_secret + myself = RemoteResource.current_resource + myself.meta[:keycloak_client_secret].presence.try(:strip) + end - def keycloak_fetch_token(code, keycloak_action_url) - # Query Keycloak; this returns all the info we need at the same time. - auth_header = keycloak_basic_auth_header # method is below - - response = Typhoeus.post(KEYCLOAK_TOKEN_URI, - :body => { - :code => code, - :redirect_uri => keycloak_action_url, - :grant_type => 'authorization_code', - }, - :headers => { :Accept => 'application/json', - :Authorization => auth_header, - } - ) - - # Parse the response - body = response.response_body - json = JSON.parse(body) - jwt_id_token = json["id_token"] - identity_struct, _ = JWT.decode(jwt_id_token, nil, false) - - return identity_struct - rescue => ex - Rails.logger.error "KEYCLOAK token request failed: #{ex.class} #{ex.message}" - return nil - end - - # Returns the value for the Authorization header - # when doing the client authentication. - # - # "Basic 1745djfuebwifh37236djdf74.etc.etc" - def keycloak_basic_auth_header - client_id = keycloak_client_id - client_secret = keycloak_client_secret - "Basic " + Base64.strict_encode64("#{client_id}:#{client_secret}") - end - - # Returns a string that should stay constants during the entire - # keycloak negotiations. The current Rails session_id, encoded, will do - # the trick. The Rails session is maintained by a cookie already - # created and maintained, at this point. - def keycloak_current_state - Digest::MD5.hexdigest( request.session_options[:id] ) - end - - # Return the registered keycloak endpoint client ID. - # This value must be configured by the CBRAIN admin - # in the meta data of the portal. Returns nil if unset. - def keycloak_client_id - myself = RemoteResource.current_resource - myself.meta[:keycloak_client_id].presence.try(:strip) - end - - # Return the registered keycloak endpoint client secret. - # This value must be configured by the CBRAIN admin - # in the meta data of the portal. Returns nil if unset. - def keycloak_client_secret - myself = RemoteResource.current_resource - myself.meta[:keycloak_client_secret].presence.try(:strip) - end - - # Returns true if the CBRAIN system is configured for - # keycloak auth. - def keycloak_auth_configured? - myself = RemoteResource.current_resource - site_uri = myself.site_url_prefix.presence - # Three conditions: site uri, client ID, client secret. - return false if ! site_uri - return false if ! keycloak_client_id - return false if ! keycloak_client_secret - true - end - - # Record the keycloak identity for the current user. + # Returns true if the CBRAIN system is configured for + # keycloak auth. + def keycloak_auth_configured? + myself = RemoteResource.current_resource + site_uri = myself.site_url_prefix.presence + # Three conditions: site uri, client ID, client secret. + return false if ! site_uri + return false if ! keycloak_client_id + return false if ! keycloak_client_secret + true + end + + # Record the keycloak identity for the current user. # (This maybe should be made into a User model method) def record_keycloak_identity(user, keycloak_identity) @@ -201,7 +201,6 @@ def user_must_link_to_keycloak?(user) user.meta[:allowed_keycloak_provider_names].present? end - def wipe_user_password_after_keycloak_link(user) user.update_attribute(:crypted_password, 'Wiped-By-Keycloak-Link-' + User.random_string) user.update_attribute(:salt , 'Wiped-By-Keycloak-Link-' + User.random_string) @@ -263,4 +262,5 @@ def find_users_with_specific_identity(identity) .select { |user| user.meta[:keycloak_provider_id] == provider_id } end -end \ No newline at end of file +end + From 36a299b0b863230cf3c1e13c4872429f3f566bf1 Mon Sep 17 00:00:00 2001 From: Natacha Beck Date: Fri, 24 May 2024 22:58:57 +0000 Subject: [PATCH 3/6] [RM] Backup file --- BrainPortal/lib/keycloak_helpers.rb.bk | 266 ------------------------- 1 file changed, 266 deletions(-) delete mode 100644 BrainPortal/lib/keycloak_helpers.rb.bk diff --git a/BrainPortal/lib/keycloak_helpers.rb.bk b/BrainPortal/lib/keycloak_helpers.rb.bk deleted file mode 100644 index 64681984e..000000000 --- a/BrainPortal/lib/keycloak_helpers.rb.bk +++ /dev/null @@ -1,266 +0,0 @@ - -# -# NeuroHub Project -# -# Copyright (C) 2021 -# The Royal Institution for the Advancement of Learning -# McGill University -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# - -# Helper for logging in using Keycloak identity stuff -module KeycloakHelpers - - Revision_info=CbrainFileRevision[__FILE__] #:nodoc: - - # KEYCLOAK authentication URL constants - # Maybe should be made configurable. - KEYCLOAK_AUTHORIZE_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/auth" # will be issued a GET with params - KEYCLOAK_TOKEN_URI = "keycloak:8080/realms/lasso-realm/protocol/openid-connect/token" # will be issued a POST with a single code - KEYCLOAK_LOGOUT_URI = "http://localhost:8080/realms/lasso-realm/protocol/openid-connect/logout" # for pages that provide this link - - # Returns the URI to send users to the KEYCLOAK authentication page. - # The parameter keycloak_action_url should be the URL to the controller - # action here in CBRAIN that will received the POST response. - def keycloak_login_uri(keycloak_action_url) - return nil if api_request? - return nil unless keycloak_auth_configured? - - # Create the URI to authenticate with KEYCLOAK - keycloak_params = { - :client_id => keycloak_client_id, - :response_type => 'code', - :scope => "openid email profile", - :redirect_uri => keycloak_action_url, # generated from Rails routes - :state => keycloak_current_state, # method is below - } - KEYCLOAK_AUTHORIZE_URI + '?' + keycloak_params.to_query - end - - def keycloak_logout_uri - KEYCLOAK_LOGOUT_URI - end - - def keycloak_fetch_token(code, keycloak_action_url) - # Query Keycloak; this returns all the info we need at the same time. - auth_header = keycloak_basic_auth_header # method is below - - response = Typhoeus.post(KEYCLOAK_TOKEN_URI, - :body => { - :code => code, - :redirect_uri => keycloak_action_url, - :grant_type => 'authorization_code', - }, - :headers => { :Accept => 'application/json', - :Authorization => auth_header, - } - ) - - # Parse the response - body = response.response_body - json = JSON.parse(body) - jwt_id_token = json["id_token"] - identity_struct, _ = JWT.decode(jwt_id_token, nil, false) - - return identity_struct - rescue => ex - Rails.logger.error "KEYCLOAK token request failed: #{ex.class} #{ex.message}" - return nil - end - - # Returns the value for the Authorization header - # when doing the client authentication. - # - # "Basic 1745djfuebwifh37236djdf74.etc.etc" - def keycloak_basic_auth_header - client_id = keycloak_client_id - client_secret = keycloak_client_secret - "Basic " + Base64.strict_encode64("#{client_id}:#{client_secret}") - end - - # Returns a string that should stay constants during the entire - # keycloak negotiations. The current Rails session_id, encoded, will do - # the trick. The Rails session is maintained by a cookie already - # created and maintained, at this point. - def keycloak_current_state - Digest::MD5.hexdigest( request.session_options[:id] ) - end - - # Return the registered keycloak endpoint client ID. - # This value must be configured by the CBRAIN admin - # in the meta data of the portal. Returns nil if unset. - def keycloak_client_id - myself = RemoteResource.current_resource - myself.meta[:keycloak_client_id].presence.try(:strip) - end - - # Return the registered keycloak endpoint client secret. - # This value must be configured by the CBRAIN admin - # in the meta data of the portal. Returns nil if unset. - def keycloak_client_secret - myself = RemoteResource.current_resource - myself.meta[:keycloak_client_secret].presence.try(:strip) - end - - # Returns true if the CBRAIN system is configured for - # keycloak auth. - def keycloak_auth_configured? - myself = RemoteResource.current_resource - site_uri = myself.site_url_prefix.presence - # Three conditions: site uri, client ID, client secret. - return false if ! site_uri - return false if ! keycloak_client_id - return false if ! keycloak_client_secret - true - end - - # Record the keycloak identity for the current user. - # (This maybe should be made into a User model method) - def record_keycloak_identity(user, keycloak_identity) - - # In the case where a user must auth with a specific set of - # keycloak providers, we find the first identity that - # matches a name of that set. - identity = set_of_identities(keycloak_identity).detect do |idstruct| - user_can_link_to_keycloak_identity?(user, idstruct) - end - - provider_id = identity['aud'] || cb_error("Keycloak: No identity provider") - provider_name = identity['azp'] || cb_error("Keycloak: No identity provider name") - pref_username = identity['preferred_username'] || - identity['name'] || cb_error("Keycloak: No preferred username") - - # Special case for ORCID, because we already have fields for that provider - # We do NOT do this in the case where the user is forced to auth with keycloak. - if provider_name == 'ORCID' && ! user_must_link_to_keycloak?(user) - orcid = pref_username.sub(/@.*/, "") - user.meta['orcid'] = orcid - user.addlog("Linked to ORCID identity: '#{orcid}' through Keycloak") - return - end - - user.meta[:keycloak_provider_id] = provider_id - user.meta[:keycloak_provider_name] = provider_name # used in show page - user.meta[:keycloak_preferred_username] = pref_username - user.addlog("Linked to Keycloak identity: '#{pref_username}' on provider '#{provider_name}'") - end - - # Removes the recorded keycloak identity for +user+ - def unlink_keycloak_identity(user) - user.meta[:keycloak_provider_id] = nil - user.meta[:keycloak_provider_name] = nil - user.meta[:keycloak_preferred_username] = nil - user.addlog("Unlinked Keycloak identity") - end - - def set_of_identities(keycloak_identity) - keycloak_identity['identity_set'] || [ keycloak_identity ] - end - - def set_of_identity_provider_names(keycloak_identity) - set_of_identities(keycloak_identity).map { |s| s['azp'] } - end - - # Returns an array of allowed identity provider names. - # Returns nil if they are all allowed - def allowed_keycloak_provider_names(user) - user.meta[:allowed_keycloak_provider_names] - .presence - &.split(/\s*,\s*/) - &.map(&:strip) - end - - def user_can_link_to_keycloak_identity?(user, identity) - allowed = allowed_keycloak_provider_names(user) - return true if allowed.nil? - return true if allowed.size == 1 && allowed[0] == '*' - prov_names = set_of_identity_provider_names(identity) - return true if (allowed & prov_names).present? # if the intersection is not empty - false - end - - def user_has_link_to_keycloak?(user) - user.meta[:keycloak_provider_id].present? && - user.meta[:keycloak_provider_name].present? && - user.meta[:keycloak_preferred_username].present? - end - - def user_must_link_to_keycloak?(user) - user.meta[:allowed_keycloak_provider_names].present? - end - - def wipe_user_password_after_keycloak_link(user) - user.update_attribute(:crypted_password, 'Wiped-By-Keycloak-Link-' + User.random_string) - user.update_attribute(:salt , 'Wiped-By-Keycloak-Link-' + User.random_string) - user.update_attribute(:password_reset , false) - end - - # Given a keycloak identity structure, find the user that matches it. - # Returns the user object if found; returns a string error message otherwise. - def find_user_with_keycloak_identity(keycloak_identity) - - provider_name = keycloak_identity['azp'] - pref_username = keycloak_identity['preferred_username'] || keycloak_identity['name'] - - id_set = set_of_identities(keycloak_identity) # a keycloak record can contain several identities - - # For each present identity, find all users that have it. - # We only allow ONE cbrain user to link to any of the identities. - users = id_set.inject([]) do |ulist, subident| - ulist |= find_users_with_specific_identity(subident) - end - - if users.size == 0 - Rails.logger.error "KEYCLOAK warning: no CBRAIN accounts found for identity '#{pref_username}' on provider '#{provider_name}'" - return "No CBRAIN user matches your Keycloak identity. Create a CBRAIN account or link your existing CBRAIN account to your Keycloak provider." - end - - if users.size > 1 - loginnames = users.map(&:login).join(", ") - Rails.logger.error "KEYCLOAK error: multiple CBRAIN accounts (#{loginnames}) found for identity '#{pref_username}' on provider '#{provider_name}'" - return "Several CBRAIN user accounts match your Keycloak identity. Please contact the CBRAIN admins." - end - - # The one lucky user - return users.first - end - - # Returns an array of all users that have linked their - # account to the +identity+ provider. The array can - # be empty (no such users) or contain more than one - # user (an account management error). - def find_users_with_specific_identity(identity) - provider_id = identity['aud'] || cb_error("Keycloak: No identity provider") - provider_name = identity['azp'] || cb_error("Keycloak: No identity provider name") - pref_username = identity['preferred_username'] || - identity['name'] || cb_error("Keycloak: No preferred username") - - # Special case for ORCID, because we already have fields for that provider - if provider_name == 'ORCID' - orcid = pref_username.sub(/@.*/, "") - users = User.find_all_by_meta_data(:orcid, orcid).to_a - return users if users.present? - # otherwise we fall through to detect users who linked with ORCID through Keycloak - end - - # All other keycloak providers - # We need a user which match both the preferred username and provider_id - users = User.find_all_by_meta_data(:keycloak_preferred_username, pref_username) - .to_a - .select { |user| user.meta[:keycloak_provider_id] == provider_id } - end - -end - From dc13e70fc87093dd886e5e57cd11a44d5cb06e8c Mon Sep 17 00:00:00 2001 From: natacha-beck Date: Mon, 27 May 2024 05:30:10 -0400 Subject: [PATCH 4/6] [ADD] do cleanup --- BrainPortal/app/views/sessions/new.html.erb | 21 --------------------- BrainPortal/config/routes.rb | 3 --- 2 files changed, 24 deletions(-) diff --git a/BrainPortal/app/views/sessions/new.html.erb b/BrainPortal/app/views/sessions/new.html.erb index 870e51d36..2acab054b 100644 --- a/BrainPortal/app/views/sessions/new.html.erb +++ b/BrainPortal/app/views/sessions/new.html.erb @@ -89,27 +89,6 @@ <% end %> - <% if @keycloak_uri %> - - -   - - - - - OR - - - <%= link_to 'Sign In With Keycloak', @keycloak_uri, :class => 'button globus_button' %> -
- (Only available if you have already linked your
- CBRAIN account to a Keycloak identity) - - - - <% end %> - <% end -%> diff --git a/BrainPortal/config/routes.rb b/BrainPortal/config/routes.rb index 0320191b2..b4b8e34db 100644 --- a/BrainPortal/config/routes.rb +++ b/BrainPortal/config/routes.rb @@ -222,9 +222,6 @@ get '/globus' => 'sessions#globus' post '/unlink_globus' => 'sessions#unlink_globus' get '/mandatory_globus' => 'sessions#mandatory_globus' - # Keycloak authentication - get '/keycloak' => 'sessions#keycloak' - post '/unlink_keycloak' => 'sessions#unlink_keycloak' # Report Maker get "/report", :controller => :portal, :action => :report From 8a8c67f356cea6722b8da1b25de9930b39b568f9 Mon Sep 17 00:00:00 2001 From: natacha-beck Date: Mon, 27 May 2024 05:31:21 -0400 Subject: [PATCH 5/6] [ADD] do cleanup --- BrainPortal/app/controllers/application_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/BrainPortal/app/controllers/application_controller.rb b/BrainPortal/app/controllers/application_controller.rb index 8b879e00d..3876b169a 100644 --- a/BrainPortal/app/controllers/application_controller.rb +++ b/BrainPortal/app/controllers/application_controller.rb @@ -41,7 +41,6 @@ class ApplicationController < ActionController::Base include ExceptionHelpers include MessageHelpers include GlobusHelpers - include KeycloakHelpers helper_method :start_page_path From 5a0455d253df321d38cb2fcfccd6b88dbf11c872 Mon Sep 17 00:00:00 2001 From: natacha-beck Date: Mon, 27 May 2024 05:56:01 -0400 Subject: [PATCH 6/6] [ADD] do cleanup --- BrainPortal/lib/globus_helpers.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BrainPortal/lib/globus_helpers.rb b/BrainPortal/lib/globus_helpers.rb index 6632e81ba..c41f81f13 100644 --- a/BrainPortal/lib/globus_helpers.rb +++ b/BrainPortal/lib/globus_helpers.rb @@ -37,9 +37,9 @@ module GlobusHelpers # Define OIDC scope - $scope,$oidc_name = !RemoteResource.current_resource.meta[:oidc_client].casecmp?("keycloak") ? - ["urn:globus:auth:scope:auth.globus.org:view_identities openid email profile", "Globus"] : - ["openid email profile", "Keycloak"] + $scope,$oidc_name = (RemoteResource.current_resource.meta[:oidc_client] || "").casecmp?("keycloak") ? + ["openid email profile", "Keycloak"] : + ["urn:globus:auth:scope:auth.globus.org:view_identities openid email profile", "Globus"] # Returns the URI to send users to the OIDC authentication page.