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.

276 lines
7.5 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
module SecondFactorManager
TOTP_ALLOWED_DRIFT_SECONDS = 30
extend ActiveSupport::Concern
2020-01-15 18:27:12 +08:00
SecondFactorAuthenticationResult =
Struct.new(
FEATURE: Add 2FA support to the Discourse Connect Provider protocol (#16386) Discourse has the Discourse Connect Provider protocol that makes it possible to use a Discourse instance as an identity provider for external sites. As a natural extension to this protocol, this PR adds a new feature that makes it possible to use Discourse as a 2FA provider as well as an identity provider. The rationale for this change is that it's very difficult to implement 2FA support in a website and if you have multiple websites that need to have 2FA, it's unrealistic to build and maintain a separate 2FA implementation for each one. But with this change, you can piggyback on Discourse to take care of all the 2FA details for you for as many sites as you wish. To use Discourse as a 2FA provider, you'll need to follow this guide: https://meta.discourse.org/t/-/32974. It walks you through what you need to implement on your end/site and how to configure your Discourse instance. Once you're done, there is only one additional thing you need to do which is to include `require_2fa=true` in the payload that you send to Discourse. When Discourse sees `require_2fa=true`, it'll prompt the user to confirm their 2FA using whatever methods they've enabled (TOTP or security keys), and once they confirm they'll be redirected back to the return URL you've configured and the payload will contain `confirmed_2fa=true`. If the user has no 2FA methods enabled however, the payload will not contain `confirmed_2fa`, but it will contain `no_2fa_methods=true`. You'll need to be careful to re-run all the security checks and ensure the user can still access the resource on your site after they return from Discourse. This is very important because there's nothing that guarantees the user that will come back from Discourse after they confirm 2FA is the same user that you've redirected to Discourse. Internal ticket: t62183.
2022-04-13 20:04:09 +08:00
:ok,
:error,
:reason,
:backup_enabled,
:security_key_enabled,
:totp_enabled,
:multiple_second_factor_methods,
:used_2fa_method,
2020-01-15 18:27:12 +08:00
)
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
last_used = totp.last_used.to_i if totp.last_used
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 18:04:33 +08:00
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
self&.user_second_factors.totps.exists?
end
2018-06-28 16:12:32 +08: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 18:04:33 +08:00
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
self&.user_second_factors.backup_codes.exists?
2018-06-28 16:12:32 +08: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 18:04:33 +08: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 18:27:12 +08:00
def has_any_second_factor_methods_enabled?
totp_enabled? || security_keys_enabled?
2020-01-15 18:27:12 +08:00
end
def has_multiple_second_factor_methods?
2020-01-15 18:27:12 +08: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 18:27:12 +08: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]
FEATURE: Add 2FA support to the Discourse Connect Provider protocol (#16386) Discourse has the Discourse Connect Provider protocol that makes it possible to use a Discourse instance as an identity provider for external sites. As a natural extension to this protocol, this PR adds a new feature that makes it possible to use Discourse as a 2FA provider as well as an identity provider. The rationale for this change is that it's very difficult to implement 2FA support in a website and if you have multiple websites that need to have 2FA, it's unrealistic to build and maintain a separate 2FA implementation for each one. But with this change, you can piggyback on Discourse to take care of all the 2FA details for you for as many sites as you wish. To use Discourse as a 2FA provider, you'll need to follow this guide: https://meta.discourse.org/t/-/32974. It walks you through what you need to implement on your end/site and how to configure your Discourse instance. Once you're done, there is only one additional thing you need to do which is to include `require_2fa=true` in the payload that you send to Discourse. When Discourse sees `require_2fa=true`, it'll prompt the user to confirm their 2FA using whatever methods they've enabled (TOTP or security keys), and once they confirm they'll be redirected back to the return URL you've configured and the payload will contain `confirmed_2fa=true`. If the user has no 2FA methods enabled however, the payload will not contain `confirmed_2fa`, but it will contain `no_2fa_methods=true`. You'll need to be careful to re-run all the security checks and ensure the user can still access the resource on your site after they return from Discourse. This is very important because there's nothing that guarantees the user that will come back from Discourse after they confirm 2FA is the same user that you've redirected to Discourse. Internal ticket: t62183.
2022-04-13 20:04:09 +08:00
if authenticate_totp(second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:totp]
return ok_result
else
return invalid_totp_or_backup_code_result
end
2020-01-15 18:27:12 +08:00
when UserSecondFactor.methods[:backup_codes]
FEATURE: Add 2FA support to the Discourse Connect Provider protocol (#16386) Discourse has the Discourse Connect Provider protocol that makes it possible to use a Discourse instance as an identity provider for external sites. As a natural extension to this protocol, this PR adds a new feature that makes it possible to use Discourse as a 2FA provider as well as an identity provider. The rationale for this change is that it's very difficult to implement 2FA support in a website and if you have multiple websites that need to have 2FA, it's unrealistic to build and maintain a separate 2FA implementation for each one. But with this change, you can piggyback on Discourse to take care of all the 2FA details for you for as many sites as you wish. To use Discourse as a 2FA provider, you'll need to follow this guide: https://meta.discourse.org/t/-/32974. It walks you through what you need to implement on your end/site and how to configure your Discourse instance. Once you're done, there is only one additional thing you need to do which is to include `require_2fa=true` in the payload that you send to Discourse. When Discourse sees `require_2fa=true`, it'll prompt the user to confirm their 2FA using whatever methods they've enabled (TOTP or security keys), and once they confirm they'll be redirected back to the return URL you've configured and the payload will contain `confirmed_2fa=true`. If the user has no 2FA methods enabled however, the payload will not contain `confirmed_2fa`, but it will contain `no_2fa_methods=true`. You'll need to be careful to re-run all the security checks and ensure the user can still access the resource on your site after they return from Discourse. This is very important because there's nothing that guarantees the user that will come back from Discourse after they confirm 2FA is the same user that you've redirected to Discourse. Internal ticket: t62183.
2022-04-13 20:04:09 +08:00
if authenticate_backup_code(second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:backup_codes]
return ok_result
else
return invalid_totp_or_backup_code_result
end
2020-01-15 18:27:12 +08:00
when UserSecondFactor.methods[:security_key]
FEATURE: Add 2FA support to the Discourse Connect Provider protocol (#16386) Discourse has the Discourse Connect Provider protocol that makes it possible to use a Discourse instance as an identity provider for external sites. As a natural extension to this protocol, this PR adds a new feature that makes it possible to use Discourse as a 2FA provider as well as an identity provider. The rationale for this change is that it's very difficult to implement 2FA support in a website and if you have multiple websites that need to have 2FA, it's unrealistic to build and maintain a separate 2FA implementation for each one. But with this change, you can piggyback on Discourse to take care of all the 2FA details for you for as many sites as you wish. To use Discourse as a 2FA provider, you'll need to follow this guide: https://meta.discourse.org/t/-/32974. It walks you through what you need to implement on your end/site and how to configure your Discourse instance. Once you're done, there is only one additional thing you need to do which is to include `require_2fa=true` in the payload that you send to Discourse. When Discourse sees `require_2fa=true`, it'll prompt the user to confirm their 2FA using whatever methods they've enabled (TOTP or security keys), and once they confirm they'll be redirected back to the return URL you've configured and the payload will contain `confirmed_2fa=true`. If the user has no 2FA methods enabled however, the payload will not contain `confirmed_2fa`, but it will contain `no_2fa_methods=true`. You'll need to be careful to re-run all the security checks and ensure the user can still access the resource on your site after they return from Discourse. This is very important because there's nothing that guarantees the user that will come back from Discourse after they confirm 2FA is the same user that you've redirected to Discourse. Internal ticket: t62183.
2022-04-13 20:04:09 +08:00
if authenticate_security_key(secure_session, second_factor_token)
ok_result.used_2fa_method = UserSecondFactor.methods[:security_key]
return ok_result
else
return invalid_security_key_result
end
2018-06-28 16:12:32 +08:00
end
2020-01-15 18:27:12 +08: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 ::DiscourseWebauthn::SecurityKeyError => err
2020-01-15 18:27:12 +08:00
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)
::DiscourseWebauthn::SecurityKeyAuthenticationService.new(
2020-01-15 18:27:12 +08:00
self,
security_key_credential,
challenge: DiscourseWebauthn.challenge(self, secure_session),
rp_id: DiscourseWebauthn.rp_id(self, secure_session),
2020-01-15 18:27:12 +08:00
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 16:12:32 +08:00
end
def generate_backup_codes
codes = []
10.times { codes << SecureRandom.hex(16) }
2018-06-28 16:12:32 +08:00
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 18:27:12 +08:00
parsed_data = JSON.parse(code.data)
stored_code = parsed_data["code_hash"]
stored_salt = parsed_data["salt"]
2018-06-28 16:12:32 +08: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)
# Backup codes have high entropy, so we can afford to use
# a lower number of iterations than for user-specific passwords
iterations = Rails.env.test? ? 10 : 64_000
Pbkdf2.hash_password(code, salt, iterations, "sha256")
2018-06-28 16:12:32 +08:00
end
def require_rotp
require "rotp" if !defined?(ROTP)
end
end