BigW Consortium Gitlab

api_guard.rb 5.56 KB
Newer Older
Valery Sizov committed
1 2 3 4
# Guard API with OAuth 2.0 Access Token

require 'rack/oauth2'

5 6 7
module API
  module APIGuard
    extend ActiveSupport::Concern
Valery Sizov committed
8

9
    PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN".freeze
10 11
    PRIVATE_TOKEN_PARAM = :private_token

12 13 14 15
    included do |base|
      # OAuth2 Resource Server Authentication
      use Rack::OAuth2::Server::Resource::Bearer, 'The API' do |request|
        # The authenticator only fetches the raw token string
Valery Sizov committed
16

17 18 19
        # Must yield access token to store it in the env
        request.access_token
      end
Valery Sizov committed
20

21
      helpers HelperMethods
Valery Sizov committed
22

23 24
      install_error_responders(base)
    end
Valery Sizov committed
25

26 27 28 29 30 31 32 33 34 35 36 37 38
    # Helper Methods for Grape Endpoint
    module HelperMethods
      # Invokes the doorkeeper guard.
      #
      # If token is presented and valid, then it sets @current_user.
      #
      # If the token does not have sufficient scopes to cover the requred scopes,
      # then it raises InsufficientScopeError.
      #
      # If the token is expired, then it raises ExpiredError.
      #
      # If the token is revoked, then it raises RevokedError.
      #
39
      # If the token is not found (nil), then it returns nil
40 41 42 43 44 45 46
      #
      # Arguments:
      #
      #   scopes: (optional) scopes required for this guard.
      #           Defaults to empty array.
      #
      def doorkeeper_guard(scopes: [])
47 48 49
        access_token = find_access_token
        return nil unless access_token

50
        case AccessTokenValidationService.new(access_token).validate(scopes: scopes)
51 52 53 54 55 56 57 58 59 60 61
        when AccessTokenValidationService::INSUFFICIENT_SCOPE
          raise InsufficientScopeError.new(scopes)

        when AccessTokenValidationService::EXPIRED
          raise ExpiredError

        when AccessTokenValidationService::REVOKED
          raise RevokedError

        when AccessTokenValidationService::VALID
          @current_user = User.find(access_token.resource_owner_id)
62 63
        end
      end
Valery Sizov committed
64

65 66
      def find_user_by_private_token(scopes: [])
        token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s
Valery Sizov committed
67

68
        return nil unless token_string.present?
Valery Sizov committed
69

70
        find_user_by_authentication_token(token_string) || find_user_by_personal_access_token(token_string, scopes)
Valery Sizov committed
71 72
      end

73 74 75
      def current_user
        @current_user
      end
Valery Sizov committed
76

77 78 79 80 81 82 83 84 85 86 87 88
      # Set the authorization scope(s) allowed for the current request.
      #
      # Note: A call to this method adds to any previous scopes in place. This is done because
      # `Grape` callbacks run from the outside-in: the top-level callback (API::API) runs first, then
      # the next-level callback (API::API::Users, for example) runs. All these scopes are valid for the
      # given endpoint (GET `/api/users` is accessible by the `api` and `read_user` scopes), and so they
      # need to be stored.
      def allow_access_with_scope(*scopes)
        @scopes ||= []
        @scopes.concat(scopes.map(&:to_s))
      end

89
      private
Valery Sizov committed
90

91 92 93 94 95 96 97 98
      def find_user_by_authentication_token(token_string)
        User.find_by_authentication_token(token_string)
      end

      def find_user_by_personal_access_token(token_string, scopes)
        access_token = PersonalAccessToken.active.find_by_token(token_string)
        return unless access_token

99
        if AccessTokenValidationService.new(access_token).include_any_scope?(scopes)
100 101 102 103
          User.find(access_token.user_id)
        end
      end

104 105 106
      def find_access_token
        @access_token ||= Doorkeeper.authenticate(doorkeeper_request, Doorkeeper.configuration.access_token_methods)
      end
Valery Sizov committed
107

108 109 110
      def doorkeeper_request
        @doorkeeper_request ||= ActionDispatch::Request.new(env)
      end
Valery Sizov committed
111 112
    end

113 114
    module ClassMethods
      private
Valery Sizov committed
115

116
      def install_error_responders(base)
117
        error_classes = [MissingTokenError, TokenNotFoundError,
Douwe Maan committed
118
                         ExpiredError, RevokedError, InsufficientScopeError]
Valery Sizov committed
119

120 121 122 123
        base.send :rescue_from, *error_classes, oauth2_bearer_token_error_handler
      end

      def oauth2_bearer_token_error_handler
124
        proc do |e|
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
          response =
            case e
            when MissingTokenError
              Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new

            when TokenNotFoundError
              Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
                :invalid_token,
                "Bad Access Token.")

            when ExpiredError
              Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
                :invalid_token,
                "Token is expired. You can either do re-authorization or token refresh.")

            when RevokedError
              Rack::OAuth2::Server::Resource::Bearer::Unauthorized.new(
                :invalid_token,
                "Token was revoked. You have to re-authorize from the user.")

            when InsufficientScopeError
              # FIXME: ForbiddenError (inherited from Bearer::Forbidden of Rack::Oauth2)
              # does not include WWW-Authenticate header, which breaks the standard.
              Rack::OAuth2::Server::Resource::Bearer::Forbidden.new(
                :insufficient_scope,
                Rack::OAuth2::Server::Resource::ErrorMethods::DEFAULT_DESCRIPTION[:insufficient_scope],
                { scope: e.scopes })
            end

          response.finish
        end
156
      end
Valery Sizov committed
157 158
    end

159 160 161
    #
    # Exceptions
    #
Valery Sizov committed
162

163 164 165 166
    MissingTokenError = Class.new(StandardError)
    TokenNotFoundError = Class.new(StandardError)
    ExpiredError = Class.new(StandardError)
    RevokedError = Class.new(StandardError)
Valery Sizov committed
167

168 169 170 171 172
    class InsufficientScopeError < StandardError
      attr_reader :scopes
      def initialize(scopes)
        @scopes = scopes
      end
Valery Sizov committed
173 174
    end
  end
175
end