discourse/app/models/concerns/second_factor_manager.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

252 lines
7.0 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
module SecondFactorManager
TOTP_ALLOWED_DRIFT_SECONDS = 30
extend ActiveSupport::Concern
2020-01-15 05:27:12 -05:00
SecondFactorAuthenticationResult = Struct.new(
:ok, :error, :reason, :backup_enabled, :security_key_enabled, :totp_enabled, :multiple_second_factor_methods
)
def create_totp(opts = {})
require_rotp
UserSecondFactor.create!({
user_id: self.id,
method: UserSecondFactor.methods[:totp],
data: ROTP::Base32.random
}.merge(opts))
end
def get_totp_object(data)
require_rotp
ROTP::TOTP.new(data, issuer: SiteSetting.title.gsub(":", ""))
end
def totp_provisioning_uri(data)
get_totp_object(data).provisioning_uri(self.email)
end
def authenticate_totp(token)
totps = self&.user_second_factors.totps
authenticated = false
totps.each do |totp|
last_used = 0
if totp.last_used
last_used = totp.last_used.to_i
end
authenticated = !token.blank? && totp.totp_object.verify(
token,
drift_ahead: TOTP_ALLOWED_DRIFT_SECONDS,
drift_behind: TOTP_ALLOWED_DRIFT_SECONDS,
after: last_used
)
if authenticated
totp.update!(last_used: DateTime.now)
break
end
end
!!authenticated
end
def totp_enabled?
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 05:04:33 -05:00
!SiteSetting.enable_discourse_connect &&
SiteSetting.enable_local_logins &&
self&.user_second_factors.totps.exists?
end
2018-06-28 04:12:32 -04:00
def backup_codes_enabled?
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 05:04:33 -05:00
!SiteSetting.enable_discourse_connect &&
SiteSetting.enable_local_logins &&
self&.user_second_factors.backup_codes.exists?
2018-06-28 04:12:32 -04:00
end
def security_keys_enabled?
FEATURE: Rename 'Discourse SSO' to DiscourseConnect (#11978) The 'Discourse SSO' protocol is being rebranded to DiscourseConnect. This should help to reduce confusion when 'SSO' is used in the generic sense. This commit aims to: - Rename `sso_` site settings. DiscourseConnect specific ones are prefixed `discourse_connect_`. Generic settings are prefixed `auth_` - Add (server-side-only) backwards compatibility for the old setting names, with deprecation notices - Copy `site_settings` database records to the new names - Rename relevant translation keys - Update relevant translations This commit does **not** aim to: - Rename any Ruby classes or methods. This might be done in a future commit - Change any URLs. This would break existing integrations - Make any changes to the protocol. This would break existing integrations - Change any functionality. Further normalization across DiscourseConnect and other auth methods will be done separately The risks are: - There is no backwards compatibility for site settings on the client-side. Accessing auth-related site settings in Javascript is fairly rare, and an error on the client side would not be security-critical. - If a plugin is monkey-patching parts of the auth process, changes to locale keys could cause broken error messages. This should also be unlikely. The old site setting names remain functional, so security-related overrides will remain working. A follow-up commit will be made with a post-deploy migration to delete the old `site_settings` rows.
2021-02-08 05:04:33 -05:00
!SiteSetting.enable_discourse_connect &&
SiteSetting.enable_local_logins &&
self&.security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true).exists?
end
2020-01-15 05:27:12 -05:00
def has_any_second_factor_methods_enabled?
totp_enabled? || security_keys_enabled?
end
def has_multiple_second_factor_methods?
2020-01-15 05:27:12 -05:00
security_keys_enabled? && totp_or_backup_codes_enabled?
end
def totp_or_backup_codes_enabled?
totp_enabled? || backup_codes_enabled?
end
def only_security_keys_enabled?
security_keys_enabled? && !totp_or_backup_codes_enabled?
end
def only_totp_or_backup_codes_enabled?
!security_keys_enabled? && totp_or_backup_codes_enabled?
end
def remaining_backup_codes
self&.user_second_factors&.backup_codes&.count
end
2020-01-15 05:27:12 -05:00
def authenticate_second_factor(params, secure_session)
ok_result = SecondFactorAuthenticationResult.new(true)
return ok_result if !security_keys_enabled? && !totp_or_backup_codes_enabled?
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method]&.to_i
if second_factor_method.blank? || UserSecondFactor.methods[second_factor_method].blank?
return invalid_second_factor_method_result
end
if !valid_second_factor_method_for_user?(second_factor_method)
return not_enabled_second_factor_method_result
end
case second_factor_method
when UserSecondFactor.methods[:totp]
return authenticate_totp(second_factor_token) ? ok_result : invalid_totp_or_backup_code_result
when UserSecondFactor.methods[:backup_codes]
return authenticate_backup_code(second_factor_token) ? ok_result : invalid_totp_or_backup_code_result
when UserSecondFactor.methods[:security_key]
return authenticate_security_key(secure_session, second_factor_token) ? ok_result : invalid_security_key_result
2018-06-28 04:12:32 -04:00
end
2020-01-15 05:27:12 -05:00
# if we have gotten down to this point without being
# OK or invalid something has gone very weird.
invalid_second_factor_method_result
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key_result(err.message)
end
def valid_second_factor_method_for_user?(method)
case method
when UserSecondFactor.methods[:totp]
return totp_enabled?
when UserSecondFactor.methods[:backup_codes]
return backup_codes_enabled?
when UserSecondFactor.methods[:security_key]
return security_keys_enabled?
end
false
end
def authenticate_security_key(secure_session, security_key_credential)
::Webauthn::SecurityKeyAuthenticationService.new(
self,
security_key_credential,
challenge: Webauthn.challenge(self, secure_session),
rp_id: Webauthn.rp_id(self, secure_session),
origin: Discourse.base_url
).authenticate_security_key
end
def invalid_totp_or_backup_code_result
invalid_second_factor_authentication_result(
I18n.t("login.invalid_second_factor_code"),
"invalid_second_factor"
)
end
def invalid_security_key_result(error_message = nil)
invalid_second_factor_authentication_result(
error_message || I18n.t("login.invalid_security_key"),
"invalid_security_key"
)
end
def invalid_second_factor_method_result
invalid_second_factor_authentication_result(
I18n.t("login.invalid_second_factor_method"),
"invalid_second_factor_method"
)
end
def not_enabled_second_factor_method_result
invalid_second_factor_authentication_result(
I18n.t("login.not_enabled_second_factor_method"),
"not_enabled_second_factor_method"
)
end
def invalid_second_factor_authentication_result(error_message, reason)
SecondFactorAuthenticationResult.new(
false,
error_message,
reason,
backup_codes_enabled?,
security_keys_enabled?,
totp_enabled?,
has_multiple_second_factor_methods?
)
2018-06-28 04:12:32 -04:00
end
def generate_backup_codes
codes = []
10.times do
codes << SecureRandom.hex(16)
2018-06-28 04:12:32 -04:00
end
codes_json = codes.map do |code|
salt = SecureRandom.hex(16)
{ salt: salt,
code_hash: hash_backup_code(code, salt)
}
end
if self.user_second_factors.backup_codes.empty?
create_backup_codes(codes_json)
else
self.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
create_backup_codes(codes_json)
end
codes
end
def create_backup_codes(codes)
codes.each do |code|
UserSecondFactor.create!(
user_id: self.id,
data: code.to_json,
enabled: true,
method: UserSecondFactor.methods[:backup_codes]
)
end
end
def authenticate_backup_code(backup_code)
if !backup_code.blank?
codes = self&.user_second_factors&.backup_codes
codes.each do |code|
2020-01-15 05:27:12 -05:00
parsed_data = JSON.parse(code.data)
stored_code = parsed_data["code_hash"]
stored_salt = parsed_data["salt"]
2018-06-28 04:12:32 -04:00
backup_hash = hash_backup_code(backup_code, stored_salt)
next unless backup_hash == stored_code
code.update(enabled: false, last_used: DateTime.now)
return true
end
false
end
false
end
def hash_backup_code(code, salt)
Pbkdf2.hash_password(code, salt, Rails.configuration.pbkdf2_iterations, Rails.configuration.pbkdf2_algorithm)
end
def require_rotp
require 'rotp' if !defined? ROTP
end
end