Project

General

Profile

Feature #24808 » 0001-Oauth-provider.patch

Marius BĂLTEANU, 2025-05-30 20:19

View differences:

Gemfile
19 19
gem "stimulus-rails", "~> 1.3"
20 20
gem "importmap-rails", "~> 2.0"
21 21
gem 'commonmarker', '~> 2.3.0'
22
gem "doorkeeper", "~> 5.8.2"
23
gem "bcrypt", require: false
24
gem "doorkeeper-i18n", "~> 5.2"
22 25

  
23 26
#  Ruby Standard Gems
24 27
gem 'csv', '~> 3.3.2'
......
115 118
  gem 'rubocop-performance', '~> 1.25.0', require: false
116 119
  gem 'rubocop-rails', '~> 2.31.0', require: false
117 120
  gem 'bundle-audit', require: false
121
  # for testing oauth provider capabilities
122
  gem 'oauth2'
123
  gem 'rest-client'
124
  gem 'webrick'
118 125
end
119 126

  
120 127
local_gemfile = File.join(File.dirname(__FILE__), "Gemfile.local")
app/assets/images/icons.svg
59 59
      <path d="M12 15v6"/>
60 60
      <path d="M5 15h3l-3 6h3"/>
61 61
    </symbol>
62
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--apps">
63
      <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"/>
64
      <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"/>
65
      <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"/>
66
      <path d="M14 7l6 0"/>
67
      <path d="M17 4l0 6"/>
68
    </symbol>
62 69
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--arrow-right">
63 70
      <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"/>
64 71
    </symbol>
......
394 401
      <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"/>
395 402
      <path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>
396 403
    </symbol>
404
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--shield-check">
405
      <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"/>
406
      <path d="M15 19l2 2l4 -4"/>
407
    </symbol>
397 408
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--stats">
398 409
      <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"/>
399 410
      <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"/>
app/assets/stylesheets/application.css
1285 1285
  color: #A6750C;
1286 1286
}
1287 1287

  
1288
.warning .oauth-permissions { display:inline-block;text-align:left; }
1289
.warning .oauth-permissions p { margin-top:0;-webkit-margin-before:0;}
1290

  
1288 1291
#errorExplanation ul { font-size: 0.9em;}
1289 1292
#errorExplanation h2, #errorExplanation p { display: none; }
1290 1293

  
app/controllers/application_controller.rb
131 131
      if (key = api_key_from_request)
132 132
        # Use API key
133 133
        user = User.find_by_api_key(key)
134
      elsif access_token = Doorkeeper.authenticate(request)
135
        # Oauth
136
        if access_token.accessible?
137
          user = User.active.find_by_id(access_token.resource_owner_id)
138
          user.oauth_scope = access_token.scopes.all.map(&:to_sym)
139
        else
140
          doorkeeper_render_error
141
        end
134 142
      elsif /\ABasic /i.match?(request.authorization.to_s)
135 143
        # HTTP Basic, either username/password or API key/random
136 144
        authenticate_with_http_basic do |username, password|
app/controllers/oauth2_applications_controller.rb
1
# frozen_string_literal: true
2

  
3
#
4
# Redmine - project management software
5
# Copyright (C) 2006-  Jean-Philippe Lang
6
#
7
# This program is free software; you can redistribute it and/or
8
# modify it under the terms of the GNU General Public License
9
# as published by the Free Software Foundation; either version 2
10
# of the License, or (at your option) any later version.
11
#
12
# This program is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with this program; if not, write to the Free Software
19
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
20
#
21
class Oauth2ApplicationsController < Doorkeeper::ApplicationsController
22
  private
23

  
24
  def application_params
25
    params[:doorkeeper_application] ||= {}
26
    params[:doorkeeper_application][:scopes] ||= []
27

  
28
    scopes = Redmine::AccessControl.public_permissions.map{|p| p.name.to_s}
29

  
30
    if params[:doorkeeper_application][:scopes].is_a?(Array)
31
      scopes |= params[:doorkeeper_application][:scopes]
32
    else
33
      scopes |= params[:doorkeeper_application][:scopes].split(/\s+/)
34
    end
35
    params[:doorkeeper_application][:scopes] = scopes.join(' ')
36
    super
37
  end
38
end
app/models/role.rb
198 198
  # action can be:
199 199
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
200 200
  # * a permission Symbol (eg. :edit_project)
201
  def allowed_to?(action)
201
  # scope can be:
202
  # * an array of permissions which will be used as filter (logical AND)
203

  
204
  def allowed_to?(action, scope=nil)
202 205
    if action.is_a? Hash
203
      allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
206
      allowed_actions(scope).include? "#{action[:controller]}/#{action[:action]}"
204 207
    else
205
      allowed_permissions.include? action
208
      allowed_permissions(scope).include? action
206 209
    end
207 210
  end
208 211

  
......
298 301

  
299 302
  private
300 303

  
301
  def allowed_permissions
302
    @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
