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