From 87b64d1d472af9a43abdfcb7e2ac736dbfa3b182 Mon Sep 17 00:00:00 2001 From: Jan Schulz-Hofen Date: Tue, 10 Jan 2017 16:50:01 +0100 Subject: [PATCH 4/4] Add OAuth2 provider capability using doorkeeper gem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - available oauth flows limited to the most common, "authorization code flow" - Redmine style UI for Doorkeeper OAuth2 provider Customized views according to https://github.com/doorkeeper-gem/doorkeeper/wiki/Customizing-views - Use Redmine’s permissions as OAuth2 scopes User#allowed_to? will deny any actions performed by OAuth2 apps which aren’t authorized for the required scope/permission, regardless of the user’s actual role/membership. In addition to the permission based scopes, an admin scope is introduced which can be used to grant administrator access to an application. - adds system test to test the oauth provider capabilities - application creation - authorization and access token generation by a user - API access using access token --- Gemfile | 6 + app/controllers/application_controller.rb | 8 + .../oauth2_applications_controller.rb | 19 +++ app/models/user.rb | 19 ++- .../doorkeeper/applications/_form.html.erb | 39 +++++ .../doorkeeper/applications/edit.html.erb | 6 + .../doorkeeper/applications/index.html.erb | 33 +++++ .../doorkeeper/applications/new.html.erb | 6 + .../doorkeeper/applications/show.html.erb | 54 +++++++ .../doorkeeper/authorizations/error.html.erb | 6 + .../doorkeeper/authorizations/new.html.erb | 48 ++++++ .../doorkeeper/authorizations/show.html.erb | 8 + .../authorized_applications/index.html.erb | 31 ++++ app/views/my/account.html.erb | 1 + app/views/users/show.api.rsb | 2 +- config/application.rb | 9 ++ config/initializers/doorkeeper.rb | 67 +++++++++ config/locales/de.yml | 12 ++ config/locales/en.yml | 13 ++ config/routes.rb | 6 + ...20170107092155_create_doorkeeper_tables.rb | 68 +++++++++ db/migrate/20200812065227_enable_pkce.rb | 8 + lib/redmine.rb | 4 + public/images/application_key.png | Bin 0 -> 670 bytes public/images/application_view_tile.png | Bin 0 -> 465 bytes public/stylesheets/application.css | 5 + test/system/oauth_provider_test.rb | 137 ++++++++++++++++++ test/unit/user_test.rb | 61 ++++++++ 28 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 app/controllers/oauth2_applications_controller.rb create mode 100644 app/views/doorkeeper/applications/_form.html.erb create mode 100644 app/views/doorkeeper/applications/edit.html.erb create mode 100644 app/views/doorkeeper/applications/index.html.erb create mode 100644 app/views/doorkeeper/applications/new.html.erb create mode 100644 app/views/doorkeeper/applications/show.html.erb create mode 100644 app/views/doorkeeper/authorizations/error.html.erb create mode 100644 app/views/doorkeeper/authorizations/new.html.erb create mode 100644 app/views/doorkeeper/authorizations/show.html.erb create mode 100644 app/views/doorkeeper/authorized_applications/index.html.erb create mode 100644 config/initializers/doorkeeper.rb create mode 100644 db/migrate/20170107092155_create_doorkeeper_tables.rb create mode 100644 db/migrate/20200812065227_enable_pkce.rb create mode 100755 public/images/application_key.png create mode 100755 public/images/application_view_tile.png create mode 100644 test/system/oauth_provider_test.rb diff --git a/Gemfile b/Gemfile index 7facc3064..c4abec592 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,9 @@ gem 'i18n', '~> 1.8.2' gem "rbpdf", "~> 1.20.0" gem 'addressable' gem 'rubyzip', (RUBY_VERSION < '2.4' ? '~> 1.3.0' : '~> 2.3.0') +gem "doorkeeper", "~> 5.4" +gem "bcrypt", require: false +gem "doorkeeper-i18n", "~> 5.0" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin] @@ -91,6 +94,9 @@ group :test do gem 'rubocop', '~> 0.81.0' gem 'rubocop-performance', '~> 1.5.0' gem 'rubocop-rails', '~> 2.5.0' + # for testing oauth provider capabilities + gem 'oauth2' + gem 'rest-client' end local_gemfile = File.join(File.dirname(__FILE__), "Gemfile.local") diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fca9ebc90..c47592b35 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -123,6 +123,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| diff --git a/app/controllers/oauth2_applications_controller.rb b/app/controllers/oauth2_applications_controller.rb new file mode 100644 index 000000000..a921886ef --- /dev/null +++ b/app/controllers/oauth2_applications_controller.rb @@ -0,0 +1,19 @@ +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 diff --git a/app/models/user.rb b/app/models/user.rb index 6c2adc141..070a5610f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -100,6 +100,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 = 60 @@ -693,6 +694,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') @@ -713,7 +728,7 @@ class User < Principal roles.any? do |role| (context.is_public? || role.member?) && - role.allowed_to?(action) && + role.allowed_to?(action, @oauth_scope) && (block_given? ? yield(role, self) : true) end elsif context && context.is_a?(Array) @@ -732,7 +747,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_given? ? yield(role, self) : true) end else diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb new file mode 100644 index 000000000..e4f778f63 --- /dev/null +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -0,0 +1,39 @@ +<%= error_messages_for 'application' %> +
+