304
  def allowed_permissions(scope = nil)
305
    scope = scope.sort if scope.present? # to maintain stable cache keys
306
    @allowed_permissions ||= {}
307
    @allowed_permissions[scope] ||= begin
308
      unscoped = permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
309
      scope.present? ? unscoped & scope : unscoped
310
    end
303 311
  end
304 312

  
305
  def allowed_actions
306
    @actions_allowed ||=
307
      allowed_permissions.inject([]) do |actions, permission|
313
  def allowed_actions(scope = nil)
314
    scope = scope.sort if scope.present? # to maintain stable cache keys
315
    @actions_allowed ||= {}
316
    @actions_allowed[scope] ||=
317
      allowed_permissions(scope).inject([]) do |actions, permission|
308 318
        actions += Redmine::AccessControl.allowed_actions(permission)
309 319
      end.flatten
310 320
  end
app/models/user.rb
112 112
  attr_accessor :password, :password_confirmation, :generate_password
113 113
  attr_accessor :last_before_login_on
114 114
  attr_accessor :remote_ip
115
  attr_writer   :oauth_scope
115 116

  
116 117
  LOGIN_LENGTH_LIMIT = 60
117 118
  MAIL_LENGTH_LIMIT = 254
......
732 733
    end
733 734
  end
734 735

  
736
  def admin?
737
    if authorized_by_oauth?
738
      # when signed in via oauth, the user only acts as admin when the admin scope is set
739
      super and @oauth_scope.include?(:admin)
740
    else
741
      super
742
    end
743
  end
744

  
745
  # true if the user has signed in via oauth
746
  def authorized_by_oauth?
747
    !@oauth_scope.nil?
748
  end
749

  
735 750
  # Return true if the user is allowed to do the specified action on a specific context
736 751
  # Action can be:
737 752
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
......
752 767

  
753 768
      roles.any? do |role|
754 769
        (context.is_public? || role.member?) &&
755
        role.allowed_to?(action) &&
770
        role.allowed_to?(action, @oauth_scope) &&
756 771
        (block ? yield(role, self) : true)
757 772
      end
758 773
    elsif context && context.is_a?(Array)
......
771 786
      # authorize if user has at least one role that has this permission
772 787
      roles = self.roles.to_a | [builtin_role]
773 788
      roles.any? do |role|
774
        role.allowed_to?(action) &&
789
        role.allowed_to?(action, @oauth_scope) &&
775 790
        (block ? yield(role, self) : true)
776 791
      end
777 792
    else
app/views/doorkeeper/applications/_form.html.erb
1
<%= error_messages_for 'application' %>
2
<div class="box tabular">
3
  <p><%= f.text_field :name, :required => true %></p>
4

  
5
  <p>
6
    <%= f.text_area :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %>
7
    <em class="info">
8
      <%= t('doorkeeper.applications.help.redirect_uri') %>
9
    </em>
10
  </p>
11
</div>
12

  
13
<h3><%= l(:'activerecord.attributes.doorkeeper/application.scopes') %></h3>
14
<p><em class="info"><%= l :text_oauth_info_scopes %></em></p>
15
<div class="box tabular" id="scopes">
16
<fieldset><legend><%= l :label_oauth_admin_access %></legend>
17
  <label class="floating" style="width: auto;">
18
    <%= check_box_tag 'doorkeeper_application[scopes][]', 'admin', @application.scopes.include?('admin'),
19
      :id => "doorkeeper_application_scopes_admin"
20
    %>
21
    <%= l :text_oauth_admin_permission %>
22
  </label>
23
</fieldset>
24
<% perms_by_module = Redmine::AccessControl.permissions.group_by {|p| p.project_module.to_s} %>
25
<% perms_by_module.keys.sort.each do |mod| %>
26
    <fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend>
27
    <% perms_by_module[mod].each do |permission| %>
28
        <label class="floating">
29
        <%= check_box_tag 'doorkeeper_application[scopes][]', permission.name.to_s, (permission.public? || @application.scopes.include?( permission.name.to_s)),
30
              :id => "doorkeeper_application_scopes_#{permission.name}",
31
              :disabled => permission.public? %>
32
        <%= l_or_humanize(permission.name, :prefix => 'permission_') %>
33
        </label>
34
    <% end %>
35
    </fieldset>
36
<% end %>
37
<br /><%= check_all_links 'scopes' %>
38
<%= hidden_field_tag 'doorkeeper_application[scopes][]', '' %>
39
</div>
app/views/doorkeeper/applications/edit.html.erb
1
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
2

  
3
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application) do |f| %>
4
  <%= render :partial => 'form', :locals => {:f => f} %>
5
  <%= submit_tag l(:button_save) %>
6
<% end %>
app/views/doorkeeper/applications/index.html.erb
1
<div class="contextual">
2
<%= link_to sprite_icon('add', t('.new')), new_oauth_application_path, :class => 'icon icon-add' %>
3
</div>
4

  
5
<%= title l 'label_oauth_application_plural' %>
6

  
7
<% if @applications.any? %>
8
<div class="autoscroll">
9
<table class="list">
10
  <thead><tr>
