Commit daed01a5 authored by Jan Provaznik's avatar Jan Provaznik 💬 Committed by Thiago Presa
Browse files

Merge branch 'security-if-51113-hash_tokens-11-4' into 'security-11-4'

[11.4] Persist only SHA digest of PersonalAccessToken#token

See merge request gitlab/gitlabhq!2551
parent 9266cb27
Loading
Loading
Loading
Loading
+24 −31
Original line number Original line Diff line number Diff line
@@ -5,57 +5,50 @@ module TokenAuthenticatable


  private
  private


  def write_new_token(token_field)
  class_methods do
    new_token = generate_available_token(token_field)
    private # rubocop:disable Lint/UselessAccessModifier
    write_attribute(token_field, new_token)
  end


  def generate_available_token(token_field)
    def add_authentication_token_field(token_field, options = {})
    loop do
      @token_fields = [] unless @token_fields
      token = generate_token(token_field)
      break token unless self.class.unscoped.find_by(token_field => token)
    end
  end


  def generate_token(token_field)
      if @token_fields.include?(token_field)
    Devise.friendly_token
        raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field")
      end
      end


  class_methods do
      @token_fields << token_field
    def authentication_token_fields
      @token_fields || []
    end


    private # rubocop:disable Lint/UselessAccessModifier
      attr_accessor :cleartext_tokens


    def add_authentication_token_field(token_field)
      strategy = if options[:digest]
      @token_fields = [] unless @token_fields
                   TokenAuthenticatableStrategies::Digest.new(self, token_field, options)
      @token_fields << token_field
                 else
                   TokenAuthenticatableStrategies::Insecure.new(self, token_field, options)
                 end


      define_singleton_method("find_by_#{token_field}") do |token|
      define_singleton_method("find_by_#{token_field}") do |token|
        find_by(token_field => token) if token
        strategy.find_token_authenticatable(token)
      end
      end


      define_method("ensure_#{token_field}") do
      define_method(token_field) do
        current_token = read_attribute(token_field)
        strategy.get_token(self)
        current_token.blank? ? write_new_token(token_field) : current_token
      end
      end


      define_method("set_#{token_field}") do |token|
      define_method("set_#{token_field}") do |token|
        write_attribute(token_field, token) if token
        strategy.set_token(self, token)
      end

      define_method("ensure_#{token_field}") do
        strategy.ensure_token(self)
      end
      end


      # Returns a token, but only saves when the database is in read & write mode
      # Returns a token, but only saves when the database is in read & write mode
      define_method("ensure_#{token_field}!") do
      define_method("ensure_#{token_field}!") do
        send("reset_#{token_field}!") if read_attribute(token_field).blank? # rubocop:disable GitlabSecurity/PublicSend
        strategy.ensure_token!(self)

        read_attribute(token_field)
      end
      end


      # Resets the token, but only saves when the database is in read & write mode
      # Resets the token, but only saves when the database is in read & write mode
      define_method("reset_#{token_field}!") do
      define_method("reset_#{token_field}!") do
        write_new_token(token_field)
        strategy.reset_token!(self)
        save! if Gitlab::Database.read_write?
      end
      end
    end
    end
  end
  end
+69 −0
Original line number Original line Diff line number Diff line
# frozen_string_literal: true

module TokenAuthenticatableStrategies
  class Base
    def initialize(klass, token_field, options)
      @klass = klass
      @token_field = token_field
      @options = options
    end

    def find_token_authenticatable(instance, unscoped = false)
      raise NotImplementedError
    end

    def get_token(instance)
      raise NotImplementedError
    end

    def set_token(instance)
      raise NotImplementedError
    end

    def ensure_token(instance)
      write_new_token(instance) unless token_set?(instance)
    end

    # Returns a token, but only saves when the database is in read & write mode
    def ensure_token!(instance)
      reset_token!(instance) unless token_set?(instance)
      get_token(instance)
    end

    # Resets the token, but only saves when the database is in read & write mode
    def reset_token!(instance)
      write_new_token(instance)
      instance.save! if Gitlab::Database.read_write?
    end

    protected

    def write_new_token(instance)
      new_token = generate_available_token
      set_token(instance, new_token)
    end

    def generate_available_token
      loop do
        token = generate_token
        break token unless find_token_authenticatable(token, true)
      end
    end

    def generate_token
      @options[:token_generator] ? @options[:token_generator].call : Devise.friendly_token
    end

    def relation(unscoped)
      unscoped ? @klass.unscoped : @klass
    end

    def token_set?(instance)
      raise NotImplementedError
    end

    def token_field_name
      @token_field
    end
  end
