From ac0c23613ea9db91f9004a401ab62cc7ed1fcb84 Mon Sep 17 00:00:00 2001 From: Jakub Jirutka Date: Sun, 5 Mar 2023 18:47:54 +0100 Subject: [PATCH] Add support for reauthentication against OIDC to activate sudo_mode Currently, if the sudo_mode is enabled and the user tries to perform any sensitive action (e.g. change application settings), they have to enter their *local* password. However, if the user is logged in via OIDC, they typically don't know their local password (it has been autogenerated on the first login via OIDC). This commit implements an opt-in feature that replaces this local password verification with reauthentication on the OIDC provider, i.e. even if the user is still logged in, they are forced to re-enter their password (or any other kind of authentication) on the OIDC provider. --- Gemfile | 1 + README.rdoc | 4 ++ app/controllers/oidc_controller.rb | 33 +++++++++- app/models/oidc_session.rb | 9 ++- app/views/settings/_redmine_oidc.html.erb | 5 ++ config/locales/de.yml | 1 + config/locales/en.yml | 1 + lib/redmine_oidc.rb | 1 + lib/redmine_oidc/settings.rb | 1 + .../sudo_mode_controller_patch.rb | 63 +++++++++++++++++++ 10 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 lib/redmine_oidc/sudo_mode_controller_patch.rb diff --git a/Gemfile b/Gemfile index 06aeb43..8ad17a6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ gem 'openid_connect', '~> 2.2' gem 'redis', '~> 4.2' gem 'hiredis', '~> 0.6.3' +gem 'repost', '~> 0.4.1' diff --git a/README.rdoc b/README.rdoc index 9c1f3e6..a408830 100644 --- a/README.rdoc +++ b/README.rdoc @@ -87,6 +87,10 @@ admin_role:: +realm_access.roles+. Example: ROLES/REDMINE/ADMIN +sudo_mode_reauthenticate:: + If +sudo_mode+ is enabled in Redmine, require users to reauthenticate for + sensitive actions against the OIDC provider instead of entering their local + password. == Mapping users diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index ee6b586..bf1f796 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -22,9 +22,12 @@ class OidcController < ApplicationController skip_before_action :check_if_login_required def login - if !User.current.logged? + if params[:reauth] || !User.current.logged? + # max_age 0 forces the OIDC provider to reauthenticate the user and add + # the auth_time claim to the response. redirect_to OidcSession.spawn(session).authorization_endpoint( - redirect_uri: oidc_callback_url(back_url: params[:back_url])) + redirect_uri: oidc_callback_url(back_url: params[:back_url]), + max_age: params[:reauth] ? 0 : nil) else redirect_to my_page_path end @@ -34,7 +37,14 @@ def callback oidc_session = OidcSession.spawn(session) oidc_session.update!(params) oidc_session.acquire! - oidc_session.authorized? ? login_user : lock_user + + if session.has_key?(:oidc_sudo_deferred) && User.current.logged? + perform_sudo_action + elsif oidc_session.authorized? + login_user + else + lock_user + end rescue Exception => exception logger.error "#{exception.class}: #{exception.message}" render 'callback', :status => :loop_detected @@ -110,4 +120,21 @@ def unsuccessful_login(user) end end + def perform_sudo_action + logger.info "Successful reauthentication for '#{User.current.login}' from #{request.remote_ip} at #{Time.now.utc}" + + back_url = validate_back_url(params[:back_url].to_s) + action = session.delete(:oidc_sudo_deferred) + + if back_url && action && action[:back_url] == params[:back_url] + if action[:options][:method] == :get + redirect_to(back_url) + else + repost(back_url, params: action[:params], options: action[:options]) + end + else + redirect_to(my_page_path) + end + end + end diff --git a/app/models/oidc_session.rb b/app/models/oidc_session.rb index 3c61cbc..d95a05a 100644 --- a/app/models/oidc_session.rb +++ b/app/models/oidc_session.rb @@ -35,13 +35,14 @@ def self.spawn(session) end end - def authorization_endpoint(redirect_uri:) + def authorization_endpoint(redirect_uri:, max_age: nil) @redirect_uri = redirect_uri save! oidc_client.authorization_uri( nonce: oidc_nonce, state: oidc_nonce, scope: oidc_scope, + max_age: max_age, ) end @@ -111,6 +112,12 @@ def id_token_expiration_timestamp decoded_id_token.raw_attributes['exp'] end + def auth_time + auth_time = decoded_id_token.raw_attributes['auth_time'] + raise Exception.new("No auth_time in id_token") unless auth_time + auth_time + end + def authorized? not access_roles.disjoint?(roles) end diff --git a/app/views/settings/_redmine_oidc.html.erb b/app/views/settings/_redmine_oidc.html.erb index c77d19f..5d7b1e3 100644 --- a/app/views/settings/_redmine_oidc.html.erb +++ b/app/views/settings/_redmine_oidc.html.erb @@ -48,4 +48,9 @@ <%= text_field_tag 'settings[session_check_users_csv]', oidc_settings.session_check_users_csv, size: 60, placeholder: 'emusterfrau,jdoe' %> BETA

