app/models/user.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

# == Schema Information
#
# Table name: users
#
#  id                     :integer          not null, primary key
#  username               :string(255)      default(""), not null
#  email                  :string(255)      default("")
#  encrypted_password     :string(255)      default(""), not null
#  reset_password_token   :string(255)
#  reset_password_sent_at :datetime
#  remember_created_at    :datetime
#  sign_in_count          :integer          default(0), not null
#  current_sign_in_at     :datetime
#  last_sign_in_at        :datetime
#  current_sign_in_ip     :string(255)
#  last_sign_in_ip        :string(255)
#  created_at             :datetime
#  updated_at             :datetime
#  admin                  :boolean          default(FALSE)
#  enabled                :boolean          default(TRUE)
#  ldap_name              :string(255)
#  failed_attempts        :integer          default(0)
#  locked_at              :datetime
#  namespace_id           :integer
#  display_name           :string(255)
#  provider               :string(255)
#  uid                    :string(255)
#  bot                    :boolean          default(FALSE)
#  ldap_group_checked     :integer          default(0)
#
# Indexes
#
#  index_users_on_display_name          (display_name) UNIQUE
#  index_users_on_email                 (email) UNIQUE
#  index_users_on_namespace_id          (namespace_id)
#  index_users_on_reset_password_token  (reset_password_token) UNIQUE
#  index_users_on_username              (username) UNIQUE
#

