Add OAuth2 provider capability using doorkeeper gem (#24808).

Patch by Jens Krämer (user:jkraemer).

git-svn-id: https://svn.redmine.org/redmine/trunk@23837 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2025-06-12 07:09:03 +00:00
parent e56d84b632
commit 2d3b3b939e
29 changed files with 750 additions and 11 deletions

View File

@@ -19,6 +19,9 @@ gem 'rack', '>= 3.1.3'
gem "stimulus-rails", "~> 1.3"
gem "importmap-rails", "~> 2.0"
gem 'commonmarker', '~> 2.3.0'
gem "doorkeeper", "~> 5.8.2"
gem "bcrypt", require: false
gem "doorkeeper-i18n", "~> 5.2"
# Ruby Standard Gems
gem 'csv', '~> 3.3.2'
@@ -115,6 +118,10 @@ group :test do
gem 'rubocop-performance', '~> 1.25.0', require: false
gem 'rubocop-rails', '~> 2.32.0', require: false
gem 'bundle-audit', require: false
# for testing oauth provider capabilities
gem 'oauth2'
gem 'rest-client'
gem 'webrick'
end
local_gemfile = File.join(File.dirname(__FILE__), "Gemfile.local")

View File

@@ -59,6 +59,13 @@
<path d="M12 15v6"/>
<path d="M5 15h3l-3 6h3"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--apps">
<path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
<path d="M14 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
<path d="M14 7l6 0"/>
<path d="M17 4l0 6"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--arrow-right">
<path d="M4 9h8v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1z"/>
</symbol>
@@ -398,6 +405,10 @@
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--shield-check">
<path d="M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06"/>
<path d="M15 19l2 2l4 -4"/>
</symbol>
<symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--stats">
<path d="M3 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
<path d="M15 9a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1316,6 +1316,9 @@ div.flash.warning svg.icon-svg, .conflict svg.icon-svg {
color: #A6750C;
}
.warning .oauth-permissions { display:inline-block;text-align:left; }
.warning .oauth-permissions p { margin-top:0;-webkit-margin-before:0;}
#errorExplanation ul { font-size: 0.9em;}
#errorExplanation h2, #errorExplanation p { display: none; }

View File

@@ -131,6 +131,14 @@ class ApplicationController < ActionController::Base
if (key = api_key_from_request)
# Use API key
user = User.find_by_api_key(key)
elsif access_token = Doorkeeper.authenticate(request)
# Oauth
if access_token.accessible?
user = User.active.find_by_id(access_token.resource_owner_id)
user.oauth_scope = access_token.scopes.all.map(&:to_sym)
else
doorkeeper_render_error
end
elsif /\ABasic /i.match?(request.authorization.to_s)
# HTTP Basic, either username/password or API key/random
authenticate_with_http_basic do |username, password|

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
#
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
#
# 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.
#
class Oauth2ApplicationsController < Doorkeeper::ApplicationsController
private
def application_params
params[:doorkeeper_application] ||= {}
params[:doorkeeper_application][:scopes] ||= []
scopes = Redmine::AccessControl.public_permissions.map{|p| p.name.to_s}
if params[:doorkeeper_application][:scopes].is_a?(Array)
scopes |= params[:doorkeeper_application][:scopes]
else
scopes |= params[:doorkeeper_application][:scopes].split(/\s+/)
end
params[:doorkeeper_application][:scopes] = scopes.join(' ')
super
end
end

View File

@@ -198,11 +198,14 @@ class Role < ApplicationRecord
# action can be:
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
# * a permission Symbol (eg. :edit_project)
def allowed_to?(action)
# scope can be:
# * an array of permissions which will be used as filter (logical AND)
def allowed_to?(action, scope=nil)
if action.is_a? Hash
allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
allowed_actions(scope).include? "#{action[:controller]}/#{action[:action]}"
else
allowed_permissions.include? action
allowed_permissions(scope).include? action
end
end
@@ -298,13 +301,20 @@ class Role < ApplicationRecord
private
def allowed_permissions
@allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
def allowed_permissions(scope = nil)
scope = scope.sort if scope.present? # to maintain stable cache keys
@allowed_permissions ||= {}
@allowed_permissions[scope] ||= begin
unscoped = permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
scope.present? ? unscoped & scope : unscoped
end
end
def allowed_actions
@actions_allowed ||=
allowed_permissions.inject([]) do |actions, permission|
def allowed_actions(scope = nil)
scope = scope.sort if scope.present? # to maintain stable cache keys
@actions_allowed ||= {}
@actions_allowed[scope] ||=
allowed_permissions(scope).inject([]) do |actions, permission|
actions += Redmine::AccessControl.allowed_actions(permission)
end.flatten
end

