From 2067b5e8ae77654412336445b4f6f31780095889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Mon, 23 Sep 2024 11:10:39 +0200 Subject: [PATCH 01/11] Install/configure Doorkeeper Doorkeeper is an OAuth2 server gem. More info: https://github.com/doorkeeper-gem/doorkeeper Custom config: * fr i18n * inherits from APIController (make some iterations to be compliant) * api mode only -> no need to manage application with default UI (maybe will be some UI in the future) * skip controllers within routes related to UI (related to previous point) * each application has an owner (https://github.com/doorkeeper-gem/doorkeeper/wiki/Associate-users-to-OAuth-applications-%28ownership%29) * configure scopes --- .rubocop.yml | 1 + Gemfile | 2 + Gemfile.lock | 6 + app/controllers/api/frontal_controller.rb | 2 +- app/controllers/api_controller.rb | 10 + app/controllers/concerns/authentication.rb | 18 +- app/models/user.rb | 17 + config/initializers/doorkeeper.rb | 528 ++++++++++++++++++ config/routes.rb | 4 + ...20240921104426_create_doorkeeper_tables.rb | 98 ++++ ...20240921121249_add_owner_to_application.rb | 9 + db/schema.rb | 49 ++ 12 files changed, 737 insertions(+), 7 deletions(-) create mode 100644 config/initializers/doorkeeper.rb create mode 100644 db/migrate/20240921104426_create_doorkeeper_tables.rb create mode 100644 db/migrate/20240921121249_add_owner_to_application.rb diff --git a/.rubocop.yml b/.rubocop.yml index 19a7a710c..2875b5501 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -23,6 +23,7 @@ AllCops: - "app/migration/**/*" - "tmp/**/*" - "sandbox/**/*" + - "config/initializers/doorkeeper.rb" FactoryBot/FactoryAssociationWithStrategy: Enabled: false diff --git a/Gemfile b/Gemfile index 381004752..2e91da395 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,8 @@ gem 'caxlsx' gem 'aws-sdk-s3', require: false gem 'bootsnap', require: false gem 'csv' +gem 'doorkeeper' +gem 'doorkeeper-i18n' gem 'draper' gem 'emailable' gem 'importmap-rails', '~> 1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9510089ef..5dcb496c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -187,6 +187,10 @@ GEM reline (>= 0.3.8) diff-lcs (1.5.1) docile (1.4.0) + doorkeeper (5.7.1) + railties (>= 5) + doorkeeper-i18n (5.2.7) + doorkeeper (>= 5.2) draper (4.0.2) actionpack (>= 5.0) activemodel (>= 5.0) @@ -630,6 +634,8 @@ DEPENDENCIES cuprite database_cleaner-active_record debug + doorkeeper + doorkeeper-i18n draper emailable factory_bot_rails diff --git a/app/controllers/api/frontal_controller.rb b/app/controllers/api/frontal_controller.rb index 005cef4e6..420111998 100644 --- a/app/controllers/api/frontal_controller.rb +++ b/app/controllers/api/frontal_controller.rb @@ -1,4 +1,4 @@ -class API::FrontalController < APIController +class API::FrontalController < ActionController::API def index if frontal? render json: { diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 19d0ffe0f..61a6cfaa4 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,2 +1,12 @@ class APIController < ActionController::API + include Authentication + + api_mode! + + def user_id_session + { + 'value' => doorkeeper_token.resource_owner_id, + 'expires_at' => doorkeeper_token.expires_in + Time.current.to_i, + } + end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index f27246691..10e904508 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,13 +4,15 @@ module Authentication included do before_action :authenticate_user! - helper_method :current_user, :current_organization, :user_signed_in? + helper_method :current_user, :current_organization, :user_signed_in? if respond_to?(:helper_method) end class_methods do def allow_unauthenticated_access(**) skip_before_action(:authenticate_user!, **) end + + alias_method :api_mode!, :allow_unauthenticated_access end def sign_in_path @@ -52,10 +54,14 @@ def user_signed_in? end def valid_user_session? - session[:user_id].present? && - session[:user_id]['value'].present? && - session[:user_id]['expires_at'].present? && - session[:user_id]['expires_at'] > Time.current + user_id_session.present? && + user_id_session['value'].present? && + user_id_session['expires_at'].present? && + user_id_session['expires_at'] > Time.current + end + + def user_id_session + session[:user_id] end def save_redirect_path @@ -63,7 +69,7 @@ def save_redirect_path end def current_user - @current_user ||= User.find_by(id: session[:user_id].try(:[], 'value')) + @current_user ||= User.find_by(id: user_id_session.try(:[], 'value')) end def current_organization diff --git a/app/models/user.rb b/app/models/user.rb index 6f6bba7f9..266bc07a5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -46,6 +46,23 @@ class User < ApplicationRecord add_instruction_boolean_settings :submit_notifications, :messages_notifications + has_many :oauth_applications, + class_name: 'Doorkeeper::Application', + as: :owner, + dependent: :restrict_with_exception + + has_many :access_grants, + class_name: 'Doorkeeper::AccessGrant', + foreign_key: :resource_owner_id, + inverse_of: :resource_owner, + dependent: :delete_all + + has_many :access_tokens, + class_name: 'Doorkeeper::AccessToken', + foreign_key: :resource_owner_id, + inverse_of: :resource_owner, + dependent: :delete_all + def full_name if family_name.present? && given_name.present? "#{family_name} #{given_name}" diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 000000000..4314abbdf --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,528 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (requires ORM extensions installed). + # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms + orm :active_record + + # This block will be called to check whether the resource owner is authenticated or not. + # resource_owner_authenticator do + # if user_signed_in? + # current_user + # else + # redirect_to(sign_in_path) + # end + # end + + # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb + # file then you need to declare this block in order to restrict access to the web interface for + # adding oauth authorized applications. In other case it will return 403 Forbidden response + # every time somebody will try to access the admin web interface. + # + # admin_authenticator do + # if user_signed_in? && current_user.admin? + # current_user + # else + # head :forbidden + # end + # end + + # You can use your own model classes if you need to extend (or even override) default + # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant. + # + # By default Doorkeeper ActiveRecord ORM uses its own classes: + # + # access_token_class "Doorkeeper::AccessToken" + # access_grant_class "Doorkeeper::AccessGrant" + # application_class "Doorkeeper::Application" + # + # Don't forget to include Doorkeeper ORM mixins into your custom models: + # + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant + # * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients) + # + # For example: + # + # access_token_class "MyAccessToken" + # + # class MyAccessToken < ApplicationRecord + # include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken + # + # self.table_name = "hey_i_wanna_my_name" + # + # def destroy_me! + # destroy + # end + # end + + # Enables polymorphic Resource Owner association for Access Tokens and Access Grants. + # By default this option is disabled. + # + # Make sure you properly setup you database and have all the required columns (run + # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails + # migrations). + # + # If this option enabled, Doorkeeper will store not only Resource Owner primary key + # value, but also it's type (class name). See "Polymorphic Associations" section of + # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations + # + # [NOTE] If you apply this option on already existing project don't forget to manually + # update `resource_owner_type` column in the database and fix migration template as it will + # set NOT NULL constraint for Access Grants table. + # + # use_polymorphic_resource_owner + + # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might + # want to use API mode that will skip all the views management and change the way how + # Doorkeeper responds to a requests. + # + api_only + + # Enforce token request content type to application/x-www-form-urlencoded. + # It is not enabled by default to not break prior versions of the gem. + # + # enforce_content_type + + # Authorization Code expiration time (default: 10 minutes). + # + # authorization_code_expires_in 10.minutes + + # Access token expiration time (default: 2 hours). + # If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response. + # It is RECOMMENDED to set expiration time explicitly. + # Prefer access_token_expires_in 100.years or similar, + # which would be functionally equivalent and avoid the risk of unexpected behavior by callers. + # + # access_token_expires_in 2.hours + + # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in + # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to + # +access_token_expires_in+ configuration option value. If you really need to issue a + # non-expiring access token (which is not recommended) then you need to return + # Float::INFINITY from this block. + # + # `context` has the following properties available: + # + # * `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # * `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # * `resource_owner` - authorized resource owner instance (if present) + # + # custom_access_token_expires_in do |context| + # context.client.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator + # + # access_token_generator '::Doorkeeper::JWT' + + # The controller +Doorkeeper::ApplicationController+ inherits from. + # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to + # +ActionController::API+. The return value of this option must be a stringified class name. + # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers + # + base_controller 'APIController' + + # Reuse access token for the same resource owner within an application (disabled by default). + # + # This option protects your application from creating new tokens before old **valid** one becomes + # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper + # doesn't update existing token expiration time, it will create a new token instead if no active matching + # token found for the application, resources owner and/or set of scopes. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # + # You can not enable this option together with +hash_token_secrets+. + # + # reuse_access_token + + # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching + # token using `matching_token_for` Access Token API that searches for valid records + # in batches in order not to pollute the memory with all the database records. By default + # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value + # depending on your needs and server capabilities. + # + # token_lookup_batch_size 10_000 + + # Set a limit for token_reuse if using reuse_access_token option + # + # This option limits token_reusability to some extent. + # If not set then access_token will be reused unless it expires. + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189 + # + # This option should be a percentage(i.e. (0,100]) + # + # token_reuse_limit 100 + + # Only allow one valid access token obtained via client credentials + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # When enabling this option, make sure that you do not expect multiple processes + # using the same credentials at the same time (e.g. web servers spanning + # multiple machines and/or processes). + # + # revoke_previous_client_credentials_token + + # Only allow one valid access token obtained via authorization code + # per client. If a new access token is obtained before the old one + # expired, the old one gets revoked (disabled by default) + # + # revoke_previous_authorization_code_token + + # Require non-confidential clients to use PKCE when using an authorization code + # to obtain an access_token (disabled by default) + # + # force_pkce + + # Hash access and refresh tokens before persisting them. + # This will disable the possibility to use +reuse_access_token+ + # since plain values can no longer be retrieved. + # + # Note: If you are already a user of doorkeeper and have existing tokens + # in your installation, they will be invalid without adding 'fallback: :plain'. + # + # hash_token_secrets + # By default, token secrets will be hashed using the + # +Doorkeeper::Hashing::SHA256+ strategy. + # + # If you wish to use another hashing implementation, you can override + # this strategy as follows: + # + # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl' + # + # Keep in mind that changing the hashing function will invalidate all existing + # secrets, if there are any. + + # Hash application secrets before persisting them. + # + # hash_application_secrets + # + # By default, applications will be hashed + # with the +Doorkeeper::SecretStoring::SHA256+ strategy. + # + # If you wish to use bcrypt for application secret hashing, uncomment + # this line instead: + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt' + + # When the above option is enabled, and a hashed token or secret is not found, + # you can allow to fall back to another strategy. For users upgrading + # doorkeeper and wishing to enable hashing, you will probably want to enable + # the fallback to plain tokens. + # + # This will ensure that old access tokens and secrets + # will remain valid even if the hashing above is enabled. + # + # This can be done by adding 'fallback: plain', e.g. : + # + # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain + + # Issue access tokens with refresh token (disabled by default), you may also + # pass a block which accepts `context` to customize when to give a refresh + # token or not. Similar to +custom_access_token_expires_in+, `context` has + # the following properties: + # + # `client` - the OAuth client application (see Doorkeeper::OAuth::Client) + # `grant_type` - the grant type of the request (see Doorkeeper::OAuth) + # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes) + # + # use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter confirmation: true (default: false) if you want to enforce ownership of + # a registered application + # NOTE: you must also run the rails g doorkeeper:application_owner generator + # to provide the necessary support + # + enable_application_owner confirmation: true + + # Define access token scopes for your provider + # For more information go to + # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes + # + default_scopes :public + optional_scopes :read_authorization_requests, + :write_authorization_requests + + # Allows to restrict only certain scopes for grant_type. + # By default, all the scopes will be available for all the grant types. + # + # Keys to this hash should be the name of grant_type and + # values should be the array of scopes for that grant type. + # Note: scopes should be from configured_scopes (i.e. default or optional) + # + # scopes_by_grant_type password: [:write], client_credentials: [:update] + + # Forbids creating/updating applications with arbitrary scopes that are + # not in configuration, i.e. +default_scopes+ or +optional_scopes+. + # (disabled by default) + # + enforce_configured_scopes + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # Callable objects such as proc, lambda, block or any object that responds to + # #call can be used in order to allow conditional checks (to allow non-SSL + # redirects to localhost for example). + # + # force_ssl_in_redirect_uri !Rails.env.development? + # + # force_ssl_in_redirect_uri { |uri| uri.host != 'localhost' } + + # Specify what redirect URI's you want to block during Application creation. + # Any redirect URI is allowed by default. + # + # You can use this option in order to forbid URI's with 'javascript' scheme + # for example. + # + # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' } + + # Allows to set blank redirect URIs for Applications in case Doorkeeper configured + # to use URI-less OAuth grant flows like Client Credentials or Resource Owner + # Password Credentials. The option is on by default and checks configured grant + # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri` + # column for `oauth_applications` database table. + # + # You can completely disable this feature with: + # + # allow_blank_redirect_uri false + # + # Or you can define your custom check: + # + # allow_blank_redirect_uri do |grant_flows, client| + # client.superapp? + # end + + # Specify how authorization errors should be handled. + # By default, doorkeeper renders json errors when access token + # is invalid, expired, revoked or has invalid scopes. + # + # If you want to render error response yourself (i.e. rescue exceptions), + # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken + # or following specific errors: + # + # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired, + # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown + # + # handle_auth_errors :raise + # + # If you want to redirect back to the client application in accordance with + # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set + # +handle_auth_errors+ to :redirect + # + # handle_auth_errors :redirect + + # Customize token introspection response. + # Allows to add your own fields to default one that are required by the OAuth spec + # for the introspection response. It could be `sub`, `aud` and so on. + # This configuration option can be a proc, lambda or any Ruby object responds + # to `.call` method and result of it's invocation must be a Hash. + # + # custom_introspection_response do |token, context| + # { + # "sub": "Z5O3upPC88QrAjx00dis", + # "aud": "https://protected.example.net/resource", + # "username": User.find(token.resource_owner_id).username + # } + # end + # + # or + # + # custom_introspection_response CustomIntrospectionResponder + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2 + # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3 + # + # grant_flows %w[authorization_code client_credentials] + + # Allows to customize OAuth grant flows that +each+ application support. + # You can configure a custom block (or use a class respond to `#call`) that must + # return `true` in case Application instance supports requested OAuth grant flow + # during the authorization request to the server. This configuration +doesn't+ + # set flows per application, it only allows to check if application supports + # specific grant flow. + # + # For example you can add an additional database column to `oauth_applications` table, + # say `t.array :grant_flows, default: []`, and store allowed grant flows that can + # be used with this application there. Then when authorization requested Doorkeeper + # will call this block to check if specific Application (passed with client_id and/or + # client_secret) is allowed to perform the request for the specific grant type + # (authorization, password, client_credentials, etc). + # + # Example of the block: + # + # ->(flow, client) { client.grant_flows.include?(flow) } + # + # In case this option invocation result is `false`, Doorkeeper server returns + # :unauthorized_client error and stops the request. + # + # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call + # @return [Boolean] `true` if allow or `false` if forbid the request + # + # allow_grant_flow_for_client do |grant_flow, client| + # # `grant_flows` is an Array column with grant + # # flows that application supports + # + # client.grant_flows.include?(grant_flow) + # end + + # If you need arbitrary Resource Owner-Client authorization you can enable this option + # and implement the check your need. Config option must respond to #call and return + # true in case resource owner authorized for the specific application or false in other + # cases. + # + # By default all Resource Owners are authorized to any Client (application). + # + # authorize_resource_owner_for_client do |client, resource_owner| + # resource_owner.admin? || client.owners_allowlist.include?(resource_owner) + # end + + # Allows additional data fields to be sent while granting access to an application, + # and for this additional data to be included in subsequently generated access tokens. + # The 'authorizations/new' page will need to be overridden to include this additional data + # in the request params when granting access. The access grant and access token models + # will both need to respond to these additional data fields, and have a database column + # to store them in. + # + # Example: + # You have a multi-tenanted platform and want to be able to grant access to a specific + # tenant, rather than all the tenants a user has access to. You can use this config + # option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id + # will be included in the access tokens. When a request is made with one of these access + # tokens, you can check that the requested data belongs to the specified tenant. + # + # Default value is an empty Array: [] + # custom_access_token_attributes [:tenant_id] + + # Hook into the strategies' request & response life-cycle in case your + # application needs advanced customization or logging: + # + # before_successful_strategy_response do |request| + # puts "BEFORE HOOK FIRED! #{request}" + # end + # + # after_successful_strategy_response do |request, response| + # puts "AFTER HOOK FIRED! #{request}, #{response}" + # end + + # Hook into Authorization flow in order to implement Single Sign Out + # or add any other functionality. Inside the block you have an access + # to `controller` (authorizations controller instance) and `context` + # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth + # or auth objects with issued token based on hook type (before or after). + # + # before_successful_authorization do |controller, context| + # Rails.logger.info(controller.request.params.inspect) + # + # Rails.logger.info(context.pre_auth.inspect) + # end + # + # after_successful_authorization do |controller, context| + # controller.session[:logout_urls] << + # Doorkeeper::Application + # .find_by(controller.request.params.slice(:redirect_uri)) + # .logout_uri + # + # Rails.logger.info(context.auth.inspect) + # Rails.logger.info(context.issued_token) + # end + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # Configure custom constraints for the Token Introspection request. + # By default this configuration option allows to introspect a token by another + # token of the same application, OR to introspect the token that belongs to + # authorized client (from authenticated client) OR when token doesn't + # belong to any client (public token). Otherwise requester has no access to the + # introspection and it will return response as stated in the RFC. + # + # Block arguments: + # + # @param token [Doorkeeper::AccessToken] + # token to be introspected + # + # @param authorized_client [Doorkeeper::Application] + # authorized client (if request is authorized using Basic auth with + # Client Credentials for example) + # + # @param authorized_token [Doorkeeper::AccessToken] + # Bearer token used to authorize the request + # + # In case the block returns `nil` or `false` introspection responses with 401 status code + # when using authorized token to introspect, or you'll get 200 with { "active": false } body + # when using authorized client to introspect as stated in the + # RFC 7662 section 2.2. Introspection Response. + # + # Using with caution: + # Keep in mind that these three parameters pass to block can be nil as following case: + # `authorized_client` is nil if and only if `authorized_token` is present, and vice versa. + # `token` will be nil if and only if `authorized_token` is present. + # So remember to use `&` or check if it is present before calling method on + # them to make sure you doesn't get NoMethodError exception. + # + # You can define your custom check: + # + # allow_token_introspection do |token, authorized_client, authorized_token| + # if authorized_token + # # customize: require `introspection` scope + # authorized_token.application == token&.application || + # authorized_token.scopes.include?("introspection") + # elsif token.application + # # `protected_resource` is a new database boolean column, for example + # authorized_client == token.application || authorized_client.protected_resource? + # else + # # public token (when token.application is nil, token doesn't belong to any application) + # true + # end + # end + # + # Or you can completely disable any token introspection: + # + # allow_token_introspection false + # + # If you need to block the request at all, then configure your routes.rb or web-server + # like nginx to forbid the request. + + # WWW-Authenticate Realm (default: "Doorkeeper"). + # + # realm "Doorkeeper" +end diff --git a/config/routes.rb b/config/routes.rb index 2b55ae196..3ed49d772 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ Rails.application.routes.draw do + use_doorkeeper scope: '/api/oauth' do + skip_controllers :applications, :authorized_applications + end + root 'pages#home' get 'auth/:provider/callback', to: 'sessions#create' diff --git a/db/migrate/20240921104426_create_doorkeeper_tables.rb b/db/migrate/20240921104426_create_doorkeeper_tables.rb new file mode 100644 index 000000000..bd5fcff9a --- /dev/null +++ b/db/migrate/20240921104426_create_doorkeeper_tables.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +class CreateDoorkeeperTables < ActiveRecord::Migration[7.2] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + + # Remove `null: false` if you are planning to use grant flows + # that doesn't require redirect URI to be used during authorization + # like Client Credentials flow or Resource Owner Password. + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + 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.references :resource_owner, 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.string :scopes, null: false, default: '' + t.datetime :created_at, null: false + t.datetime :revoked_at + end + + add_index :oauth_access_grants, :token, unique: true + add_foreign_key( + :oauth_access_grants, + :oauth_applications, + column: :application_id + ) + + create_table :oauth_access_tokens do |t| + t.references :resource_owner, index: true + + # Remove `null: false` if you are planning to use Password + # Credentials Grant flow that doesn't require an application. + t.references :application, null: false + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.string :scopes + t.datetime :created_at, null: false + t.datetime :revoked_at + + # The authorization server MAY issue a new refresh token, in which case + # *the client MUST discard the old refresh token* and replace it with the + # new refresh token. The authorization server MAY revoke the old + # refresh token after issuing a new refresh token to the client. + # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + # + # Doorkeeper implementation: if there is a `previous_refresh_token` column, + # refresh tokens will be revoked after a related access token is used. + # If there is no `previous_refresh_token` column, previous tokens are + # revoked as soon as a new access token is created. + # + # Comment out this line if you want refresh tokens to be instantly + # revoked after use. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :oauth_access_tokens, :token, unique: true + + # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592 + if ActiveRecord::Base.connection.adapter_name == "SQLServer" + execute <<~SQL.squish + CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token) + WHERE refresh_token IS NOT NULL + SQL + else + add_index :oauth_access_tokens, :refresh_token, unique: true + end + + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + + # Uncomment below to ensure a valid reference to the resource owner's table + add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id + add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id + end +end diff --git a/db/migrate/20240921121249_add_owner_to_application.rb b/db/migrate/20240921121249_add_owner_to_application.rb new file mode 100644 index 000000000..897ee0b60 --- /dev/null +++ b/db/migrate/20240921121249_add_owner_to_application.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOwnerToApplication < ActiveRecord::Migration[7.2] + def change + add_column :oauth_applications, :owner_id, :bigint, null: true + add_column :oauth_applications, :owner_type, :string, null: true + add_index :oauth_applications, [:owner_id, :owner_type] + end +end diff --git a/db/schema.rb b/db/schema.rb index 712b2a765..c981a7faf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -282,6 +282,51 @@ t.index ["from_id"], name: "index_messages_on_from_id" end + create_table "oauth_access_grants", force: :cascade do |t| + t.bigint "resource_owner_id", null: false + t.bigint "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" + t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true + end + + create_table "oauth_access_tokens", force: :cascade do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "owner_id" + t.string "owner_type" + t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "organizations", force: :cascade do |t| t.string "siret", null: false t.jsonb "mon_compte_pro_payload", default: {}, null: false @@ -356,5 +401,9 @@ add_foreign_key "malware_scans", "active_storage_attachments", column: "attachment_id" add_foreign_key "messages", "authorization_requests" add_foreign_key "messages", "users", column: "from_id" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "revocation_of_authorizations", "authorization_requests" end From 80eb22f20ccc5300d7b93ba082d466d290b34d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Mon, 23 Sep 2024 11:10:39 +0200 Subject: [PATCH 02/11] API: Introduce first endpoint Dummy /me, without serializers. Will need some refactor for testing. --- .../api/v1/credentials_controller.rb | 7 ++++++ config/routes.rb | 12 ++++++---- spec/factories/oauth.rb | 13 +++++++++++ .../api/v1/credentials_controller_spec.rb | 22 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/credentials_controller.rb create mode 100644 spec/factories/oauth.rb create mode 100644 spec/requests/api/v1/credentials_controller_spec.rb diff --git a/app/controllers/api/v1/credentials_controller.rb b/app/controllers/api/v1/credentials_controller.rb new file mode 100644 index 000000000..1e0b68b66 --- /dev/null +++ b/app/controllers/api/v1/credentials_controller.rb @@ -0,0 +1,7 @@ +class API::V1::CredentialsController < APIController + before_action :doorkeeper_authorize! + + def me + render json: current_user.attributes.slice('id', 'email', 'family_name', 'given_name', 'job_title') + end +end diff --git a/config/routes.rb b/config/routes.rb index 3ed49d772..df7a7ee59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,4 @@ Rails.application.routes.draw do - use_doorkeeper scope: '/api/oauth' do - skip_controllers :applications, :authorized_applications - end - root 'pages#home' get 'auth/:provider/callback', to: 'sessions#create' @@ -94,8 +90,16 @@ end end + use_doorkeeper scope: '/api/oauth' do + skip_controllers :applications, :authorized_applications + end + namespace :api do resources :frontal, only: :index + + namespace :v1 do + get '/me', to: 'credentials#me' + end end get '/dgfip/export', to: 'dgfip/export#show', as: :dgfip_export diff --git a/spec/factories/oauth.rb b/spec/factories/oauth.rb new file mode 100644 index 000000000..fb40ce071 --- /dev/null +++ b/spec/factories/oauth.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :oauth_application, class: 'Doorkeeper::Application' do + name { 'MyApp' } + redirect_uri { 'https://app.com/callback' } + owner factory: :user + end + + factory :access_token, class: 'Doorkeeper::AccessToken' do + application factory: :oauth_application + expires_in { 2.hours } + scopes { 'public' } + end +end diff --git a/spec/requests/api/v1/credentials_controller_spec.rb b/spec/requests/api/v1/credentials_controller_spec.rb new file mode 100644 index 000000000..78ca65c04 --- /dev/null +++ b/spec/requests/api/v1/credentials_controller_spec.rb @@ -0,0 +1,22 @@ +RSpec.describe 'Credentials', type: :request do + context 'when unauthorized' do + it 'returns unauthorized' do + get '/api/v1/me' + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authorized' do + let(:user) { create(:user) } + let(:application) { create(:oauth_application, owner: user) } + let(:access_token) { create(:access_token, application:, resource_owner_id: user.id) } + + it 'returns the current user' do + get '/api/v1/me', params: {}, headers: { 'Authorization' => "Bearer #{access_token.token}" } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq(user.attributes.slice('id', 'email', 'family_name', 'given_name', 'job_title')) + end + end +end From 526248a062f0e2bacec7ac9f612f50962e20fd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Mon, 23 Sep 2024 11:10:39 +0200 Subject: [PATCH 03/11] Introduce developer role --- app/models/user.rb | 11 +++++++---- spec/factories/users.rb | 14 ++++++++++++++ spec/models/user_spec.rb | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 266bc07a5..62aae96da 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,6 +39,7 @@ class User < ApplicationRecord )", [ "#{authorization_request_type.underscore}:instructor", + "#{authorization_request_type.underscore}:developer", "#{authorization_request_type.underscore}:reporter", ] ) @@ -82,13 +83,15 @@ def instructor?(authorization_request_type = nil) def reporter_roles (roles.select { |role| role.end_with?(':reporter') - } + instructor_roles).uniq + } + instructor_roles + developer_roles).uniq end def instructor_roles - roles.select do |role| - role.end_with?(':instructor') - end + roles.select { |role| role.end_with?(':instructor') } + end + + def developer_roles + roles.select { |role| role.end_with?(':developer') } end def reporter?(authorization_request_type = nil) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index b4db41273..3be37c93f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -42,5 +42,19 @@ end end end + + trait :developer do + transient do + authorization_request_types do + %w[hubee_cert_dc api_entreprise] + end + end + + after(:build) do |user, evaluator| + evaluator.authorization_request_types.each do |authorization_request_type| + user.roles << "#{authorization_request_type}:developer" + end + end + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 12d1ca6aa..5249e40e5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -22,12 +22,13 @@ let(:authorization_request_type) { 'api_entreprise' } + let!(:valid_developer) { create(:user, :developer, authorization_request_types: %i[api_entreprise]) } let!(:valid_instructor) { create(:user, :instructor, authorization_request_types: %i[api_entreprise]) } let!(:valid_instructor_with_multiple_authorization_type) { create(:user, :instructor, authorization_request_types: %i[api_entreprise api_particulier]) } let!(:invalid_instructor) { create(:user, :instructor, authorization_request_types: %i[api_particulier]) } let!(:valid_reporter) { create(:user, :reporter, authorization_request_types: %i[api_entreprise]) } - it { is_expected.to contain_exactly(valid_reporter, valid_instructor, valid_instructor_with_multiple_authorization_type) } + it { is_expected.to contain_exactly(valid_reporter, valid_developer, valid_instructor, valid_instructor_with_multiple_authorization_type) } end describe '#reporter?' do From c6cd26e83bee164386f00feb4ac6098787620337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Mon, 23 Sep 2024 11:10:39 +0200 Subject: [PATCH 04/11] Introduce OpenAPI v1 file --- Gemfile | 1 + Gemfile.lock | 6 + config/openapi/v1.yaml | 461 +++++++++++++++++++++++++++++ config/routes.rb | 2 + spec/requests/api/open_api_spec.rb | 12 + 5 files changed, 482 insertions(+) create mode 100644 config/openapi/v1.yaml create mode 100644 spec/requests/api/open_api_spec.rb diff --git a/Gemfile b/Gemfile index 2e91da395..04b9d56e4 100644 --- a/Gemfile +++ b/Gemfile @@ -85,4 +85,5 @@ group :test do gem 'rspec-rails', '7.1.0' gem 'simplecov', require: false gem 'webmock' + gem 'openapi3_parser' end diff --git a/Gemfile.lock b/Gemfile.lock index 5dcb496c7..afbae3927 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -138,6 +138,9 @@ GEM nokogiri (~> 1.10, >= 1.10.4) rubyzip (>= 1.3.0, < 3) coderay (1.1.3) + commonmarker (2.0.2.1-aarch64-linux) + commonmarker (2.0.2.1-arm64-darwin) + commonmarker (2.0.2.1-x86_64-linux) concurrent-ruby (1.3.5) connection_pool (2.5.0) crack (1.0.0) @@ -392,6 +395,8 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + openapi3_parser (0.10.0) + commonmarker (>= 1.0) ostruct (0.6.1) parallel (1.26.3) parser (3.3.7.0) @@ -664,6 +669,7 @@ DEPENDENCIES omniauth omniauth-oauth2 omniauth-rails_csrf_protection + openapi3_parser pg (~> 1.5) propshaft puma (>= 5.0) diff --git a/config/openapi/v1.yaml b/config/openapi/v1.yaml new file mode 100644 index 000000000..7389691cb --- /dev/null +++ b/config/openapi/v1.yaml @@ -0,0 +1,461 @@ +openapi: 3.0.0 +info: + title: API DataPass + description: | + Seule la gestion des demandes d'habilitations (lecture/écriture) est accessible sur cette version. + + L'authentification s'effectue via OAuth2, avec les scopes suivants: + - `read_authorization_requests` -> accès en lecture aux demandes d'habilitations + - `write_authorization_requests` -> accès en écriture aux demandes d'habilitations + + Ces scopes sont optionnels. Le seul scope par défaut est `public` + + A noter qu'un client OAuth2 est associé à un utilisateur, et que les types de demandes d'habilitations accessibles sont régies par les droits associés à cet utilisateur. Pour qu'un utilisateur puisse manipuler à travers l'API les demandes d'habilitations d'un certain type, il doit être référencé comme étant développeur pour ce type. Pour cela, il faut qu'un administrateur l'ajoute manuellement. + + Le type d'habilitation est associé à la ressource `Definition`, les formulaires eux à la resource `Formulaire`. + + Chaque `Demande` possède une clé `data` qui correspond aux attributs associés à ce type d'habilitation, celles-ci sont défini dans la ressource `Definition->attributs` + version: 0.1.0 +servers: + - description: Production + url: https://v2.datapass.api.gouv.fr/api/v1 + - description: Staging + url: https://staging.v2.datapass.api.gouv.fr/api/v1 + - description: Sandbox + url: https://sandbox.v2.datapass.api.gouv.fr/api/v1 + - description: Développement (local) + url: http://localhost:3000/api/v1 +components: + securitySchemes: + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://v2.datapass.api.gouv.fr/api/oauth/authorize + tokenUrl: https://v2.datapass.api.gouv.fr/api/oauth/token + scopes: + read_authorization_requests: Accès en lecture sur les demandes d'habilitation autorisés pour l'utilisateur + write_authorization_requests: Accès en écriture sur les demandes d'habilitation autorisés pour l'utilisateur + public: Scope pour l'accès aux données publiques et de base (/me) + schemas: + BaseDemande: + type: object + properties: + definition_id: + type: string + example: api_particulier + description: ID technique de la définition du type de demande d'habilitation. La liste est récupérable via le endpoint `/api/v1/definitions` + form_id: + type: string + example: api-particulier + description: ID technique du formulaire. La liste est récupérable via le endpoint `/api/v1/definitions/{definition_id}/formulaires` + data: + type: object + additionalProperties: true + description: Données associées à la demande. La liste des clés acceptées est régie par le type de définition, dans la clé `attributs` + example: + intitule: Ma demande + descrition: La description de la demande + scopes: + - cnaf_quotient_familial + - cnaf_allocataires + status: + type: string + example: draft + description: Status de la demande + enum: + - draft + - submitted + - changes_requested + - validated + - refused + - archived + - revoked + + Demande: + description: Demande d'habilitation + allOf: + - type: object + properties: + id: + type: integer + example: 51 + description: Identifiant technique de la demande + public_id: + type: string + format: uuid + example: 33e18ac4-8dc3-456d-8985-01871d80fec2 + description: Identifiant public de la demande. A l'aide de cet identifiant il est possible de consulter la demande sans être connecté. Le chemin est `/public/demandes/{public_id}`. + applicant: + $ref: '#/components/schemas/Utilisateur' + organization: + $ref: '#/components/schemas/Organisation' + - $ref: '#/components/schemas/BaseDemande' + + CreationDemande: + description: Payload pour la création d'une demande d'habilitation + allOf: + - $ref: '#/components/schemas/BaseDemande' + - type: object + properties: + organization_siret: + type: string + example: '13002526500013' + description: Numéro de siret de l'organisation a associer à la demande + applicant_email: + type: string + example: utilisateur@gouv.fr + description: Email du demandeur a associer à la demande + + MiseAJourDemande: + description: Payload pour la mise à jour d'une demande d'habilitation + type: object + properties: + data: + type: object + additionalProperties: true + description: Données associées à la demande. La liste des clés acceptées est régie par le type de définition, dans la clé `attributs` + example: + intitule: Ma demande + descrition: La description de la demande + scopes: + - scope1 + - scope2 + + Utilisateur: + type: object + description: Utilisateur. L'ensemble des informations, excepté l'identifiant, sont issue de MonComptePro. + properties: + id: + type: integer + example: 42 + description: Identifiant technique de l'utilisateur + email: + type: string + example: utilisateur@gouv.fr + description: Courriel de l'utilisateur + family_name: + type: string + example: Dupont + description: Nom de famille de l'utilisateur + nullable: true + given_name: + type: string + example: Jean + description: Prénom de l'utilisateur + nullable: true + job_title: + type: string + example: Maire + description: Intitulé de poste de l'utilisateur + nullable: true + + Organisation: + type: object + description: Organisation. Les informations renvoyées sont tiré de l'INSEE et de MonComptePro. + properties: + id: + type: integer + example: 51 + description: Identifiant technique de l'organisation + siret: + type: string + example: '13002526500013' + description: Numéro de siret de l'organisation + raison_sociale: + type: string + description: Raison sociale de l'organisation. Cette information provient de l'INSEE + example: Direction interministerielle du numerique (DINUM) + nullable: true + + Definition: + type: object + description: Type d'habilitation, décrivant les champs associés aux demandes + properties: + id: + type: string + example: api_particulier + description: Identifiant de la définition. Celui-ci est unique. + name: + type: string + example: API Particulier + description: Nom de la définition + description: + type: string + example: L'API Particulier permet d'accéder à des données d'usagers + description: Description de la définition + attributes: + type: object + description: | + Liste des attributs associés à cette définition, sous la forme `nom: type`. Cette clé permet de déterminer les attributs dans la clé `Demande->data`. Les valeurs possibles sont uniquement `string` et `array`, les `array` sont de niveau 1 et possède uniquement des `string` + example: + intitule: string + description: string + scopes: array + scopes: + description: Scopes disponibles (vide si aucun scope) + type: array + items: + $ref: '#/components/schemas/ScopeDefinition' + + ScopeDefinition: + type: object + description: Définition associé à un scope + properties: + name: + description: Nom du scope + example: Quotient familial CAF & MSA + type: string + value: + description: Valeur (technique) du scope + example: cnaf_quotient_familial + type: string + + Formulaire: + type: object + description: Formulaire associé à un type d'habilitation. Un type d'habilitation peut avoir 1 ou plusieurs formulaires (en fonction du cas d'usage) + properties: + id: + type: string + example: formulaire_1 + description: Identifiant du formulaire. Celui-ci est unique (i.e. non scopé à une définition). + name: + type: string + example: Formulaire pour l'éditeur UMadCorp + description: Nom du formulaire + description: + type: string + example: Ce formulaire est spécifique à l'éditeur UMadCorp et permet d'obtenir une habilitation pour le logiciel SisiLaFamille + description: Description du formulaire + definition_id: + type: string + example: api_particulier + description: Identidiant de la définition à laquelle le formulaire est rattaché + default_data: + type: object + description: Données par défaut associées aux attributs définie dans la définition. Ces données sont affectées à une demande à la création. Si à la création des données sont passé en paramètre, les données par défaut sont ignorés. + example: + intitule: "Intitulé par défaut" + description: "Description par défaut" + + ErrorResponse: + type: object + properties: + errors: + type: array + items: + type: object + properties: + status: + type: string + description: "Le code d'état HTTP associé à l'erreur." + example: "422" + source: + type: object + properties: + pointer: + type: string + description: "Un pointeur JSON indiquant l'attribut source de l'erreur." + example: "/data/attributes/nom" + required: + - pointer + title: + type: string + description: "Un titre court pour l'erreur." + example: "Validation Error" + detail: + type: string + description: "Un message détaillé décrivant l'erreur." + example: "Le nom ne peut pas être vide." + required: + - status + - title + - detail + + responses: + UnauthorizedError: + description: Accès non autorisé - le jeton n'est pas valide ou absent. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + ForbiddenError: + description: Accès interdit - les scopes associés au jeton ne permettent pas d'effectuer cette action. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + ValidationError: + description: La sauvegarde ou la mise à jour a échoué. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +paths: + /demandes: + get: + summary: Récupérer la liste des demandes d'habilitations + tags: + - Demandes d'habilitations + security: + - OAuth2: [read_authorization_requests] + parameters: + - name: limit + in: query + description: Nombre maximum de demandes à récupérer + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 100 + - name: offset + in: query + description: Nombre de demandes à sauter avant de commencer à récupérer les résultats + required: false + schema: + type: integer + default: 0 + minimum: 0 + responses: + 200: + description: Liste des demandes récupérée avec succès + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Demande' + 401: + $ref: '#/components/responses/UnauthorizedError' + 403: + $ref: '#/components/responses/ForbiddenError' + post: + summary: Créer une nouvelle demande d'habilitation + tags: + - Demandes d'habilitations + security: + - OAuth2: [write_authorization_requests] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreationDemande' + responses: + 201: + description: Demande d'habilitation créée avec succès + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Demande' + 401: + $ref: '#/components/responses/UnauthorizedError' + 403: + $ref: '#/components/responses/ForbiddenError' + 422: + $ref: '#/components/responses/ValidationError' + /demandes/{id}: + get: + summary: Récupérer une demande d'habilitation + tags: + - Demandes d'habilitations + security: + - OAuth2: [read_authorization_requests] + parameters: + - name: id + in: path + description: Identifiant de la demande d'habilitation + required: true + schema: + type: string + responses: + 200: + description: Demande d'habilitation + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Demande' + patch: + summary: Mettre à jour une demande d'habilitation + tags: + - Demandes d'habilitations + security: + - OAuth2: [write_authorization_requests] + parameters: + - name: id + in: path + description: Identifiant de la demande d'habilitation + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MiseAJourDemande' + responses: + 200: + description: Demande d'habilitation mise à jour avec succès + content: + application/json: + schema: + $ref: '#/components/schemas/Demande' + 401: + $ref: '#/components/responses/UnauthorizedError' + 403: + $ref: '#/components/responses/ForbiddenError' + 422: + $ref: '#/components/responses/ValidationError' + /definitions: + get: + tags: + - Définitions et formulaires + summary: Récupérer la liste des définitions + security: + - OAuth2: [read_authorization_requests] + responses: + 200: + description: Liste des définitions récupérée avec succès + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Definition' + 401: + $ref: '#/components/responses/UnauthorizedError' + 403: + $ref: '#/components/responses/ForbiddenError' + /definitions/{id}/formulaires: + get: + summary: Récupérer la liste des formulaires pour une définition + tags: + - Définitions et formulaires + security: + - OAuth2: [read_authorization_requests] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + 200: + description: Liste des formulaires récupérée avec succès + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Formulaire' + 401: + $ref: '#/components/responses/UnauthorizedError' + 403: + $ref: '#/components/responses/ForbiddenError' diff --git a/config/routes.rb b/config/routes.rb index df7a7ee59..a83db17de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,6 +94,8 @@ skip_controllers :applications, :authorized_applications end + get '/api-docs/v1.yaml', to: ->(env) { [200, { 'Content-Type' => 'application/yaml' }, [File.read(Rails.root.join('config/openapi/v1.yaml'))]] } + namespace :api do resources :frontal, only: :index diff --git a/spec/requests/api/open_api_spec.rb b/spec/requests/api/open_api_spec.rb new file mode 100644 index 000000000..7b19a43a3 --- /dev/null +++ b/spec/requests/api/open_api_spec.rb @@ -0,0 +1,12 @@ +RSpec.describe 'OpenAPI files', type: :request do + it 'works and render a valid OpenAPI file' do + get '/api-docs/v1.yaml' + + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq('application/yaml') + expect(YAML.safe_load(response.body)).to be_a(Hash) + + document = Openapi3Parser.load(response.body) + expect(document).to be_valid, "OpenAPI file is invalid: #{document.errors}" + end +end From 2effcd0966a76151efc53f7bed25a727503a67c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Mon, 23 Sep 2024 11:10:39 +0200 Subject: [PATCH 05/11] Add developers documentation Use redoc to make OpenAPI file available on the site --- app/controllers/open_api_controller.rb | 9 +++++++ app/views/open_api/show.html.erb | 29 +++++++++++++++++++++ config/routes.rb | 3 ++- features/developpeurs/documentation.feature | 9 +++++++ features/step_definitions/web_steps.rb | 4 +++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 app/controllers/open_api_controller.rb create mode 100644 app/views/open_api/show.html.erb create mode 100644 features/developpeurs/documentation.feature diff --git a/app/controllers/open_api_controller.rb b/app/controllers/open_api_controller.rb new file mode 100644 index 000000000..79708706b --- /dev/null +++ b/app/controllers/open_api_controller.rb @@ -0,0 +1,9 @@ +class OpenAPIController < ApplicationController + include Authentication + + allow_unauthenticated_access + + layout 'application' + + def show; end +end diff --git a/app/views/open_api/show.html.erb b/app/views/open_api/show.html.erb new file mode 100644 index 000000000..25f789b3b --- /dev/null +++ b/app/views/open_api/show.html.erb @@ -0,0 +1,29 @@ +
+
+ + diff --git a/config/routes.rb b/config/routes.rb index a83db17de..feeaa8881 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -94,7 +94,8 @@ skip_controllers :applications, :authorized_applications end - get '/api-docs/v1.yaml', to: ->(env) { [200, { 'Content-Type' => 'application/yaml' }, [File.read(Rails.root.join('config/openapi/v1.yaml'))]] } + get '/api-docs/v1.yaml', to: ->(env) { [200, { 'Content-Type' => 'application/yaml', 'Content-Disposition' => 'inline;filename="datapass-v1.yaml"' }, [File.read(Rails.root.join('config/openapi/v1.yaml'))]] }, as: :open_api_v1 + get '/developpeurs/documentation', to: 'open_api#show' namespace :api do resources :frontal, only: :index diff --git a/features/developpeurs/documentation.feature b/features/developpeurs/documentation.feature new file mode 100644 index 000000000..4cf8a8bee --- /dev/null +++ b/features/developpeurs/documentation.feature @@ -0,0 +1,9 @@ +# language: fr + +Fonctionnalité: Développeurs: consultation de la documentation + @javascript + + Scénario: Je peux voir la documentation OpenAPI de manière jolie + Quand je me rends sur le chemin "/developpeurs/documentation" + Alors la page contient "API DataPass" + diff --git a/features/step_definitions/web_steps.rb b/features/step_definitions/web_steps.rb index 9b6bfcde3..0c171f67c 100644 --- a/features/step_definitions/web_steps.rb +++ b/features/step_definitions/web_steps.rb @@ -2,6 +2,10 @@ visit root_path end +Quand('je me rends sur le chemin {string}') do |string| + visit string +end + Quand('print the page') do log page.body end From 82edc29266bbdd1df461c46749fc49421cc0db3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Wed, 25 Sep 2024 09:19:54 +0200 Subject: [PATCH 06/11] OpenAPI file: mark write endpoints as NIY --- config/openapi/v1.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/openapi/v1.yaml b/config/openapi/v1.yaml index 7389691cb..0c2af0c65 100644 --- a/config/openapi/v1.yaml +++ b/config/openapi/v1.yaml @@ -336,7 +336,8 @@ paths: post: summary: Créer une nouvelle demande d'habilitation tags: - - Demandes d'habilitations + - Non implémenté + deprecated: true security: - OAuth2: [write_authorization_requests] requestBody: @@ -384,7 +385,8 @@ paths: patch: summary: Mettre à jour une demande d'habilitation tags: - - Demandes d'habilitations + - Non implémenté + deprecated: true security: - OAuth2: [write_authorization_requests] parameters: From 991d9c1b09ba7f7f85fa86ea24c8d36c128d503b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Fri, 17 Jan 2025 16:15:33 +0100 Subject: [PATCH 07/11] Prepare serializers/controllers --- .../api/v1/credentials_controller.rb | 4 +--- app/controllers/api/v1_controller.rb | 24 +++++++++++++++++++ app/controllers/api_controller.rb | 4 ++-- config/initializers/doorkeeper.rb | 2 +- .../api/v1/credentials_controller_spec.rb | 5 ++++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/v1_controller.rb diff --git a/app/controllers/api/v1/credentials_controller.rb b/app/controllers/api/v1/credentials_controller.rb index 1e0b68b66..4bac1d65e 100644 --- a/app/controllers/api/v1/credentials_controller.rb +++ b/app/controllers/api/v1/credentials_controller.rb @@ -1,6 +1,4 @@ -class API::V1::CredentialsController < APIController - before_action :doorkeeper_authorize! - +class API::V1::CredentialsController < API::V1Controller def me render json: current_user.attributes.slice('id', 'email', 'family_name', 'given_name', 'job_title') end diff --git a/app/controllers/api/v1_controller.rb b/app/controllers/api/v1_controller.rb new file mode 100644 index 000000000..1647c6bd3 --- /dev/null +++ b/app/controllers/api/v1_controller.rb @@ -0,0 +1,24 @@ +class API::V1Controller < APIController + rescue_from Doorkeeper::Errors::TokenUnknown, with: :render_unauthorized_error + + before_action :doorkeeper_authorize! + + protected + + def render_unauthorized_error + render_error(401, title: 'Non autorisé', detail: "Un jeton d'accès est requis mais absent dans les en-têtes de la requête.", source: { pointer: 'headers/Authorization' }) + end + + def render_error(http_code, title:, detail:, source: nil) + render json: { + errors: [ + { + status: http_code.to_s, + title:, + detail:, + source:, + }.compact + ] + }, status: http_code + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 61a6cfaa4..8502ff484 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -5,8 +5,8 @@ class APIController < ActionController::API def user_id_session { - 'value' => doorkeeper_token.resource_owner_id, - 'expires_at' => doorkeeper_token.expires_in + Time.current.to_i, + 'value' => doorkeeper_token.try(:resource_owner_id), + 'expires_at' => (doorkeeper_token.try(:expires_in) || 0) + Time.current.to_i, } end end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 4314abbdf..8871d76e6 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -324,7 +324,7 @@ # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired, # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown # - # handle_auth_errors :raise + handle_auth_errors :raise # # If you want to redirect back to the client application in accordance with # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set diff --git a/spec/requests/api/v1/credentials_controller_spec.rb b/spec/requests/api/v1/credentials_controller_spec.rb index 78ca65c04..af23cdc28 100644 --- a/spec/requests/api/v1/credentials_controller_spec.rb +++ b/spec/requests/api/v1/credentials_controller_spec.rb @@ -4,6 +4,11 @@ get '/api/v1/me' expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body['errors']).to include( + hash_including( + status: '401' + ) + ) end end From 4bddbfba0440ecff60761c3053a7935fa7a9ac90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Fri, 17 Jan 2025 16:46:10 +0100 Subject: [PATCH 08/11] Harden data method within serializer We can have old/invalid key within data, just ignored them --- .../webhook_authorization_request_serializer.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/serializers/webhook_authorization_request_serializer.rb b/app/serializers/webhook_authorization_request_serializer.rb index 2e8c4a672..cac7a7ee1 100644 --- a/app/serializers/webhook_authorization_request_serializer.rb +++ b/app/serializers/webhook_authorization_request_serializer.rb @@ -11,7 +11,11 @@ class WebhookAuthorizationRequestSerializer < ApplicationSerializer def data object.data.keys.index_with { |key| - object.public_send(key) - }.symbolize_keys + begin + object.public_send(key) + rescue NoMethodError + nil + end + }.compact.symbolize_keys end end From 66ff68523aae125db1661166c989366a79d374ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Fri, 17 Jan 2025 16:48:19 +0100 Subject: [PATCH 09/11] Scout commit --- spec/requests/api/v1/credentials_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/api/v1/credentials_controller_spec.rb b/spec/requests/api/v1/credentials_controller_spec.rb index af23cdc28..c530da5a7 100644 --- a/spec/requests/api/v1/credentials_controller_spec.rb +++ b/spec/requests/api/v1/credentials_controller_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe 'Credentials', type: :request do +RSpec.describe 'API: Credentials', type: :request do context 'when unauthorized' do it 'returns unauthorized' do get '/api/v1/me' From 5c3dbe6f66e49a276205d0a916d2d672e33d333c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Fri, 17 Jan 2025 16:48:37 +0100 Subject: [PATCH 10/11] API: Implement /api/v1/demandes --- .../v1/authorization_requests_controller.rb | 24 +++++++++++++++++++ .../v1/authorization_request_serializer.rb | 1 + config/openapi/v1.yaml | 15 +++++++++--- config/routes.rb | 3 +++ .../authorization_requests_controller_spec.rb | 24 +++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 app/controllers/api/v1/authorization_requests_controller.rb create mode 100644 app/serializers/api/v1/authorization_request_serializer.rb create mode 100644 spec/requests/api/v1/authorization_requests_controller_spec.rb diff --git a/app/controllers/api/v1/authorization_requests_controller.rb b/app/controllers/api/v1/authorization_requests_controller.rb new file mode 100644 index 000000000..09c75d13c --- /dev/null +++ b/app/controllers/api/v1/authorization_requests_controller.rb @@ -0,0 +1,24 @@ +class API::V1::AuthorizationRequestsController < API::V1Controller + def index + authorization_requests = AuthorizationRequest + .where(type: valid_authorization_request_types) + .offset(params[:offset]) + .limit(params.fetch(:limit, 10)) + + if authorization_requests.any? + render json: authorization_requests, + each_serializer: API::V1::AuthorizationRequestSerializer, + status: :ok + else + render_error(404, title: 'Non trouvé', detail: 'Aucune demande n\'a été trouvé') + end + end + + private + + def valid_authorization_request_types + current_user.developer_roles.map do |role| + "AuthorizationRequest::#{role.split(':')[0].classify}" + end + end +end diff --git a/app/serializers/api/v1/authorization_request_serializer.rb b/app/serializers/api/v1/authorization_request_serializer.rb new file mode 100644 index 000000000..4bc223354 --- /dev/null +++ b/app/serializers/api/v1/authorization_request_serializer.rb @@ -0,0 +1 @@ +class API::V1::AuthorizationRequestSerializer < WebhookAuthorizationRequestSerializer; end diff --git a/config/openapi/v1.yaml b/config/openapi/v1.yaml index 0c2af0c65..d42f3343b 100644 --- a/config/openapi/v1.yaml +++ b/config/openapi/v1.yaml @@ -124,7 +124,7 @@ components: Utilisateur: type: object - description: Utilisateur. L'ensemble des informations, excepté l'identifiant, sont issue de MonComptePro. + description: Utilisateur. L'ensemble des informations, excepté l'identifiant, sont issue de ProConnect. properties: id: type: integer @@ -152,7 +152,7 @@ components: Organisation: type: object - description: Organisation. Les informations renvoyées sont tiré de l'INSEE et de MonComptePro. + description: Organisation. Les informations renvoyées sont tiré de l'INSEE et de ProConnect. properties: id: type: integer @@ -273,6 +273,13 @@ components: - detail responses: + NotFoundError: + description: Resource non trouvée + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + UnauthorizedError: description: Accès non autorisé - le jeton n'est pas valide ou absent. content: @@ -297,7 +304,7 @@ components: paths: /demandes: get: - summary: Récupérer la liste des demandes d'habilitations + summary: Récupérer la liste des demandes d'habilitations associé aux droits développeur de l'utilisateur tags: - Demandes d'habilitations security: @@ -333,6 +340,8 @@ paths: $ref: '#/components/responses/UnauthorizedError' 403: $ref: '#/components/responses/ForbiddenError' + 404: + $ref: '#/components/responses/NotFoundError' post: summary: Créer une nouvelle demande d'habilitation tags: diff --git a/config/routes.rb b/config/routes.rb index feeaa8881..c0865814e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -95,6 +95,7 @@ end get '/api-docs/v1.yaml', to: ->(env) { [200, { 'Content-Type' => 'application/yaml', 'Content-Disposition' => 'inline;filename="datapass-v1.yaml"' }, [File.read(Rails.root.join('config/openapi/v1.yaml'))]] }, as: :open_api_v1 + get '/developpeurs', to: redirect('/developpeurs/documentation') get '/developpeurs/documentation', to: 'open_api#show' namespace :api do @@ -102,6 +103,8 @@ namespace :v1 do get '/me', to: 'credentials#me' + + resources :authorization_requests, path: 'demandes', only: :index end end diff --git a/spec/requests/api/v1/authorization_requests_controller_spec.rb b/spec/requests/api/v1/authorization_requests_controller_spec.rb new file mode 100644 index 000000000..7d38113fd --- /dev/null +++ b/spec/requests/api/v1/authorization_requests_controller_spec.rb @@ -0,0 +1,24 @@ +RSpec.describe 'API: Authorization requests', type: :request do + let(:user) { create(:user, :developer) } + let(:application) { create(:oauth_application, owner: user) } + let(:access_token) { create(:access_token, application:, resource_owner_id: user.id) } + + describe 'index' do + subject(:get_index) do + get '/api/v1/demandes', headers: { 'Authorization' => "Bearer #{access_token.token}" } + end + + context 'when there is at least one authorization request associated to one of the user developer role' do + let!(:valid_authorization_request) { create(:authorization_request, :api_entreprise) } + let!(:invalid_authorization_request) { create(:authorization_request, :api_particulier) } + + it 'reponds OK with data' do + get_index + + expect(response.status).to eq(200) + expect(response.parsed_body.count).to eq(1) + expect(response.parsed_body[0]['id']).to eq(valid_authorization_request.id) + end + end + end +end From 6e70fda2ed65e68ccdf383c2bee7d0ab51f27ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loi=CC=88c=20Delmaire?= Date: Fri, 17 Jan 2025 16:55:14 +0100 Subject: [PATCH 11/11] API: Implement /api/v1/demandes/{id} --- .../v1/authorization_requests_controller.rb | 12 +++++++ config/openapi/v1.yaml | 2 ++ config/routes.rb | 2 +- .../authorization_requests_controller_spec.rb | 31 ++++++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/authorization_requests_controller.rb b/app/controllers/api/v1/authorization_requests_controller.rb index 09c75d13c..19a335659 100644 --- a/app/controllers/api/v1/authorization_requests_controller.rb +++ b/app/controllers/api/v1/authorization_requests_controller.rb @@ -14,6 +14,18 @@ def index end end + def show + authorization_request = AuthorizationRequest + .where(type: valid_authorization_request_types) + .find(params[:id]) + + render json: authorization_request, + serializer: API::V1::AuthorizationRequestSerializer, + status: :ok + rescue ActiveRecord::RecordNotFound + render_error(404, title: 'Non trouvé', detail: 'Aucune demande n\'a été trouvé') + end + private def valid_authorization_request_types diff --git a/config/openapi/v1.yaml b/config/openapi/v1.yaml index d42f3343b..3caed2e8d 100644 --- a/config/openapi/v1.yaml +++ b/config/openapi/v1.yaml @@ -391,6 +391,8 @@ paths: schema: allOf: - $ref: '#/components/schemas/Demande' + 404: + $ref: '#/components/responses/NotFoundError' patch: summary: Mettre à jour une demande d'habilitation tags: diff --git a/config/routes.rb b/config/routes.rb index c0865814e..31eabc906 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -104,7 +104,7 @@ namespace :v1 do get '/me', to: 'credentials#me' - resources :authorization_requests, path: 'demandes', only: :index + resources :authorization_requests, path: 'demandes', only: %i[index show] end end diff --git a/spec/requests/api/v1/authorization_requests_controller_spec.rb b/spec/requests/api/v1/authorization_requests_controller_spec.rb index 7d38113fd..656bb8f77 100644 --- a/spec/requests/api/v1/authorization_requests_controller_spec.rb +++ b/spec/requests/api/v1/authorization_requests_controller_spec.rb @@ -1,5 +1,5 @@ RSpec.describe 'API: Authorization requests', type: :request do - let(:user) { create(:user, :developer) } + let(:user) { create(:user, :developer, authorization_request_types: %w[api_entreprise]) } let(:application) { create(:oauth_application, owner: user) } let(:access_token) { create(:access_token, application:, resource_owner_id: user.id) } @@ -21,4 +21,33 @@ end end end + + describe 'show' do + subject(:get_show) do + get "/api/v1/demandes/#{id}", headers: { 'Authorization' => "Bearer #{access_token.token}" } + end + + let(:id) { authorization_request.id } + + context 'with valid authorization request' do + let(:authorization_request) { create(:authorization_request, :api_entreprise) } + + it 'responds OK with data' do + get_show + + expect(response.status).to eq(200) + expect(response.parsed_body['id']).to eq(authorization_request.id) + end + end + + context 'with invalid authorization request' do + let(:authorization_request) { create(:authorization_request, :api_particulier) } + + it 'responds 404' do + get_show + + expect(response.status).to eq(404) + end + end + end end