diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb new file mode 100644 index 000000000000..77755db8948d --- /dev/null +++ b/app/controllers/oauth_clients_controller.rb @@ -0,0 +1,160 @@ +#-- 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 controller handles OAuth2 Authorization Code Grant redirects from a Authorization Server to +# "callback" endpoint. +class OAuthClientsController < ApplicationController + before_action :find_oauth_client + before_action :set_redirect_uri + before_action :set_code + before_action :set_connection_manager + + # Provide the OAuth2 "callback" endpoint. + # The Authorization Server redirects + # here after successful authentication and authorization. + # This endpoint gets a "code" parameter that cryptographically + # contains a grant. + # We get here by a URL like this: + # http://localhost:4200/oauth_clients/asdf12341234qsdfasdfasdf/callback? + # state=http%3A%2F%2Flocalhost%3A4200%2Fprojects%2Fdemo-project%2Foauth2_example& + # code=MQoOnUTJGFdAo5jBGD1SqnDH0PV6yioG7NoYM2zZZlK3g6LuKrGUmOxjIS1bIy7fHEfZy2WrgYcx + def callback + # Exchange the code with a token using a HTTP call to the Authorization Server + service_result = @connection_manager.code_to_token(@code) + + if service_result.success? + # Redirect the user to the page that initially wanted to access the OAuth2 resource. + # "state" is a nonce that identifies a cookie which holds that page's URL. + redirect_to @redirect_uri + else + # We got a list of errors from ::OAuthClients::ConnectionManager + set_oauth_errors(service_result) + + redirect_user_or_admin(@redirect_uri) do + # If the current user is an admin, we send her directly to the + # settings that she needs to edit. + redirect_to admin_settings_storage_path(@oauth_client.integration) + end + end + end + + private + + def set_oauth_errors(service_result) + flash[:error] = ["#{t(:'oauth_client.errors.oauth_authorization_code_grant_had_errors')}:"] + service_result.errors.each do |error| + flash[:error] << "#{t(:'oauth_client.errors.oauth_reported')}: #{error.full_message}" + end + end + + def set_code + # The OAuth2 provider should have sent a code when using response_type = "code" + # So this could either be an error from the Authorization Server (i.e. Nextcloud) or + # ::OAuthClient::ConnectionManager has used the wrong response_type. + @code = params[:code] + + if @code.blank? + flash[:error] = [I18n.t('oauth_client.errors.oauth_code_not_present'), + I18n.t('oauth_client.errors.oauth_code_not_present_explanation')] + + redirect_user_or_admin(get_redirect_uri) do + # If the current user is an admin, we send her directly to the + # settings that she needs to edit/fix. + redirect_to admin_settings_storage_path(@oauth_client.integration) + end + end + end + + def set_redirect_uri + # redirect_uri is used by OpenProject to redirect to + # after receiving an OAuth2 access token. So it should not be blank. + service_result = ::OAuthClients::RedirectUriFromStateService + .new(state: params[:state], cookies:) + .call + + if service_result.success? + @redirect_uri = service_result.result + else + # To protect against CSRF we cancel this request. There was either no + # state parameter given, or there was no corresponding cookie present. + flash[:error] = [I18n.t('oauth_client.errors.oauth_state_not_present'), + I18n.t('oauth_client.errors.oauth_state_not_present_explanation')] + + redirect_user_or_admin(nil) do + # Guide the user to the settings that she needs to edit/fix. + redirect_to admin_settings_storage_path(@oauth_client.integration) + end + end + end + + def set_connection_manager + @connection_manager = OAuthClients::ConnectionManager.new(user: User.current, oauth_client: @oauth_client) + end + + def find_oauth_client + @oauth_client = OAuthClient.find_by(client_id: params[:oauth_client_id]) + if @oauth_client.nil? + # oauth_client can be nil if OAuthClient was not found. + # This happens during admin setup if the user forgot to update the return_uri + # on the Authorization Server (i.e. Nextcloud) after updating the OpenProject + # side with a new client_id and client_secret. + flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), + I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] + + redirect_user_or_admin(get_redirect_uri) do + # Something must be wrong in the storage's setup + redirect_to admin_settings_storages_path + end + end + end + + def redirect_user_or_admin(redirect_uri = nil) + # This needs to be modified as soon as we support more integration types. + if User.current.admin && redirect_uri && nextcloud? + yield + elsif redirect_uri + flash[:error] = [t(:'oauth_client.errors.oauth_issue_contact_admin')] + redirect_to redirect_uri + else + redirect_to ::API::V3::Utilities::PathHelper::ApiV3Path::root_url + end + end + + def nextcloud? + @oauth_client&.integration && \ + @oauth_client.integration.is_a?(::Storages::Storage) && \ + @oauth_client.integration.provider_type == 'nextcloud' + end + + def get_redirect_uri + ::OAuthClients::RedirectUriFromStateService + .new(state: params[:state], cookies:) + .call + .result + end +end diff --git a/app/models/oauth_client_token.rb b/app/models/oauth_client_token.rb new file mode 100644 index 000000000000..bc19751ab958 --- /dev/null +++ b/app/models/oauth_client_token.rb @@ -0,0 +1,41 @@ +#-- 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. +#++ + +# OAuthClientToken stores the OAuth2 Bearer+Refresh tokens that +# an OAuth2 server (Nextcloud or similar) provides after a user +# has granted access. +class OAuthClientToken < ApplicationRecord + # OAuthClientToken sits between User and OAuthClient + belongs_to :user, optional: false + belongs_to :oauth_client, optional: false + + validates :user, uniqueness: { scope: :oauth_client } + + validates :access_token, length: { minimum: 1, maximum: 255 } + validates :refresh_token, length: { minimum: 1, maximum: 255 } +end diff --git a/app/services/oauth_clients/connection_manager.rb b/app/services/oauth_clients/connection_manager.rb new file mode 100644 index 000000000000..d0bbd83afd2a --- /dev/null +++ b/app/services/oauth_clients/connection_manager.rb @@ -0,0 +1,209 @@ +#-- 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. +#++ + +require "rack/oauth2" +require "uri/http" + +module OAuthClients + class ConnectionManager + attr_reader :user, :oauth_client + + def initialize(user:, oauth_client:) + @user = user + @oauth_client = oauth_client + end + + # Main method to initiate the OAuth2 flow called by a "client" component + # that wants to access OAuth2 protected resources. + # Returns an OAuthClientToken object or a String in case a renew is required. + # @param state (OAuth2 RFC) encapsulates the state of the calling page (URL + params) to return + # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud only has one global scope. + def get_access_token(scope: [], state: nil) + # Check for an already existing token from last call + token = get_existing_token + return ServiceResult.new(success: true, result: token) if token.present? + + # Return a String with a redirect URL to Nextcloud instead of a token + @redirect_url = redirect_to_oauth_authorize(scope:, state:) + ServiceResult.new(success: false, result: @redirect_url) + end + + # The bearer/access token has expired or is due for renew for other reasons. + # Talk to OAuth2 Authorization Server to exchange the renew_token for a new bearer token. + def refresh_token + # There should already be an existing token, + # otherwise this method has been called too early (internal flow error). + oauth_client_token = get_existing_token + if oauth_client_token.nil? + return service_result_with_error(I18n.t('oauth_client.errors.refresh_token_called_without_existing_token')) + end + + # Get the Rack::OAuth2::Client and call access_token!, then return a ServiceResult. + service_result = request_new_token(refresh_token: oauth_client_token.refresh_token) + return service_result unless service_result.success? + + # Updated tokens, handle model checking errors and return a ServiceResult + update_oauth_client_token(oauth_client_token, service_result.result) + end + + # Redirect to the "authorize" endpoint of the OAuth2 Authorization Server. + # @param state (OAuth2 RFC) is a nonce referencing a cookie containing the calling page (URL + params) to which to + # return to at the end of the whole flow. + # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud only has one global scope. + def redirect_to_oauth_authorize(scope: [], state: nil) + client = rack_oauth_client # Configure and start the rack-oauth2 client + client.authorization_uri(scope:, state:) + end + + # Called by callback_page with a cryptographic "code" that indicates + # that the user has successfully authorized the OAuth2 Authorization Server. + # We now are going to exchange this code to a token (bearer+refresh) + def code_to_token(code) + # Return a Rack::OAuth2::AccessToken::Bearer or an error string + service_result = request_new_token(authorization_code: code) + return service_result unless service_result.success? + + # Create a new OAuthClientToken from Rack::OAuth::AccessToken::Bearer and return + ServiceResult.new( + success: true, + result: create_new_oauth_client_token(service_result.result) + ) + end + + private + + # Check if a OAuthClientToken already exists and return nil otherwise. + # Don't handle the case of an expired token. + def get_existing_token + # Check if we've got a token in the database and return nil otherwise. + OAuthClientToken.find_by(user_id: @user, oauth_client_id: @oauth_client.id) + end + + # Calls client.access_token! + # Convert the various exceptions into user-friendly error strings. + def request_new_token(options = {}) + rack_access_token = rack_oauth_client(options) + .access_token!(:body) # Rack::OAuth2::AccessToken + + ServiceResult.new(success: true, + result: rack_access_token) + rescue Rack::OAuth2::Client::Error => e # Handle Rack::OAuth2 specific errors + service_result_with_error(i18n_rack_oauth2_error_message(e)) + rescue Timeout::Error, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, + Errno::EINVAL, Errno::ENETUNREACH, Errno::ECONNRESET, Errno::ECONNREFUSED, JSON::ParserError => e + service_result_with_error( + "#{I18n.t('oauth_client.errors.oauth_returned_http_error')}: #{e.class}: #{e.message.to_html}" + ) + rescue StandardError => e + service_result_with_error( + "#{I18n.t('oauth_client.errors.oauth_returned_standard_error')}: #{e.class}: #{e.message.to_html}" + ) + end + + # Localize the error message + def i18n_rack_oauth2_error_message(rack_oauth2_client_exception) + i18n_key = "oauth_client.errors.rack_oauth2.#{rack_oauth2_client_exception.message}" + if I18n.exists? i18n_key + I18n.t(i18n_key) + else + "#{I18n.t('oauth_client.errors.oauth_returned_error')}: #{rack_oauth2_client_exception.message.to_html}" + end + end + + # Return a fully configured RackOAuth2Client. + # This client does all the heavy lifting with the OAuth2 protocol. + def rack_oauth_client(options = {}) + rack_oauth_client = build_basic_rack_oauth_client + + # Write options, for example authorization_code and refresh_token + rack_oauth_client.refresh_token = options[:refresh_token] if options[:refresh_token] + rack_oauth_client.authorization_code = options[:authorization_code] if options[:authorization_code] + + rack_oauth_client + end + + def build_basic_rack_oauth_client + oauth_client_uri = URI.parse(@oauth_client.integration.host) + oauth_client_scheme = oauth_client_uri.scheme + oauth_client_host = oauth_client_uri.host + oauth_client_port = oauth_client_uri.port + + Rack::OAuth2::Client.new( + identifier: @oauth_client.client_id, + secret: @oauth_client.client_secret, + scheme: oauth_client_scheme, + host: oauth_client_host, + port: oauth_client_port, + authorization_endpoint: "/apps/oauth2/authorize", + token_endpoint: "/apps/oauth2/api/v1/token" + ) + end + + # Create a new OpenProject token object based on the return values + # from a Rack::OAuth2::AccessToken::Bearer token + def create_new_oauth_client_token(rack_access_token) + OAuthClientToken.create( + user: @user, + oauth_client: @oauth_client, + origin_user_id: rack_access_token.raw_attributes[:user_id], # ID of user at OAuth2 Authorization Server + access_token: rack_access_token.access_token, + token_type: rack_access_token.token_type, # :bearer + refresh_token: rack_access_token.refresh_token, + expires_in: rack_access_token.raw_attributes[:expires_in], + scope: rack_access_token.scope + ) + end + + # Update an OpenProject token based on updated values from a + # Rack::OAuth2::AccessToken::Bearer after a OAuth2 refresh operation + def update_oauth_client_token(oauth_client_token, rack_oauth2_access_token) + success = oauth_client_token.update( + access_token: rack_oauth2_access_token.access_token, + refresh_token: rack_oauth2_access_token.refresh_token, + expires_in: rack_oauth2_access_token.expires_in + ) + + if success + ServiceResult.new(success: true, result: oauth_client_token) + else + result = ServiceResult.new(success: false) + result.errors.add(:base, I18n.t('oauth_client.errors.refresh_token_updated_failed')) + result.add_dependent!(ServiceResult.new(success: false, errors: oauth_client_token.errors)) + result + end + end + + # Shortcut method to convert an error message into an unsuccessful + # ServiceResult with that error message + def service_result_with_error(message) + ServiceResult.new(success: false).tap do |result| + result.errors.add(:base, message) + end + end + end +end diff --git a/app/services/oauth_clients/redirect_uri_from_state_service.rb b/app/services/oauth_clients/redirect_uri_from_state_service.rb new file mode 100644 index 000000000000..cb70a810aba9 --- /dev/null +++ b/app/services/oauth_clients/redirect_uri_from_state_service.rb @@ -0,0 +1,57 @@ +#-- 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. +#++ + +require "rack/oauth2" +require "uri/http" + +module OAuthClients + class RedirectUriFromStateService + def initialize(state:, cookies:) + @state = state + @cookies = cookies + end + + def call + redirect_uri = oauth_state_cookie + + if redirect_uri.present? && ::API::V3::Utilities::PathHelper::ApiV3Path::same_origin?(redirect_uri) + ServiceResult.new(success: true, result: redirect_uri) + else + ServiceResult.new(success: false) + end + end + + private + + def oauth_state_cookie + return nil if @state.blank? + + @cookies["oauth_state_#{@state}"] + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index e6fc01091f05..fa8f2d25b722 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3213,4 +3213,44 @@ en: revoke_my_application_confirmation: "Do you really want to remove this application? This will revoke %{token_count} active for it." my_registered_applications: "Registered OAuth applications" + oauth_client: + labels: + label_oauth_integration: "OAuth2 integration" + label_redirect_uri: "Redirect URI" + label_request_token: "Request token" + label_refresh_token: "Refresh token" + errors: + oauth_authorization_code_grant_had_errors: "OAuth2 returned an error" + oauth_reported: "OAuth2 provider reported" + oauth_returned_error: "OAuth2 returned an error" + oauth_returned_json_error: "OAuth2 returned a JSON error" + oauth_returned_http_error: "OAuth2 returned a network error" + oauth_returned_standard_error: "OAuth2 returned an internal error" + wrong_token_type_returned: "OAuth2 returned a wrong type of token, expecting AccessToken::Bearer" + oauth_issue_contact_admin: "OAuth2 reported an error. Please contact your system administrator." + oauth_client_not_found: "OAuth2 client not found in 'callback' endpoint (redirect_uri)." + refresh_token_called_without_existing_token: > + Internal error: Called refresh_token without a previously existing token. + refresh_token_updated_failed: "Error during update of OAuthClientToken" + oauth_client_not_found_explanation: > + This error appears after you have updated the client_id and client_secret + in OpenProject, but haven't updated the 'Return URI' field in the OAuth2 provider. + oauth_code_not_present: "OAuth2 'code' not found in 'callback' endpoint (redirect_uri)." + oauth_code_not_present_explanation: > + This error appears if you have selected the wrong response_type + in the OAuth2 provider. Response_type should be 'code' or similar. + oauth_state_not_present: "OAuth2 'state' not found in 'callback' endpoint (redirect_uri)." + oauth_state_not_present_explanation: > + The 'state' is used to indicate to OpenProject where to continue + after a successful OAuth2 authentication. + A missing 'state' is an internal error that may appear during setup. + Please contact your system administrator. + rack_oauth2: + client_secret_invalid: "Client secret is invalid" + invalid_request: > + OAuth2 server responded with 'invalid_request'. + This error appears if you try to authorize multiple times. + invalid_response: "OAuth2 server provided an invalid response" + + you: you diff --git a/config/routes.rb b/config/routes.rb index dee08a838f38..408f9c2d9823 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -579,6 +579,11 @@ get '(/*state)', to: 'angular#notifications_layout', as: :notifications_center end + # OAuthClient needs a "callback" URL that Nextcloud calls with a "code" (see OAuth2 RFC) + scope 'oauth_clients/:oauth_client_id' do + get 'callback', controller: 'oauth_clients', action: :callback + end + # Routes for design related documentation and examples pages get '/design/spot', to: 'angular#empty_layout' get '/design/styleguide' => redirect('/assets/styleguide.html') diff --git a/db/migrate/20220518154147_create_oauth_client_tokens.rb b/db/migrate/20220518154147_create_oauth_client_tokens.rb new file mode 100644 index 000000000000..d1bc03293a14 --- /dev/null +++ b/db/migrate/20220518154147_create_oauth_client_tokens.rb @@ -0,0 +1,18 @@ +class CreateOAuthClientTokens < ActiveRecord::Migration[6.1] + def change + create_table :oauth_client_tokens do |t| + t.references :oauth_client, null: false, foreign_key: { to_table: :oauth_clients, on_delete: :cascade } + t.references :user, null: false, index: true, foreign_key: { to_table: :users, on_delete: :cascade } + + t.string :access_token + t.string :refresh_token + t.string :token_type + t.integer :expires_in + t.string :scope + t.string :origin_user_id # ID of the current user on the _OAuth2_provider_side_ + + t.timestamps + t.index %i[user_id oauth_client_id], unique: true + end + end +end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index 79176c373f7b..499ec95eac29 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -92,6 +92,10 @@ def self.root "#{root_path}api/v3" end + def self.same_origin?(url) + url.to_s.start_with? root_url + end + index :action show :action diff --git a/modules/storages/app/views/storages/admin/storages/show.html.erb b/modules/storages/app/views/storages/admin/storages/show.html.erb index 3f2225b56e24..cafa8894dec7 100644 --- a/modules/storages/app/views/storages/admin/storages/show.html.erb +++ b/modules/storages/app/views/storages/admin/storages/show.html.erb @@ -56,6 +56,7 @@ See COPYRIGHT and LICENSE files for more details.