#-- encoding: UTF-8 #-- copyright # OpenProject is an open source project management software. # Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details. #++ require 'digest/sha1' class User < Principal include ::Scopes::Scoped USER_FORMATS_STRUCTURE = { firstname_lastname: [:firstname, :lastname], firstname: [:firstname], lastname_firstname: [:lastname, :firstname], lastname_coma_firstname: [:lastname, :firstname], username: [:login] }.freeze USER_MAIL_OPTION_ALL = ['all', :label_user_mail_option_all].freeze USER_MAIL_OPTION_SELECTED = ['selected', :label_user_mail_option_selected].freeze USER_MAIL_OPTION_ONLY_MY_EVENTS = ['only_my_events', :label_user_mail_option_only_my_events].freeze USER_MAIL_OPTION_ONLY_ASSIGNED = ['only_assigned', :label_user_mail_option_only_assigned].freeze USER_MAIL_OPTION_ONLY_OWNER = ['only_owner', :label_user_mail_option_only_owner].freeze USER_MAIL_OPTION_NON = ['none', :label_user_mail_option_none].freeze MAIL_NOTIFICATION_OPTIONS = [ USER_MAIL_OPTION_ALL, USER_MAIL_OPTION_SELECTED, USER_MAIL_OPTION_ONLY_MY_EVENTS, USER_MAIL_OPTION_ONLY_ASSIGNED, USER_MAIL_OPTION_ONLY_OWNER, USER_MAIL_OPTION_NON ].freeze has_and_belongs_to_many :groups, join_table: "#{table_name_prefix}group_users#{table_name_suffix}", after_add: ->(user, group) { group.user_added(user) }, after_remove: ->(user, group) { group.user_removed(user) } has_many :categories, foreign_key: 'assigned_to_id', dependent: :nullify has_many :assigned_issues, foreign_key: 'assigned_to_id', class_name: 'WorkPackage', dependent: :nullify has_many :responsible_for_issues, foreign_key: 'responsible_id', class_name: 'WorkPackage', dependent: :nullify 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_one :api_token, class_name: '::Token::API', dependent: :destroy belongs_to :auth_source # Authorized OAuth grants has_many :oauth_grants, class_name: 'Doorkeeper::AccessGrant', foreign_key: 'resource_owner_id' # User-defined oauth applications has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner # 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) } scope_classes Users::Scopes::FindByLogin 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.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 attr_accessor :password, :password_confirmation attr_accessor :last_before_login_on validates_presence_of :login, :firstname, :lastname, :mail, unless: Proc.new { |user| user.is_a?(AnonymousUser) || user.is_a?(DeletedUser) || user.is_a?(SystemUser) } validates_uniqueness_of :login, if: Proc.new { |user| !user.login.blank? }, case_sensitive: false validates_uniqueness_of :mail, allow_blank: true, case_sensitive: false # Login must contain letters, numbers, underscores only validates_format_of :login, with: /\A[a-z0-9_\-@\.+ ]*\z/i validates_length_of :login, maximum: 256 validates_length_of :firstname, :lastname, maximum: 30 validates_format_of :mail, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, allow_blank: true validates_length_of :mail, maximum: 60, allow_nil: true validates_confirmation_of :password, allow_nil: true validates_inclusion_of :mail_notification, in: MAIL_NOTIFICATION_OPTIONS.map(&:first), allow_blank: true auto_strip_attributes :login, nullify: false auto_strip_attributes :mail, nullify: false validate :login_is_not_special_value validate :password_meets_requirements after_save :update_password before_create :sanitize_mail_notification_setting before_destroy :delete_associated_private_queries before_destroy :reassign_associated scope :in_group, ->(group) { within_group(group) } scope :not_in_group, ->(group) { within_group(group, false) } scope :within_group, ->(group, positive = true) { group_id = group.is_a?(Group) ? [group.id] : Array(group).map(&:to_i) sql_condition = group_id.any? ? 'WHERE gu.group_id IN (?)' : '' sql_not = positive ? '' : 'NOT' sql_query = ["#{User.table_name}.id #{sql_not} IN (SELECT gu.user_id FROM #{table_name_prefix}group_users#{table_name_suffix} gu #{sql_condition})"] if group_id.any? sql_query.push group_id end where(sql_query) } scope :admin, -> { where(admin: true) } scope :newest, -> { not_builtin.order(created_on: :desc) } def self.unique_attribute :login end prepend ::Mixins::UniqueFinder def sanitize_mail_notification_setting self.mail_notification = Setting.default_notification_option if mail_notification.blank? true end 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 && 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 end end def reload(*args) @name = nil @projects_by_role = nil @authorization_service = ::Authorization::UserAllowedService.new(self) @project_role_cache = ::Users::ProjectRoleCache.new(self) super end def mail=(arg) write_attribute(:mail, arg.to_s.strip) 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) activate_user! user, session if session return nil if !user.active? || OpenProject::Configuration.disable_password_login? if user.auth_source # user has an external authentication method return nil unless user.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? attrs = AuthSource.authenticate(login, password) try_to_create(attrs) if attrs end # Try to create the user from attributes def self.try_to_create(attrs, notify: false) new(attrs).tap do |user| user.language = Setting.default_language if OpenProject::Enterprise.user_limit_reached? OpenProject::Enterprise.send_activation_limit_notification_about(user) if notify Rails.logger.error("User '#{user.login}' could not be created as user limit exceeded.") user.errors.add :base, I18n.t(:error_enterprise_activation_user_limit) elsif user.save user.reload Rails.logger.info("User '#{user.login}' created from external auth source: #{user.auth_source&.type} - #{user.auth_source&.name}") else Rails.logger.error("User '#{user.login}' could not be created: #{user.errors.full_messages.join(". ")}") end end end # Returns the user who matches the given autologin +key+ or nil def self.try_to_autologin(key) token = Token::AutoLogin.find_by_plaintext_value(key) # Make sure there's only 1 token that matches the key if token if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active? token.user.log_successful_login token.user end end end # Formats the user's name. def name(formatter = nil) case formatter || Setting.user_format when :firstname_lastname then "#{firstname} #{lastname}" when :lastname_firstname then "#{lastname} #{firstname}" when :lastname_coma_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 authentication_provider return if identity_url.blank? identity_url.split(':', 2).first.titleize end def status_name STATUSES.invert[status].to_s end def active? status == STATUSES[:active] end def registered? status == STATUSES[:registered] end def locked? status == STATUSES[:locked] 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 = STATUSES[:active] end def register self.status = STATUSES[:registered] end def invite self.status = STATUSES[:invited] end def lock self.status = STATUSES[:locked] end def activate! update_attribute(:status, STATUSES[:active]) end def register! update_attribute(:status, STATUSES[:registered]) end def invite! update_attribute(:status, STATUSES[:invited]) end def invited? status == STATUSES[:invited] end def lock! update_attribute(:status, STATUSES[:locked]) end # 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 auth_source_id.present? auth_source.authenticate(login, clear_password) else return false if current_password.nil? current_password.matches_plaintext?(clear_password, update_legacy: update_legacy) end end # Does the backend storage allow this user to change their password? def change_password_allowed? return false if uses_external_authentication? || OpenProject::Configuration.disable_password_login? return true if auth_source_id.blank? auth_source.allow_password_changes? end # Is the user authenticated via an external authentication source via OmniAuth? def uses_external_authentication? identity_url.present? 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.now) end def pref preference || build_preference end def time_zone @time_zone ||= (pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[pref.time_zone]) end def wants_comments_in_reverse_order? pref.comments_in_reverse_order? end # Return an array of project ids for which the user has explicitly turned mail notifications on def notified_projects_ids @notified_projects_ids ||= memberships.reload.select(&:mail_notification?).map(&:project_id) end def notified_project_ids=(ids) Member .where(user_id: id) .update_all(mail_notification: false) if ids && !ids.empty? Member .where(user_id: id, project_id: ids) .update_all(mail_notification: true) end @notified_projects_ids = nil notified_projects_ids end def valid_notification_options self.class.valid_notification_options(self) end # Only users that belong to more than 1 project can select projects for which they are notified def self.valid_notification_options(user = nil) # Note that @user.membership.size would fail since AR ignores # :include association option when doing a count if user.nil? || user.memberships.length < 1 MAIL_NOTIFICATION_OPTIONS.reject { |option| option.first == 'selected' } else MAIL_NOTIFICATION_OPTIONS end end # Find a user account by matching the exact login and then a case-insensitive # version. Exact matches will be given priority. def self.find_by_login(login) # First look for an exact match user = find_by(login: login) # Fail over to case-insensitive if none was found user || where(["LOWER(login) = ?", login.to_s.downcase]).first 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.rest_api_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? Date.today else Time.now.in_time_zone(time_zone).to_date end end def logged? true end def anonymous? !logged? end # Return user's roles for project def roles_for_project(project) project_role_cache.fetch(project) end alias :roles :roles_for_project # Cheap version of Project.visible.count def number_of_known_projects if admin? Project.count else Project.public_projects.count + memberships.size end end # Return true if the user is a member of project def member_of?(project) roles_for_project(project).any?(&:member?) end # Returns a hash of user's projects grouped by roles def projects_by_role return @projects_by_role if @projects_by_role @projects_by_role = Hash.new { |h, k| h[k] = [] } memberships.each do |membership| membership.roles.each do |role| @projects_by_role[role] << membership.project if membership.project end end @projects_by_role.each do |_role, projects| projects.uniq! end @projects_by_role end # Returns true if user is arg or belongs to arg def is_or_belongs_to?(arg) if arg.is_a?(User) self == arg elsif arg.is_a?(Group) arg.users.include?(self) else false end end def self.allowed(action, project) Authorization.users(action, project) end def self.allowed_members(action, project) Authorization.users(action, project).where.not(members: { id: nil }) end def allowed_to?(action, context, options = {}) authorization_service.call(action, context, options).result end def allowed_to_in_project?(action, project, options = {}) authorization_service.call(action, project, options).result end def allowed_to_globally?(action, options = {}) authorization_service.call(action, nil, options.merge(global: true)).result end def preload_projects_allowed_to(action) authorization_service.preload_projects_allowed_to(action) end # Utility method to help check if a user should be notified about an # event. def notify_about?(object) active? && (mail_notification == 'all' || (object.is_a?(WorkPackage) && object.notify?(self))) 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 yield ensure User.current = previous_user 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? identity_url.nil? && passwords.empty? && auth_source.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] ||= begin anonymous_user = AnonymousUser.first if anonymous_user.nil? (anonymous_user = AnonymousUser.new.tap do |u| u.lastname = 'Anonymous' u.login = '' u.firstname = '' u.mail = '' u.status = User::STATUSES[:active] end).save raise 'Unable to create the anonymous user.' if anonymous_user.new_record? end anonymous_user end end def self.system system_user = SystemUser.first if system_user.nil? system_user = SystemUser.new( firstname: "", lastname: "System", login: "", mail: "", admin: false, status: User::STATUSES[:active], first_login: false ) system_user.save(validate: false) raise 'Unable to create the automatic migration user.' unless system_user.persisted? end system_user end protected # Login must not be special value 'me' def login_is_not_special_value if login.present? && login == 'me' 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 do |error| errors.add(:password, error) end if former_passwords_include?(password) errors.add(:password, I18n.t(:reused, count: Setting[:password_count_former_banned].to_i, scope: [:activerecord, :errors, :models, :user, :attributes, :password])) 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 authorization_service @authorization_service ||= ::Authorization::UserAllowedService.new(self, role_cache: project_role_cache) end def project_role_cache @project_role_cache ||= ::Users::ProjectRoleCache.new(self) 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..-1] || []).each(&:destroy) end def reassign_associated substitute = DeletedUser.first [WorkPackage, Attachment, WikiContent, News, Comment, Message].each do |klass| klass.where(['author_id = ?', id]).update_all ['author_id = ?', substitute.id] end [TimeEntry, ::Query].each do |klass| klass.where(['user_id = ?', id]).update_all ['user_id = ?', substitute.id] end Journals::UserReferenceUpdateService .new(self) .call(substitute) end def delete_associated_private_queries ::Query.where(user_id: id, is_public: false).delete_all 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.log_failed_login if user.present? 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.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.now end def self.default_admin_account_changed? !User.active.find_by_login('admin').try(:current_password).try(:matches_plaintext?, 'admin') end end