18887: Fix salted_secret check. Add test.
[arvados.git] / services / api / app / models / api_client_authorization.rb
index 6b308a231cb7ede8cf50b949da75a861a46219d3..52922d32b1868fdb53d8bcd3f1197d149a93bb63 100644 (file)
@@ -7,12 +7,15 @@ class ApiClientAuthorization < ArvadosModel
   include KindAndEtag
   include CommonApiTemplate
   extend CurrentApiClient
+  extend DbCurrentTime
 
   belongs_to :api_client
   belongs_to :user
   after_initialize :assign_random_api_token
   serialize :scopes, Array
 
+  before_validation :clamp_token_expiration
+
   api_accessible :user, extend: :common do |t|
     t.add :owner_uuid
     t.add :user_id
@@ -32,7 +35,12 @@ class ApiClientAuthorization < ArvadosModel
   UNLOGGED_CHANGES = ['last_used_at', 'last_used_by_ip_address', 'updated_at']
 
   def assign_random_api_token
-    self.api_token ||= rand(2**256).to_s(36)
+    begin
+      self.api_token ||= rand(2**256).to_s(36)
+    rescue ActiveModel::MissingAttributeError
+      # Ignore the case where self.api_token doesn't exist, which happens when
+      # the select=[...] is used.
+    end
   end
 
   def owner_uuid
@@ -108,6 +116,37 @@ class ApiClientAuthorization < ArvadosModel
     clnt
   end
 