+

+ <%= label_tag 'settings[sudo_mode_reauthenticate]', l('oidc.settings.sudo_mode_reauthenticate') %> + <%= check_box_tag 'settings[sudo_mode_reauthenticate]', 1, oidc_settings.sudo_mode_reauthenticate %> + BETA +

<%= error_messages_for oidc_settings %> diff --git a/config/locales/de.yml b/config/locales/de.yml index 66adf98..215ce9d 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -36,6 +36,7 @@ de: admin_role: Administrationsrolle session_check_enabled: Session Check aktivieren session_check_users_csv: Komma-separierte Liste der Logins mit Session Check (* = alle) + sudo_mode_reauthenticate: Reauthentifizierung von Benutzern für sensible Aktionen über OIDC (wenn der sudo_mode aktiviert ist) error: permissions: heading: >- diff --git a/config/locales/en.yml b/config/locales/en.yml index cedad2a..97e1f20 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -36,6 +36,7 @@ en: admin_role: Administration role session_check_enabled: Enable session check session_check_users_csv: Comma-separated list of logins with session check (* = all) + sudo_mode_reauthenticate: Reauthenticate users for sensitive actions via OIDC (if sudo mode is enabled) error: permissions: heading: >- diff --git a/lib/redmine_oidc.rb b/lib/redmine_oidc.rb index 7da5f1f..082f637 100644 --- a/lib/redmine_oidc.rb +++ b/lib/redmine_oidc.rb @@ -20,6 +20,7 @@ require_dependency 'redmine_oidc/application_controller_patch' require_dependency 'redmine_oidc/avatars_helper_patch' require_dependency 'redmine_oidc/hooks' + require_dependency 'redmine_oidc/sudo_mode_controller_patch' end module RedmineOidc diff --git a/lib/redmine_oidc/settings.rb b/lib/redmine_oidc/settings.rb index 39f7444..efc562d 100644 --- a/lib/redmine_oidc/settings.rb +++ b/lib/redmine_oidc/settings.rb @@ -33,6 +33,7 @@ class Settings admin_role session_check_enabled session_check_users_csv + sudo_mode_reauthenticate ) attr_accessor *VALID_KEYS.map(&:to_sym) diff --git a/lib/redmine_oidc/sudo_mode_controller_patch.rb b/lib/redmine_oidc/sudo_mode_controller_patch.rb new file mode 100644 index 0000000..f19367d --- /dev/null +++ b/lib/redmine_oidc/sudo_mode_controller_patch.rb @@ -0,0 +1,63 @@ +# OpenID Connect Authentication for Redmine +# Copyright (C) 2020-2021 Contargo GmbH & Co. KG +# +# 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 2 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +require 'repost' + +module RedmineOidc + module SudoModeControllerPatch + def require_sudo_mode(*param_names) + return super unless RedmineOidc.settings.enabled + return super unless RedmineOidc.settings.sudo_mode_reauthenticate + + return true if Redmine::SudoMode.active? + + if OidcSession.spawn(session).auth_time > Redmine::SudoMode.timeout.ago.to_i + logger.info "Activating sudo mode for user #{User.current.login}" + Redmine::SudoMode.active! + end + + # Note: This method must be called even right after `Redmine::SudoMode.active!` + # because despite its name, it has side effects! + return true if Redmine::SudoMode.active? + + if param_names.blank? + # This list was copied from the original `require_sudo_mode`, but without `_method`. + param_names = params.keys - %w(id action controller authenticity_token utf8) + end + + back_url = url_for(**params.slice(:controller, :action, :id, :project_id).to_unsafe_hash) + + session[:oidc_sudo_deferred] = { + back_url: back_url, + params: params.slice(*param_names).to_unsafe_hash, + options: { + method: request.method_symbol, + authenticity_token: :auto, + }, + } + + logger.info "Reauthenticating #{User.current.login} for sudo mode" + redirect_to oidc_login_path(back_url: back_url, reauth: true) + + false + end + end +end + +unless Redmine::SudoMode::Controller.included_modules.include?(RedmineOidc::SudoModeControllerPatch) + Redmine::SudoMode::Controller.prepend(RedmineOidc::SudoModeControllerPatch) +end