Automatically add OAuth application with storages

pull/10889/head
Wieland Lindenthal 2 years ago committed by Eric Schubert
parent 2869ee24be
commit 38ef24a9cf
No known key found for this signature in database
GPG Key ID: 1D346C019BD4BAA2
  1. 34
      app/cells/oauth/applications/row_cell.rb
  2. 14
      app/contracts/oauth/application_contract.rb
  3. 11
      app/helpers/oauth_helper.rb
  4. 5
      db/migrate/20220629073727_add_polymorphic_integration_to_oauth_application.rb
  5. 33
      lib/open_project/patches/doorkeeper_application.rb
  6. 34
      modules/storages/app/controllers/storages/admin/storages_controller.rb
  7. 1
      modules/storages/app/models/storages/storage.rb
  8. 58
      modules/storages/app/services/storages/oauth_applications/create_service.rb
  9. 13
      modules/storages/app/services/storages/storages/create_service.rb
  10. 19
      modules/storages/app/services/storages/storages/update_service.rb
  11. 32
      modules/storages/app/views/storages/admin/storages/edit.html.erb
  12. 4
      modules/storages/app/views/storages/admin/storages/new_oauth_client.html.erb
  13. 26
      modules/storages/app/views/storages/admin/storages/show.html.erb
  14. 46
      modules/storages/app/views/storages/admin/storages/show_oauth_application.html.erb
  15. 7
      modules/storages/config/locales/en.yml
  16. 3
      modules/storages/config/routes.rb
  17. 32
      modules/storages/spec/services/storages/storages/create_service_spec.rb
  18. 31
      modules/storages/spec/services/storages/storages/update_service_spec.rb

@ -10,7 +10,11 @@ module OAuth
end
def name
link_to application.name, oauth_application_path(application)
if application.integration_type == 'Storages::Storage'
link_to application.name, admin_settings_storage_path(application.integration)
else
link_to application.name, oauth_application_path(application)
end
end
def owner
@ -39,18 +43,28 @@ module OAuth
delegate :confidential, to: :application
def edit_link
link_to(
I18n.t(:button_edit),
edit_oauth_application_path(application),
class: "oauth-application--edit-link icon icon-edit"
)
if application.integration_type == 'Storages::Storage'
link_to(
I18n.t(:button_edit),
edit_admin_settings_storage_path(application.integration),
class: "oauth-application--edit-link icon icon-edit"
)
else
link_to(
I18n.t(:button_edit),
edit_oauth_application_path(application),
class: "oauth-application--edit-link icon icon-edit"
)
end
end
def button_links
[
edit_link,
delete_link(oauth_application_path(application))
]
buttons = [edit_link]
if application.integration.blank?
buttons.unshift delete_link(oauth_application_path(application))
end
buttons
end
end
end

@ -33,6 +33,7 @@ module OAuth
end
validate :validate_client_credential_user
validate :validate_integration
attribute :name
attribute :redirect_uri
@ -41,13 +42,22 @@ module OAuth
attribute :owner_type
attribute :scopes
attribute :client_credentials_user_id
attribute :integration_id
attribute :integration_type
private
def validate_integration
if (model.integration_id.nil? && model.integration_type.present?) ||
(model.integration_id.present? && model.integration_type.nil?)
errors.add :integration, :invalid
end
end
def validate_client_credential_user
return unless model.client_credentials_user_id.present?
return if model.client_credentials_user_id.blank?
unless User.where(id: model.client_credentials_user_id).exists?
unless User.exists?(id: model.client_credentials_user_id)
errors.add :client_credentials_user_id, :invalid
end
end

@ -39,6 +39,17 @@ module OAuthHelper
end
end
##
# Show first two and last two characters, with **** in the middle
def short_secret(secret)
result = ""
if secret.is_a?(String) && secret.present?
result = "#{secret[...2]}****#{secret[-2...]}"
end
result
end
##
# Get granted applications for the given user
def granted_applications(user = current_user)

@ -0,0 +1,5 @@
class AddPolymorphicIntegrationToOAuthApplication < ActiveRecord::Migration[7.0]
def change
add_reference :oauth_applications, :integration, polymorphic: true
end
end

@ -0,0 +1,33 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
# This patch adds an optional polymorphic relation to OAuth applications.
Doorkeeper::Application.class_eval do
belongs_to :integration, polymorphic: true
end

