mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Allow to use API Tokens as Bearer tokens
We generate those tokens with a prefix, so that we can decide by looking at a token, whether it's an API Token or a different kind of token, so that we can decide which code path to choose for validating the token. The usage of access tokens as Bearer token has the usability advantage, that you can paste them as plaintext into tools that expect you to specify the token as a header. Also the Basic auth approach for our old tokens usually rather caused issues, such as browsers prompting for credentials in surprising situations. If we were to deprecate basic authentication one day, this change today could've been the first step towards that.
This commit is contained in:
@@ -30,5 +30,6 @@
|
||||
|
||||
module Token
|
||||
class API < Named
|
||||
prefix :opapi
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,6 +32,8 @@ module Token
|
||||
class AutoLogin < HashedToken
|
||||
include ExpirableToken
|
||||
|
||||
prefix :opal
|
||||
|
||||
has_many :autologin_session_links,
|
||||
class_name: "Sessions::AutologinSessionLink",
|
||||
foreign_key: "token_id",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
module Token
|
||||
class Backup < HashedToken
|
||||
prefix :opbk
|
||||
|
||||
def ready?
|
||||
return false if created_at.nil?
|
||||
|
||||
|
||||
+29
-14
@@ -64,22 +64,37 @@ module Token
|
||||
# Delete previous token of this type upon save
|
||||
before_save :delete_previous_token
|
||||
|
||||
##
|
||||
# Find a token from the token value
|
||||
def self.find_by_plaintext_value(input)
|
||||
find_by(value: input)
|
||||
end
|
||||
class << self
|
||||
##
|
||||
# A DSL method allowing to define a prefix for all generated tokens, making it possible to recognize
|
||||
# the purpose of a token by looking at the token value.
|
||||
#
|
||||
# class MyToken < HashedToken
|
||||
# prefix :my
|
||||
# end
|
||||
def prefix(value = nil)
|
||||
@prefix = value.to_s if value
|
||||
|
||||
##
|
||||
# Find tokens for the given user
|
||||
def self.for_user(user)
|
||||
where(user:)
|
||||
end
|
||||
@prefix
|
||||
end
|
||||
|
||||
##
|
||||
# Generate a random hex token value
|
||||
def self.generate_token_value
|
||||
SecureRandom.hex(32)
|
||||
##
|
||||
# Find a token from the token value
|
||||
def find_by_plaintext_value(input)
|
||||
find_by(value: input)
|
||||
end
|
||||
|
||||
##
|
||||
# Find tokens for the given user
|
||||
def for_user(user)
|
||||
where(user:)
|
||||
end
|
||||
|
||||
##
|
||||
# Generate a random hex token value
|
||||
def generate_token_value
|
||||
[prefix, SecureRandom.hex(32)].compact.join("-")
|
||||
end
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -53,7 +53,7 @@ module Token
|
||||
|
||||
class << self
|
||||
def create_and_return_value(user)
|
||||
create(user:).plain_value
|
||||
create!(user:).plain_value
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
module Token
|
||||
class ICal < HashedToken
|
||||
prefix :opical
|
||||
|
||||
# restrict the usage of one ical token to one query (calendar)
|
||||
has_one :ical_token_query_assignment, required: true, dependent: :destroy, foreign_key: :ical_token_id,
|
||||
class_name: "ICalTokenQueryAssignment", inverse_of: :ical_token
|
||||
|
||||
@@ -384,7 +384,7 @@ default:
|
||||
# password: admin
|
||||
|
||||
# By default, the APIv3 allows authentication through basic auth.
|
||||
# Uncomment the following line to restrict APIv3 access to session.
|
||||
# Uncomment the following line to prevent APIv3 access using Basic auth.
|
||||
# apiv3_enable_basic_auth: false
|
||||
|
||||
# You can configure where users should be sent after the login
|
||||
|
||||
@@ -34,6 +34,7 @@ strategies = [
|
||||
[:basic_auth_failure, namespace::BasicAuthFailure, "Basic"],
|
||||
[:global_basic_auth, namespace::GlobalBasicAuth, "Basic"],
|
||||
[:user_basic_auth, namespace::UserBasicAuth, "Basic"],
|
||||
[:user_api_token, namespace::UserAPIToken, "Bearer"],
|
||||
[:oauth, namespace::DoorkeeperOAuth, "Bearer"],
|
||||
[:anonymous_fallback, namespace::AnonymousFallback, "Basic"],
|
||||
[:jwt_oidc, namespace::JwtOidc, "Bearer"],
|
||||
@@ -48,6 +49,7 @@ OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope
|
||||
%i[global_basic_auth
|
||||
user_basic_auth
|
||||
basic_auth_failure
|
||||
user_api_token
|
||||
oauth
|
||||
jwt_oidc
|
||||
session
|
||||
@@ -59,7 +61,7 @@ OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope
|
||||
end
|
||||
|
||||
OpenProject::Authentication.update_strategies(OpenProject::Authentication::Scope::MCP_SCOPE, { store: false }) do |_|
|
||||
%i[oauth jwt_oidc user_basic_auth basic_auth_failure]
|
||||
%i[user_api_token oauth jwt_oidc user_basic_auth basic_auth_failure]
|
||||
end
|
||||
|
||||
Rails.application.configure do |app|
|
||||
|
||||
@@ -49,7 +49,15 @@ info:
|
||||
|
||||
## Authentication
|
||||
|
||||
The API supports the following authentication schemes: OAuth2, session based authentication, and basic auth.
|
||||
The API supports the following authentication schemes:
|
||||
|
||||
* Session-based authentication
|
||||
* API tokens
|
||||
* passed as Bearer token
|
||||
* passed via Basic auth
|
||||
* OAuth 2.0
|
||||
* using built-in authorization server
|
||||
* using an external authorization server (RFC 9068)
|
||||
|
||||
Depending on the settings of the OpenProject instance many resources can be accessed without being authenticated.
|
||||
In case the instance requires authentication on all requests the client will receive an **HTTP 401** status code
|
||||
@@ -57,26 +65,38 @@ info:
|
||||
|
||||
Otherwise unauthenticated clients have all the permissions of the anonymous user.
|
||||
|
||||
### Session-based Authentication
|
||||
### Session-based authentication
|
||||
|
||||
This means you have to login to OpenProject via the Web-Interface to be authenticated in the API.
|
||||
This method is well-suited for clients acting within the browser, like the Angular-Client built into OpenProject.
|
||||
|
||||
In this case, you always need to pass the HTTP header `X-Requested-With "XMLHttpRequest"` for authentication.
|
||||
|
||||
### API Key through Basic Auth
|
||||
### API token as bearer token
|
||||
|
||||
Users can authenticate towards the API v3 using basic auth with the user name `apikey` (NOT your login) and the API key as the password.
|
||||
Users can find their API key on their account page.
|
||||
Users can authenticate towards the API v3 using an API token as a bearer token.
|
||||
|
||||
Example:
|
||||
For example:
|
||||
|
||||
```shell
|
||||
API_KEY=2519132cdf62dcf5a66fd96394672079f9e9cad1
|
||||
API_KEY=opapi-2519132cdf62dcf5a66fd96394672079f9e9cad1
|
||||
curl -H "Authorization: Bearer $API_KEY" https://community.openproject.org/api/v3/users/42
|
||||
```
|
||||
|
||||
Users can generate API tokens on their account page.
|
||||
|
||||
### API token through Basic Auth
|
||||
|
||||
API tokens can also be used with basic auth, using the user name `apikey` (NOT your login) and the API token as the password.
|
||||
|
||||
For example:
|
||||
|
||||
```shell
|
||||
API_KEY=opapi-2519132cdf62dcf5a66fd96394672079f9e9cad1
|
||||
curl -u apikey:$API_KEY https://community.openproject.org/api/v3/users/42
|
||||
```
|
||||
|
||||
### OAuth2.0 authentication
|
||||
### OAuth 2.0 authentication
|
||||
|
||||
OpenProject allows authentication and authorization with OAuth2 with *Authorization code flow*, as well as *Client credentials* operation modes.
|
||||
|
||||
@@ -91,7 +111,7 @@ info:
|
||||
|
||||
- [Client credentials](https://oauth.net/2/grant-types/client-credentials/) - Requires an application to be bound to an impersonating user for non-public access
|
||||
|
||||
### OIDC provider generated JWT as a Bearer token
|
||||
### OAuth 2.0 using an external authorization server
|
||||
|
||||
There is a possibility to use JSON Web Tokens (JWT) generated by an OIDC provider configured in OpenProject as a bearer token to do authenticated requests against the API.
|
||||
The following requirements must be met:
|
||||
@@ -103,7 +123,7 @@ info:
|
||||
- JWT **scope** claim must include a valid scope to access the desired API (e.g. `api_v3` for APIv3)
|
||||
- JWT must be actual (neither expired or too early to be used)
|
||||
- JWT must be passed in Authorization header like: `Authorization: Bearer {jwt}`
|
||||
- User from **sub** claim must be logged in OpenProject before otherwise it will be not authenticated
|
||||
- User from **sub** claim must be linked to OpenProject before (e.g. by logging in), otherwise it will be not authenticated
|
||||
|
||||
In more general terms, OpenProject should be compliant to [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) when validating access tokens.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ release_date: 2024-08-14
|
||||
|
||||
Release date: 2024-08-14
|
||||
|
||||
We released [OpenProject 14.4.0](https://community.openproject.org/versions/2063). The release contains several bug fixes and we recommend updating to the newest version.
|
||||
We released [OpenProject 14.4.0](https://community.openproject.org/versions/2063). The release contains several bug fixes and we recommend updating to the newest version.
|
||||
|
||||
In these Release Notes, we will give an overview of important technical updates as well as important feature changes. At the end, you will find a complete list of all changes and bug fixes.
|
||||
|
||||
@@ -22,7 +22,7 @@ OpenProject 14.4 introduces a new feature that allows OpenID clients, such as Ne
|
||||
|
||||
With this feature, the OpenProject API will validate access tokens issued by the OpenID provider (Keycloak) by checking the token's signature and authenticating the user using the sub claim value. This integration ensures secure and efficient API authentication for OpenID clients.
|
||||
|
||||
For more details, take a look at our [API documentation](../../../api/introduction/#oidc-provider-generated-jwt-as-a-bearer-token).
|
||||
For more details, take a look at our [API documentation](../../../api/introduction/#oauth-20-using-an-external-authorization-server).
|
||||
|
||||
### Improve error messages and logs of automatically managed project folders synchronization services/jobs
|
||||
|
||||
@@ -38,7 +38,7 @@ For more details, see this [work package](https://community.openproject.org/wp/5
|
||||
|
||||
### Personal settings: Dark mode
|
||||
|
||||
Dark mode for OpenProject is finally here! In the '[My account](../../../user-guide/account-settings/#look-and-feel)' section under 'Interface', there is an **option labeled 'Mode' where users can now select 'Dark (Beta).'** – as an alternative to the light mode. When the dark mode is selected, the change applies only to that user, not to the entire instance.
|
||||
Dark mode for OpenProject is finally here! In the '[My account](../../../user-guide/account-settings/#look-and-feel)' section under 'Interface', there is an **option labeled 'Mode' where users can now select 'Dark (Beta).'** – as an alternative to the light mode. When the dark mode is selected, the change applies only to that user, not to the entire instance.
|
||||
|
||||

|
||||
|
||||
@@ -222,12 +222,12 @@ Clicking on the "Details" link will take the user to the diff view, which is als
|
||||
|
||||
## Contributions
|
||||
|
||||
A very special thank you goes to the City of Cologne again for sponsoring features on project attributes and project lists.
|
||||
A very special thank you goes to the City of Cologne again for sponsoring features on project attributes and project lists.
|
||||
|
||||
Also a big thanks to our Community members for reporting bugs and helping us identify and provide fixes.
|
||||
Special thanks for reporting and finding bugs go to Johan Bouduin, Sven Kunze and Marcel Carvalho.
|
||||
|
||||
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings! This release we would like to highlight the three following users:
|
||||
Last but not least, we are very grateful for our very engaged translation contributors on Crowdin, who translated quite a few OpenProject strings! This release we would like to highlight the three following users:
|
||||
|
||||
- [Jeff Li](https://crowdin.com/profile/jeff_li) for translations to Chinese Simplified,
|
||||
- [Adam Siemienski](https://crowdin.com/profile/siemienas) for translations to Polish,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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.
|
||||
#++
|
||||
|
||||
module OpenProject
|
||||
module Authentication
|
||||
module Strategies
|
||||
module Warden
|
||||
##
|
||||
# Allows users to authenticate using their API key as a Bearer token.
|
||||
# Note that in order for a user to be able to generate one
|
||||
# `Setting.rest_api_enabled` has to be `1`.
|
||||
class UserAPIToken < ::Warden::Strategies::Base
|
||||
include FailWithHeader
|
||||
|
||||
def valid?
|
||||
return false unless Setting.rest_api_enabled?
|
||||
|
||||
@access_token = ::Doorkeeper::OAuth::Token.from_bearer_authorization(
|
||||
::Doorkeeper::Grape::AuthorizationDecorator.new(request)
|
||||
)
|
||||
return false if @access_token.blank?
|
||||
|
||||
@access_token.start_with?(::Token::API.prefix)
|
||||
end
|
||||
|
||||
def authenticate!
|
||||
token = ::Token::API.find_by_plaintext_value(@access_token) # rubocop:disable Rails/DynamicFindBy
|
||||
return fail_with_header!(error: "invalid_token") if token.nil?
|
||||
|
||||
authentication_result(token.user)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authentication_result(user)
|
||||
if user.nil?
|
||||
return fail_with_header!(
|
||||
error: "invalid_token",
|
||||
error_description: "The user identified by the token is not known"
|
||||
)
|
||||
end
|
||||
|
||||
if user.active?
|
||||
success!(user)
|
||||
else
|
||||
fail_with_header!(
|
||||
error: "invalid_token",
|
||||
error_description: "The user account is locked"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -47,4 +47,18 @@ RSpec.describe Token::Base do
|
||||
expect(described_class.exists?(subject.id)).to be false
|
||||
expect(described_class.exists?(t2.id)).to be true
|
||||
end
|
||||
|
||||
context "when defining a prefix" do
|
||||
subject { subclass.new(user:) }
|
||||
|
||||
let(:subclass) { Class.new(described_class) { prefix :test } }
|
||||
|
||||
it "has a plaintext value starting with the prefix" do
|
||||
expect(subject.value).to start_with("test-")
|
||||
end
|
||||
|
||||
it "has the regular token value after the prefix" do
|
||||
expect(subject.value.delete_prefix("test-").length).to eq(64)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -225,6 +225,74 @@ RSpec.describe "API V3 Authentication" do
|
||||
end
|
||||
end
|
||||
|
||||
describe "API Key as Bearer token" do
|
||||
let(:token) { create(:api_token, user:) }
|
||||
let(:bearer_token) { token.plain_value }
|
||||
let(:expected_message) { "You did not provide the correct credentials." }
|
||||
|
||||
before do
|
||||
user
|
||||
|
||||
header "Authorization", "Bearer #{bearer_token}"
|
||||
|
||||
get resource
|
||||
end
|
||||
|
||||
context "with a valid access token" do
|
||||
it "authenticates successfully" do
|
||||
expect(last_response).to have_http_status :ok
|
||||
end
|
||||
end
|
||||
|
||||
context "with an invalid access token" do
|
||||
let(:bearer_token) { "opapi-1337" }
|
||||
let(:expected_www_auth_header) do
|
||||
%{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3", error="invalid_token"}
|
||||
end
|
||||
|
||||
it "returns unauthorized" do
|
||||
expect(last_response).to have_http_status :unauthorized
|
||||
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
|
||||
expect(JSON.parse(last_response.body)).to eq(error_response_body)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the token's user can't be found" do
|
||||
let(:expected_www_auth_header) do
|
||||
%{Bearer realm="OpenProject API", #{resource_metadata}, scope="api_v3", error="invalid_token"}
|
||||
end
|
||||
|
||||
around do |ex|
|
||||
# create the token before deleting the user; right now it especially works, because a foreign key constraint prevents
|
||||
# tokens without users
|
||||
token
|
||||
user.destroy!
|
||||
ex.run
|
||||
end
|
||||
|
||||
it "returns unauthorized" do
|
||||
expect(last_response).to have_http_status :unauthorized
|
||||
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
|
||||
expect(JSON.parse(last_response.body)).to eq(error_response_body)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the token's user is locked" do
|
||||
let(:user) { create(:user, :locked) }
|
||||
let(:expected_www_auth_header) do
|
||||
"Bearer realm=\"OpenProject API\", #{resource_metadata}, scope=\"api_v3\", error=\"invalid_token\", " \
|
||||
"error_description=\"#{expected_error_description}\""
|
||||
end
|
||||
let(:expected_error_description) { "The user account is locked" }
|
||||
|
||||
it "returns unauthorized" do
|
||||
expect(last_response).to have_http_status :unauthorized
|
||||
expect(last_response.header["WWW-Authenticate"]).to eq(expected_www_auth_header)
|
||||
expect(JSON.parse(last_response.body)).to eq(error_response_body)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "basic auth" do
|
||||
let(:expected_message) { "You need to be authenticated to access this resource." }
|
||||
|
||||
|
||||
@@ -81,9 +81,9 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
|
||||
it_behaves_like "MCP unauthenticated response"
|
||||
end
|
||||
|
||||
context "when passing an API key via Basic auth" do
|
||||
context "when passing an API token via Bearer authentication" do
|
||||
subject do
|
||||
header "Authorization", "Basic #{Base64.encode64("apikey:#{apikey.plain_value}")}"
|
||||
header "Authorization", "Bearer #{apikey.plain_value}"
|
||||
header "Content-Type", "application/json"
|
||||
post "/mcp", request_body.to_json
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user