View File

@@ -112,6 +112,7 @@ class User < Principal
attr_accessor :password, :password_confirmation, :generate_password
attr_accessor :last_before_login_on
attr_accessor :remote_ip
attr_writer :oauth_scope
LOGIN_LENGTH_LIMIT = 60
MAIL_LENGTH_LIMIT = 254
@@ -732,6 +733,20 @@ class User < Principal
end
end
def admin?
if authorized_by_oauth?
# when signed in via oauth, the user only acts as admin when the admin scope is set
super and @oauth_scope.include?(:admin)
else
super
end
end
# true if the user has signed in via oauth
def authorized_by_oauth?
!@oauth_scope.nil?
end
# Return true if the user is allowed to do the specified action on a specific context
# Action can be:
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
@@ -752,7 +767,7 @@ class User < Principal
roles.any? do |role|
(context.is_public? || role.member?) &&
role.allowed_to?(action) &&
role.allowed_to?(action, @oauth_scope) &&
(block ? yield(role, self) : true)
end
elsif context && context.is_a?(Array)
@@ -771,7 +786,7 @@ class User < Principal
# authorize if user has at least one role that has this permission
roles = self.roles.to_a | [builtin_role]
roles.any? do |role|
role.allowed_to?(action) &&
role.allowed_to?(action, @oauth_scope) &&
(block ? yield(role, self) : true)
end
else

View File

@@ -0,0 +1,39 @@
<%= error_messages_for 'application' %>
<div class="box tabular">
<p><%= f.text_field :name, :required => true %></p>
<p>
<%= f.text_area :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %>
<em class="info">
<%= t('doorkeeper.applications.help.redirect_uri') %>
</em>
</p>
</div>
<h3><%= l(:'activerecord.attributes.doorkeeper/application.scopes') %></h3>
<p><em class="info"><%= l :text_oauth_info_scopes %></em></p>
<div class="box tabular" id="scopes">
<fieldset><legend><%= l :label_oauth_admin_access %></legend>
<label class="floating" style="width: auto;">
<%= check_box_tag 'doorkeeper_application[scopes][]', 'admin', @application.scopes.include?('admin'),
:id => "doorkeeper_application_scopes_admin"
%>
<%= l :text_oauth_admin_permission %>
</label>
</fieldset>
<% perms_by_module = Redmine::AccessControl.permissions.group_by {|p| p.project_module.to_s} %>
<% perms_by_module.keys.sort.each do |mod| %>
<fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend>
<% perms_by_module[mod].each do |permission| %>
<label class="floating">
<%= check_box_tag 'doorkeeper_application[scopes][]', permission.name.to_s, (permission.public? || @application.scopes.include?( permission.name.to_s)),
:id => "doorkeeper_application_scopes_#{permission.name}",
:disabled => permission.public? %>
<%= l_or_humanize(permission.name, :prefix => 'permission_') %>
</label>
<% end %>
</fieldset>
<% end %>
<br /><%= check_all_links 'scopes' %>
<%= hidden_field_tag 'doorkeeper_application[scopes][]', '' %>
</div>

View File

@@ -0,0 +1,6 @@
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<%= submit_tag l(:button_save) %>
<% end %>

View File

