app/controllers/api/v2/tokens_controller.rb

Summary

Maintainability
A
35 mins
Test Coverage
# frozen_string_literal: true

# TokensController is used to deliver the token that the docker client should
# use in order to perform operation into the registry. This is the last step in
# the authentication process for Portus' point of view.
class Api::V2::TokensController < Api::BaseController
  before_action :attempt_authentication_against_application_tokens

  # Try to perform authentication using the application tokens. The password
  # provided via HTTP basic auth is going to be checked against the application
  # tokens a user might have created.
  # If the user has a valid application token then the other forms of
  # authentication (Portus' database, LDAP) are going to be skipped.
  def attempt_authentication_against_application_tokens
    user = authenticate_with_http_basic do |username, password|
      user = User.find_by(username: username)
      user if user&.application_token_valid?(password)
    end
    sign_in(user, store: true) if user
  end

  # Returns the token that the docker client should use in order to perform
  # operation into the private registry.
  def show
    authenticate_user! if request.headers["Authorization"]
    registry = Registry.by_hostname_or_external(params[:service])

    auth_scopes = []
    auth_scopes = authorize_scopes(registry) unless registry.nil?

    token = Portus::JwtToken.new(params[:account], params[:service], auth_scopes)
    logger.tagged("jwt_token", "claim") { logger.debug token.claim }
    render json: token.encoded_hash
  end

  private

  # If there was a scope specified in the request parameters, try to authorize
  # the given scopes. That is, it "filters" the scopes that can be requested
  # depending of the issuer of the request and its permissions.
  #
  # If no scope was specified, this is a login request and it just returns nil.
  def authorize_scopes(registry)
    scopes =  Array(Rack::Utils.parse_query(request.query_string)["scope"])
    return if scopes.empty?

    auth_scopes = {}

    # First try to fetch the requested scopes and the handler. If no scopes
    # were successfully given, respond with a 401.
    scopes.each do |scope|
      auth_scope, actions = scope_handler(registry, scope)

      actions.each { |action| triage_action!(auth_scope, action) }
      next if auth_scope.actions.empty?

      # If there is already a similar scope (type and resource name), we combine
      # them into one:
      # e.g. scope=repository:busybox:push&scope=repository:busybox:pull
      #      -> access=>[{:type=>"repository", :name=>"busybox", :actions=>["push", "pull"]}
      k = [auth_scope.resource_type, auth_scope.resource_name]
      if auth_scopes[k]
        auth_scopes[k].actions.concat(auth_scope.actions).uniq!
      else
        auth_scopes[k] = auth_scope
      end
    end
    auth_scopes.values
  end

  # It will try to check if the current user is authorized to access the
  # scope given in this iteration. If everything is fine, then nothing will
  # happen, otherwise there are two possible exceptions that can be raised:
  #
  #   - NoMethodError: the targeted resource does not handle the scope that
  #     is being checked. It will raise a ScopeNotHandled.
  #   - Pundit::NotAuthorizedError: the targeted resource unauthorized the
  #     given user for the scope that is being checked. In this case this
  #     scope gets removed from `auth_scope.actions`.
  def triage_action!(auth_scope, action)
    authorize auth_scope.resource, "#{action}?".to_sym
  rescue NoMethodError, Pundit::NotAuthorizedError, Portus::AuthScope::ResourceNotFound
    logger.debug "action #{action} not handled/authorized, removing from actions"
    auth_scope.actions.delete_if { |a| match_action(action, a) }
  end

  # Returns true if the given item matches the given action.
  def match_action(action, item)
    action = "*" if action == "all"
    action == item
  end

  # From the given scope string, try to fetch a scope handler class for it.
  # Scope handlers are defined in "app/models/*/auth_scope.rb" files.
  def scope_handler(registry, scope_string)
    str = scope_string.split(":", 3)
    raise ScopeNotHandled, "Wrong format for scope string" if str.length != 3

    case str[0]
    when "repository"
      auth_scope = Namespace::AuthScope.new(registry, scope_string)
    when "registry"
      auth_scope = Registry::AuthScope.new(registry, scope_string)
    else
      raise ScopeNotHandled, "Scope not handled: #{str[0]}"
    end

    [auth_scope, auth_scope.scopes.dup]
  end
end