[#43644] Revoke access to Storage granted by OAuth

https://community.openproject.org/work_packages/43644
feature/43644-revoke-access-to-storage-granted-by-oauth
Andreas Pfohl 2 years ago
parent d1ffd42fb9
commit 3144fe9883
No known key found for this signature in database
GPG Key ID: FF58F3B771328EB4
  1. 2
      app/helpers/no_results_helper.rb
  2. 77
      app/views/my/_clients.html.erb
  3. 117
      app/views/my/_provided.html.erb
  4. 158
      app/views/my/access_token.html.erb
  5. 6
      config/locales/en.yml
  6. 13
      docs/user-guide/nextcloud-integration/README.md
  7. BIN
      docs/user-guide/nextcloud-integration/account_settings.png
  8. 4
      modules/bim/spec/features/bcf/api_authorization_spec.rb
  9. 5
      spec/features/oauth/authorization_code_flow_spec.rb
  10. 3
      spec/features/oauth/pkce_spec.rb

@ -27,7 +27,7 @@
#++
module NoResultsHelper
# Helper to render the /common/no_results partial custamizable content.
# Helper to render the /common/no_results partial customizable content.
# Example usage:
# no_results_box action_url: new_project_version_path(@project),
# display_action: authorize_for('messages', 'new')

@ -0,0 +1,77 @@
<p><%= t(:text_client_access_token_hint) %></p>
<% if !granted_applications.empty? %>
<div class="generic-table--container">
<div class="generic-table--results-container" style="max-height: 340px;">
<table id="access-token-table" class="generic-table">
<colgroup>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= t('attributes.name') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= User.human_attribute_name(:created_at) %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.expiration') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.action') %>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<% granted_applications.each do |application, tokens| %>
<% latest = tokens.sort_by(&:created_at).last %>
<tr id="oauth-application-grant-<%= application.id %>">
<td>
<%= t('oauth.application.named', name: application.name) %>
(<%= t('oauth.x_active_tokens', count: tokens.count) %>)
</td>
<td>
<span><%= format_time(latest.created_at) %></span>
</td>
<td>
<span><%= format_time(latest.created_at + latest.expires_in.seconds) %></span>
</td>
<td>
<%= link_to t(:button_disconnect),
revoke_my_oauth_application_path(application_id: application.id),
data: { confirm: I18n.t('oauth.disconnect_my_application_confirmation',
token_count: t('oauth.x_active_tokens', count: tokens.count)) },
method: :post,
class: 'icon icon-remove-link' %>
</td>
</tr>
<% end %>
<%= call_hook(:view_access_tokens_table, user: @user) %>
</tbody>
</table>
</div>
</div>
<% else %>
<%= no_results_box %>
<% end %>

@ -0,0 +1,117 @@
<p><%= t(:text_provided_access_token_hint) %></p>
<% if has_tokens? %>
<div class="generic-table--container">
<div class="generic-table--results-container" style="max-height: 340px;">
<table id="access-token-table" class="generic-table">
<colgroup>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= t('attributes.name') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= User.human_attribute_name(:created_at) %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.expiration') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.action') %>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<% if Setting.feeds_enabled? %>
<% if @user.rss_token %>
<tr>
<td><%= t(:label_feeds_access_key_type) %></td>
<td>
<span title="<%= format_time(@user.rss_token.created_at) %>">
<%= format_time(@user.rss_token.created_at.to_s) %>
</span>
</td>
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to t(:button_reset),
{ action: 'generate_rss_key' },
method: :post,
class: 'icon icon-delete' %>
</td>
</tr>
<% else %>
<tr>
<td><%= t(:label_feeds_access_key_type) %></td>
<td><%= t(:label_missing_feeds_access_key) %></td>
<td></td>
<td>
<%= link_to t(:button_generate),
{ action: 'generate_rss_key' },
method: :post,
class: 'icon icon-key' %>
</a>
</td>
</tr>
<% end %>
<% end %>
<% if Setting.rest_api_enabled? %>
<% if @user.api_token %>
<tr>
<td><%= t(:label_api_access_key_type) %></td>
<td>
<span title="<%= format_time(@user.api_token.created_at) %>">
<%= format_time(@user.api_token.created_at.to_s) %>
</span>
</td>
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to t(:button_reset),
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-delete' %>
</td>
</tr>
<% else %>
<tr>
<td><%= t(:label_api_access_key_type) %></td>
<td><%= t(:label_missing_api_access_key) %></td>
<td></td>
<td>
<%= link_to t(:button_generate),
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-key' %>
</a>
</td>
</tr>
<% end %>
<% end %>
<%= call_hook(:view_access_tokens_table, user: @user) %>
</tbody>
</table>
</div>
</div>
<% else %>
<%= no_results_box %>
<% end %>