11
    <th><%= t('.name') %></th>
12
    <th><%= t('.callback_url') %></th>
13
    <th><%= t('.scopes') %></th>
14
    <th></th>
15
  </tr></thead>
16
  <tbody>
17
  <% @applications.each do |application| %>
18
    <tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
19
      <td class="name"><span><%= link_to application.name, oauth_application_path(application) %></span></td>
20
      <td class="description"><%= truncate application.redirect_uri.split.join(', '), length: 50 %></td>
21
      <td class="description"><%= safe_join application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></td>
22
      <td class="buttons">
23
        <%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(application), class: 'icon icon-edit' %>
24
        <%= 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' %>
25
      </td>
26
    </tr>
27
  <% end %>
28
  </tbody>
29
</table>
30
</div>
31
<% else %>
32
  <p class="nodata"><%= l(:label_no_data) %></p>
33
<% end %>
app/views/doorkeeper/applications/new.html.erb
1
<%= title [l('label_oauth_application_plural'), oauth_applications_path], t('.title') %>
2

  
3
<%= labelled_form_for @application, url: doorkeeper_submit_path(@application)  do |f| %>
4
<%= render :partial => 'form', :locals => { :f => f } %>
5
<%= submit_tag l(:button_create) %>
6
<% end %>
app/views/doorkeeper/applications/show.html.erb
1
<div class="contextual">
2
<%= link_to sprite_icon('edit', t('doorkeeper.applications.buttons.edit')), edit_oauth_application_path(@application), :accesskey => accesskey(:edit), class: 'icon icon-edit' %>
3
<%= 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' %>
4
</div>
5

  
6
<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %>
7

  
8
<div class="box">
9
  <h3 class="icon icon-passwd"><%= sprite_icon('key', l(:label_information_plural)) %></h3>
10
  <p>
11
    <span class="label"><%= t('.application_id') %>:</span>
12
    <code><%= h @application.uid %></code>
13
  </p>
14
  <p>
15
    <span class="label"><%= t('.secret') %>:</span>
16
    <code>
17
      <% secret = flash[:application_secret].presence || @application.plaintext_secret %>
18
      <% flash.delete :application_secret %>
19
      <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
20
        <%= t('.secret_hashed') %>
21
      <% else %>
22
        <%= secret %>
23
      <% end %>
24
    </code>
25
    <% if secret.present? && Doorkeeper.config.application_secret_hashed? %>
26
       <strong><%= t "text_oauth_copy_secret_now" %></strong>
27
    <% end %>
28
  </p>
29
  <p>
30
    <span class="label"><%= t('.scopes') %>:</span>
31
    <code><%= safe_join @application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %></code>
32
  </p>
33
</div>
34

  
35
<h3><%= t('.callback_urls') %></h3>
36

  
37
<div class="autoscroll">
38
<table class="list">
39
  <thead><tr>
40
    <th><%= t('.callback_url') %></th>
41
    <th></th>
42
  </tr></thead>
43
  <tbody>
44
  <% @application.redirect_uri.split.each do |uri| %>
45
    <tr class="<%= cycle("odd", "even") %>">
46
      <td class="name"><span><%= uri %></span></td>
47
      <td class="buttons">
48
        <%= 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' %>
49
      </td>
50
    </tr>
51
  <% end %>
52
  </tbody>
53
</table>
54
</div>
app/views/doorkeeper/authorizations/error.html.erb
1
<h2><%= t('doorkeeper.authorizations.error.title') %></h2>
2

  
3
<p id="errorExplanation"><%= @pre_auth.error_response.body[:error_description] %></p>
4
<p><a href="javascript:history.back()"><%= l(:button_back) %></a></p>
5

  
6
<% html_title t('doorkeeper.authorizations.error.title') %>
app/views/doorkeeper/authorizations/new.html.erb
1
<%= title t('.title') %>
2

  
3
<div class="warning">
4
<p><strong><%=h @pre_auth.client.name %></strong></p>
5

  
6
<p><%= raw t('.prompt', client_name: content_tag(:strong, class: "text-info") { @pre_auth.client.name }) %></p>
7

  
8
<div class="oauth-permissions">
9
  <p><%= t('.able_to') %>:</p>
10
  <ul>
11
    <li><%= l :text_oauth_implicit_permissions %></li>
12
    <% @pre_auth.scopes.each do |scope| %>
13
      <% if scope == 'admin' %>
14
        <li><%= l :label_oauth_permission_admin %></li>
15
      <% else %>
16
        <li><%= l_or_humanize(scope, prefix: 'permission_') %></li>
17
      <% end %>
18
    <% end %>
19
  </ul>
20
</div>
21

  
22
<% if @pre_auth.scopes.include?('admin') %>
23
  <p><%= l :text_oauth_admin_permission_info %></p>
