Files
2026-05-26 12:43:35 +02:00

836 lines
24 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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