From fa9b29cab0841706ec30dd44949ea3076c82282e 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 | 14 ++ config/routes.rb | 5 + ...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 | 130 ++++++++++++++++++ test/unit/user_test.rb | 61 ++++++++ 28 files changed, 666 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 23c142258..6a812df80 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,9 @@ gem 'i18n', '~> 1.8.2' gem "rbpdf", "~> 1.20.0" gem 'addressable' gem 'rubyzip', '~> 2.3.0' +gem "doorkeeper", "~> 5.5.1" +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] @@ -95,6 +98,9 @@ group :test do gem 'rubocop', '~> 1.12.0' gem 'rubocop-performance', '~> 1.10.1' gem 'rubocop-rails', '~> 2.9.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 b5644e89d..0a393e5b8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -126,6 +126,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 a3d2449d2..ae09fe6b1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,6 +101,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 @@ -720,6 +721,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') @@ -740,7 +755,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) @@ -759,7 +774,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 c54183a8c..adc648d99 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 a19a8c637..445fa8f3f 100644 --- a/app/views/users/show.api.rsb +++ b/app/views/users/show.api.rsb @@ -10,7 +10,7 @@ api.user do api.last_login_on @user.last_login_on api.passwd_changed_on @user.passwd_changed_on 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 diff --git a/config/application.rb b/config/application.rb index dc8d5f89d..0a44a5d83 100644 --- a/config/application.rb +++ b/config/application.rb @@ -82,6 +82,15 @@ module RedmineApp :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..fd298bc90 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +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 e3b115bcc..59795f2d0 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -966,6 +966,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 @@ -1383,3 +1386,12 @@ de: error_invalid_authenticity_token: Invalid form authenticity token. error_query_statement_invalid: An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator. + 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 9d779a2fe..af9fb64c4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -579,6 +579,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 @@ -1356,3 +1360,13 @@ en: text_user_destroy_confirmation: "Are you sure you want to delete this user and remove all references to them? This cannot be undone. Often, locking a user instead of deleting them is the better solution. To confirm, please enter their login (%{login}) below." text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below." + + 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 767c345f0..b2a1f13c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/db/migrate/20170107092155_create_doorkeeper_tables.rb b/db/migrate/20170107092155_create_doorkeeper_tables.rb new file mode 100644 index 000000000..b5585c105 --- /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 + 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 465e7dded..e93b0b75e 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -311,6 +311,10 @@ Redmine::MenuManager.map :admin_menu do |menu| 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'} 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: '/', &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 + diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index ab1fe56ab..1531d5e9f 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -1345,4 +1345,65 @@ class UserTest < ActiveSupport::TestCase else puts "Skipping openid tests." 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 end -- 2.20.1