class User < ApplicationRecord
  include PublicActivity::Common

  enum ldap_status: { unchecked: 0, checked: 1, disabled: 2 }

  enabled_devise_modules = [:database_authenticatable, :registerable, :lockable,
                            :recoverable, :rememberable, :trackable, :validatable,
                            :omniauthable,
                            omniauth_providers:  %i[
                              google_oauth2
                              open_id
                              openid_connect
                              github
                              gitlab
                              bitbucket
                            ],
                            authentication_keys: [:username]]

  enabled_devise_modules.delete(:validatable) if APP_CONFIG.enabled?("ldap")
  devise(*enabled_devise_modules)

  APPLICATION_TOKENS_MAX = 5

  # Actions performed before/after create.
  validates :username, presence: true, uniqueness: true
  validates :password, confirmation: true
  validate :private_namespace_and_team_available, on: :create
  validate :portus_user_validation, on: :update
  after_create :create_personal_namespace!, if: :needs_namespace?
  after_create :no_skip_validation!

  # Actions performed before destroy
  before_destroy :update_tags!

  belongs_to :namespace
  has_many :team_users, dependent: :destroy
  has_many :teams, through: :team_users
  has_many :stars, dependent: :destroy
  has_many :application_tokens, dependent: :destroy
  has_many :tags, dependent: :nullify
  has_many :comments, dependent: :destroy

  scope :not_portus, -> { where.not(username: "portus") }
  scope :enabled,    -> { not_portus.where(enabled: true) }
  scope :admins,     -> { not_portus.where(enabled: true, admin: true) }

  class <<self
    attr_accessor :skip_portus_validation
  end

  # Creates the Portus hidden user.
  def self.create_portus_user!
    User.skip_portus_validation = true
    User.create!(
      username: "portus",
      password: Rails.application.secrets.portus_password,
      email:    "portus@portus.com",
      admin:    true
    )
  end

  # Returns portus user
  def self.portus
    find_by(username: "portus")
  end

  # Creates a user and sets this password as an empty string. This way we don't
  # store sensible data that is already stored in another place (e.g. LDAP), and
  # regular login won't be allowed for such users. The `params` argument is the
  # parameters that will be passed into the `User.create` method call.
  #
  # Returns two objects: a user object, and a boolean stating whether everything
  # was ok or not. Callers should check this second object before doing anything
  # at all with the first (since it might be an invalid user object).
  def self.create_without_password(params)
    user    = nil
    created = false

    ActiveRecord::Base.transaction do
      # We create it first with a dummy password that will be erased later on.
      user = User.create(params.merge(password: SecureRandom.hex(16)))

      if user.persisted?
        created = user.update(encrypted_password: "")
        user.destroy unless created
      end
    end
    [user, created]
  end

  # Returns true this user is allowed to login, otherwise it returns
  # false. Right now, only users that were created from LDAP cannot login
  # outside of the LDAP context.
  def login_allowed?
    return true if APP_CONFIG.enabled?("ldap")

    encrypted_password != ""
  end

  # Special method used by Devise to require an email on signup. This is always
  # true except for LDAP.
  def email_required?
    !(APP_CONFIG.enabled?("ldap") && email.blank?)
  end

  # Adds an error if the user to be updated is the portus one. This is a
  # validation on update, so it can be skipped when strictly required.
  def portus_user_validation
    # If nothing really changed (e.g. Rails simply touched this record), then we
    # can leave early.
    return if saved_changes.keys.empty?

    # If validation for this was temporarily disabled (e.g. we are creating the
    # Portus hidden user for the first time), then enable it back but return
    # early.
    if User.skip_portus_validation
      User.skip_portus_validation = nil
      return
    end

    return unless portus? || portus?(username_before_last_save)

    errors.add(:username, "cannot be updated")
  end

  # It adds an error if the username clashes with either a namespace or a team.
  def private_namespace_and_team_available
    ns = Namespace.make_valid(username)
    return if ns

    errors.add(:username, "'#{username}' cannot be transformed into a valid namespace name")
  end

  # Returns true if the current user is the Portus user. You can provide a value
  # as an alternative to the value of `username`.
  def portus?(field = nil)
    f = field.nil? ? username : field
    f == "portus"
  end

  # Returns avatar url if gravatar is enabled and email not blank
  def avatar_url
    GravatarImageTag.gravatar_url(email) if APP_CONFIG.enabled?("gravatar") && email.present?
  end

  # Returns the username to be displayed.
  def display_username
    return username unless APP_CONFIG.enabled?("display_name")

    display_name.presence || username
  end

  # This method will be called automatically once a user is created. It will
  # also be created for each user of the system when a registry is saved in the
  # system.
  def create_personal_namespace!
    return unless needs_namespace?

    namespace_name = Namespace.make_valid(username)
    team_name = Team.make_valid(username)

    # Note that this shouldn't be a problem since the User controller will make
    # sure that we don't create a user that clashes with this team.
    team = Team.create!(name: team_name, owners: [self], hidden: true)

    default_description = "This personal namespace belongs to #{username}."
    namespace = Namespace.find_or_create_by!(
      team:        team,
      name:        namespace_name,
      visibility:  Namespace.visibilities[:visibility_private],
      description: default_description,
      registry:    Registry.get # TODO: fix once we handle more registries
    )

    # Skipping validation on purpose, so after creating the portus hidden user,
    # a namespace can be assigned to it even if updates are forbidden
    # afterwards.
    update_attribute("namespace", namespace)
  end

  # Find the user that can be guessed from the given push event.
  def self.find_from_event(event)
    actor = User.find_by(username: event["actor"]["name"])
    logger.error "Cannot find user #{event["actor"]["name"]}" if actor.nil?
    actor
  end

  # Toggle the 'admin' attribute for this user. It will also update the
  # registry accordingly.
  def toggle_admin!
    admin = !admin?
    return unless update(admin: admin) && Registry.any?

    # TODO: fix once we handle more registries
    team = Registry.get.global_namespace.team
    admin ? team.owners << self : team.owners.delete(self)
  end

  ##
  # Enabling/disabling users.

  # Toggle the enabled attribute for a user. This is an instance method because
  # it is a user that enables/disables another user.
  def toggle_enabled!(user)
    enabled = user.enabled?

    # Return false if the action is not allowed.
    return false if enabled && !can_disable?(user)
    return false if !enabled && !admin?

    user.update(enabled: !enabled)
  end

  # This method is picked up by Devise before signing in a user.
  def active_for_authentication?
    super && enabled? && login_allowed?
  end

  # The flashy message to be shown for disabled users that try to login.
  def inactive_message
    if login_allowed?
      "Sorry, this account has been disabled."
    else
      "This user can only login through an LDAP server."
    end
  end

  # Returns all users who match the query.
  def self.search_from_query(members, query)
    enabled.where.not(id: members).where(arel_table[:username].matches(query))
  end

  # Looks for an application token that matches with the plain one provided
  # as parameter.
  # Return true if there's an application token matching it, false otherwise
  def application_token_valid?(plain_token)
    application_tokens.each do |t|
      return true if t.token_hash == BCrypt::Engine.hash_secret(plain_token, t.token_salt)
    end

    false
  end

  # Update the tags owned by this user before this user gets destroyed.
  def update_tags!
    Tag.where(user_id: id).update_all(user_id: nil, username: username)
  end

  # Update the activities owned by this user. This method should only be called
  # before destroying this user.
  def update_activities!(owner)
    # Originally this was handled in a single query, but with that is was not
    # possible to fix a bug as specified in PR #1144. Now it's handled in a
    # block that ends up performing multiple queries, which we want to perform
    # atomically (thus the transaction).
    ApplicationRecord.transaction do
      PublicActivity::Activity.where(owner_id: id).find_each do |a|
        a.owner_id   = nil
        a.owner_type = nil
        a.parameters = a.parameters.merge(owner_name: display_username)
        a.save
      end
    end

    create_activity :delete,
                    owner:      owner,
                    parameters: { username: username }
  end

  # Create user form params and omniauth data.
  #   params - hash with :username and :display_name
  #   data   - hash from oauth provider. We use info: {:email}, :provider and :uid.
  def self.create_from_oauth(params, data)
    params.merge! data["info"].slice("email")
    params.merge! data.slice("provider", "uid")
    params[:password] = Devise.friendly_token[0, 20]
    User.create params
  end

  # Suggest username based on nickname for GitHub or username for GitLab.
  # For prlviders which doesn't supply username suggestion is based on left
  # side of user's email.
  # If username exists then try variant username + "_nn".
  def suggest_username(data)
    self.username = extract_username data
    user = User.find_by(username: username)
    return if user.nil?

    num = 1
    while user && num < 999
      suggest_username = "#{username}_#{num.to_s.rjust(2, "0")}"
      user = User.find_by(username: suggest_username)
      num += 1
    end
    self.username = suggest_username unless user
  end

  # Checks memberships for this user on LDAP groups and tries to add this same
  # user into existing teams as a member.
  def ldap_add_as_member!
    Rails.logger.tagged(:ldap) do
      Rails.logger.info "Looking up an LDAP group membership for '#{username}'"
    end

    portus_user = User.portus

    ::Portus::LDAP::Search.new.user_groups(username).each do |group|
      t = Team.find_by(name: group)
      next if t.nil?
      next if t.ldap_group_checked == Team.ldap_statuses[:disabled]
      next if t.users.map(&:id).include?(id)

      t.add_team_member!(portus_user, username)
    end

    update!(ldap_group_checked: User.ldap_statuses[:checked])
  end

  protected

  # Validations can no longer be skipped after calling this method.
  def no_skip_validation!
    User.skip_portus_validation = nil
  end

  # Returns true if the current user needs a namespace. This is not the case in
  # the following situations:
  #   1. A registry has not been created yet.
  #   2. A namespace has already been created for this user.
  #   3. It's not a regular user, but a bot.
  def needs_namespace?
    Registry.any? && namespace_id.nil? && !bot
  end

  # Get username from provider's data.
  def extract_username(data)
    data["nickname"] || data["username"] || data["email"]&.match(/^[^@]*/).to_s
  end

  # Returns whether the given user can be disabled or not. The following rules
  # apply:
  #   1. A user can disable himself unless it's the last admin on the system.
  #   2. The admin user is the only one that can disable other users.
  def can_disable?(user)
    # The "portus" user can never be disabled.
    return false if user.portus?

    if self == user
      # An admin cannot disable himself if he's the only admin in the system.
      # Otherwise, regular users can disable themselves.
      return true unless admin?

      User.admins.count > 1
    else
      # Only admin users can disable other users.
      admin?
    end
  end
end