+  def self.check_anonymous_user_token(token:, remote:)
+    case token[0..2]
+    when 'v2/'
+      _, token_uuid, secret, optional = token.split('/')
+      unless token_uuid.andand.length == 27 && secret.andand.length.andand > 0 &&
+             token_uuid == Rails.configuration.ClusterID+"-gj3su-anonymouspublic"
+        # invalid v2 token, or v2 token for another user
+        return nil
+      end
+    else
+      # v1 token
+      secret = token
+    end
+
+    # Usually, the secret is salted
+    salted_secret = OpenSSL::HMAC.hexdigest('sha1', Rails.configuration.Users.AnonymousUserToken, remote)
+
+    # The anonymous token could be specified as a full v2 token in the config,
+    # but the config loader strips it down to the secret part.
+    # The anonymous token content and minimum length is verified in lib/config
+    if secret.length >= 0 && (secret == Rails.configuration.Users.AnonymousUserToken || secret == salted_secret)
+      return ApiClientAuthorization.new(user: User.find_by_uuid(anonymous_user_uuid),
+                                        uuid: Rails.configuration.ClusterID+"-gj3su-anonymouspublic",
+                                        api_token: secret,
+                                        api_client: anonymous_user_token_api_client,
+                                        scopes: ['GET /'])
+    else
+      return nil
+    end
+  end
+
   def self.check_system_root_token token
     if token == Rails.configuration.SystemRootToken
       return ApiClientAuthorization.new(user: User.find_by_uuid(system_user_uuid),
@@ -123,6 +162,11 @@ class ApiClientAuthorization < ArvadosModel
     return nil if token.nil? or token.empty?
     remote ||= Rails.configuration.ClusterID
 
+    auth = self.check_anonymous_user_token(token: token, remote: remote)
+    if !auth.nil?
+      return auth
+    end
+
     auth = self.check_system_root_token(token)
     if !auth.nil?
       return auth
@@ -246,21 +290,34 @@ class ApiClientAuthorization < ArvadosModel
 
     remote_user_prefix = remote_user['uuid'][0..4]
 
-    if token_uuid == ''
-      # Use the same UUID as the remote when caching the token.
-      begin
-        remote_token = SafeJSON.load(
-          clnt.get_content('https://' + host + '/arvados/v1/api_client_authorizations/current',
-                           {'remote' => Rails.configuration.ClusterID},
-                           {'Authorization' => 'Bearer ' + token}))
-        token_uuid = remote_token['uuid']
-        if !token_uuid.match(HasUuid::UUID_REGEX) || token_uuid[0..4] != upstream_cluster_id
-          raise "remote cluster #{upstream_cluster_id} returned invalid token uuid #{token_uuid.inspect}"
-        end
-      rescue => e
-        Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
-        return nil
+    # Get token scope, and make sure we use the same UUID as the
+    # remote when caching the token.
+    remote_token = nil
+    begin
+      remote_token = SafeJSON.load(
+        clnt.get_content('https://' + host + '/arvados/v1/api_client_authorizations/current',
+                         {'remote' => Rails.configuration.ClusterID},
+                         {'Authorization' => 'Bearer ' + token}))
+      Rails.logger.debug "retrieved remote token #{remote_token.inspect}"
+      token_uuid = remote_token['uuid']
+      if !token_uuid.match(HasUuid::UUID_REGEX) || token_uuid[0..4] != upstream_cluster_id
+        raise "remote cluster #{upstream_cluster_id} returned invalid token uuid #{token_uuid.inspect}"
+      end
+    rescue HTTPClient::BadResponseError => e
+      if e.res.status != 401
+        raise
+      end
+      rev = SafeJSON.load(clnt.get_content('https://' + host + '/discovery/v1/apis/arvados/v1/rest'))['revision']
+      if rev >= '20010101' && rev < '20210503'
+        Rails.logger.warn "remote cluster #{upstream_cluster_id} at #{host} with api rev #{rev} does not provide token expiry and scopes; using scopes=['all']"
+      else
+        # remote server is new enough that it should have accepted
+        # this request if the token was valid
+        raise
       end
+    rescue => e
+      Rails.logger.warn "error getting remote token details for #{token.inspect}: #{e}"
+      return nil
     end
 
     # Clusters can only authenticate for their own users.
@@ -303,7 +360,17 @@ class ApiClientAuthorization < ArvadosModel
         user.last_name = "from cluster #{remote_user_prefix}"
       end
 
-      user.save!
+      begin
+        user.save!
+      rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
+        Rails.logger.debug("remote user #{remote_user['uuid']} already exists, retrying...")
+        # Some other request won the race: retry fetching the user record.
+        user = User.find_by_uuid(remote_user['uuid'])
+        if !user
+          Rails.logger.warn("cannot find or create remote user #{remote_user['uuid']}")
+          return nil
+        end
+      end
 
       if user.is_invited && !remote_user['is_invited']
         # Remote user is not "invited" state, they should be unsetup, which
@@ -336,26 +403,43 @@ class ApiClientAuthorization < ArvadosModel
         end
       end
 
-      # We will accept this token (and avoid reloading the user
-      # record) for 'RemoteTokenRefresh' (default 5 minutes).
-      # Possible todo:
-      # Request the actual api_client_auth record from the remote
-      # server in case it wants the token to expire sooner.
-      auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
-        auth.user = user
-        auth.api_client_id = 0
-      end
       # If stored_secret is set, we save stored_secret in the database
       # but return the real secret to the caller. This way, if we end
       # up returning the auth record to the client, they see the same
       # secret they supplied, instead of the HMAC we saved in the
       # database.
       stored_secret = stored_secret || secret
+
+      # We will accept this token (and avoid reloading the user
+      # record) for 'RemoteTokenRefresh' (default 5 minutes).
+      exp = [db_current_time + Rails.configuration.Login.RemoteTokenRefresh,
+             remote_token.andand['expires_at']].compact.min
+      scopes = remote_token.andand['scopes'] || ['all']
+      begin
+        retries ||= 0
+        auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
+          auth.user = user
+          auth.api_token = stored_secret
+          auth.api_client_id = 0
+          auth.scopes = scopes
+          auth.expires_at = exp
+        end
+      rescue ActiveRecord::RecordNotUnique
+        Rails.logger.debug("cached remote token #{token_uuid} already exists, retrying...")
+        # Some other request won the race: retry just once before erroring out
+        if (retries += 1) <= 1
+          retry
+        else
+          Rails.logger.warn("cannot find or create cached remote token #{token_uuid}")
+          return nil
+        end
+      end
       auth.update_attributes!(user: user,
                               api_token: stored_secret,
                               api_client_id: 0,
-                              expires_at: Time.now + Rails.configuration.Login.RemoteTokenRefresh)
-      Rails.logger.debug "cached remote token #{token_uuid} with secret #{stored_secret} in local db"
+                              scopes: scopes,
+                              expires_at: exp)
+      Rails.logger.debug "cached remote token #{token_uuid} with secret #{stored_secret} and scopes #{scopes} in local db"
       auth.api_token = secret
       return auth
     end
@@ -384,6 +468,15 @@ class ApiClientAuthorization < ArvadosModel
 
   protected
 
+  def clamp_token_expiration
+    if Rails.configuration.API.MaxTokenLifetime > 0
+      max_token_expiration = db_current_time + Rails.configuration.API.MaxTokenLifetime
+      if (self.new_record? || self.expires_at_changed?) && (self.expires_at.nil? || (self.expires_at > max_token_expiration && !current_user.andand.is_admin))
+        self.expires_at = max_token_expiration
+      end
+    end
+  end
+
   def permission_to_create
     current_user.andand.is_admin or (current_user.andand.id == self.user_id)
   end
@@ -394,7 +487,6 @@ class ApiClientAuthorization < ArvadosModel
   end
 
   def log_update
-
     super unless (saved_changes.keys - UNLOGGED_CHANGES).empty?
   end
 end