24
<% end %>
25
</div>
26

  
27
<p>
28
  <%= form_tag oauth_authorization_path, method: :post do %>
29
    <%= hidden_field_tag :client_id, @pre_auth.client.uid %>
30
    <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
31
    <%= hidden_field_tag :state, @pre_auth.state %>
32
    <%= hidden_field_tag :response_type, @pre_auth.response_type %>
33
    <%= hidden_field_tag :scope, @pre_auth.scope %>
34
    <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
35
    <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
36
    <%= submit_tag t('doorkeeper.authorizations.buttons.authorize') %>
37
  <% end %>
38
  <%= form_tag oauth_authorization_path, method: :delete do %>
39
    <%= hidden_field_tag :client_id, @pre_auth.client.uid %>
40
    <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %>
41
    <%= hidden_field_tag :state, @pre_auth.state %>
42
    <%= hidden_field_tag :response_type, @pre_auth.response_type %>
43
    <%= hidden_field_tag :scope, @pre_auth.scope %>
44
    <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %>
45
    <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %>
46
    <%= submit_tag t('doorkeeper.authorizations.buttons.deny') %>
47
  <% end %>
48
</p>
app/views/doorkeeper/authorizations/show.html.erb
1
<%= title [l('label_oauth_authorized_application_plural'), oauth_authorized_applications_path]  %>
2

  
3
<fieldset class="tabular"><legend><%= l(:label_information_plural) %></legend>
4
  <p>
5
    <label><%= t('.title') %>:</label>
6
    <code><%= params[:code] %></code>
7
  </p>
8
</fieldset>
app/views/doorkeeper/authorized_applications/index.html.erb
1
<%= title [t(:label_my_account), my_account_path], l('label_oauth_authorized_application_plural') %>
2

  
3
<% if @applications.any? %>
4
<div class="autoscroll">
5
<table class="list">
6
  <thead><tr>
7
    <th><%= t('doorkeeper.authorized_applications.index.application') %></th>
8
    <th><%= t('doorkeeper.authorized_applications.index.created_at') %></th>
9
    <th></th>
10
  </tr></thead>
11
  <tbody>
12
  <% @applications.each do |application| %>
13
    <tr id="application_<%= application.id %>" class="<%= cycle("odd", "even") %>">
14
      <td class="name"><span><%= application.name %></span></td>
15
      <td ><%= format_date application.created_at %></td>
16
      <td class="buttons">
17
        <%= 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' %>
18
      </td>
19
    </tr>
20
  <% end %>
21
  </tbody>
22
</table>
23
</div>
24
<% else %>
25
  <p class="nodata"><%= l(:label_no_data) %></p>