@@ -0,0 +1,33 @@
<div class="contextual">
<%= link_to sprite_icon('add', t('.new')), new_oauth_application_path, :class => 'icon icon-add' %>
</div>
<%= title l 'label_oauth_application_plural' %>
<% if @applications.any? %>
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= t('.name') %></th>
<th><%= t('.callback_url') %></th>
<th><%= t('.scopes') %></th>
<th></th>
</tr></thead>
<tbody>
<% @applications.each do |application| %>
<tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
<td class="name"><span><%= link_to application.name, oauth_application_path(application) %></span></td>
<td class="description"><%= truncate application.redirect_uri.split.join(', '), length: 50 %></td>
<td class="description"><%= safe_join application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></td>
<td class="buttons">
<%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(application), class: 'icon icon-edit' %>
<%= link_to sprite_icon('del', t('doorkeeper.applications.buttons.destroy')), oauth_application_path(application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>

View File

@@ -0,0 +1,6 @@
<%= title [l('label_oauth_application_plural'), oauth_applications_path], t('.title') %>
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
<%= render :partial => 'form', :locals => { :f => f } %>
<%= submit_tag l(:button_create) %>
<% end %>

View File

@@ -0,0 +1,54 @@
<div class="contextual">
<%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(@application), :accesskey => accesskey(:edit), class: 'icon icon-edit' %>
<%= link_to sprite_icon('del', t('doorkeeper.applications.buttons.destroy')), oauth_application_path(@application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %>
</div>
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
<div class="box">
<h3 class="icon icon-passwd"><%= sprite_icon('key', l(:label_information_plural)) %></h3>
<p>
<span class="label"><%= t('.application_id') %>:</span>
<code><%= h @application.uid %></code>
</p>
<p>
<span class="label"><%= t('.secret') %>:</span>
<code>
<% secret = flash[:application_secret].presence || @application.plaintext_secret %>
<% flash.delete :application_secret %>
<% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
<%= t('.secret_hashed') %>
<% else %>
<%= secret %>
<% end %>
</code>
<% if secret.present? && Doorkeeper.config.application_secret_hashed? %>
<strong><%= t "text_oauth_copy_secret_now" %></strong>
<% end %>
</p>
<p>
<span class="label"><%= t('.scopes') %>:</span>
<code><%= safe_join @application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></code>
</p>
</div>
<h3><%= t('.callback_urls') %></h3>
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= t('.callback_url') %></th>
<th></th>
</tr></thead>
<tbody>
<% @application.redirect_uri.split.each do |uri| %>
<tr class="<%= cycle("odd", "even") %>">
<td class="name"><span><%= uri %></span></td>
<td class="buttons">
<%= link_to sprite_icon('shield-check', t('doorkeeper.applications.buttons.authorize')), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code', scope: @application.scopes), class: 'icon icon-authorize', target: '_blank' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,6 @@
<h2><%= t('doorkeeper.authorizations.error.title') %></h2>
<p id="errorExplanation"><%= @pre_auth.error_response.body[:error_description] %></p>
<p><a href="javascript:history.back()"><%= l(:button_back) %></a></p>
<% html_title t('doorkeeper.authorizations.error.title') %>

View File

@@ -0,0 +1,48 @@
<%= title t('.title') %>
<div class="warning">
<p><strong><%=h @pre_auth.client.name %></strong></p>
<p><%= raw t('.prompt', client_name: content_tag(:strong, class: "text-info") { @pre_auth.client.name }) %></p>
<div class="oauth-permissions">
<p><%= t('.able_to') %>:</p>
<ul>
<li><%= l :text_oauth_implicit_permissions %></li>
<% @pre_auth.scopes.each do |scope| %>
<% if scope == 'admin' %>
<li><%= l :label_oauth_permission_admin %></li>
<% else %>
<li><%= l_or_humanize(scope, prefix: 'permission_') %></li>
<% end %>
<% end %>
</ul>
</div>
<% if @pre_auth.scopes.include?('admin') %>
<p><%= l :text_oauth_admin_permission_info %></p>
<% end %>
</div>
<p>
<%= form_tag oauth_authorization_path, method: :post do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
<%= hidden_field_tag :state, @pre_auth.state %>
<%= hidden_field_tag :response_type, @pre_auth.response_type %>
<%= hidden_field_tag :scope, @pre_auth.scope %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
<%= submit_tag t('doorkeeper.authorizations.buttons.authorize') %>
<% end %>
<%= form_tag oauth_authorization_path, method: :delete do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
<%= hidden_field_tag :state, @pre_auth.state %>
<%= hidden_field_tag :response_type, @pre_auth.response_type %>
<%= hidden_field_tag :scope, @pre_auth.scope %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
<%= submit_tag t('doorkeeper.authorizations.buttons.deny') %>
<% end %>
</p>

View File

@@ -0,0 +1,8 @@
<%= title [l('label_oauth_authorized_application_plural'), oauth_authorized_applications_path] %>
<fieldset class="tabular"><legend><%= l(:label_information_plural) %></legend>
<p>
<label><%= t('.title') %>:</label>
<code><%= params[:code] %></code>
</p>
</fieldset>

View File

@@ -0,0 +1,31 @@
<%= title [t(:label_my_account), my_account_path], l('label_oauth_authorized_application_plural') %>
<% if @applications.any? %>
<div class="autoscroll">
<table class="list">
<thead><tr>
<th><%= t('doorkeeper.authorized_applications.index.application') %></th>
<th><%= t('doorkeeper.authorized_applications.index.created_at') %></th>
<th></th>
</tr></thead>
<tbody>
<% @applications.each do |application| %>
<tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
<td class="name"><span><%= application.name %></span></td>
<td ><%= format_date application.created_at %></td>
<td class="buttons">
<%= link_to sprite_icon('del', t('doorkeeper.authorized_applications.buttons.revoke')), oauth_authorized_application_path(application), :data => {:confirm => t('doorkeeper.authorized_applications.confirmations.revoke')}, :method => :delete, :class => 'icon icon-del' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>
<% content_for :sidebar do %>
<% @user = User.current %>
<%= render :partial => 'my/sidebar' %>
<% end %>

View File

@@ -1,6 +1,7 @@
<div class="contextual">
<%= additional_emails_link(@user) %>
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
<%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %>
<%= call_hook(:view_my_account_contextual, :user => @user)%>
</div>

View File

@@ -11,7 +11,7 @@ api.user do
api.passwd_changed_on @user.passwd_changed_on
api.avatar_url gravatar_url(@user.mail, {rating: nil, size: nil, default: Setting.gravatar_default}) if @user.mail && Setting.gravatar_enabled?
api.twofa_scheme @user.twofa_scheme if User.current.admin? || (User.current == @user)
api.api_key @user.api_key if User.current.admin? || (User.current == @user)
api.api_key @user.api_key if (User.current.admin? || (User.current == @user && !User.current.authorized_by_oauth?))
api.status @user.status if User.current.admin?
render_api_custom_values @user.visible_custom_field_values, api

View File

@@ -10,6 +10,72 @@ Rails.application.config.to_prepare do
ActiveSupport::XmlMini.backend = 'Nokogiri'
Redmine::Preparation.prepare
Doorkeeper.configure do
orm :active_record
# Issue access tokens with refresh token
use_refresh_token
# Authorization Code expiration time (default: 10 minutes).
#
# authorization_code_expires_in 10.minutes
# Access token expiration time (default: 2 hours).
# If you want to disable expiration, set this to `nil`.
#
# access_token_expires_in 2.hours
# Hash access and refresh tokens before persisting them.
# https://doorkeeper.gitbook.io/guides/security/token-and-application-secrets
hash_token_secrets
# Hash application secrets before persisting them.
hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
# limit supported flows to Auth code
grant_flows ['authorization_code']
realm Redmine::Info.app_name
base_controller 'ApplicationController'
default_scopes(*Redmine::AccessControl.public_permissions.map(&:name))
optional_scopes(*(Redmine::AccessControl.permissions.map(&:name) << :admin))
# Forbids creating/updating applications with arbitrary scopes that are
# not in configuration, i.e. +default_scopes+ or +optional_scopes+.
enforce_configured_scopes
allow_token_introspection false
# allow http loopback redirect URIs but require https for all others
force_ssl_in_redirect_uri { |uri| !%w[localhost 127.0.0.1 web localohst:8080].include?(uri.host) }
# Specify what redirect URI's you want to block during Application creation.
forbid_redirect_uri { |uri| %w[data vbscript javascript].include?(uri.scheme.to_s.downcase) }
resource_owner_authenticator do
if require_login
if Setting.rest_api_enabled?
User.current
else
deny_access
end
end
end
admin_authenticator do |_routes|
if !Setting.rest_api_enabled? || !User.current.admin?
deny_access
end
end
end
# Use Redmine standard layouts and helpers for Doorkeeper OAuth2 screens
Doorkeeper::ApplicationsController.layout "admin"
Doorkeeper::ApplicationsController.main_menu = false
Doorkeeper::AuthorizationsController.layout "base"
Doorkeeper::AuthorizedApplicationsController.layout "base"
Doorkeeper::AuthorizedApplicationsController.main_menu = false
end
# Load the secret token from the Redmine configuration file
@@ -40,6 +106,14 @@ Rails.application.config.to_prepare do
paths = theme.asset_paths
Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
end
Doorkeeper::ApplicationsController.class_eval do
require_sudo_mode :create, :show, :update, :destroy
end
Doorkeeper::AuthorizationsController.class_eval do
require_sudo_mode :create, :destroy
end
end
Rails.application.deprecators[:redmine] = ActiveSupport::Deprecation.new('7.0', 'Redmine')

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
# rubocop:disable Lint/EmptyBlock
Doorkeeper.configure do
end
Rails.application.config.to_prepare do
end
# rubocop:enable Lint/EmptyBlock

View File

@@ -971,6 +971,9 @@ de:
permission_view_time_entries: Gebuchte Aufwände ansehen
permission_view_wiki_edits: Wiki-Versionsgeschichte ansehen
permission_view_wiki_pages: Wiki ansehen
permission_view_project: Projekte ansehen
permission_search_project: Projekte suchen
permission_view_members: Projektmitglieder anzeigen
project_module_boards: Foren
project_module_calendar: Kalender
@@ -1477,3 +1480,12 @@ de:
other: "%{count} others"
text_setting_gravatar_default_initials_html: Users' initials are sent to <a href="https://www.gravatar.com">https://www.gravatar.com</a>
to generate their avatars.
label_oauth_permission_admin: Administrator-Zugriff
label_oauth_admin_access: Administration
label_oauth_application_plural: Applikationen
label_oauth_authorized_application_plural: Autorisierte Applikationen
text_oauth_admin_permission: Voller Admin-Zugriff. Wenn diese Applikation durch einen Administrator autorisiert wird, kann sie alle Daten lesen und schreiben, auch im Namen anderer Benutzer.
text_oauth_admin_permission_info: Diese Applikation verlangt vollen Administrator-Zugriff. Wenn Sie ein Administrator sind (oder in Zukunft Administrator werden), wird sie in der Lage sein, alle Daten zu lesen und zu schreiben, auch im Namen anderer Benutzer. Dies kann vermieden werden, indem die Applikation mit einem anderen Benutzerkonto ohne Administrator-Privileg autorisiert wird.
text_oauth_copy_secret_now: Das Geheimnis bitte jetzt an einen sicheren Ort kopieren, es kann nicht erneut angezeigt werden.
text_oauth_implicit_permissions: Zugriff auf Benutzername, Login sowie auf die primäre Email-Adresse
text_oauth_info_scopes: Scopes für die Applikation auswählen. Die Applikation wird niemals mehr Rechte haben als hier ausgewählt. Sie wird außerdem auf die Rollen und Projektmitgliedschaften des Benutzers, der sie autorisiert hat, beschränkt sein.

View File

@@ -139,6 +139,9 @@ en:
must_contain_special_chars: "must contain special characters (!, $, %, ...)"
domain_not_allowed: "contains a domain not allowed (%{domain})"
too_simple: "is too simple"
attributes:
doorkeeper/application:
scopes: Scopes
actionview_instancetag_blank_option: Please select
@@ -605,6 +608,10 @@ en:
permission_manage_related_issues: Manage related issues
permission_import_issues: Import issues
permission_log_time_for_other_users: Log spent time for other users
permission_view_project: View projects
permission_search_project: Search projects
permission_view_members: View project members
project_module_issue_tracking: Issue tracking
project_module_time_tracking: Time tracking
@@ -1158,6 +1165,10 @@ en:
label_time_by_author: "%{time} by %{author}"
label_involved_principals: Author / Previous assignee
label_progressbar: Progress bar
label_oauth_permission_admin: Administrate this Redmine
label_oauth_admin_access: Administration
label_oauth_application_plural: Applications
label_oauth_authorized_application_plural: Authorized applications
button_login: Login
button_submit: Submit
@@ -1343,6 +1354,11 @@ en:
text_allowed_queries_to_select: Public (to any users) queries only selectable
text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it.
text_setting_gravatar_default_initials_html: Users' initials are sent to <a href="https://www.gravatar.com">https://www.gravatar.com</a> to generate their avatars.
text_oauth_admin_permission: Full administrative access. When authorized by an Administrator, this application will be able to read and write all data and impersonate other users.
text_oauth_admin_permission_info: This application requests full administrative access. If you are an Administrator (or become one in the future), it will be able to read and write all data and impersonate other users on your behalf. If you want to avoid this, authorize it as a user without Administrator privileges instead.
text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
text_oauth_implicit_permissions: View your name, login and primary email address
text_oauth_info_scopes: Select the scopes this application may request. The application will not be allowed to do more than what is selected here. It will also always be limited by the roles and project memberships of the user who authorized it.
default_role_manager: Manager
default_role_developer: Developer

View File

@@ -18,6 +18,11 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Rails.application.routes.draw do
use_doorkeeper do
controllers :applications => 'oauth2_applications'
end
root :to => 'welcome#index'
root :to => 'welcome#index', :as => 'home'
match 'login', :to => 'account#login', :as => 'signin', :via => [:get, :post]

View File

@@ -0,0 +1,68 @@
class CreateDoorkeeperTables < ActiveRecord::Migration[7.2]
def change
create_table :oauth_applications do |t|
t.string :name, null: false
t.string :uid, null: false
t.string :secret, null: false
t.text :redirect_uri, null: false
t.text :scopes, null: false
t.boolean :confidential, null: false, default: true
t.timestamps null: false
end
add_index :oauth_applications, :uid, unique: true
create_table :oauth_access_grants do |t|
t.integer :resource_owner_id, null: false
t.references :application, null: false
t.string :token, null: false
t.integer :expires_in, null: false
t.text :redirect_uri, null: false
t.datetime :created_at, null: false
t.datetime :revoked_at
t.text :scopes
end
add_index :oauth_access_grants, :token, unique: true
add_foreign_key(
:oauth_access_grants,
:oauth_applications,
column: :application_id
)
add_foreign_key(
:oauth_access_grants,
:users,
column: :resource_owner_id
)
create_table :oauth_access_tokens do |t|
t.integer :resource_owner_id
t.references :application
t.string :token, null: false
t.string :refresh_token
t.integer :expires_in
t.datetime :revoked_at
t.datetime :created_at, null: false
t.text :scopes
t.string :previous_refresh_token, null: false, default: ""
end
add_index :oauth_access_tokens, :token, unique: true
add_index :oauth_access_tokens, :resource_owner_id
add_index :oauth_access_tokens, :refresh_token, unique: true
add_foreign_key(
:oauth_access_tokens,
:oauth_applications,
column: :application_id
)
add_foreign_key(
:oauth_access_tokens,
:users,
column: :resource_owner_id
)
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class EnablePkce < ActiveRecord::Migration[7.2]
def change
add_column :oauth_access_grants, :code_challenge, :string, null: true
add_column :oauth_access_grants, :code_challenge_method, :string, null: true
end
end

View File

@@ -280,6 +280,11 @@ module Redmine
{:controller => 'auth_sources', :action => 'index'},
:icon => 'server-authentication',
:html => {:class => 'icon icon-server-authentication'}
menu.push :applications, {:controller => 'oauth2_applications', :action => 'index'},
:if => Proc.new { Setting.rest_api_enabled? },
:caption => :'doorkeeper.layouts.admin.nav.applications',
:icon => 'apps',
:html => {:class => 'icon icon-applications'}
menu.push :plugins, {:controller => 'admin', :action => 'plugins'},
:last => true,
:icon => 'plugins',

View File

@@ -0,0 +1,131 @@
# frozen_string_literal: true
require_relative '../application_system_test_case'
require 'oauth2'
require 'webrick'
class OauthProviderSystemTest < ApplicationSystemTestCase
fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
:trackers, :projects_trackers, :enabled_modules, :issue_statuses, :issues,
:enumerations, :custom_fields, :custom_values, :custom_fields_trackers,
:watchers, :journals, :journal_details, :versions,
:workflows
test 'application creation and authorization' do
#
# admin creates the application, granting permissions and generating a uuid
# and secret.
#
log_user 'admin', 'admin'
with_settings rest_api_enabled: 1 do
visit '/admin'
within 'div#admin-menu ul' do
click_link 'Applications'
end
click_link 'New Application'
fill_in 'Name', with: 'Oauth Test'
# as per https://tools.ietf.org/html/rfc8252#section-7.3, the port can be
# anything when the redirect URI's host is 127.0.0.1.
fill_in 'Redirect URI', with: 'http://127.0.0.1'
check 'View Issues'
click_button 'Create'
end
assert_text "Application created"
assert app = Doorkeeper::Application.find_by_name('Oauth Test')
find 'h2', visible: true, text: /Oauth Test/
find 'p code', visible: true, text: app.uid
find 'p strong', visible: true, text: /will not be shown again/
find 'p code', visible: true, text: /View Issues/
# scrape the clear text secret from the page
app_secret = all(:css, 'p code')[1].text
click_link 'Sign out'
#
# regular user authorizes the application
#
client = OAuth2::Client.new(app.uid, app_secret, site: "http://127.0.0.1:#{test_port}/")
# set up a dummy http listener to handle the redirect
port = rand 10000..20000
redirect_uri = "http://127.0.0.1:#{port}"
# the request handler below will set this to the auth token
token = nil
# launches webrick, listening for the redirect with the auth code.
launch_client_app(port: port) do |req, res|
# get access code from code url param
if code = req.query['code'].presence
# exchange it for token
token = client.auth_code.get_token(code, redirect_uri: redirect_uri)
res.body = "<html><body><p>Authorization succeeded, you may close this window now.</p></body></html>"
end
end
log_user 'jsmith', 'jsmith'
with_settings rest_api_enabled: 1 do
visit '/my/account'
click_link 'Authorized applications'
find 'p.nodata', visible: true
# an oauth client would send the user to this url to request permission
url = client.auth_code.authorize_url redirect_uri: redirect_uri, scope: 'view_issues view_project'
uri = URI.parse url
visit uri.path + '?' + uri.query
find 'h2', visible: true, text: 'Authorization required'
find 'p', visible: true, text: /Authorize Oauth Test/
find '.oauth-permissions', visible: true, text: /View Issues/
find '.oauth-permissions', visible: true, text: /View project/
click_button 'Authorize'
assert grant = app.access_grants.last
assert_equal 'view_issues view_project', grant.scopes.to_s
# check for output defined above in the request handler
find 'p', visible: true, text: /Authorization succeeded/
assert token.present?
visit '/my/account'
click_link 'Authorized applications'
find 'td', visible: true, text: /Oauth Test/
click_link 'Sign out'
# Now, use the token for some API requests
assert_raise(RestClient::Unauthorized) do
RestClient.get "http://localhost:#{test_port}/projects/onlinestore/issues.json"
end
headers = { 'Authorization' => "Bearer #{token.token}" }
r = RestClient.get "http://localhost:#{test_port}/projects/onlinestore/issues.json", headers
issues = JSON.parse(r.body)['issues']
assert issues.any?
# time entries access is not part of the granted scopes
assert_raise(RestClient::Forbidden) do
RestClient.get "http://localhost:#{test_port}/projects/onlinestore/time_entries.json", headers
end
end
end
private
def launch_client_app(port: 12345, path: '/', &block)
server = WEBrick::HTTPServer.new Port: port
trap('INT') { server.shutdown }
server.mount_proc(path, block)
Thread.new { server.start }
port
end
def test_port
Capybara.current_session.server.port
end
end

View File

@@ -175,6 +175,32 @@ class RoleTest < ActiveSupport::TestCase
assert_equal false, role.permissions_tracker_ids?(:view_issues, 1)
end
def test_allowed_to_with_symbol
role = Role.create!(:name => 'Test', :permissions => [:view_issues])
assert_equal true, role.allowed_to?(:view_issues)
assert_equal false, role.allowed_to?(:add_issues)
end
def test_allowed_to_with_symbol_and_scope
role = Role.create!(:name => 'Test', :permissions => [:view_issues, :delete_issues])
assert_equal true, role.allowed_to?(:view_issues, [:view_issues, :add_issues])
assert_equal false, role.allowed_to?(:add_issues, [:view_issues, :add_issues])
assert_equal false, role.allowed_to?(:delete_issues, [:view_issues, :add_issues])
end
def test_allowed_to_with_hash
role = Role.create!(:name => 'Test', :permissions => [:view_issues])
assert_equal true, role.allowed_to?(:controller => 'issues', :action => 'show')
assert_equal false, role.allowed_to?(:controller => 'issues', :action => 'create')
end
def test_allowed_to_with_hash_and_scope
role = Role.create!(:name => 'Test', :permissions => [:view_issues, :delete_issues])
assert_equal true, role.allowed_to?({:controller => 'issues', :action => 'show'}, [:view_issues, :add_issues])
assert_equal false, role.allowed_to?({:controller => 'issues', :action => 'create'}, [:view_issues, :add_issues])
assert_equal false, role.allowed_to?({:controller => 'issues', :action => 'destroy'}, [:view_issues, :add_issues])
end
def test_has_permission_without_permissions
role = Role.create!(:name => 'Test')
assert_equal false, role.has_permission?(:delete_issues)

View File

@@ -1398,6 +1398,67 @@ class UserTest < ActiveSupport::TestCase
end
end
def test_should_recognize_authorized_by_oauth
u = User.find 2
assert_not u.authorized_by_oauth?
u.oauth_scope = [:add_issues, :view_issues]
assert u.authorized_by_oauth?
end
def test_admin_should_be_limited_by_oauth_scope
u = User.find_by_admin(true)
assert u.admin?
u.oauth_scope = [:add_issues, :view_issues]
assert_not u.admin?
u.oauth_scope = [:add_issues, :view_issues, :admin]
assert u.admin?
u = User.find_by_admin(false)
assert_not u.admin?
u.oauth_scope = [:add_issues, :view_issues, :admin]
assert_not u.admin?
end
def test_oauth_scope_should_limit_global_user_permissions
admin = User.find 1
user = User.find 2
[admin, user].each do |u|
assert u.allowed_to?(:add_issues, nil, global: true)
assert u.allowed_to?(:view_issues, nil, global: true)
u.oauth_scope = [:view_issues]
assert_not u.allowed_to?(:add_issues, nil, global: true)
assert u.allowed_to?(:view_issues, nil, global: true)
end
end
def test_oauth_scope_should_limit_project_user_permissions
admin = User.find 1
project = Project.find 5
assert admin.allowed_to?(:add_issues, project)
assert admin.allowed_to?(:view_issues, project)
admin.oauth_scope = [:view_issues]
assert_not admin.allowed_to?(:add_issues, project)
assert admin.allowed_to?(:view_issues, project)
admin.oauth_scope = [:view_issues, :admin]
assert admin.allowed_to?(:add_issues, project)
assert admin.allowed_to?(:view_issues, project)
user = User.find 2
project = Project.find 1
assert user.allowed_to?(:add_issues, project)
assert user.allowed_to?(:view_issues, project)
user.oauth_scope = [:view_issues]
assert_not user.allowed_to?(:add_issues, project)
assert user.allowed_to?(:view_issues, project)
user.oauth_scope = [:view_issues, :admin]
assert_not user.allowed_to?(:add_issues, project)
assert user.allowed_to?(:view_issues, project)
end
def test_destroy_should_delete_associated_reactions
users(:users_004).reactions.create!(
[