summaryrefslogtreecommitdiff
path: root/lib/gitlab/auth.rb
blob: c97ef5a10ef0b49924d4a2d6efa90a6fb119f7a0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# frozen_string_literal: true

module Gitlab
  module Auth
    MissingPersonalAccessTokenError = Class.new(StandardError)
    IpBlacklisted = Class.new(StandardError)

    # Scopes used for GitLab API access
    API_SCOPE = :api
    READ_API_SCOPE = :read_api
    READ_USER_SCOPE = :read_user
    API_SCOPES = [API_SCOPE, READ_API_SCOPE, READ_USER_SCOPE].freeze

    PROFILE_SCOPE = :profile
    EMAIL_SCOPE = :email
    OPENID_SCOPE = :openid
    # Scopes used for OpenID Connect
    OPENID_SCOPES = [OPENID_SCOPE].freeze
    # OpenID Connect profile scopes
    PROFILE_SCOPES = [PROFILE_SCOPE, EMAIL_SCOPE].freeze

    # Scopes used for GitLab Repository access
    READ_REPOSITORY_SCOPE = :read_repository
    WRITE_REPOSITORY_SCOPE = :write_repository
    REPOSITORY_SCOPES = [READ_REPOSITORY_SCOPE, WRITE_REPOSITORY_SCOPE].freeze

    # Scopes used for GitLab Docker Registry access
    READ_REGISTRY_SCOPE = :read_registry
    WRITE_REGISTRY_SCOPE = :write_registry
    REGISTRY_SCOPES = [READ_REGISTRY_SCOPE, WRITE_REGISTRY_SCOPE].freeze

    # Scopes used for GitLab as admin
    SUDO_SCOPE = :sudo
    ADMIN_MODE_SCOPE = :admin_mode
    ADMIN_SCOPES = [SUDO_SCOPE].freeze

    # Default scopes for OAuth applications that don't define their own
    DEFAULT_SCOPES = [API_SCOPE].freeze

    CI_JOB_USER = 'gitlab-ci-token'

    class << self
      prepend_mod_with('Gitlab::Auth') # rubocop: disable Cop/InjectEnterpriseEditionModule

      def omniauth_enabled?
        Gitlab.config.omniauth.enabled
      end

      def find_for_git_client(login, password, project:, ip:)
        raise "Must provide an IP for rate limiting" if ip.nil?

        rate_limiter = Gitlab::Auth::IpRateLimiter.new(ip)

        raise IpBlacklisted if !skip_rate_limit?(login: login) && rate_limiter.banned?

        # `user_with_password_for_git` should be the last check
        # because it's the most expensive, especially when LDAP
        # is enabled.
        result =
          service_request_check(login, password, project) ||
          build_access_token_check(login, password) ||
          lfs_token_check(login, password, project) ||
          oauth_access_token_check(login, password) ||
          personal_access_token_check(password, project) ||
          deploy_token_check(login, password, project) ||
          user_with_password_for_git(login, password) ||
          Gitlab::Auth::Result::EMPTY

        rate_limit!(rate_limiter, success: result.success?, login: login)
        look_to_limit_user(result.actor)

        return result if result.success? || authenticate_using_internal_or_ldap_password?

        # If sign-in is disabled and LDAP is not configured, recommend a
        # personal access token on failed auth attempts
        raise Gitlab::Auth::MissingPersonalAccessTokenError
      end

      # Find and return a user if the provided password is valid for various
      # authenticators (OAuth, LDAP, Local Database).
      #
      # Specify `increment_failed_attempts: true` to increment Devise `failed_attempts`.
      # CAUTION: Avoid incrementing failed attempts when authentication falls through
      # different mechanisms, as in `.find_for_git_client`. This may lead to
      # unwanted access locks when the value provided for `password` was actually
      # a PAT, deploy token, etc.
      def find_with_user_password(login, password, increment_failed_attempts: false)
        # Avoid resource intensive checks if login credentials are not provided
        return unless login.present? && password.present?

        # Nothing to do here if internal auth is disabled and LDAP is
        # not configured
        return unless authenticate_using_internal_or_ldap_password?

        Gitlab::Auth::UniqueIpsLimiter.limit_user! do
          user = User.find_by_login(login)

          break if user && !user.can_log_in_with_non_expired_password?

          authenticators = []

          if user
            authenticators << Gitlab::Auth::OAuth::Provider.authentication(user, 'database')

            # Add authenticators for all identities if user is not nil
            user&.identities&.each do |identity|
              authenticators << Gitlab::Auth::OAuth::Provider.authentication(user, identity.provider)
            end
          else
            # If no user is provided, try LDAP.
            #   LDAP users are only authenticated via LDAP
            authenticators << Gitlab::Auth::Ldap::Authentication
          end

          authenticators.compact!

          # return found user that was authenticated first for given login credentials
          authenticated_user = authenticators.find do |auth|
            authenticated_user = auth.login(login, password)
            break authenticated_user if authenticated_user
          end

          user_auth_attempt!(user, success: !!authenticated_user) if increment_failed_attempts

          authenticated_user
        end
      end

      private

      def rate_limit!(rate_limiter, success:, login:)
        return if skip_rate_limit?(login: login)

        if success
          # Repeated login 'failures' are normal behavior for some Git clients so
          # it is important to reset the ban counter once the client has proven
          # they are not a 'bad guy'.
          rate_limiter.reset!
        elsif rate_limiter.register_fail!
          # Register a login failure so that Rack::Attack can block the next
          # request from this IP if needed.
          # This returns true when the failures are over the threshold and the IP
          # is banned.
          Gitlab::AppLogger.info "IP #{rate_limiter.ip} failed to login " \
              "as #{login} but has been temporarily banned from Git auth"
        end
      end

      def skip_rate_limit?(login:)
        CI_JOB_USER == login
      end

      def look_to_limit_user(actor)
        Gitlab::Auth::UniqueIpsLimiter.limit_user!(actor) if actor.is_a?(User)
      end

      def authenticate_using_internal_or_ldap_password?
        Gitlab::CurrentSettings.password_authentication_enabled_for_git? || Gitlab::Auth::Ldap::Config.enabled?
      end

      def service_request_check(login, password, project)
        matched_login = /(?<service>^[a-zA-Z]*-ci)-token$/.match(login)

        return unless project && matched_login.present?

        underscored_service = matched_login['service'].underscore

        return unless Integration.available_integration_names.include?(underscored_service)

        # We treat underscored_service as a trusted input because it is included
        # in the Integration.available_integration_names allowlist.
        accessor = Project.integration_association_name(underscored_service)
        service = project.public_send(accessor) # rubocop:disable GitlabSecurity/PublicSend

        return unless service && service.activated? && service.valid_token?(password)

        Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)
      end

      def user_with_password_for_git(login, password)
        user = find_with_user_password(login, password)
        return unless user

        verifier = TwoFactorAuthVerifier.new(user)

        if user.two_factor_enabled? || verifier.two_factor_authentication_enforced?
          raise Gitlab::Auth::MissingPersonalAccessTokenError
        end

        Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)
      end

      def oauth_access_token_check(login, password)
        if login == "oauth2" && password.present?
          token = Doorkeeper::AccessToken.by_token(password)

          if valid_oauth_token?(token)
            user = User.id_in(token.resource_owner_id).first
            return unless user && user.can_log_in_with_non_expired_password?

            Gitlab::Auth::Result.new(user, nil, :oauth, abilities_for_scopes(token.scopes))
          end
        end
      end

      def personal_access_token_check(password, project)
        return unless password.present?

        finder_options = { state: 'active' }
        finder_options[:impersonation] = false unless Gitlab.config.gitlab.impersonation_enabled

        token = PersonalAccessTokensFinder.new(finder_options).find_by_token(password)

        return unless token

        return unless valid_scoped_token?(token, all_available_scopes)

        if project && token.user.project_bot?
          return unless can_read_project?(token.user, project)
        end

        if token.user.can_log_in_with_non_expired_password? || token.user.project_bot?
          ::PersonalAccessTokens::LastUsedService.new(token).execute

          Gitlab::Auth::Result.new(token.user, nil, :personal_access_token, abilities_for_scopes(token.scopes))
        end
      end

      def can_read_project?(user, project)
        user.can?(:read_project, project)
      end

      def valid_oauth_token?(token)
        token && token.accessible? && valid_scoped_token?(token, Doorkeeper.configuration.scopes)
      end

      def valid_scoped_token?(token, scopes)
        AccessTokenValidationService.new(token).include_any_scope?(scopes)
      end

      def abilities_for_scopes(scopes)
        abilities_by_scope = {
          api: full_authentication_abilities,
          read_api: read_only_authentication_abilities,
          read_registry: [:read_container_image],
          write_registry: [:create_container_image],
          read_repository: [:download_code],
          write_repository: [:download_code, :push_code]
        }

        scopes.flat_map do |scope|
          abilities_by_scope.fetch(scope.to_sym, [])
        end.uniq
      end

      def deploy_token_check(login, password, project)
        return unless password.present?

        token = DeployToken.active.find_by_token(password)

        return unless token && login
        return if login != token.username

        # Registry access (with jwt) does not have access to project
        return if project && !token.has_access_to?(project)
        # When repository is disabled, no resources are accessible via Deploy Token
        return if project&.repository_access_level == ::ProjectFeature::DISABLED

        scopes = abilities_for_scopes(token.scopes)

        if valid_scoped_token?(token, all_available_scopes)
          Gitlab::Auth::Result.new(token, project, :deploy_token, scopes)
        end
      end

      def lfs_token_check(login, encoded_token, project)
        deploy_key_matches = login.match(/\Alfs\+deploy-key-(\d+)\z/)

        actor =
          if deploy_key_matches
            DeployKey.find(deploy_key_matches[1])
          else
            User.find_by_login(login)
          end

        return unless actor

        token_handler = Gitlab::LfsToken.new(actor)

        authentication_abilities =
          if token_handler.user?
            read_write_project_authentication_abilities
          elsif token_handler.deploy_key_pushable?(project)
            read_write_authentication_abilities
          else
            read_only_authentication_abilities
          end

        if token_handler.token_valid?(encoded_token)
          Gitlab::Auth::Result.new(actor, nil, token_handler.type, authentication_abilities)
        end
      end

      def build_access_token_check(login, password)
        return unless login == CI_JOB_USER
        return unless password

        build = find_build_by_token(password)
        return unless build
        return unless build.project.builds_enabled?

        if build.user
          return unless build.user.can_log_in_with_non_expired_password? || (build.user.project_bot? && can_read_project?(build.user, build.project))

          # If user is assigned to build, use restricted credentials of user
          Gitlab::Auth::Result.new(build.user, build.project, :build, build_authentication_abilities)
        else
          # Otherwise use generic CI credentials (backward compatibility)
          Gitlab::Auth::Result.new(nil, build.project, :ci, build_authentication_abilities)
        end
      end

      public

      def build_authentication_abilities
        [
          :read_project,
          :build_download_code,
          :build_read_container_image,
          :build_create_container_image,
          :build_destroy_container_image
        ]
      end

      def read_only_project_authentication_abilities
        [
          :read_project,
          :download_code
        ]
      end

      def read_write_project_authentication_abilities
        read_only_project_authentication_abilities + [
          :push_code
        ]
      end

      def read_only_authentication_abilities
        read_only_project_authentication_abilities + [
          :read_container_image
        ]
      end

      def read_write_authentication_abilities
        read_only_authentication_abilities + [
          :push_code,
          :create_container_image
        ]
      end

      def full_authentication_abilities
        read_write_authentication_abilities + [
          :admin_container_image
        ]
      end

      def available_scopes_for(current_user)
        scopes = non_admin_available_scopes
        scopes += ADMIN_SCOPES if current_user.admin?

        scopes
      end

      def all_available_scopes
        non_admin_available_scopes + ADMIN_SCOPES
      end

      # Other available scopes
      def optional_scopes
        all_available_scopes + OPENID_SCOPES + PROFILE_SCOPES - DEFAULT_SCOPES
      end

      def registry_scopes
        return [] unless Gitlab.config.registry.enabled

        REGISTRY_SCOPES
      end

      def resource_bot_scopes
        Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
      end

      private

      def non_admin_available_scopes
        API_SCOPES + REPOSITORY_SCOPES + registry_scopes
      end

      def find_build_by_token(token)
        ::Gitlab::Database::LoadBalancing::Session.current.use_primary do
          ::Ci::AuthJobFinder.new(token: token).execute
        end
      end

      def user_auth_attempt!(user, success:)
        return unless user && Gitlab::Database.read_write?
        return user.unlock_access! if success

        user.increment_failed_attempts!
      end
    end
  end
end