# frozen_string_literal: true

#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

module AuthenticationMethods
  class AccessTokenError < Exception
  end

  class AccessTokenScopeError < StandardError
  end

  class LoggedOutError < Exception
  end

  def self.access_token(request, params_method = :params)
    auth_header = request.authorization
    if auth_header.present? && (header_parts = auth_header.split(' ', 2)) && header_parts[0] == 'Bearer'
      header_parts[1]
    else
      request.send(params_method)['access_token'].presence
    end
  end

  def self.user_id(request)
    request.session[:user_id]
  end

  def load_pseudonym_from_jwt
    return unless api_request?
    token_string = AuthenticationMethods.access_token(request)
    return unless token_string.present?
    begin
      services_jwt = Canvas::Security::ServicesJwt.new(token_string)
      @current_user = User.find(services_jwt.user_global_id)
      @current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)
      unless @current_user && @current_pseudonym
        raise AccessTokenError
      end
      if services_jwt.masquerading_user_global_id
        @real_current_user = User.find(services_jwt.masquerading_user_global_id)
        @real_current_pseudonym = SisPseudonym.for(@real_current_user, @domain_root_account, type: :implicit, require_sis: false)
        logger.warn "#{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
      end
      @authenticated_with_jwt = true
    rescue JSON::JWT::InvalidFormat,       # definitely not a JWT
           Canvas::Security::TokenExpired, # it could be a JWT, but it's expired if so
           Canvas::Security::InvalidToken  # not formatted like a JWT
      # these will happen for some configurations (no consul)
      # and for some normal use cases (old token, access token),
      # so we can return and move on
      return
    rescue Imperium::TimeoutError => exception
      # Something went wrong in the Network
      # these are indications of infrastructure or data problems
      # so we should log them for resolution, but recover gracefully
      Canvas::Errors.capture_exception(:jwt_check, exception, :warn)
    end
  end

  ALLOWED_SCOPE_INCLUDES = %w{uuid}

  def filter_includes(key)
    # no funny business
    params.delete(key) unless params[key].class == Array
    return unless params.key?(key)
    params[key] &= ALLOWED_SCOPE_INCLUDES
  end

  def validate_scopes
    return unless @access_token

    developer_key = @access_token.developer_key
    request_method = request.method.casecmp('HEAD') == 0 ? 'GET' : request.method.upcase

    if developer_key.try(:require_scopes)
      scope_patterns = @access_token.url_scopes_for_method(request_method).concat(AccessToken.always_allowed_scopes)
      if scope_patterns.any? { |scope| scope =~ request.path }
        unless developer_key.try(:allow_includes)
          filter_includes(:include)
          filter_includes(:includes)
        end
      else
        raise AccessTokenScopeError
      end
    end
  end

  def self.graphql_type_authorized?(access_token, type)
    if access_token&.developer_key&.require_scopes
      # allowing the root query type for now, but any other type is forbidden
      type == "Query"
    else
      true
    end
  end

  def load_pseudonym_from_access_token
    return unless api_request? ||
      (params[:controller] == 'oauth2_provider' && params[:action] == 'destroy') ||
      (params[:controller] == 'login' && params[:action] == 'session_token')

    token_string = AuthenticationMethods.access_token(request)

    if token_string
      @access_token = AccessToken.authenticate(token_string)
      if !@access_token
        raise AccessTokenError
      end

      account = access_token_account(@domain_root_account, @access_token)
      raise AccessTokenError unless @access_token.authorized_for_account?(account)

      @current_user = @access_token.user
      @real_current_user = @access_token.real_user
      @real_current_pseudonym = SisPseudonym.for(@real_current_user, @domain_root_account, type: :implicit, require_sis: false) if @real_current_user
      @current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)

      unless @current_user && @current_pseudonym
        raise AccessTokenError
      end
      validate_scopes
      @access_token.used!

      RequestContextGenerator.add_meta_header('at', @access_token.global_id)
      RequestContextGenerator.add_meta_header('dk', @access_token.global_developer_key_id) if @access_token.developer_key_id
    end
  end

  def access_token_account(domain_root_account, access_token)
    dev_key_account_id = access_token.dev_key_account_id
    if dev_key_account_id.blank? || domain_root_account.id == dev_key_account_id
      domain_root_account
    else
      get_context
      (@context && Context.get_account(@context)) || domain_root_account
    end
  end

  def load_user
    @current_user = @current_pseudonym = nil

    masked_authenticity_token # ensure that the cookie is set

    load_pseudonym_from_jwt

    unless @current_pseudonym.present?
      load_pseudonym_from_access_token
    end

    if !@current_pseudonym
      if @policy_pseudonym_id
        @current_pseudonym = Pseudonym.where(id: @policy_pseudonym_id).first
      else
        @pseudonym_session = PseudonymSession.find_with_validation
        if @pseudonym_session
          @current_pseudonym = @pseudonym_session.record
          @current_pseudonym.user.reload if @current_pseudonym.shard != @current_pseudonym.user.shard

          # if the session was created before the last time the user explicitly
          # logged out (of any session for any of their pseudonyms), invalidate
          # this session
          invalid_before = @current_pseudonym.user.last_logged_out
          # they logged out in the future?!? something's busted; just ignore it -
          # either my clock is off or whoever set this value's clock is off
          invalid_before = nil if invalid_before && invalid_before > Time.now.utc
          if invalid_before &&
            (session_refreshed_at = request.env['encrypted_cookie_store.session_refreshed_at']) &&
            session_refreshed_at < invalid_before

            logger.info "[AUTH] Invalidating session: Session created before user logged out."
            destroy_session
            @current_pseudonym = nil
            if api_request? || request.format.json?
              raise LoggedOutError
            end
          end

          if @current_pseudonym &&
            session[:cas_session] &&
            @current_pseudonym.cas_ticket_expired?(session[:cas_session])

            logger.info "[AUTH] Invalidating session: CAS ticket expired - #{session[:cas_session]}."
            destroy_session
            @current_pseudonym = nil

            raise LoggedOutError if api_request? || request.format.json?

            redirect_to_login
          end
        end
      end

      if params[:login_success] == '1' && !@current_pseudonym
        # they just logged in successfully, but we can't find the pseudonym now?
        # sounds like somebody hates cookies.
        return redirect_to(login_url(:needs_cookies => '1'))
      end
      @current_user = @current_pseudonym && @current_pseudonym.user
    end

    logger.info "[AUTH] inital load: pseud -> #{@current_pseudonym&.id}, user -> #{@current_user&.id}"
    if @current_user && @current_user.unavailable?
      logger.info "[AUTH] Invalid request: User is currently UNAVAILABLE"
      @current_pseudonym = nil
      @current_user = nil
    end

    # required by the user throttling middleware
    session[:user_id] = @current_user.global_id if @current_user

    if @current_user && %w(become_user_id me become_teacher become_student).any? { |k| params.key?(k) }
      request_become_user = nil
      if params[:become_user_id]
        request_become_user = User.where(id: params[:become_user_id]).first
      elsif params.keys.include?('me')
        request_become_user = @current_user
      elsif params.keys.include?('become_teacher')
        course = Course.find(params[:course_id] || params[:id]) rescue nil
        teacher = course.teachers.first if course
        if teacher
          request_become_user = teacher
        else
          flash[:error] = I18n.t('lib.auth.errors.teacher_not_found', "No teacher found")
        end
      elsif params.keys.include?('become_student')
        course = Course.find(params[:course_id] || params[:id]) rescue nil
        student = course.students.first if course
        if student
          request_become_user = student
        else
          flash[:error] = I18n.t('lib.auth.errors.student_not_found', "No student found")
        end
      end

      if request_become_user && request_become_user.id != session[:become_user_id].to_i && request_become_user.can_masquerade?(@current_user, @domain_root_account)
        params_without_become = params.dup
        params_without_become.delete_if {|k,v| [ 'become_user_id', 'become_teacher', 'become_student', 'me' ].include? k }
        params_without_become[:only_path] = true
        session[:masquerade_return_to] = url_for(params_without_become.to_unsafe_h)
        return redirect_to user_masquerade_url(request_become_user.id)
      end
    end

    as_user_id = api_request? && params[:as_user_id].presence
    as_user_id ||= session[:become_user_id]
    if as_user_id
      begin
        user = api_find(User, as_user_id)
      rescue ActiveRecord::RecordNotFound
      end
      if user && @real_current_user
        if @current_user != user
          # if we're already masquerading from an access token, and now try to
          # masquerade as someone else
          render :json => {:errors => "Cannot change masquerade"}, :status => :unauthorized
          return false
        # else: they do match, everything is already set
        end
        logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url} via masquerade token"
      elsif user && user.can_masquerade?(@current_user, @domain_root_account)
        @real_current_user = @current_user
        @current_user = user
        @real_current_pseudonym = @current_pseudonym
        @current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)
        logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
      elsif api_request?
        # fail silently for UI, but not for API
        render :json => {:errors => "Invalid as_user_id"}, :status => :unauthorized
        return false
      end
    end

    logger.info "[AUTH] final user: #{@current_user&.id}"
    @current_user
  end
  private :load_user

  def require_user
    if @current_user && @current_pseudonym
      true
    else
      redirect_to_login
      false
    end
  end
  protected :require_user

  def clean_return_to(url)
    return nil if url.blank?
    begin
      uri = URI.parse(url)
    rescue URI::Error
      return nil
    end
    return nil unless uri.path && uri.path[0] == '/'
    return "#{request.protocol}#{request.host_with_port}#{uri.path.sub(%r{/download$}, '')}" if uri.path =~ %r{/files/(\d+~)?\d+/download$}
    return "#{request.protocol}#{request.host_with_port}#{uri.path}#{uri.query && "?#{uri.query}"}#{uri.fragment && "##{uri.fragment}"}"
  end

  def return_to(url, fallback)
    url = clean_return_to(url) || clean_return_to(fallback)
    redirect_to url
  end

  def store_location(uri=nil, overwrite=true)
    if overwrite || !session[:return_to]
      uri ||= request.get? ? request.fullpath : request.referrer
      session[:return_to] = clean_return_to(uri)
    end
  end
  protected :store_location

  def redirect_back_or_default(default)
    session.delete(:return_to) || default
  end
  protected :redirect_back_or_default

  def redirect_to_referrer_or_default(default)
    redirect_back(fallback_location: default)
  end

  def redirect_to_login
    return unless fix_ms_office_redirects
    respond_to do |format|
      format.json { render_json_unauthorized }
      format.all do
        store_location
        flash[:warning] = I18n.t('lib.auth.errors.not_authenticated', "You must be logged in to access this page") unless request.path == '/'
        redirect_to login_url(params.permit(:canvas_login, :authentication_provider))
      end
    end
  end

  def render_json_unauthorized
    add_www_authenticate_header if api_request? && !@current_user
    if @current_user
      render json: {
        status: I18n.t('lib.auth.status_unauthorized', 'unauthorized'),
        errors: [{ message: I18n.t('lib.auth.not_authorized', "user not authorized to perform that action") }]
      },
      status: :unauthorized
    else
      render json: {
        status: I18n.t('lib.auth.status_unauthenticated', 'unauthenticated'),
        errors: [{ :message => I18n.t('lib.auth.authentication_required', "user authorization required") }]
      },
      status: :unauthorized
    end
  end

  def add_www_authenticate_header
    response['WWW-Authenticate'] = %{Bearer realm="canvas-lms"}
  end

  # Reset the session, and copy the specified keys over to the new session.
  # Please consider the security implications of any keys you copy over.
  def reset_session_saving_keys(*keys)
    # can't use slice, because session has a different ctor than a normal hash
    saved = {}
    keys.each { |k| saved[k] = session[k] if session[k] }
    reset_session
    saved.each_pair { |k, v| session[k] = v }
  end
end