@ -32,145 +32,19 @@ See COPYRIGHT and LICENSE files for more details.
<% breadcrumb_paths(t(:label_my_account), I18n.t('my_account.access_tokens.access_tokens')) %>
<%= toolbar title: I18n.t('my_account.access_tokens.access_tokens') %>
<p><%= t(:text_access_token_hint) %></p>
<% if has_tokens? %>
<div class="generic-table--container">
<div class="generic-table--results-container" style="max-height: 340px;">
<table id="access-token-table" class="generic-table">
<colgroup>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= t('attributes.name') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= User.human_attribute_name(:created_at) %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.expiration') %>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<%= I18n.t('my_account.access_tokens.headers.action') %>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<% if Setting.feeds_enabled? %>
<% if @user.rss_token %>
<tr>
<td><%= t(:label_feeds_access_key_type) %></td>
<td>
<span title="<%= format_time(@user.rss_token.created_at) %>">
<%= format_time(@user.rss_token.created_at.to_s) %>
</span>
</td>
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to t(:button_reset),
{ action: 'generate_rss_key' },
method: :post,
class: 'icon icon-delete' %>
</td>
</tr>
<% else %>
<tr>
<td><%= t(:label_feeds_access_key_type) %></td>
<td><%= t(:label_missing_feeds_access_key) %></td>
<td></td>
<td>
<%= link_to t(:button_generate),
{ action: 'generate_rss_key' },
method: :post,
class: 'icon icon-key' %>
</a>
</td>
</tr>
<% end %>
<% end %>
<% if Setting.rest_api_enabled? %>
<% if @user.api_token %>
<tr>
<td><%= t(:label_api_access_key_type) %></td>
<td>
<span title="<%= format_time(@user.api_token.created_at) %>">
<%= format_time(@user.api_token.created_at.to_s) %>
</span>
</td>
<td><%= I18n.t('my_account.access_tokens.indefinite_expiration') %></td>
<td>
<%= link_to t(:button_reset),
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-delete' %>
</td>
</tr>
<% else %>
<tr>
<td><%= t(:label_api_access_key_type) %></td>
<td><%= t(:label_missing_api_access_key) %></td>
<td></td>
<td>
<%= link_to t(:button_generate),
{ action: 'generate_api_key' },
method: :post,
class: 'icon icon-key' %>
</a>
</td>
</tr>
<% end %>
<% end %>
<% granted_applications.each do |application, tokens| %>
<% latest = tokens.sort_by(&:created_at).last %>
<tr id="oauth-application-grant-<%= application.id %>">
<td>
<%= t('oauth.application.named', name: application.name) %>
&nbsp;
(<%= t('oauth.x_active_tokens', count: tokens.count) %>)
</td>
<td>
<span><%= format_time(latest.created_at) %></span>
</td>
<td>
<span><%= format_time(latest.created_at + latest.expires_in.seconds) %></span>
</td>
<td>
<%= link_to t(:button_revoke),
revoke_my_oauth_application_path(application_id: application.id),
data: { confirm: I18n.t('oauth.revoke_my_application_confirmation',
token_count: t('oauth.x_active_tokens', count: tokens.count)) },
method: :post,
class: 'icon icon-delete' %>
</td>
</tr>
<% end %>
<%= call_hook(:view_access_tokens_table, user: @user) %>
</tbody>
</table>
</div>
</div>
<% else %>
<%= no_results_box %>
<% end %>
<% tabs = [
{
name: 'ProvidedTokens',
partial: 'my/provided',
path: my_access_token_path(tab: 'ProvidedTokens'),
label: 'Provided Tokens'
},
{
name: 'ClientTokens',
partial: 'my/clients',
path: my_access_token_path(tab: 'ClientTokens'),
label: 'Client Tokens'
}
] %>
<%= render_tabs tabs %>