26
<% end %>
27

  
28
<% content_for :sidebar do %>
29
<% @user = User.current %>
30
<%= render :partial => 'my/sidebar' %>
31
<% end %>
app/views/my/account.html.erb
1 1
<div class="contextual">
2 2
<%= additional_emails_link(@user) %>
3 3
<%= link_to(sprite_icon('key', l(:button_change_password)), { :action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
4
<%= link_to(sprite_icon('apps', l('label_oauth_authorized_application_plural')), oauth_authorized_applications_path, :class => 'icon icon-applications') if Setting.rest_api_enabled? %>
4 5
<%= call_hook(:view_my_account_contextual, :user => @user)%>
5 6
</div>
6 7

  
app/views/users/show.api.rsb
11 11
  api.passwd_changed_on @user.passwd_changed_on
12 12
  api.avatar_url gravatar_url(@user.mail, {rating: nil, size: nil, default: Setting.gravatar_default}) if @user.mail && Setting.gravatar_enabled?
13 13
  api.twofa_scheme      @user.twofa_scheme if User.current.admin? || (User.current == @user)
14
  api.api_key    @user.api_key if User.current.admin? || (User.current == @user)
14
  api.api_key    @user.api_key if (User.current.admin? || (User.current == @user && !User.current.authorized_by_oauth?))
15 15
  api.status     @user.status if User.current.admin?
16 16

  
17 17
  render_api_custom_values @user.visible_custom_field_values, api
config/icon_source.yml
233 233
  svg: bulb
234 234
- name: message-report
235 235
  svg: message-report
236
- name: apps
237
  svg: apps
238
- name: shield-check
239
  svg: shield-check
config/initializers/30-redmine.rb
12 12
  ActiveSupport::XmlMini.backend = 'Nokogiri'
13 13

  
14 14
  Redmine::Preparation.prepare
15

  
16
  Doorkeeper.configure do
17
    orm :active_record
18

  
19
    # Issue access tokens with refresh token
20
    use_refresh_token
21

  
22
    # Authorization Code expiration time (default: 10 minutes).
23
    #
24
    # authorization_code_expires_in 10.minutes
25

  
26
    # Access token expiration time (default: 2 hours).
27
    # If you want to disable expiration, set this to `nil`.
28
    #
29
    # access_token_expires_in 2.hours
30

  
31
    # Hash access and refresh tokens before persisting them.
32
    # https://doorkeeper.gitbook.io/guides/security/token-and-application-secrets
33
    hash_token_secrets
34

  
35
    # Hash application secrets before persisting them.
36
    hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
37

  
38
    # limit supported flows to Auth code
39
    grant_flows ['authorization_code']
40

  
41
    realm           Redmine::Info.app_name
42
    base_controller 'ApplicationController'
43
    default_scopes(*Redmine::AccessControl.public_permissions.map(&:name))
44
    optional_scopes(*(Redmine::AccessControl.permissions.map(&:name) << :admin))
45

  
46
    # Forbids creating/updating applications with arbitrary scopes that are
47
    # not in configuration, i.e. +default_scopes+ or +optional_scopes+.
48
    enforce_configured_scopes
49

  
50
    allow_token_introspection false
51

  
52
    # allow http loopback redirect URIs but require https for all others
53
    force_ssl_in_redirect_uri { |uri| !%w[localhost 127.0.0.1 web localohst:8080].include?(uri.host) }
54

  
55
    # Specify what redirect URI's you want to block during Application creation.
56
    forbid_redirect_uri { |uri| %w[data vbscript javascript].include?(uri.scheme.to_s.downcase) }
57

  
58
    resource_owner_authenticator do
59
      if require_login
60
        if Setting.rest_api_enabled?
61
          User.current
62
        else
63
          deny_access
64
        end
65
      end
66
    end
67

  
68
    admin_authenticator do |_routes|
69
      if !Setting.rest_api_enabled? || !User.current.admin?
70
        deny_access
71
      end
72
    end
73
  end
74

  
75
  # Use Redmine standard layouts and helpers for Doorkeeper OAuth2 screens
76
  Doorkeeper::ApplicationsController.layout "admin"
77
  Doorkeeper::ApplicationsController.main_menu = false
78
  Doorkeeper::AuthorizationsController.layout "base"
79
  Doorkeeper::AuthorizedApplicationsController.layout "base"
80
  Doorkeeper::AuthorizedApplicationsController.main_menu = false
15 81
end
16 82

  
17 83
# Load the secret token from the Redmine configuration file
......
42 108
    paths = theme.asset_paths
43 109
    Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
44 110
  end
111

  
112
  Doorkeeper::ApplicationsController.class_eval do
113
    require_sudo_mode :create, :show, :update, :destroy
114
  end
115

  
116
  Doorkeeper::AuthorizationsController.class_eval do
117
    require_sudo_mode :create, :destroy
118
  end
45 119
end
46 120

  
47 121
Rails.application.deprecators[:redmine] = ActiveSupport::Deprecation.new('7.0', 'Redmine')
config/initializers/doorkeeper.rb
1
# frozen_string_literal: true
2

  
3
# rubocop:disable Lint/EmptyBlock
4
Doorkeeper.configure do
5
end
6

  
7
Rails.application.config.to_prepare do
8
end
9
# rubocop:enable Lint/EmptyBlock
config/locales/de.yml
971 971
  permission_view_time_entries: Gebuchte Aufwände ansehen
972 972
  permission_view_wiki_edits: Wiki-Versionsgeschichte ansehen
973 973
  permission_view_wiki_pages: Wiki ansehen
974
  permission_view_project: Projekte ansehen
975
  permission_search_project: Projekte suchen
976
  permission_view_members: Projektmitglieder anzeigen
974 977

  
975 978
  project_module_boards: Foren
976 979
  project_module_calendar: Kalender
......
1477 1480
    other: "%{count} others"
1478 1481
  text_setting_gravatar_default_initials_html: Users' initials are sent to <a href="https://www.gravatar.com">https://www.gravatar.com</a>
1479 1482
    to generate their avatars.
1483
  label_oauth_permission_admin: Administrator-Zugriff
1484
  label_oauth_admin_access: Administration
1485
  label_oauth_application_plural: Applikationen
1486
  label_oauth_authorized_application_plural: Autorisierte Applikationen
1487
  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.
1488
  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.
1489
  text_oauth_copy_secret_now: Das Geheimnis bitte jetzt an einen sicheren Ort kopieren, es kann nicht erneut angezeigt werden.
1490
  text_oauth_implicit_permissions: Zugriff auf Benutzername, Login sowie auf die primäre Email-Adresse
1491
  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.
config/locales/en.yml
139 139
        must_contain_special_chars: "must contain special characters (!, $, %, ...)"
140 140
        domain_not_allowed: "contains a domain not allowed (%{domain})"
141 141
        too_simple: "is too simple"
142
    attributes:
143
      doorkeeper/application:
144
        scopes: Scopes
142 145

  
143 146
  actionview_instancetag_blank_option: Please select
144 147

  
......
605 608
  permission_manage_related_issues: Manage related issues
606 609
  permission_import_issues: Import issues
607 610
  permission_log_time_for_other_users: Log spent time for other users
611
  permission_view_project: View projects
612
  permission_search_project: Search projects
613
  permission_view_members: View project members
614

  
608 615

  
609 616
  project_module_issue_tracking: Issue tracking
610 617
  project_module_time_tracking: Time tracking
......
1158 1165
  label_time_by_author: "%{time} by %{author}"
1159 1166
  label_involved_principals: Author / Previous assignee
1160 1167
  label_progressbar: Progress bar
1168
  label_oauth_permission_admin: Administrate this Redmine
1169
  label_oauth_admin_access: Administration
1170
  label_oauth_application_plural: Applications
1171
  label_oauth_authorized_application_plural: Authorized applications
1161 1172

  
1162 1173
  button_login: Login
1163 1174
  button_submit: Submit
......
1343 1354
  text_allowed_queries_to_select: Public (to any users) queries only selectable
1344 1355
  text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it.
1345 1356
  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.
1357
  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.
1358
  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.
1359
  text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
1360
  text_oauth_implicit_permissions: View your name, login and primary email address
1361
  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.
1346 1362

  
1347 1363
  default_role_manager: Manager
1348 1364
  default_role_developer: Developer
config/routes.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
Rails.application.routes.draw do
21
  use_doorkeeper do
22
    controllers :applications => 'oauth2_applications'
23
  end
24

  
25
  root :to => 'welcome#index'
21 26
  root :to => 'welcome#index', :as => 'home'
22 27

  
23 28
  match 'login', :to => 'account#login', :as => 'signin', :via => [:get, :post]
db/migrate/20170107092155_create_doorkeeper_tables.rb
1
class CreateDoorkeeperTables < ActiveRecord::Migration[4.2]
2
  def change
3
    create_table :oauth_applications do |t|
4
      t.string  :name,         null: false
5
      t.string  :uid,          null: false
6
      t.string  :secret,       null: false
7
      t.text    :redirect_uri, null: false
8
      t.text    :scopes,       null: false
9
      t.boolean :confidential, null: false, default: true
10
      t.timestamps             null: false
11
    end
12

  
13
    add_index :oauth_applications, :uid, unique: true
14

  
15
    create_table :oauth_access_grants do |t|
16
      t.integer  :resource_owner_id, null: false
17
      t.references :application,     null: false
18
      t.string   :token,             null: false
19
      t.integer  :expires_in,        null: false
20
      t.text     :redirect_uri,      null: false
21
      t.datetime :created_at,        null: false
22
      t.datetime :revoked_at
23
      t.text     :scopes
24
    end
25

  
26
    add_index :oauth_access_grants, :token, unique: true
27
    add_foreign_key(
28
      :oauth_access_grants,
29
      :oauth_applications,
30
      column: :application_id
31
    )
32
    add_foreign_key(
33
      :oauth_access_grants,
34
      :users,
35
      column: :resource_owner_id
36
    )
37

  
38
    create_table :oauth_access_tokens do |t|
39
      t.integer  :resource_owner_id
40
      t.references :application
41

  
42
      t.string   :token,                  null: false
43

  
44
      t.string   :refresh_token
45
      t.integer  :expires_in
46
      t.datetime :revoked_at
47
      t.datetime :created_at,             null: false
48
      t.text     :scopes
49

  
50
      t.string   :previous_refresh_token, null: false, default: ""
51
    end
52

  
53
    add_index :oauth_access_tokens, :token, unique: true
54
    add_index :oauth_access_tokens, :resource_owner_id
55
    add_index :oauth_access_tokens, :refresh_token, unique: true
56

  
57
    add_foreign_key(
58
      :oauth_access_tokens,
59
      :oauth_applications,
60
      column: :application_id
61
    )
62
    add_foreign_key(
63
      :oauth_access_tokens,
64
      :users,
65
      column: :resource_owner_id
66
    )
67
  end
68
end
db/migrate/20200812065227_enable_pkce.rb
1
# frozen_string_literal: true
2

  
3
class EnablePkce < ActiveRecord::Migration[5.2]
4
  def change
5
    add_column :oauth_access_grants, :code_challenge, :string, null: true
6
    add_column :oauth_access_grants, :code_challenge_method, :string, null: true
7
  end
8
end
lib/redmine/preparation.rb
280 280
                  {:controller => 'auth_sources', :action => 'index'},
281 281
                  :icon => 'server-authentication',
282 282
                  :html => {:class => 'icon icon-server-authentication'}
283
        menu.push :applications, {:controller => 'oauth2_applications', :action => 'index'},
284
                  :if => Proc.new { Setting.rest_api_enabled? },
285
                  :caption => :'doorkeeper.layouts.admin.nav.applications',
286
                  :icon => 'apps',
287
                  :html => {:class => 'icon icon-applications'}
283 288
        menu.push :plugins, {:controller => 'admin', :action => 'plugins'},
284 289
                  :last => true,
285 290
                  :icon => 'plugins',
test/system/oauth_provider_test.rb
1
# frozen_string_literal: true
2

  
3
require File.expand_path('../application_system_test_case', __dir__)
4
require 'oauth2'
5
require 'webrick'
6

  
7
class OauthProviderSystemTest < ApplicationSystemTestCase
8
  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
9
           :trackers, :projects_trackers, :enabled_modules, :issue_statuses, :issues,
10
           :enumerations, :custom_fields, :custom_values, :custom_fields_trackers,
11
           :watchers, :journals, :journal_details, :versions,
12
           :workflows
13
  test 'application creation and authorization' do
14
    #
15
    # admin creates the application, granting permissions and generating a uuid
16
    # and secret.
17
    #
18
    log_user 'admin', 'admin'
19
    with_settings rest_api_enabled: 1 do
20
      visit '/admin'
21
      within 'div#admin-menu ul' do
22
        click_link 'Applications'
23
      end
24
      click_link 'New Application'
25
      fill_in 'Name', with: 'Oauth Test'
26

  
27
      # as per https://tools.ietf.org/html/rfc8252#section-7.3, the port can be
28
      # anything when the redirect URI's host is 127.0.0.1.
29
      fill_in 'Redirect URI', with: 'http://127.0.0.1'
30

  
31
      check 'View Issues'
32
      click_button 'Create'
33
    end
34

  
35
    assert app = Doorkeeper::Application.find_by_name('Oauth Test')
36

  
37
    find 'h2', visible: true, text: /Oauth Test/
38
    find 'p code', visible: true, text: app.uid
39
    find 'p strong', visible: true, text: /will not be shown again/
40
    find 'p code', visible: true, text: /View Issues/
41

  
42
    # scrape the clear text secret from the page
43
    app_secret = all(:css, 'p code')[1].text
44

  
45
    click_link 'Sign out'
46

  
47
    #
48
    # regular user authorizes the application
49
    #
50
    client = OAuth2::Client.new(app.uid, app_secret, site: "http://127.0.0.1:#{test_port}/")
51

  
52
    # set up a dummy http listener to handle the redirect
53
    port = rand 10000..20000
54
    redirect_uri = "http://127.0.0.1:#{port}"
55
    # the request handler below will set this to the auth token
56
    token = nil
57

  
58
    # launches webrick, listening for the redirect with the auth code.
59
    launch_client_app(port: port) do |req, res|
60
      # get access code from code url param
61
      if code = req.query['code'].presence
62
        # exchange it for token
63
        token = client.auth_code.get_token(code, redirect_uri: redirect_uri)
64
        res.body = "<html><body><p>Authorization succeeded, you may close this window now.</p></body></html>"
65
      end
66
    end
67

  
68
    log_user 'jsmith', 'jsmith'
69
    with_settings rest_api_enabled: 1 do
70
      visit '/my/account'
71
      click_link 'Authorized applications'
72
      find 'p.nodata', visible: true
73

  
74
      # an oauth client would send the user to this url to request permission
75
      url = client.auth_code.authorize_url redirect_uri: redirect_uri, scope: 'view_issues view_project'
76
      uri = URI.parse url
77
      visit uri.path + '?' + uri.query
78

  
79
      find 'h2', visible: true, text: 'Authorization required'
80
      find 'p', visible: true, text: /Authorize Oauth Test/
81
      find '.oauth-permissions', visible: true, text: /View Issues/
82
      find '.oauth-permissions', visible: true, text: /View project/
83

  
84
      click_button 'Authorize'
85

  
86
      assert grant = app.access_grants.last
87
      assert_equal 'view_issues view_project', grant.scopes.to_s
88

  
89
      # check for output defined above in the request handler
90
      find 'p', visible: true, text: /Authorization succeeded/
91
      assert token.present?
92

  
93
      visit '/my/account'
94
      click_link 'Authorized applications'
95
      find 'td', visible: true, text: /Oauth Test/
96
      click_link 'Sign out'
97

  
98
      # Now, use the token for some API requests
99
      assert_raise(RestClient::Unauthorized) do
100
        RestClient.get "http://localhost:#{test_port}/projects/onlinestore/issues.json"
101
      end
102

  
103
      headers = { 'Authorization' => "Bearer #{token.token}" }
104
      r = RestClient.get "http://localhost:#{test_port}/projects/onlinestore/issues.json", headers
105
      issues = JSON.parse(r.body)['issues']
106
      assert issues.any?
107

  
108
      # time entries access is not part of the granted scopes
109
      assert_raise(RestClient::Forbidden) do
110
        RestClient.get "http://localhost:#{test_port}/projects/onlinestore/time_entries.json", headers
111
      end
112
    end
113
  end
114

  
115
  private
116

  
117
  def launch_client_app(port: 12345, path: '/', &block)
118
    server = WEBrick::HTTPServer.new Port: port
119
    trap('INT') { server.shutdown }
120
    server.mount_proc(path, block)
121
    Thread.new { server.start }
122
    port
123
  end
124

  
125
  def test_port
126
    Capybara.current_session.server.port
127
  end
128
end
test/unit/role_test.rb
175 175
    assert_equal false, role.permissions_tracker_ids?(:view_issues, 1)
176 176
  end
177 177

  
178
  def test_allowed_to_with_symbol
179
    role = Role.create!(:name => 'Test', :permissions => [:view_issues])
180
    assert_equal true, role.allowed_to?(:view_issues)
181
    assert_equal false, role.allowed_to?(:add_issues)
182
  end
183

  
184
  def test_allowed_to_with_symbol_and_scope
185
    role = Role.create!(:name => 'Test', :permissions => [:view_issues, :delete_issues])
186
    assert_equal true, role.allowed_to?(:view_issues, [:view_issues, :add_issues])
187
    assert_equal false, role.allowed_to?(:add_issues, [:view_issues, :add_issues])
188
    assert_equal false, role.allowed_to?(:delete_issues, [:view_issues, :add_issues])
189
  end
190

  
191
  def test_allowed_to_with_hash
192
    role = Role.create!(:name => 'Test', :permissions => [:view_issues])
193
    assert_equal true, role.allowed_to?(:controller => 'issues', :action => 'show')
194
    assert_equal false, role.allowed_to?(:controller => 'issues', :action => 'create')
195
  end
196

  
197
  def test_allowed_to_with_hash_and_scope
198
    role = Role.create!(:name => 'Test', :permissions => [:view_issues, :delete_issues])
199
    assert_equal true, role.allowed_to?({:controller => 'issues', :action => 'show'}, [:view_issues, :add_issues])
200
    assert_equal false, role.allowed_to?({:controller => 'issues', :action => 'create'}, [:view_issues, :add_issues])
201
    assert_equal false, role.allowed_to?({:controller => 'issues', :action => 'destroy'}, [:view_issues, :add_issues])
202
  end
203

  
178 204
  def test_has_permission_without_permissions
179 205
    role = Role.create!(:name => 'Test')
180 206
    assert_equal false, role.has_permission?(:delete_issues)
test/unit/user_test.rb
1398 1398
    end
1399 1399
  end
1400 1400

  
1401
  def test_should_recognize_authorized_by_oauth
1402
    u = User.find 2
1403
    assert_not u.authorized_by_oauth?
1404
    u.oauth_scope = [:add_issues, :view_issues]
1405
    assert u.authorized_by_oauth?
1406
  end
1407

  
1408
  def test_admin_should_be_limited_by_oauth_scope
1409
    u = User.find_by_admin(true)
1410
    assert u.admin?
1411

  
1412
    u.oauth_scope = [:add_issues, :view_issues]
1413
    assert_not u.admin?
1414

  
1415
    u.oauth_scope = [:add_issues, :view_issues, :admin]
1416
    assert u.admin?
1417

  
1418
    u = User.find_by_admin(false)
1419
    assert_not u.admin?
1420
    u.oauth_scope = [:add_issues, :view_issues, :admin]
1421
    assert_not u.admin?
1422
  end
1423

  
1424
  def test_oauth_scope_should_limit_global_user_permissions
1425
    admin = User.find 1
1426
    user = User.find 2
1427
    [admin, user].each do |u|
1428
      assert u.allowed_to?(:add_issues, nil, global: true)
1429
      assert u.allowed_to?(:view_issues, nil, global: true)
1430
      u.oauth_scope = [:view_issues]
1431
      assert_not u.allowed_to?(:add_issues, nil, global: true)
1432
      assert u.allowed_to?(:view_issues, nil, global: true)
1433
    end
1434
  end
1435

  
1436
  def test_oauth_scope_should_limit_project_user_permissions
1437
    admin = User.find 1
1438
    project = Project.find 5
1439
    assert admin.allowed_to?(:add_issues, project)
1440
    assert admin.allowed_to?(:view_issues, project)
1441
    admin.oauth_scope = [:view_issues]
1442
    assert_not admin.allowed_to?(:add_issues, project)
1443
    assert admin.allowed_to?(:view_issues, project)
1444

  
1445
    admin.oauth_scope = [:view_issues, :admin]
1446
    assert admin.allowed_to?(:add_issues, project)
1447
    assert admin.allowed_to?(:view_issues, project)
1448

  
1449
    user = User.find 2
1450
    project = Project.find 1
1451
    assert user.allowed_to?(:add_issues, project)
1452
    assert user.allowed_to?(:view_issues, project)
1453
    user.oauth_scope = [:view_issues]
1454
    assert_not user.allowed_to?(:add_issues, project)
1455
    assert user.allowed_to?(:view_issues, project)
1456

  
1457
    user.oauth_scope = [:view_issues, :admin]
1458
    assert_not user.allowed_to?(:add_issues, project)
1459
    assert user.allowed_to?(:view_issues, project)
1460
  end
1461

  
1401 1462
  def test_destroy_should_delete_associated_reactions
1402 1463
    users(:users_004).reactions.create!(
1403 1464
      [
(27-27/27)