end
+50 −0
Original line number Original line Diff line number Diff line
# frozen_string_literal: true

module TokenAuthenticatableStrategies
  class Digest < Base
    def find_token_authenticatable(token, unscoped = false)
      return unless token

      token_authenticatable = relation(unscoped).find_by(token_field_name => Gitlab::CryptoHelper.sha256(token))

      if @options[:fallback]
        token_authenticatable ||= fallback_strategy.find_token_authenticatable(token)
      end

      token_authenticatable
    end

    def get_token(instance)
      token = instance.cleartext_tokens&.[](@token_field)
      token ||= fallback_strategy.get_token(instance) if @options[:fallback]

      token
    end

    def set_token(instance, token)
      return unless token

      instance.cleartext_tokens ||= {}
      instance.cleartext_tokens[@token_field] = token
      instance[token_field_name] = Gitlab::CryptoHelper.sha256(token)
      instance[@token_field] = nil if @options[:fallback]
    end

    protected

    def fallback_strategy
      @fallback_strategy ||= TokenAuthenticatableStrategies::Insecure.new(@klass, @token_field, @options)
    end

    def token_set?(instance)
      token_digest = instance.read_attribute(token_field_name)
      token_digest ||= instance.read_attribute(@token_field) if @options[:fallback]

      token_digest.present?
    end

    def token_field_name
      "#{@token_field}_digest"
    end
  end
end
+23 −0
Original line number Original line Diff line number Diff line
# frozen_string_literal: true

module TokenAuthenticatableStrategies
  class Insecure < Base
    def find_token_authenticatable(token, unscoped = false)
      relation(unscoped).find_by(@token_field => token) if token
    end

    def get_token(instance)
      instance.read_attribute(@token_field)
    end

    def set_token(instance, token)
      instance[@token_field] = token if token
    end

    protected

    def token_set?(instance)
      instance.read_attribute(@token_field).present?
    end
  end
end
+11 −5
Original line number Original line Diff line number Diff line
@@ -3,7 +3,7 @@
class PersonalAccessToken < ActiveRecord::Base
class PersonalAccessToken < ActiveRecord::Base
  include Expirable
  include Expirable
  include TokenAuthenticatable
  include TokenAuthenticatable
  add_authentication_token_field :token
  add_authentication_token_field :token, digest: true, fallback: true


  REDIS_EXPIRY_TIME = 3.minutes
  REDIS_EXPIRY_TIME = 3.minutes


@@ -33,16 +33,22 @@ def active?


  def self.redis_getdel(user_id)
  def self.redis_getdel(user_id)
    Gitlab::Redis::SharedState.with do |redis|
    Gitlab::Redis::SharedState.with do |redis|
      token = redis.get(redis_shared_state_key(user_id))
      encrypted_token = redis.get(redis_shared_state_key(user_id))
      redis.del(redis_shared_state_key(user_id))
      redis.del(redis_shared_state_key(user_id))
      token
      begin
        Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
      rescue => ex
        logger.warn "Failed to decrypt PersonalAccessToken value stored in Redis for User ##{user_id}: #{ex.class}"
        encrypted_token
      end
    end
    end
  end
  end


  def self.redis_store!(user_id, token)
  def self.redis_store!(user_id, token)
    encrypted_token = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)

    Gitlab::Redis::SharedState.with do |redis|
    Gitlab::Redis::SharedState.with do |redis|
      redis.set(redis_shared_state_key(user_id), token, ex: REDIS_EXPIRY_TIME)
      redis.set(redis_shared_state_key(user_id), encrypted_token, ex: REDIS_EXPIRY_TIME)
      token
    end
    end
  end
  end


Loading