@ -1046,6 +1046,7 @@ en:
button_delete: "Delete"
button_decline: "Decline"
button_delete_watcher: "Delete watcher %{name}"
button_disconnect: "Disconnect"
button_download: "Download"
button_duplicate: "Duplicate"
button_edit: "Edit"
@ -2680,7 +2681,8 @@ en:
skip_last_comma: "false"
text_accessibility_hint: "The accessibility mode is designed for users who are blind, motorically handicaped or have a bad eyesight. For the latter focused elements are specially highlighted. Please notice, that the Backlogs module is not available in this mode."
text_access_token_hint: "Access tokens allow you to grant external applications access to resources in OpenProject."
text_provided_access_token_hint: "Provided tokens allow you to grant external applications access to resources in OpenProject."
text_client_access_token_hint: "Client tokens allow you to grant OpenProject access to resources in external applications."
text_analyze: "Further analyze: %{subject}"
text_are_you_sure: "Are you sure?"
text_are_you_sure_with_children: "Delete work package and all child work packages?"
@ -3254,7 +3256,7 @@ en:
By default, OpenProject provides OAuth 2.0 authorization via %{authorization_code_flow_link}.
You can optionally enable %{client_credentials_flow_link}, but you must provide a user on whose behalf requests will be performed.
authorization_error: "An authorization error has occurred."
revoke_my_application_confirmation: "Do you really want to remove this application? This will revoke %{token_count} active for it."
disconnect_my_application_confirmation: "Do you really want to disconnect fropm this application? This will revoke %{token_count} active for it."
my_registered_applications: "Registered OAuth applications"
oauth_client:

@ -53,6 +53,19 @@ To begin using this integration, you will need to first connect your OpenProject
> **Note:** To disconnect the link between your OpenProject and Nextcloud accounts, head on over to Nextcloud and navigate to _Settings → Connected accounts_. There, clicking **Disconnect from OpenProject** button. To re-link the two accounts, simply follow the above instructions again.
## Disconnecting your OpenProject account from Nextcloud
You can disconnect you OpenProject account from the logged-in Nextcloud account if you wish. It might be that you were
logged in with the wrong OpenProject account and accidentally connected it to you Nextcloud account. In order to do that
there is an option within the settings page of your OpenProject account called **Client Tokens**:
![OpenProject account settings page](account_settings.png)
Here you can see all connection to any Nextcloud storages that are active at the current time. Click the **disconnect**
link next to the storage you want to disconnect.
Now you can had back to your project and log in again.
## Using the Nextcloud integration
The following video gives you a short overview of how to use this integration:

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

@ -118,8 +118,8 @@ describe 'authorization for BCF api',
access_token = body['access_token']
# Should show that grant in my account
visit my_account_path
click_on 'Access token'
visit my_access_token_path(tab: 'ClientTokens')
# click_on 'Access token'
expect(page).to have_selector("#oauth-application-grant-#{app.id}", text: app.name)
expect(page).to have_selector('td', text: app.name)

@ -88,8 +88,7 @@ describe 'OAuth authorization code flow',
get_and_test_token(code)
# Should show that grant in my account
visit my_account_path
click_on 'Access token'
visit my_access_token_path(tab: 'ClientTokens')
expect(page).to have_selector("#oauth-application-grant-#{app.id}", text: app.name)
expect(page).to have_selector('td', text: app.name)
@ -97,7 +96,7 @@ describe 'OAuth authorization code flow',
# Revoke the application
within("#oauth-application-grant-#{app.id}") do
SeleniumHubWaiter.wait
click_on 'Revoke'
click_on 'Disconnect'
end
page.driver.browser.switch_to.alert.accept

@ -105,8 +105,7 @@ describe 'OAuth authorization code flow with PKCE',
get_and_test_token(code)
# Should show that grant in my account
visit my_account_path
click_on 'Access token'
visit my_access_token_path(tab: 'ClientTokens')
expect(page).to have_selector("#oauth-application-grant-#{app.id}", text: app.name)
expect(page).to have_selector('td', text: app.name)

Loading…
Cancel
Save