@ -41,9 +41,7 @@ class Storages::Admin::StoragesController < ApplicationController
# Before executing any action below: Make sure the current user is an admin
# and set the @<controller_name> variable to the object referenced in the URL.
before_action :require_admin
before_action :find_model_object, only: %i[show destroy edit update]
before_action :set_shortened_secret, only: %i[show edit update]
before_action :find_model_object, only: %i[show destroy edit update replace_oauth_application]
# menu_item is defined in the Redmine::MenuManager::MenuController
# module, included from ApplicationController.
@ -89,14 +87,9 @@ class Storages::Admin::StoragesController < ApplicationController
service_result = Storages::Storages::CreateService.new(user: current_user).call(permitted_storage_params)
@object = service_result.result
if service_result.success?
if service_result.success? && (@oauth_application = service_result.dependent_results&.first&.result)
flash[:notice] = I18n.t(:notice_successful_create)
if @object.oauth_client
# admin_settings_storage_path is automagically created by Ruby routes.
redirect_to admin_settings_storage_path(@object)
else
redirect_to new_admin_settings_storage_oauth_client_path(@object)
end
render :show_oauth_application
else
@errors = service_result.errors
render :new
@ -141,13 +134,18 @@ class Storages::Admin::StoragesController < ApplicationController
redirect_to admin_settings_storages_path
end
# Show first two and last two characters, with **** in the middle
def shortened_secret(secret)
result = ""
if secret.is_a?(String) && secret.present?
result = "#{secret[...2]}****#{secret[-2...]}"
def replace_oauth_application
@object.oauth_application.destroy
service_result = ::Storages::OAuthApplications::CreateService.new(storage: @object, user: current_user).call
if service_result.success?
flash[:notice] = I18n.t('storages.notice_oauth_application_replaced')
@oauth_application = service_result.result
render :show_oauth_application
else
@errors = service_result.errors
render :edit
end
result
end
# Used by: admin layout
@ -182,8 +180,4 @@ class Storages::Admin::StoragesController < ApplicationController
.require(:storages_storage)
.permit('name', 'provider_type', 'host', 'oauth_client_id', 'oauth_client_secret')
end
def set_shortened_secret
@short_secret = shortened_secret(@object.oauth_client&.client_secret.to_s)
end
end