<%= f.text_field :name, :required => true %>

+ +

+ <%= f.text_area :redirect_uri, :required => true, :size => 60, :label => :'activerecord.attributes.doorkeeper/application.redirect_uri' %> + + <%= t('doorkeeper.applications.help.redirect_uri') %> + +

+
+ +

<%= l(:'activerecord.attributes.doorkeeper/application.scopes') %>

+

<%= l :text_oauth_info_scopes %>

+
+
<%= l :label_oauth_admin_access %> + +
+<% perms_by_module = Redmine::AccessControl.permissions.group_by {|p| p.project_module.to_s} %> +<% perms_by_module.keys.sort.each do |mod| %> +
<%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %> + <% perms_by_module[mod].each do |permission| %> + + <% end %> +
+<% end %> +
<%= check_all_links 'scopes' %> +<%= hidden_field_tag 'doorkeeper_application[scopes][]', '' %> +
diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb new file mode 100644 index 000000000..aebc1a841 --- /dev/null +++ b/app/views/doorkeeper/applications/edit.html.erb @@ -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 %> diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb new file mode 100644 index 000000000..49a4799a7 --- /dev/null +++ b/app/views/doorkeeper/applications/index.html.erb @@ -0,0 +1,33 @@ +
+<%= link_to t('.new'), new_oauth_application_path, :class => 'icon icon-add' %> +
+ +<%= title l 'label_oauth_application_plural' %> + +<% if @applications.any? %> +
+ + + + + + + + + <% @applications.each do |application| %> + "> + + + + + + <% end %> + +
<%= t('.name') %><%= t('.callback_url') %><%= t('.scopes') %>
<%= link_to application.name, oauth_application_path(application) %><%= truncate application.redirect_uri.split.join(', '), length: 50 %><%= safe_join application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %> + <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'icon icon-edit' %> + <%= link_to t('doorkeeper.applications.buttons.destroy'), oauth_application_path(application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %> +
+
+<% else %> +

<%= l(:label_no_data) %>

+<% end %> diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb new file mode 100644 index 000000000..e2a39ac93 --- /dev/null +++ b/app/views/doorkeeper/applications/new.html.erb @@ -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 %> diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb new file mode 100644 index 000000000..7fe068125 --- /dev/null +++ b/app/views/doorkeeper/applications/show.html.erb @@ -0,0 +1,54 @@ +
+<%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(@application), :accesskey => accesskey(:edit), class: 'icon icon-edit' %> +<%= link_to t('doorkeeper.applications.buttons.destroy'), oauth_application_path(@application), :data => {:confirm => t('doorkeeper.applications.confirmations.destroy')}, :method => :delete, :class => 'icon icon-del' %> +
+ +<%= title [l('label_oauth_application_plural'), oauth_applications_path], @application.name %> + +
+

<%= l(:label_information_plural) %>

+

+ <%= t('.application_id') %>: + <%= h @application.uid %> +

+

+ <%= t('.secret') %>: + + <% 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 %> + + <% if secret.present? && Doorkeeper.config.application_secret_hashed? %> + <%= t "text_oauth_copy_secret_now" %> + <% end %> +

+

+ <%= t('.scopes') %>: + <%= safe_join @application.scopes.map{|scope| h l_or_humanize(scope, prefix: 'permission_')}, ", " %> +

+
+ +

<%= t('.callback_urls') %>

+ +
+ + + + + + + <% @application.redirect_uri.split.each do |uri| %> + "> + + + + <% end %> + +
<%= t('.callback_url') %>
<%= uri %> + <%= link_to 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' %> +
+
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 000000000..59cedf8f3 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,6 @@ +

<%= t('doorkeeper.authorizations.error.title') %>

+ +

<%= @pre_auth.error_response.body[:error_description] %>

+

<%= l(:button_back) %>

+ +<% html_title t('doorkeeper.authorizations.error.title') %> diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 000000000..898f2e645 --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,48 @@ +<%= title t('.title') %> + +
+

<%=h @pre_auth.client.name %>

+ +

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

+ +
+

<%= t('.able_to') %>:

+
    +
  • <%= l :text_oauth_implicit_permissions %>
  • + <% @pre_auth.scopes.each do |scope| %> + <% if scope == 'admin' %> +
  • <%= l :label_oauth_permission_admin %>
  • + <% else %> +
  • <%= l_or_humanize(scope, prefix: 'permission_') %>
  • + <% end %> + <% end %> +
+
+ +<% if @pre_auth.scopes.include?('admin') %> +

<%= l :text_oauth_admin_permission_info %>

+<% end %> +
+ +

+ <%= 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 %> +

diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 000000000..25ee88a87 --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,8 @@ +<%= title [l('label_oauth_authorized_application_plural'), oauth_authorized_applications_path] %> + +
<%= l(:label_information_plural) %> +

+ + <%= params[:code] %> +

+
diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 000000000..f493073af --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,31 @@ +<%= title [t(:label_my_account), my_account_path], l('label_oauth_authorized_application_plural') %> + +<% if @applications.any? %> +
+ + + + + + + + <% @applications.each do |application| %> + "> + + + + + <% end %> + +
<%= t('doorkeeper.authorized_applications.index.application') %><%= t('doorkeeper.authorized_applications.index.created_at') %>
<%= application.name %><%= format_date application.created_at %> + <%= link_to 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' %> +
+
+<% else %> +

<%= l(:label_no_data) %>

+<% end %> + +<% content_for :sidebar do %> +<% @user = User.current %> +<%= render :partial => 'my/sidebar' %> +<% end %> diff --git a/app/views/my/account.html.erb b/app/views/my/account.html.erb index 87b2d7cbd..2454865d5 100644 --- a/app/views/my/account.html.erb +++ b/app/views/my/account.html.erb @@ -1,6 +1,7 @@
<%= additional_emails_link(@user) %> <%= link_to(l(:button_change_password), {:action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %> +<%= link_to(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)%>
diff --git a/app/views/users/show.api.rsb b/app/views/users/show.api.rsb index 3bc77e3be..9bc26bae4 100644 --- a/app/views/users/show.api.rsb +++ b/app/views/users/show.api.rsb @@ -9,7 +9,7 @@ api.user do api.updated_on @user.updated_on api.last_login_on @user.last_login_on api.passwd_changed_on @user.passwd_changed_on - 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 diff --git a/config/application.rb b/config/application.rb index 86b695d42..abb7f7e8a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -81,6 +81,15 @@ module RedmineApp :key => '_redmine_session', :path => config.relative_url_root || '/' + # Use Redmine standard layouts and helpers for Doorkeeper OAuth2 screens + config.to_prepare do + Doorkeeper::ApplicationsController.layout "admin" + Doorkeeper::ApplicationsController.main_menu = false + Doorkeeper::AuthorizationsController.layout "base" + Doorkeeper::AuthorizedApplicationsController.layout "base" + Doorkeeper::AuthorizedApplicationsController.main_menu = false + end + if File.exists?(File.join(File.dirname(__FILE__), 'additional_environment.rb')) instance_eval File.read(File.join(File.dirname(__FILE__), 'additional_environment.rb')) end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 000000000..8ceca507e --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,67 @@ +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].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 + if !Setting.rest_api_enabled? || !User.current.admin? + deny_access + end + end +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 diff --git a/config/locales/de.yml b/config/locales/de.yml index 181374ab8..294fb4c4a 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -963,6 +963,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 @@ -1321,3 +1324,12 @@ de: field_passwd_changed_on: Password last changed label_import_users: Import users label_days_to_html: "%{days} days up to %{date}" + 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. diff --git a/config/locales/en.yml b/config/locales/en.yml index c3f4925cd..aa7d38110 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -562,6 +562,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 @@ -1297,3 +1301,12 @@ en: text_project_is_public_anonymous: Public projects and their contents are openly available on the network. label_import_time_entries: Import time entries label_import_users: Import users + label_oauth_permission_admin: Administrate this Redmine + label_oauth_admin_access: Administration + label_oauth_application_plural: Applications + label_oauth_authorized_application_plural: Authorized applications + 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. diff --git a/config/routes.rb b/config/routes.rb index 6ff7bc3a1..1d6fc41d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,12 @@ # 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] diff --git a/db/migrate/20170107092155_create_doorkeeper_tables.rb b/db/migrate/20170107092155_create_doorkeeper_tables.rb new file mode 100644 index 000000000..070b8e6d3 --- /dev/null +++ b/db/migrate/20170107092155_create_doorkeeper_tables.rb @@ -0,0 +1,68 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration[4.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, null: false, default: '' + 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 diff --git a/db/migrate/20200812065227_enable_pkce.rb b/db/migrate/20200812065227_enable_pkce.rb new file mode 100644 index 000000000..7b1e4582e --- /dev/null +++ b/db/migrate/20200812065227_enable_pkce.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class EnablePkce < ActiveRecord::Migration[5.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 diff --git a/lib/redmine.rb b/lib/redmine.rb index 668a65da2..e5a4eb3d4 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -267,6 +267,10 @@ Redmine::MenuManager.map :admin_menu do |menu| :html => {:class => 'icon icon-settings'} menu.push :ldap_authentication, {:controller => 'auth_sources', :action => 'index'}, :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', + :html => {:class => 'icon icon-applications'} menu.push :plugins, {:controller => 'admin', :action => 'plugins'}, :last => true, :html => {:class => 'icon icon-plugins'} menu.push :info, {:controller => 'admin', :action => 'info'}, :caption => :label_information_plural, :last => true, diff --git a/public/images/application_key.png b/public/images/application_key.png new file mode 100755 index 0000000000000000000000000000000000000000..998d65c6942453d764e0d5427e6a361cbdaca856 GIT binary patch literal 670 zcmV;P0%84$P)VY)5dRg+A)?a2)_PUQ=}3+N6D zvAM#+@`kM!V{-tZdE@rkmJI_g-<_cXFa;ngdkio&ynqp0w=gY5C?&SnjoM& zmLZk=!p^n}tjxWn(MBqDrqw?XSpzw#bPoa{5GYRc6RZqTzb8$8W`H2Mi*wCy7`V3k zOsG`{R2=|dh679l4TW;{JzHB(;tL}rw>7ZhztMPfA5xs}3CnT3=0E^5LqxE@KtZa3 zc=0nX$RaLJqAm+71(tu5g3x^B3ISA#L|9#Hl>J0*_#u*r(Q*-|Kf%>vam2^IDaOYa zu7s-kXN0N{VQKOOxzXorX+4NVgNR>ZY%rpxR3Z@J>HG=0dT;fHv(qz~&IfUadXIX= zdylHp+0@7W_G3iZ9>TDm;nyX4#||);*ozrNiA|a6zHD86?LYpuGzJNu3H-P zO&@UpeyZQXi7jKe-Hk?r-sue;aDce_XqkvXP+W#F_*ot`jB?BS93Uw71|U^ZjLH`yP%FO7U<6!nLCG} z$SDlW "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: '/') + server = WEBrick::HTTPServer.new Port: port + trap 'INT' do server.shutdown end + server.mount_proc path do |req, res| + yield req, res + end + Thread.new{ server.start } + port + end + + def test_port + Capybara.current_session.server.port + end +end + diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 56e4c5ecf..7f7c40366 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -1327,4 +1327,65 @@ class UserTest < ActiveSupport::TestCase else puts "Skipping openid tests." end + + def test_should_recognize_authorized_by_oauth + u = User.find 2 + refute u.authorized_by_oauth? + u.oauth_scope = %i[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 = %i[add_issues view_issues] + refute u.admin? + + u.oauth_scope = %i[add_issues view_issues admin] + assert u.admin? + + u = User.find_by_admin(false) + refute u.admin? + u.oauth_scope = %i[add_issues view_issues admin] + refute 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 = %i[view_issues] + refute 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 = %i[view_issues] + refute admin.allowed_to?(:add_issues, project) + assert admin.allowed_to?(:view_issues, project) + + admin.oauth_scope = %i[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 = %i[view_issues] + refute user.allowed_to?(:add_issues, project) + assert user.allowed_to?(:view_issues, project) + + user.oauth_scope = %i[view_issues admin] + refute user.allowed_to?(:add_issues, project) + assert user.allowed_to?(:view_issues, project) + end end -- 2.20.1