# frozen_string_literal: true #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License version 3. # # OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: # Copyright (C) 2006-2013 Jean-Philippe Lang # Copyright (C) 2010-2013 the ChiliProject Team # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ require "digest/sha1" class User < Principal ScimEmail = Struct.new("ScimEmail", :value, :primary, :type) VALID_NAME_CHARS = "\\d\\p{Alpha}\\p{Mark}\\p{Space}\\p{Emoji}'\\u{2019}´\\-_.,@()+&*–" INVALID_NAME_REGEX = /[^#{VALID_NAME_CHARS}]/ VALID_NAME_REGEX = /\A[#{VALID_NAME_CHARS}]+\z/ CURRENT_USER_LOGIN_ALIAS = "me" USER_FORMATS_STRUCTURE = { firstname_lastname: %i[firstname lastname], firstname: [:firstname], lastname_firstname: %i[lastname firstname], lastname_n_firstname: %i[lastname firstname], lastname_comma_firstname: %i[lastname firstname], username: [:login] }.freeze include ::Associations::Groupable include ::Users::Avatars include ::Users::PermissionChecks extend DeprecatedAlias has_many :watches, class_name: "Watcher", dependent: :delete_all has_many :changesets, dependent: :nullify has_many :passwords, -> { order("id DESC") }, class_name: "UserPassword", dependent: :destroy, inverse_of: :user has_one :rss_token, class_name: "::Token::RSS", dependent: :destroy has_many :api_tokens, class_name: "::Token::API", dependent: :destroy has_many :oauth_client_tokens, dependent: :destroy has_many :working_hours, class_name: "UserWorkingHours", dependent: :destroy, inverse_of: :user has_many :non_working_times, class_name: "UserNonWorkingTime", dependent: :destroy, inverse_of: :user # The user might have one invitation token has_one :invitation_token, class_name: "::Token::Invitation", dependent: :destroy # everytime a user subscribes to a calendar, a new ical_token is generated # unlike on other token types, all previously generated ical_tokens are kept # in order to keep all previously generated ical urls valid and usable has_many :ical_tokens, class_name: "::Token::ICal", dependent: :destroy has_many :ical_meeting_tokens, class_name: "::Token::ICalMeeting", dependent: :destroy belongs_to :ldap_auth_source, optional: true # Authorized OAuth grants has_many :oauth_grants, # rubocop:disable Rails/InverseOf class_name: "Doorkeeper::AccessGrant", foreign_key: "resource_owner_id", dependent: :delete_all # User-defined oauth applications has_many :oauth_applications, class_name: "Doorkeeper::Application", as: :owner, dependent: :destroy # Meeting memberships has_many :meeting_participants, class_name: "MeetingParticipant", inverse_of: :user, dependent: :destroy has_many :recurring_meeting_interim_responses, inverse_of: :user, dependent: :destroy has_many :notification_settings, dependent: :destroy has_many :project_queries, class_name: "ProjectQuery", inverse_of: :user, dependent: :destroy has_many :emoji_reactions, dependent: :destroy has_many :reminders, foreign_key: "creator_id", dependent: :destroy, inverse_of: :creator has_many :remote_identities, dependent: :destroy # Resource allocations assigned to this user. Normal user-deletion goes # through Principals::DeleteJob, which rewrites principal_id to a # DeletedUser placeholder before destroy fires (registered in the # resource_management engine). The `dependent: :nullify` here is a # defensive fallback if a user is destroyed outside that flow — the column # is already nullable for the unassigned/filter-only state. has_many :resource_allocations, class_name: "ResourceAllocation", foreign_key: :principal_id, dependent: :nullify, inverse_of: :principal # Users blocked via brute force prevention # use lambda here, so time is evaluated on each query scope :blocked, -> { create_blocked_scope(self, true) } scope :not_blocked, -> { create_blocked_scope(self, false) } scopes :find_by_login, :newest, :notified_globally, :watcher_recipients, :with_time_zone, :having_reminder_mail_to_send def self.create_blocked_scope(scope, blocked) scope.where(blocked_condition(blocked)) end def self.blocked_condition(blocked) block_duration = Setting.brute_force_block_minutes.to_i.minutes blocked_if_login_since = Time.zone.now - block_duration negation = blocked ? "" : "NOT" ["#{negation} (users.failed_login_count >= ? AND users.last_failed_login_on > ?)", Setting.brute_force_block_after_failed_logins.to_i, blocked_if_login_since] end acts_as_customizable admin_only_allowed: true attr_accessor :password, :password_confirmation, :last_before_login_on, :current_password_input validates :login, :firstname, :lastname, :mail, presence: { unless: Proc.new { |user| user.builtin? } } validates :login, uniqueness: { if: Proc.new { |user| user.login.present? }, case_sensitive: false } validates :mail, uniqueness: { allow_blank: true, case_sensitive: false } # Login must contain letters, numbers, underscores only validates :login, format: { with: /\A[\p{L}0-9_\-@.+ ]*\z/i } validates :login, length: { maximum: 256 } validates :firstname, :lastname, length: { maximum: 256 } validates :firstname, :lastname, format: { with: VALID_NAME_REGEX, allow_blank: true } validates :mail, email: true, unless: Proc.new { |user| user.mail.blank? } validates :mail, length: { maximum: 256, allow_nil: true } validates :password, confirmation: { allow_nil: true, message: ->(*) { I18n.t("activerecord.errors.models.user.attributes.password_confirmation.confirmation") } } auto_strip_attributes :login, nullify: false auto_strip_attributes :mail, nullify: false validate :login_is_not_aliased_value validate :password_meets_requirements after_save :update_password scope :admin, -> { where(admin: true) } def self.unique_attribute :login end prepend ::Mixins::UniqueFinder def current_password passwords.first end def password_expired? current_password.expired? end # create new password if password was set def update_password if password && ldap_auth_source_id.blank? new_password = passwords.build(type: UserPassword.active_type.to_s) new_password.plain_password = password new_password.save # force reload of passwords, so the new password is sorted to the top passwords.reload clean_up_former_passwords clean_up_password_attribute end end def mail=(arg) write_attribute(:mail, arg.to_s.strip) end def self.available_custom_fields(_user) user = User.current RequestStore.fetch(:"#{name.underscore}_custom_fields_#{user.id}_#{user.admin?}") do scope = CustomField.where(type: "#{name}CustomField").order(:position) scope = scope.where(admin_only: false) if !user.admin? scope end end # Override acts_as_customizable to skip custom field validation for invited users # since custom field values cannot be provided during the invitation process. # We only skip the validation if no custom field changes are present. def custom_values_to_validate if invited? && custom_field_changes.empty? [] else super end end def self.search_in_project(query, options) options.fetch(:project).users.like(query) end # Returns the user that matches provided login and password, or nil def self.try_to_login(login, password, session = nil) # Make sure no one can sign in with an empty password return nil if password.to_s.empty? user = find_by_login(login) user = if user try_authentication_for_existing_user(user, password, session) else try_authentication_and_create_user(login, password) end unless prevent_brute_force_attack(user, login).nil? user.log_successful_login if user && !user.new_record? return user end nil end # Tries to authenticate a user in the database via external auth source # or password stored in the database def self.try_authentication_for_existing_user(user, password, session = nil) # rubocop:disable Metrics/PerceivedComplexity activate_user! user, session if session return nil if !user.active? || OpenProject::Configuration.disable_password_login? if user.ldap_auth_source # user has an external authentication method return nil unless user.ldap_auth_source.authenticate(user.login, password) else # authentication with local password return nil unless user.check_password?(password) return nil if user.force_password_change return nil if user.password_expired? end user end def self.activate_user!(user, session) if session[:invitation_token] token = Token::Invitation.find_by_plaintext_value session[:invitation_token] invited_id = token&.user&.id if user.id == invited_id user.activate! token.destroy session.delete :invitation_token end end end # Tries to authenticate with available sources and creates user on success def self.try_authentication_and_create_user(login, password) return nil if OpenProject::Configuration.disable_password_login? user = LdapAuthSource.authenticate(login, password) if user&.new_record? Rails.logger.error "Failed to auto-create user from auth-source, as data is missing." end user end # Columns required for formatting the user's name. def self.columns_for_name(formatter = nil) case formatter || Setting.user_format when :firstname [:firstname] when :username [:login] else %i[firstname lastname] end end # Formats the user's name. def name(formatter = nil) # Don't forget to check columns_for_name case formatter || Setting.user_format when :firstname_lastname then "#{firstname} #{lastname}" when :lastname_firstname then "#{lastname} #{firstname}" when :lastname_n_firstname then "#{lastname}#{firstname}" when :lastname_comma_firstname then "#{lastname}, #{firstname}" when :firstname then firstname when :username then login else "#{firstname} #{lastname}" end end # Return user's authentication provider for display def human_authentication_provider authentication_provider&.display_name end def provided_by_oidc? authentication_provider.is_a?(OpenIDConnect::Provider) end ## # Allows the API and other sources to determine locking actions # on represented collections of children of Principals. # This only covers the transition from: # lockable?: active -> locked. # activatable?: locked -> active. alias_method :lockable?, :active? alias_method :activatable?, :locked? def activate self.status = self.class.statuses[:active] end def register self.status = self.class.statuses[:registered] end def invite self.status = self.class.statuses[:invited] end def lock self.status = self.class.statuses[:locked] end deprecated_alias :activate!, :active! deprecated_alias :register!, :registered! deprecated_alias :invite!, :invited! deprecated_alias :lock!, :locked! # Returns true if +clear_password+ is the correct user's password, otherwise false # If +update_legacy+ is set, will automatically save legacy passwords using the current # format. def check_password?(clear_password, update_legacy: true) if ldap_auth_source.present? ldap_auth_source.authenticate(login, clear_password) else return false if current_password.nil? current_password.matches_plaintext?(clear_password, update_legacy:) end end # Does the backend storage allow this user to change their password? def change_password_allowed? return false if OpenProject::Configuration.disable_password_login? return false if uses_external_authentication? && current_password.nil? ldap_auth_source_id.blank? end # Is the user authenticated via an external authentication source via OmniAuth? def uses_external_authentication? user_auth_provider_links.exists? end # # Generate and set a random password. # # Also force a password change on the next login, since random passwords # are at some point given to the user, we do this via email. These passwords # are stored unencrypted in mail accounts, so they must only be valid for # a short time. def random_password! self.password = OpenProject::Passwords::Generator.random_password self.password_confirmation = password self.force_password_change = true self end ## # Brute force prevention - public instance methods # def failed_too_many_recent_login_attempts? block_threshold = Setting.brute_force_block_after_failed_logins.to_i return false if block_threshold == 0 # disabled last_failed_login_within_block_time? and failed_login_count >= block_threshold end def log_failed_login log_failed_login_count log_failed_login_timestamp save end def log_successful_login update_attribute(:last_login_on, Time.current) end def pref preference || build_preference end def time_zone @time_zone ||= ActiveSupport::TimeZone[pref.time_zone] || ActiveSupport::TimeZone["Etc/UTC"] end def reload(*) @time_zone = nil super end def wants_comments_in_reverse_order? pref.comments_in_reverse_order? end def self.find_by_rss_key(key) return nil unless Setting.feeds_enabled? token = Token::RSS.find_by(value: key) if token&.user&.active? token.user end end def self.find_by_api_key(key) return nil unless Setting.api_tokens_enabled? token = Token::API.find_by_plaintext_value(key) if token&.user&.active? token.user end end ## # Finds all users with the mail address matching the given mail # Includes searching for suffixes from +Setting.mail_suffix_separtors+. # # For example: # - With Setting.mail_suffix_separators = '+' # - Will find 'foo+bar@example.org' with input of 'foo@example.org' def self.where_mail_with_suffix(mail) skip_suffix_check, regexp = mail_regexp(mail) # If the recipient part already contains a suffix, don't expand if skip_suffix_check where("LOWER(mail) = ?", mail) else where("LOWER(mail) ~* ?", regexp) end end ## # Finds a user by mail where it checks whether the mail exists # NOTE: This will return the FIRST matching user. def self.find_by_mail(mail) where_mail_with_suffix(mail).first end def rss_key token = rss_token || ::Token::RSS.create(user: self) token.value end def to_s name end # Returns the current day according to user's time zone def today if time_zone.nil? Time.zone.today else Time.now.in_time_zone(time_zone).to_date end end def logged? true end def anonymous? !logged? end def active_admin? admin? && active? end def consent_expired? # Always if the user has not consented return true if consented_at.blank? # Did not expire if no consent_time set, but user has consented at some point return false if Setting.consent_time.blank? # Otherwise, expires when consent_time is newer than last consented_at consented_at < Setting.consent_time end # Cheap version of Project.visible.count def number_of_known_projects if admin? Project.count else Project.public_projects.count + memberships.size end end def reported_work_package_count WorkPackage.on_active_project.with_author(self).visible.count end def self.current=(user) RequestStore[:current_user] = user end def self.current RequestStore[:current_user] || User.anonymous end def self.execute_as(user, &) previous_user = User.current User.current = user OpenProject::LocaleHelper.with_locale_for(user, &) ensure User.current = previous_user end # Temporarily elevates a user's permissions to admin for the duration # of the given block. # # This method ensures that any changes to the user's admin status are # safely reverted after the block is executed, regardless of whether # an exception is raised within the block. # # Saving of the user is attempted to be prevented but this might not be foolproof. # Saving the user within the block should be avoided to prevent undesired side effects. # # @param user [User] The user that requires temporary admin elevation. def self.execute_as_admin(user) previous_user_admin_state = user.admin previous_user_readonly_state = user.readonly? user.admin = true user.reset_permission_caches user.readonly! yield ensure user.admin = previous_user_admin_state user.reset_permission_caches user.instance_variable_set(:@readonly, previous_user_readonly_state) end ## # Returns true if no authentication method has been chosen for this user yet. # There are three possible methods currently: # # - username & password # - OmniAuth # - LDAP def missing_authentication_method? !uses_external_authentication? && passwords.empty? && ldap_auth_source_id.nil? end # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only # one anonymous user per database. def self.anonymous RequestStore[:anonymous_user] ||= AnonymousUser.first end def self.system SystemUser.first end def scim_emails [ScimEmail.new(mail, true, "work")] end def scim_emails=(emails) email = emails.find { |email| email.primary == true } || emails.find { |email| email.type == "work" } || emails.min self.mail = email&.value end # rubocop:disable Naming/PredicateMethod def scim_active=(is_active) if is_active activate true else lock if active? false end end def scim_active active? end # rubocop:enable Naming/PredicateMethod def self.scim_resource_type Scimitar::Resources::User end def self.scim_attributes_map { id: :id, externalId: :scim_external_id, userName: :login, name: { givenName: :firstname, familyName: :lastname }, emails: [ { list: :scim_emails, class: User, using: { value: :value, primary: :primary, type: :type }, find_with: Proc.new do |qwe| ScimEmail.new(qwe["value"], qwe["primary"] == true, qwe["type"]) end } ], groups: [ { list: :groups, using: { value: :id } } ], active: :scim_active } end def self.scim_queryable_attributes { externalId: { column: UserAuthProviderLink.arel_table[:external_id] }, username: { column: :login }, givenName: { column: :firstname }, familyName: { column: :lastname }, emails: { column: :mail }, groups: { column: Group.arel_table[:id] }, "groups.value" => { column: Group.arel_table[:id] } } end include Scimitar::Resources::Mixin def non_working_time_entities_for_year(year) NonWorkingDay.for_year(year).to_a + non_working_times.for_year(year).to_a end def non_working_days_for_year(year) working_wdays = Setting.working_days.map { |d| d % 7 } all_dates = system_non_working_dates_for_year(year) | user_non_working_dates_for_year(year) all_dates.select { |d| working_wdays.include?(d.wday) } end private def system_non_working_dates_for_year(year) NonWorkingDay.for_year(year).pluck(:date).to_set end def user_non_working_dates_for_year(year) year_range = Date.new(year, 1, 1)..Date.new(year, 12, 31) non_working_times.for_year(year).flat_map do |t| ([t.start_date, year_range.begin].max..[t.end_date, year_range.end].min).to_a end.to_set end protected # Login must not be aliased value 'me' def login_is_not_aliased_value if login.present? && login.to_s == CURRENT_USER_LOGIN_ALIAS errors.add(:login, :invalid) end end # Password requirement validation based on settings def password_meets_requirements # Passwords are stored hashed as UserPasswords, # self.password is only set when it was changed after the last # save. Otherwise, password is nil. unless password.nil? or anonymous? password_errors = OpenProject::Passwords::Evaluator.errors_for_password(password) password_errors.each { |error| errors.add(:password, error) } if former_passwords_include?(password) errors.add(:password, I18n.t("activerecord.errors.models.user.attributes.password.reused", count: Setting[:password_count_former_banned].to_i)) end end end private def self.mail_regexp(mail) separators = Regexp.escape(Setting.mail_suffix_separators) recipient, domain = mail.split("@").map { |part| Regexp.escape(part) } skip_suffix_check = recipient.nil? || Setting.mail_suffix_separators.empty? || recipient.match?(/.+[#{separators}].+/) regexp = "^#{recipient}([#{separators}][^@]+)*@#{domain}$" [skip_suffix_check, regexp] end def former_passwords_include?(password) return false if Setting[:password_count_former_banned].to_i == 0 ban_count = Setting[:password_count_former_banned].to_i # make reducing the number of banned former passwords immediately effective # by only checking this number of former passwords passwords[0, ban_count].any? { |f| f.matches_plaintext?(password) } end def clean_up_former_passwords # minimum 1 to keep the actual user password keep_count = [1, Setting[:password_count_former_banned].to_i].max (passwords[keep_count..] || []).each(&:destroy) end def clean_up_password_attribute self.password = self.password_confirmation = nil end ## # Brute force prevention - class methods # def self.prevent_brute_force_attack(user, login) if user.nil? register_failed_login_attempt_if_user_exists_for(login) else block_user_if_too_many_recent_attempts_failed(user) end end def self.register_failed_login_attempt_if_user_exists_for(login) user = User.find_by_login(login) user.presence&.log_failed_login nil end def self.reset_failed_login_count_for(user) user.update_attribute(:failed_login_count, 0) unless user.new_record? end def self.block_user_if_too_many_recent_attempts_failed(user) if user.failed_too_many_recent_login_attempts? user = nil else reset_failed_login_count_for user end user end ## # Brute force prevention - instance methods # def last_failed_login_within_block_time? block_duration = Setting.brute_force_block_minutes.to_i.minutes last_failed_login_on and Time.zone.now - last_failed_login_on < block_duration end def log_failed_login_count if last_failed_login_within_block_time? self.failed_login_count += 1 else self.failed_login_count = 1 end end def log_failed_login_timestamp self.last_failed_login_on = Time.zone.now end def self.default_admin_account_changed? !User.active.find_by_login("admin").try(:current_password).try(:matches_plaintext?, "admin") end end