@ -54,6 +54,7 @@ class Storages::Storage < ApplicationRecord
# The OAuth client credentials that OpenProject will use to obtain user specific
# access tokens from the storage server, i.e a Nextcloud serer.
has_one :oauth_client, as: :integration, dependent: :destroy
has_one :oauth_application, class_name: '::Doorkeeper::Application', as: :integration, dependent: :destroy
PROVIDER_TYPES = [
PROVIDER_TYPE_NEXTCLOUD = 'nextcloud'.freeze

@ -0,0 +1,58 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
# The logic for creating storage was extracted from the controller and put into
# a service: https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19
# Purpose: create and persist a Storages::Storage record
# Used by: Storages::Admin::StoragesController#create, could also be used by the
# API in the future.
# Reference: https://www.openproject.org/docs/development/concepts/contracted-services/
# The comments here are also valid for the other *_service.rb files
module Storages::OAuthApplications
class CreateService
attr_accessor :user, :storage
def initialize(storage:, user:)
@storage = storage
@user = user
end
def call
::OAuth::PersistApplicationService
.new(::Doorkeeper::Application.new, user:)
.call({
name: "#{storage.name} (#{I18n.t("storages.provider_types.#{storage.provider_type}")})",
redirect_uri: File.join(storage.host, "apps/integration_openproject/oauth-redirect"),
scopes: '',
confidential: true,
owner: storage.creator,
integration: storage
})
end
end
end

@ -35,5 +35,18 @@
# The comments here are also valid for the other *_service.rb files
module Storages::Storages
class CreateService < ::BaseServices::Create
protected
def after_perform(service_call)
super(service_call)
storage = service_call.result
if storage.provider_type == 'nextcloud'
persist_service_result = ::Storages::OAuthApplications::CreateService.new(storage:, user:).call
service_call.add_dependent!(persist_service_result)
end
service_call
end
end
end

@ -29,5 +29,24 @@
# See also: create_service.rb for comments
module Storages::Storages
class UpdateService < ::BaseServices::Update
protected
def after_perform(service_call)
super(service_call)
storage = service_call.result
if storage.provider_type == 'nextcloud'
application = storage.oauth_application
persist_service_result = ::OAuth::PersistApplicationService
.new(application, user:)
.call({
name: "#{storage.name} (#{I18n.t("storages.provider_types.#{storage.provider_type}")})",
redirect_uri: File.join(storage.host, "apps/integration_openproject/oauth-redirect")
})
service_call.add_dependent!(persist_service_result)
end
service_call
end
end
end

@ -14,6 +14,34 @@
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<% if @object.oauth_application %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend">OpenProject <%= t(:'storages.label_oauth_application_details') %></legend>
<div class="attributes-key-value">
<div class="attributes-key-value--key">OpenProject <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_application.uid %></span>
</div>
</div>
<div class="attributes-key-value--key">OpenProject <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= short_secret(@object.oauth_application.secret) %></span>
</div>
</div>
</div>
<%= link_to(t("storages.buttons.replace_openproject_oauth"),
replace_oauth_application_admin_settings_storage_path(@object),
method: :delete,
data: { confirm: t(:'storages.confirm_replace_oauth_application')},
class: 'button -with-icon icon-reload' ) %>
</fieldset>
</section>
<% end %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_details') %></legend>
@ -28,11 +56,11 @@
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
<span><%= short_secret(@object.oauth_client.client_secret) %></span>
</div>
</div>
</div>
<%= link_to(t("button_replace"),
<%= link_to(t("storages.buttons.replace_provider_type_oauth", provider_type: t("storages.provider_types.#{@object.provider_type}")),
new_admin_settings_storage_oauth_client_path(@object),
data: { confirm: t(:'storages.confirm_replace_oauth_client')},
class: 'button -with-icon icon-reload' ) %>

@ -13,7 +13,7 @@
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_id") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
URI::join(@storage.host, "settings/admin/openproject").to_s,
target: "blank" %>
</span>
</div>
@ -26,7 +26,7 @@
<span class="form--field-instructions">
<%= t("storages.instructions.#{@storage.provider_type}.oauth_client_secret") %>
<%= link_to "#{t("storages.provider_types.#{@storage.provider_type}")} / #{t("storages.instructions.#{@storage.provider_type}.administration")} / #{t("storages.instructions.#{@storage.provider_type}.oauth2_clients")}",
URI::join(@storage.host, "settings/admin/security#oauth2").to_s,
URI::join(@storage.host, "settings/admin/openproject").to_s,
target: "blank" %>
</span>
</div>

@ -93,6 +93,30 @@ See COPYRIGHT and LICENSE files for more details.
</div>
</div>
<% if @object.oauth_application %>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
<h3 class="attributes-group--header-text">OpenProject <%= t(:'storages.label_oauth_application_details') %></h3>
</div>
</div>
<div class="attributes-key-value">
<div class="attributes-key-value--key">OpenProject <%= t(:'storages.label_oauth_client_id') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @object.oauth_application.uid %></span>
</div>
</div>
<div class="attributes-key-value--key">OpenProject <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= short_secret(@object.oauth_application.secret) %></span>
</div>
</div>
</div>
</div>
<% end %>
<div class="attributes-group">
<div class="attributes-group--header">
<div class="attributes-group--header-container">
@ -110,7 +134,7 @@ See COPYRIGHT and LICENSE files for more details.
<div class="attributes-key-value--key"><%= t("storages.provider_types.#{@object.provider_type}") %> <%= t(:'storages.label_oauth_client_secret') %></div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value -text">
<span><%= @short_secret %></span>
<span><%= short_secret(@object.oauth_client.client_secret) %></span>
</div>
</div>
</div>

@ -0,0 +1,46 @@
<!-- Standard Ruby view, please see the controller for comments -->
<% html_title t(:label_administration), t("project_module_storages"), @object.name, "#{t("storages.provider_types.#{@object.provider_type}")} #{t("storages.label_oauth_application_details")}" %>
<% local_assigns[:additional_breadcrumb] = "#{t("storages.provider_types.#{@object.provider_type}")} #{t("storages.label_oauth_application_details")}" %>
<%= toolbar title: "#{t("storages.provider_types.#{@object.provider_type}")} #{t("storages.label_oauth_application_details")}" %>
<%= labelled_tabular_form_for @oauth_application, url: '/' do |f| -%>
<div class="form--field">
<%= f.text_field :uid,
label: t('storages.label_oauth_client_id'),
size: 40,
container_class: '-wide',
disabled: true,
id: 'client_id' %>
<% csp_onclick('this.focus(); this.select();', '#client_id') %>
<copy-to-clipboard click-target=".client-id-copy-button"
clipboard-target="#client_id">
</copy-to-clipboard>
<button class="client-id-copy-button toolbar-input--affix toolbar-input-group--affix -append"
title="<%= t(:label_copy_to_clipboard) %>">
<%= op_icon('icon-copy') %>
<span class="hidden-for-sighted"><%= t(:label_copy_to_clipboard) %></span>
</button>
</div>
<div class="form--field">
<%= f.text_field :plaintext_secret,
label: t('storages.label_oauth_client_secret'),
size: 40,
container_class: '-wide',
disabled: true,
id: 'secret' %>
<% csp_onclick('this.focus(); this.select();', '#secret') %>
<copy-to-clipboard click-target=".secret-copy-button"
clipboard-target="#secret">
</copy-to-clipboard>
<button class="secret-copy-button toolbar-input--affix toolbar-input-group--affix -append"
title="<%= t(:label_copy_to_clipboard) %>">
<%= op_icon('icon-copy') %>
<span class="hidden-for-sighted"><%= t(:label_copy_to_clipboard) %></span>
</button>
</div>
<% if @object.oauth_client %>
<%= link_to t("storages.buttons.done_continue_setup"), admin_settings_storage_path(@object), class: "-highlight -with-icon icon-checkmark button" %>
<% else %>
<%= link_to t("storages.buttons.done_continue_setup"), new_admin_settings_storage_oauth_client_path(@object), class: "-highlight -with-icon icon-checkmark button" %>
<% end %>
<% end %>

@ -36,6 +36,10 @@ en:
too_many_elements_created_at_once: "Too many elements created at once. Expected %{max} at most, got %{actual}."
storages:
buttons:
done_continue_setup: "Done. Continue setup"
replace_openproject_oauth: "Replace OpenProject OAuth"
replace_provider_type_oauth: "Replace %{provider_type} OAuth"
page_titles:
project_settings:
index: "File storages available in this project"
@ -66,6 +70,7 @@ en:
label_file_links: "File links"
label_name: "Name"
label_host: "Host"
label_oauth_application_details: "OAuth application details"
label_oauth_client_details: "OAuth client details"
label_provider_type: "Provider type"
label_new_storage: "New storage"
@ -77,5 +82,7 @@ en:
provider_types:
label: "Provider type"
nextcloud: "Nextcloud"
confirm_replace_oauth_application: "Are you sure? All users will have to authorize again against OpenProject."
confirm_replace_oauth_client: "Are you sure? All users will have to authorize again against the storage."
oauth_client_details_missing: "To complete the setup, please add OAuth client credentials from your storage."
notice_oauth_application_replaced: "The OpenProject OAuth application was successfully replaced."

@ -31,6 +31,9 @@ OpenProject::Application.routes.draw do
namespace :settings do
resources :storages, controller: '/storages/admin/storages' do
resource :oauth_client, controller: '/storages/admin/oauth_clients', only: %i[new create]
member do
delete '/replace_oauth_application' => '/storages/admin/storages#replace_oauth_application'
end
end
end
end

@ -32,5 +32,37 @@ require 'services/base_services/behaves_like_create_service'
describe ::Storages::Storages::CreateService, type: :model do
it_behaves_like 'BaseServices create service' do
let(:factory) { :storage }
let!(:user) { create :admin }
let(:instance) do
described_class.new(user:,
contract_class:)
end
let(:call_attributes) do
{
name: 'My storage',
host: 'https://example.org',
provider_type: :nextcloud
}
end
let!(:model_instance) do
build_stubbed(factory,
creator: user,
name: call_attributes[:name],
host: call_attributes[:host],
provider_type: call_attributes[:provider_type])
end
it "creates an OAuth application (::Doorkeeper::Application)" do
expect(subject).to be_success
expect(subject.result.oauth_application).to be_a(::Doorkeeper::Application)
expect(subject.result.oauth_application.name).to include call_attributes[:name]
expect(subject.result.oauth_application.redirect_uri).to include call_attributes[:host]
expect(subject.result.oauth_application.owner).to eql user
expect(subject.dependent_results.first.result.secret).to be_present
end
end
end

@ -32,6 +32,37 @@ require 'services/base_services/behaves_like_update_service'
describe ::Storages::Storages::UpdateService, type: :model do
it_behaves_like 'BaseServices update service' do
let(:factory) { :storage }
let!(:user) { create :admin }
let(:instance) do
described_class.new(user:,
model: model_instance,
contract_class:)
end
let(:call_attributes) do
{
name: 'My updated storage',
host: 'https://new.example.org'
}
end
let!(:model_instance) do
build_stubbed(factory,
creator: user,
name: 'My updated storage',
host: 'https://updated.example.org',
provider_type: 'nextcloud')
end
let!(:oauth_application) { create :oauth_application, integration: model_instance }
it "creates an OAuth application (::Doorkeeper::Application)" do
expect(subject).to be_success
expect(subject.result.oauth_application).to be_a(::Doorkeeper::Application)
expect(subject.result.oauth_application.name).to include 'My updated storage'
expect(subject.result.oauth_application.redirect_uri).to include 'https://updated.example.org'
end
end
it 'cannot update storage creator' do

Loading…
Cancel
Save