mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
836 lines
24 KiB
Ruby
836 lines
24 KiB
Ruby
# 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
|