SECURITY: 2FA with U2F / TOTP

This commit is contained in:
Martin Brennan 2020-01-15 11:27:12 +01:00 committed by Régis Hanol
parent c3cd2389fe
commit 66f2db4ea4
25 changed files with 885 additions and 275 deletions

View File

@ -0,0 +1,23 @@
import { getWebauthnCredential } from "discourse/lib/webauthn";
document.getElementById("submit-security-key").onclick = function(e) {
e.preventDefault();
getWebauthnCredential(
document.getElementById("security-key-challenge").value,
document
.getElementById("security-key-allowed-credential-ids")
.value.split(","),
credentialData => {
document.getElementById("security-key-credential").value = JSON.stringify(
credentialData
);
$(e.target)
.parents("form")
.submit();
},
errorMessage => {
document.getElementById("security-key-error").innerText = errorMessage;
}
);
};

View File

@ -0,0 +1 @@
require("confirm-new-email/confirm-new-email").default();

View File

@ -23,15 +23,13 @@ export default Controller.extend({
actions: {
finishLogin() {
let data = {};
let data = { second_factor_method: this.secondFactorMethod };
if (this.securityKeyCredential) {
data = { security_key_credential: this.securityKeyCredential };
data.second_factor_token = this.securityKeyCredential;
} else {
data = {
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
};
data.second_factor_token = this.secondFactorToken;
}
ajax({
url: `/session/email-login/${this.model.token}`,
type: "POST",

View File

@ -120,9 +120,8 @@ export default Controller.extend(ModalFunctionality, {
data: {
login: this.loginName,
password: this.loginPassword,
second_factor_token: this.secondFactorToken,
second_factor_token: this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod,
security_key_credential: this.securityKeyCredential,
timezone: moment.tz.guess()
}
}).then(
@ -130,12 +129,9 @@ export default Controller.extend(ModalFunctionality, {
// Successful login
if (result && result.error) {
this.set("loggingIn", false);
const invalidSecurityKey = result.reason === "invalid_security_key";
const invalidSecondFactor =
result.reason === "invalid_second_factor";
if (
(invalidSecondFactor || invalidSecurityKey) &&
(result.security_key_enabled || result.totp_enabled) &&
!this.secondFactorRequired
) {
document.getElementById("modal-alert").style.display = "none";
@ -145,9 +141,9 @@ export default Controller.extend(ModalFunctionality, {
secondFactorRequired: true,
showLoginButtons: false,
backupEnabled: result.backup_enabled,
showSecondFactor: invalidSecondFactor,
showSecurityKey: invalidSecurityKey,
secondFactorMethod: invalidSecurityKey
showSecondFactor: result.totp_enabled,
showSecurityKey: result.security_key_enabled,
secondFactorMethod: result.security_key_enabled
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP,
securityKeyChallenge: result.challenge,

View File

@ -1,4 +1,4 @@
import { alias, or } from "@ember/object/computed";
import { alias, or, readOnly } from "@ember/object/computed";
import Controller from "@ember/controller";
import { default as discourseComputed } from "discourse-common/utils/decorators";
import DiscourseURL from "discourse/lib/url";
@ -18,6 +18,7 @@ export default Controller.extend(PasswordValidation, {
"model.second_factor_required",
"model.security_key_required"
),
otherMethodAllowed: readOnly("model.multiple_second_factor_methods"),
@discourseComputed("model.security_key_required")
secondFactorMethod(security_key_required) {
return security_key_required
@ -51,9 +52,8 @@ export default Controller.extend(PasswordValidation, {
type: "PUT",
data: {
password: this.accountPassword,
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod,
security_key_credential: this.securityKeyCredential
second_factor_token: this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod
}
})
.then(result => {

View File

@ -28,7 +28,7 @@
showSecurityKey=model.security_key_required
showSecondFactor=false
secondFactorMethod=secondFactorMethod
otherMethodAllowed=secondFactorRequired
otherMethodAllowed=otherMethodAllowed
action=(action "authenticateSecurityKey")}}
{{/security-key-form}}
{{else}}

View File

@ -745,23 +745,22 @@ class ApplicationController < ActionController::Base
return
end
check_totp = current_user &&
!request.format.json? &&
!is_api? &&
!current_user.anonymous? &&
((SiteSetting.enforce_second_factor == 'staff' && current_user.staff?) ||
SiteSetting.enforce_second_factor == 'all') &&
!current_user.totp_enabled?
return if !current_user
return if !should_enforce_2fa?
if check_totp
redirect_path = "#{GlobalSetting.relative_url_root}/u/#{current_user.username}/preferences/second-factor"
if !request.fullpath.start_with?(redirect_path)
redirect_to path(redirect_path)
nil
end
redirect_path = "#{GlobalSetting.relative_url_root}/u/#{current_user.username}/preferences/second-factor"
if !request.fullpath.start_with?(redirect_path)
redirect_to path(redirect_path)
nil
end
end
def should_enforce_2fa?
disqualified_from_2fa_enforcement = request.format.json? || is_api? || current_user.anonymous?
enforcing_2fa = ((SiteSetting.enforce_second_factor == 'staff' && current_user.staff?) || SiteSetting.enforce_second_factor == 'all')
!disqualified_from_2fa_enforcement && enforcing_2fa && !current_user.has_any_second_factor_methods_enabled?
end
def block_if_readonly_mode
return if request.fullpath.start_with?(path "/admin/backups")
raise Discourse::ReadOnly.new if !(request.get? || request.head?) && @readonly_mode

View File

@ -8,6 +8,7 @@ class SessionController < ApplicationController
before_action :check_local_login_allowed, only: %i(create forgot_password email_login email_login_info)
before_action :rate_limit_login, only: %i(create email_login)
before_action :rate_limit_second_factor_totp, only: %i(create email_login)
skip_before_action :redirect_to_login_if_required
skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy one_time_password)
@ -258,10 +259,6 @@ class SessionController < ApplicationController
end
def create
unless params[:second_factor_token].blank?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
params.require(:login)
params.require(:password)
@ -293,55 +290,14 @@ class SessionController < ApplicationController
end
if payload = login_error_check(user)
render json: payload
else
if user.security_keys_enabled? && params[:second_factor_token].blank?
security_key_valid = ::Webauthn::SecurityKeyAuthenticationService.new(
user,
params[:security_key_credential],
challenge: Webauthn.challenge(user, secure_session),
rp_id: Webauthn.rp_id(user, secure_session),
origin: Discourse.base_url
).authenticate_security_key
return invalid_security_key(user) if !security_key_valid
return (user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end
if user.totp_enabled?
invalid_second_factor = !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
if (params[:security_key_credential].blank? || !user.security_keys_enabled?) && invalid_second_factor
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code"),
reason: "invalid_second_factor",
backup_enabled: user.backup_codes_enabled?,
multiple_second_factor_methods: user.has_multiple_second_factor_methods?
)
end
elsif user.security_keys_enabled?
# if we have gotten this far then the user has provided the totp
# params for a security-key-only account
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code"),
reason: "invalid_second_factor",
backup_enabled: user.backup_codes_enabled?,
multiple_second_factor_methods: user.has_multiple_second_factor_methods?
)
end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
return render json: payload
end
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(user, err.message)
end
def invalid_security_key(user, err_message = nil)
Webauthn.stage_challenge(user, secure_session) if !params[:security_key_credential]
render json: failed_json.merge(
error: err_message || I18n.t("login.invalid_security_key"),
reason: "invalid_security_key",
backup_enabled: user.backup_codes_enabled?,
multiple_second_factor_methods: user.has_multiple_second_factor_methods?
).merge(Webauthn.allowed_credentials(user, secure_session))
if !authenticate_second_factor(user)
return render(json: @second_factor_failure_payload)
end
(user.active && user.email_confirmed?) ? login(user) : not_activated(user)
end
def email_login_info
@ -385,41 +341,17 @@ class SessionController < ApplicationController
end
def email_login
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
token = params[:token]
matched_token = EmailToken.confirmable(token)
if !SiteSetting.enable_local_logins_via_email &&
!matched_token&.user&.admin? # admin-login uses this route, so allow them even if disabled
!matched_token&.user&.admin? # admin-login uses this route, so allow them even if disabled
raise Discourse::NotFound
end
if security_key_credential.present?
if matched_token&.user&.security_keys_enabled?
security_key_valid = ::Webauthn::SecurityKeyAuthenticationService.new(
matched_token&.user,
params[:security_key_credential],
challenge: Webauthn.challenge(matched_token&.user, secure_session),
rp_id: Webauthn.rp_id(matched_token&.user, secure_session),
origin: Discourse.base_url
).authenticate_security_key
return invalid_security_key(matched_token&.user) if !security_key_valid
end
else
if matched_token&.user&.totp_enabled?
if !second_factor_token.present?
return render json: { error: I18n.t('login.invalid_second_factor_code') }
elsif !matched_token.user.authenticate_second_factor(second_factor_token, second_factor_method)
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
return render json: { error: I18n.t('login.invalid_second_factor_code') }
end
elsif matched_token&.user&.security_keys_enabled?
# this means the user only has security key enabled
# but has not provided credentials
return render json: { error: I18n.t('login.invalid_second_factor_code') }
end
user = matched_token&.user
if user.present? && !authenticate_second_factor(user)
return render(json: @second_factor_failure_payload)
end
if user = EmailToken.confirm(token)
@ -434,8 +366,6 @@ class SessionController < ApplicationController
end
render json: { error: I18n.t('email_login.invalid_token') }
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(matched_token&.user, err.message)
end
def one_time_password
@ -514,6 +444,21 @@ class SessionController < ApplicationController
private
def authenticate_second_factor(user)
second_factor_authentication_result = user.authenticate_second_factor(params, secure_session)
if !second_factor_authentication_result.ok
failure_payload = second_factor_authentication_result.to_h
if user.security_keys_enabled?
Webauthn.stage_challenge(user, secure_session)
failure_payload.merge!(Webauthn.allowed_credentials(user, secure_session))
end
@second_factor_failure_payload = failed_json.merge(failure_payload)
return false
end
true
end
def login_error_check(user)
return failed_to_login(user) if user.suspended?
@ -596,6 +541,11 @@ class SessionController < ApplicationController
).performed!
end
def rate_limit_second_factor_totp
return if params[:second_factor_token].blank?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
def render_sso_error(status:, text:)
@sso_error = text
render status: status, layout: 'no_ember'

View File

@ -105,7 +105,7 @@ class Users::OmniauthCallbacksController < ApplicationController
end
def user_found(user)
if user.totp_enabled?
if user.has_any_second_factor_methods_enabled?
@auth_result.omniauth_disallow_totp = true
@auth_result.email = user.email
return

View File

@ -14,7 +14,7 @@ class UsersController < ApplicationController
]
skip_before_action :check_xhr, only: [
:show, :badges, :password_reset, :update, :account_created,
:show, :badges, :password_reset_show, :password_reset_update, :update, :account_created,
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
:feature_topic, :clear_featured_topic
@ -40,7 +40,8 @@ class UsersController < ApplicationController
:perform_account_activation,
:send_activation_email,
:update_activation_email,
:password_reset,
:password_reset_show,
:password_reset_update,
:confirm_email_token,
:email_login,
:admin_login,
@ -504,68 +505,79 @@ class UsersController < ApplicationController
}
end
def password_reset
def password_reset_show
expires_now
token = params[:token]
password_reset_find_user(token, committing_change: false)
if EmailToken.valid_token_format?(token)
@user = if request.put?
EmailToken.confirm(token)
else
EmailToken.confirmable(token)&.user
end
if @user
secure_session["password-#{token}"] = @user.id
else
user_id = secure_session["password-#{token}"].to_i
@user = User.find(user_id) if user_id > 0
end
if !@error
security_params = {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: @user.totp_enabled?,
security_key_required: @user.security_keys_enabled?,
backup_enabled: @user.backup_codes_enabled?,
multiple_second_factor_methods: @user.has_multiple_second_factor_methods?
}
end
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
respond_to do |format|
format.html do
return render 'password_reset', layout: 'no_ember' if @error
if second_factor_token.present? && UserSecondFactor.methods[second_factor_method]
Webauthn.stage_challenge(@user, secure_session)
store_preloaded(
"password_reset",
MultiJson.dump(security_params.merge(Webauthn.allowed_credentials(@user, secure_session)))
)
render 'password_reset'
end
format.json do
return render json: { message: @error } if @error
Webauthn.stage_challenge(@user, secure_session)
render json: security_params.merge(Webauthn.allowed_credentials(@user, secure_session))
end
end
end
def password_reset_update
expires_now
token = params[:token]
password_reset_find_user(token, committing_change: true)
if params[:second_factor_token].present?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
second_factor_authenticated = @user&.authenticate_second_factor(second_factor_token, second_factor_method)
elsif security_key_credential.present?
security_key_authenticated = ::Webauthn::SecurityKeyAuthenticationService.new(
@user,
security_key_credential,
challenge: Webauthn.challenge(@user, secure_session),
rp_id: Webauthn.rp_id(@user, secure_session),
origin: Discourse.base_url
).authenticate_security_key
end
second_factor_totp_disabled = !@user&.totp_enabled?
if second_factor_authenticated || second_factor_totp_disabled || security_key_authenticated
secure_session["second-factor-#{token}"] = "true"
end
# no point doing anything else if we can't even find
# a user from the token
if @user
security_key_disabled = !@user&.security_keys_enabled?
if security_key_authenticated || security_key_disabled
secure_session["security-key-#{token}"] = "true"
end
if !secure_session["second-factor-#{token}"]
second_factor_authentication_result = @user.authenticate_second_factor(params, secure_session)
if !second_factor_authentication_result.ok
user_error_key = second_factor_authentication_result.reason == "invalid_security_key" ? :user_second_factors : :security_keys
@user.errors.add(user_error_key, :invalid)
@error = second_factor_authentication_result.error
else
valid_second_factor = secure_session["second-factor-#{token}"] == "true"
valid_security_key = secure_session["security-key-#{token}"] == "true"
# this must be set because the first call we authenticate e.g. TOTP, and we do
# not want to re-authenticate on the second call to change the password as this
# will cause a TOTP error saying the code has already been used
secure_session["second-factor-#{token}"] = true
end
end
if !@user
@error = I18n.t('password_reset.no_token')
elsif request.put?
if !valid_second_factor
@user.errors.add(:user_second_factors, :invalid)
@error = I18n.t('login.invalid_second_factor_code')
elsif !valid_security_key
@user.errors.add(:security_keys, :invalid)
@error = I18n.t('login.invalid_security_key')
elsif @invalid_password = params[:password].blank? || params[:password].size > User.max_password_length
if @invalid_password = params[:password].blank? || params[:password].size > User.max_password_length
@user.errors.add(:password, :invalid)
else
end
# if we have run into no errors then the user is a-ok to
# change the password
if @user.errors.empty?
@user.password = params[:password]
@user.password_required!
@user.user_auth_tokens.destroy_all
@ -585,69 +597,45 @@ class UsersController < ApplicationController
respond_to do |format|
format.html do
if @error
render layout: 'no_ember'
else
Webauthn.stage_challenge(@user, secure_session)
store_preloaded(
"password_reset",
MultiJson.dump(
{
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}.merge(Webauthn.allowed_credentials(@user, secure_session))
)
)
end
return render 'password_reset', layout: 'no_ember' if @error
return redirect_to(wizard_path) if request.put? && Wizard.user_requires_completion?(@user)
Webauthn.stage_challenge(@user, secure_session)
security_params = {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: @user.totp_enabled?,
security_key_required: @user.security_keys_enabled?,
backup_enabled: @user.backup_codes_enabled?,
multiple_second_factor_methods: @user.has_multiple_second_factor_methods?
}.merge(Webauthn.allowed_credentials(@user, secure_session))
store_preloaded("password_reset", MultiJson.dump(security_params))
return redirect_to(wizard_path) if Wizard.user_requires_completion?(@user)
render 'password_reset'
end
format.json do
if request.put?
if @error || @user&.errors&.any?
render json: {
success: false,
message: @error,
errors: @user&.errors&.to_hash,
is_developer: UsernameCheckerService.is_developer?(@user&.email),
admin: @user&.admin?
}
else
render json: {
success: true,
message: @success,
requires_approval: !Guardian.new(@user).can_access_forum?,
redirect_to: Wizard.user_requires_completion?(@user) ? wizard_path : nil
}
end
if @error || @user&.errors&.any?
render json: {
success: false,
message: @error,
errors: @user&.errors&.to_hash,
is_developer: UsernameCheckerService.is_developer?(@user&.email),
admin: @user&.admin?
}
else
if @error || @user&.errors&.any?
render json: {
message: @error,
errors: @user&.errors&.to_hash
}
else
Webauthn.stage_challenge(@user, secure_session) if !valid_security_key && !security_key_credential.present?
render json: {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}.merge(Webauthn.allowed_credentials(@user, secure_session))
end
render json: {
success: true,
message: @success,
requires_approval: !Guardian.new(@user).can_access_forum?,
redirect_to: Wizard.user_requires_completion?(@user) ? wizard_path : nil
}
end
end
end
rescue ::Webauthn::SecurityKeyError => err
render json: {
message: err.message,
errors: [err.message]
}
end
def confirm_email_token
@ -1358,6 +1346,20 @@ class UsersController < ApplicationController
private
def password_reset_find_user(token, committing_change:)
if EmailToken.valid_token_format?(token)
@user = committing_change ? EmailToken.confirm(token) : EmailToken.confirmable(token)&.user
if @user
secure_session["password-#{token}"] = @user.id
else
user_id = secure_session["password-#{token}"].to_i
@user = User.find(user_id) if user_id > 0
end
end
@error = I18n.t('password_reset.no_token') if !@user
end
def respond_to_suspicious_request
if suspicious?(params)
render json: {

View File

@ -56,11 +56,26 @@ class UsersEmailController < ApplicationController
redirect_url = path("/u/confirm-new-email/#{params[:token]}")
if !@error && @user.totp_enabled? && !@user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i)
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
flash[:invalid_second_factor] = true
redirect_to redirect_url
return
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! if params[:second_factor_token].present?
if !@error
# this is needed becase the form posts this field as JSON and it can be a
# hash when authenticatong security key.
if params[:second_factor_method].to_i == UserSecondFactor.methods[:security_key]
begin
params[:second_factor_token] = JSON.parse(params[:second_factor_token])
rescue JSON::ParserError
raise Discourse::InvalidParameters
end
end
second_factor_authentication_result = @user.authenticate_second_factor(params, secure_session)
if !second_factor_authentication_result.ok
flash[:invalid_second_factor] = true
flash[:invalid_second_factor_message] = second_factor_authentication_result.error
redirect_to redirect_url
return
end
end
if !@error
@ -92,15 +107,22 @@ class UsersEmailController < ApplicationController
end
@show_invalid_second_factor_error = flash[:invalid_second_factor]
@invalid_second_factor_message = flash[:invalid_second_factor_message]
if !@error
if @user.totp_enabled?
@backup_codes_enabled = @user.backup_codes_enabled?
if params[:show_backup].to_s == "true" && @backup_codes_enabled
@show_backup_codes = true
else
@backup_codes_enabled = @user.backup_codes_enabled?
if params[:show_backup].to_s == "true" && @backup_codes_enabled
@show_backup_codes = true
else
if @user.totp_enabled?
@show_second_factor = true
end
if @user.security_keys_enabled?
Webauthn.stage_challenge(@user, secure_session)
@show_security_key = params[:show_totp].to_s == "true" ? false : true
@security_key_challenge = Webauthn.challenge(@user, secure_session)
@security_key_allowed_credential_ids = Webauthn.allowed_credentials(@user, secure_session)[:allowed_credential_ids]
end
end
@to_email = @change_request.new_email

View File

@ -5,6 +5,10 @@ module SecondFactorManager
extend ActiveSupport::Concern
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!({
@ -67,25 +71,121 @@ module SecondFactorManager
self&.security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true).exists?
end
def has_any_second_factor_methods_enabled?
totp_enabled? || security_keys_enabled?
end
def has_multiple_second_factor_methods?
security_keys_enabled? && (totp_enabled? || backup_codes_enabled?)
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
def authenticate_second_factor(token, second_factor_method)
if second_factor_method == UserSecondFactor.methods[:totp]
authenticate_totp(token)
elsif second_factor_method == UserSecondFactor.methods[:backup_codes]
authenticate_backup_code(token)
elsif second_factor_method == UserSecondFactor.methods[:security_key]
# some craziness has happened if we have gotten here...like the user
# switching around their second factor types then continuing an already
# started login attempt
false
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
end
# 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?
)
end
def generate_backup_codes
@ -127,8 +227,9 @@ module SecondFactorManager
codes = self&.user_second_factors&.backup_codes
codes.each do |code|
stored_code = JSON.parse(code.data)["code_hash"]
stored_salt = JSON.parse(code.data)["salt"]
parsed_data = JSON.parse(code.data)
stored_code = parsed_data["code_hash"]
stored_salt = parsed_data["salt"]
backup_hash = hash_backup_code(backup_code, stored_salt)
next unless backup_hash == stored_code

View File

@ -21,25 +21,49 @@
<%=form_tag(u_confirm_new_email_path, method: :put) do %>
<%= hidden_field_tag 'token', @token.token %>
<%= hidden_field_tag 'second_factor_token', nil, id: 'security-key-credential' %>
<div id="security-key-error"></div>
<% if @show_invalid_second_factor_error %>
<div class='alert alert-error'><%= @invalid_second_factor_message %></div>
<% end %>
<% if @show_backup_codes %>
<div id="backup-second-factor-form" style="">
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:backup_code] %>
<h3><%= t('login.second_factor_backup_title') %></h3>
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
<div><%= render 'common/second_factor_backup_input' %></div>
<%= submit_tag(t("submit"), class: "btn btn-primary") %>
</div>
<br/>
<%= link_to t("login.second_factor_toggle.totp"), show_backup: "false" %>
<br/>
<% elsif @show_security_key %>
<%= hidden_field_tag 'security_key_challenge', @security_key_challenge, id: 'security-key-challenge' %>
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:security_key] %>
<%= hidden_field_tag 'security_key_allowed_credential_ids', @security_key_allowed_credential_ids, id: 'security-key-allowed-credential-ids' %>
<div id="security-key-form">
<h3><%= t('login.security_key_authenticate') %></h3>
<p><%= t('login.security_key_description') %></p>
<%= button_tag t('login.security_key_authenticate'), id: 'submit-security-key' %>
</div>
<br/>
<% if @show_second_factor %>
<%= link_to t("login.security_key_alternative"), show_totp: "true" %>
<% end %><br/>
<% if @backup_codes_enabled %>
<%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %>
<% end %>
<% elsif @show_second_factor %>
<div id="primary-second-factor-form">
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:totp] %>
<h3><%= t('login.second_factor_title') %></h3>
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
<div><%= render 'common/second_factor_text_field' %></div>
<% if @show_invalid_second_factor_error %>
<div class='alert alert-error'><%= t('login.invalid_second_factor_code') %></div>
<% end %>
<%= submit_tag t('submit'), class: "btn btn-primary" %>
</div>
<br/>
<% if @backup_codes_enabled %>
<%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %>
<% end %>
@ -48,4 +72,11 @@
<% end %>
<%end%>
<% end%>
<%= preload_script "ember_jquery" %>
<%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script "locales/i18n" %>
<%= preload_script "discourse/lib/webauthn" %>
<%= preload_script "confirm-new-email/confirm-new-email" %>
<%= preload_script "confirm-new-email/confirm-new-email.no-module" %>
</div>

View File

@ -151,8 +151,8 @@ module Discourse
wizard-start.js
locales/i18n.js
discourse/lib/webauthn.js
admin-login/admin-login.js
admin-login/admin-login.no-module.js
confirm-new-email/confirm-new-email.js
confirm-new-email/confirm-new-email.no-module.js
onpopstate-handler.js
embed-application.js
}

View File

@ -2319,6 +2319,8 @@ en:
auto_deleted_by_timer: "Automatically deleted by timer."
login:
invalid_second_factor_method: "The selected second factor method is invalid."
not_enabled_second_factor_method: "The selected second factor method is not enabled for your account."
security_key_description: "When you have your physical security key prepared press the Authenticate with Security Key button below."
security_key_alternative: "Try another way"
security_key_authenticate: "Authenticate with Security Key"
@ -2364,7 +2366,7 @@ en:
invalid_second_factor_code: "Invalid authentication code. Each code can only be used once."
invalid_security_key: "Invalid security key."
second_factor_toggle:
totp: "Use an authenticator app instead"
totp: "Use an authenticator app or security key instead"
backup_code: "Use a backup code instead"
admin:

View File

@ -403,9 +403,9 @@ Discourse::Application.routes.draw do
get "#{root_path}/account-created/resent" => "users#account_created"
get "#{root_path}/account-created/edit-email" => "users#account_created"
get({ "#{root_path}/password-reset/:token" => "users#password_reset" }.merge(index == 1 ? { as: :password_reset_token } : {}))
get({ "#{root_path}/password-reset/:token" => "users#password_reset_show" }.merge(index == 1 ? { as: :password_reset_token } : {}))
get "#{root_path}/confirm-email-token/:token" => "users#confirm_email_token", constraints: { format: 'json' }
put "#{root_path}/password-reset/:token" => "users#password_reset"
put "#{root_path}/password-reset/:token" => "users#password_reset_update"
get "#{root_path}/activate-account/:token" => "users#activate_account"
put({ "#{root_path}/activate-account/:token" => "users#perform_account_activation" }.merge(index == 1 ? { as: 'perform_activate_account' } : {}))

View File

@ -9,7 +9,7 @@ module Webauthn
# the steps followed here. Memoized methods are called in their
# place in the step flow to make the process clearer.
def authenticate_security_key
return false if @params.blank?
return false if @params.blank? || (!@params.is_a?(Hash) && !@params.is_a?(ActionController::Parameters))
# 3. Identify the user being authenticated and verify that this user is the
# owner of the public key credential source credentialSource identified by credential.id:

View File

@ -3,8 +3,16 @@
require 'rails_helper'
RSpec.describe SecondFactorManager do
fab!(:user_second_factor_totp) { Fabricate(:user_second_factor_totp) }
let(:user) { user_second_factor_totp.user }
fab!(:user) { Fabricate(:user) }
fab!(:user_second_factor_totp) { Fabricate(:user_second_factor_totp, user: user) }
fab!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
public_key: valid_security_key_data[:public_key],
credential_id: valid_security_key_data[:credential_id]
)
end
fab!(:another_user) { Fabricate(:user) }
fab!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup) }
@ -78,7 +86,7 @@ RSpec.describe SecondFactorManager do
describe "when user's second factor record is disabled" do
it 'should return false' do
user.user_second_factors.totps.first.update!(enabled: false)
disable_totp
expect(user.totp_enabled?).to eq(false)
end
end
@ -107,6 +115,252 @@ RSpec.describe SecondFactorManager do
end
end
describe "#has_multiple_second_factor_methods?" do
context "when security keys and totp are enabled" do
it "retrns true" do
expect(user.has_multiple_second_factor_methods?).to eq(true)
end
end
context "if the totp gets disabled" do
it "retrns false" do
disable_totp
expect(user.has_multiple_second_factor_methods?).to eq(false)
end
end
context "if the security key gets disabled" do
it "retrns false" do
disable_security_key
expect(user.has_multiple_second_factor_methods?).to eq(false)
end
end
end
describe "#only_security_keys_enabled?" do
it "returns true if totp disabled and security key enabled" do
disable_totp
expect(user.only_security_keys_enabled?).to eq(true)
end
end
describe "#only_totp_or_backup_codes_enabled?" do
it "returns true if totp enabled and security key disabled" do
disable_security_key
expect(user.only_totp_or_backup_codes_enabled?).to eq(true)
end
end
describe "#authenticate_second_factor" do
let(:params) { {} }
let(:secure_session) { {} }
context "when neither security keys nor totp/backup codes are enabled" do
before do
disable_security_key && disable_totp
end
it "returns OK, because it doesn't need to authenticate" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
context "when only security key is enabled" do
before do
disable_totp
simulate_localhost_webauthn_challenge
Webauthn.stage_challenge(user, secure_session)
end
context "when security key params are valid" do
let(:params) { { second_factor_token: valid_security_key_auth_post_data, second_factor_method: UserSecondFactor.methods[:security_key] } }
it "returns OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
context "when security key params are invalid" do
let(:params) do
{
second_factor_token: {
signature: 'bad',
clientData: 'bad',
authenticatorData: 'bad',
credentialId: 'bad'
},
second_factor_method: UserSecondFactor.methods[:security_key]
}
end
it "returns not OK" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("webauthn.validation.not_found_error"))
end
end
end
context "when only totp is enabled" do
before do
disable_security_key
end
context "when totp is valid" do
let(:params) do
{
second_factor_token: user.user_second_factors.totps.first.totp_object.now,
second_factor_method: UserSecondFactor.methods[:totp]
}
end
it "returns OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
context "when totp is invalid" do
let(:params) do
{
second_factor_token: "blah",
second_factor_method: UserSecondFactor.methods[:totp]
}
end
it "returns not OK" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.invalid_second_factor_code"))
end
end
end
context "when both security keys and totp are enabled" do
let(:invalid_method) { 4 }
let(:method) { invalid_method }
before do
simulate_localhost_webauthn_challenge
Webauthn.stage_challenge(user, secure_session)
end
context "when method selected is invalid" do
it "returns an error" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.invalid_second_factor_method"))
end
end
context "when method selected is TOTP" do
let(:method) { UserSecondFactor.methods[:totp] }
let(:token) { user.user_second_factors.totps.first.totp_object.now }
context "when totp params are provided" do
let(:params) do
{
second_factor_token: token,
second_factor_method: method
}
end
it "validates totp OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
context "when the user does not have TOTP enabled" do
let(:token) { 'test' }
before do
user.totps.destroy_all
end
it "returns an error" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
end
end
end
end
context "when method selected is Security Keys" do
let(:method) { UserSecondFactor.methods[:security_key] }
before do
simulate_localhost_webauthn_challenge
Webauthn.stage_challenge(user, secure_session)
end
context "when security key params are valid" do
let(:params) { { second_factor_token: valid_security_key_auth_post_data, second_factor_method: method } }
it "returns OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
context "when the user does not have security keys enabled" do
before do
user.security_keys.destroy_all
end
it "returns an error" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
end
end
end
end
context "when method selected is Backup Codes" do
let(:method) { UserSecondFactor.methods[:backup_codes] }
let!(:backup_code) { Fabricate(:user_second_factor_backup, user: user) }
context "when backup code params are provided" do
let(:params) do
{
second_factor_token: 'iAmValidBackupCode',
second_factor_method: method
}
end
context "when backup codes enabled" do
it "validates codes OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
context "when backup codes disabled" do
before do
user.user_second_factors.backup_codes.destroy_all
end
it "returns an error" do
result = user.authenticate_second_factor(params, secure_session)
expect(result.ok).to eq(false)
expect(result.error).to eq(I18n.t("login.not_enabled_second_factor_method"))
end
end
end
end
context "when no totp params are provided" do
let(:params) { { second_factor_token: valid_security_key_auth_post_data, second_factor_method: UserSecondFactor.methods[:security_key] } }
it "validates the security key OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
context "when totp params are provided" do
let(:params) do
{
second_factor_token: user.user_second_factors.totps.first.totp_object.now,
second_factor_method: UserSecondFactor.methods[:totp]
}
end
it "validates totp OK" do
expect(user.authenticate_second_factor(params, secure_session).ok).to eq(true)
end
end
end
end
context 'backup codes' do
describe '#generate_backup_codes' do
it 'should generate and store 10 backup codes' do
@ -187,4 +441,12 @@ RSpec.describe SecondFactorManager do
end
end
end
def disable_totp
user.user_second_factors.totps.first.update!(enabled: false)
end
def disable_security_key
user.security_keys.first.destroy!
end
end

View File

@ -62,6 +62,20 @@ describe Webauthn::SecurityKeyAuthenticationService do
expect(security_key.reload.last_used).not_to eq(nil)
end
context "when params is blank" do
let(:params) { nil }
it "returns false with no validation" do
expect(subject.authenticate_security_key).to eq(false)
end
end
context "when params is not blank and not a hash" do
let(:params) { 'test' }
it "returns false with no validation" do
expect(subject.authenticate_security_key).to eq(false)
end
end
context 'when the credential ID does not match any user security key in the database' do
let(:credential_id) { 'badid' }

View File

@ -160,7 +160,7 @@ RSpec.configure do |config|
config.include MessageBus
config.include RSpecHtmlMatchers
config.include IntegrationHelpers, type: :request
config.include WebauthnIntegrationHelpers, type: :request
config.include WebauthnIntegrationHelpers
config.include SiteSettingsHelpers
config.mock_framework = :mocha
config.order = 'random'

View File

@ -153,6 +153,42 @@ RSpec.describe ApplicationController do
get "/"
expect(response.status).to eq(200)
end
context "when enforcing second factor for staff" do
before do
SiteSetting.enforce_second_factor = "staff"
sign_in(admin)
end
context "when the staff member has not enabled TOTP or security keys" do
it "redirects the staff to the second factor preferences" do
get "/"
expect(response).to redirect_to("/u/#{admin.username}/preferences/second-factor")
end
end
context "when the staff member has enabled TOTP" do
before do
Fabricate(:user_second_factor_totp, user: admin)
end
it "does not redirects the staff to set up 2FA" do
get "/"
expect(response.status).to eq(200)
end
end
context "when the staff member has enabled security keys" do
before do
Fabricate(:user_security_key_with_random_credential, user: admin)
end
it "does not redirects the staff to set up 2FA" do
get "/"
expect(response.status).to eq(200)
end
end
end
end
describe 'invalid request params' do

View File

@ -323,7 +323,7 @@ RSpec.describe Users::OmniauthCallbacksController do
expect(user.confirm_password?("securepassword")).to eq(false)
end
context 'when user has second factor enabled' do
context 'when user has TOTP enabled' do
before do
user.create_totp(enabled: true)
end
@ -346,6 +346,29 @@ RSpec.describe Users::OmniauthCallbacksController do
end
end
context 'when user has security key enabled' do
before do
Fabricate(:user_security_key_with_random_credential, user: user)
end
it 'should return the right response' do
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
data = JSON.parse(cookies[:authentication_data])
expect(data["email"]).to eq(user.email)
expect(data["omniauth_disallow_totp"]).to eq(true)
user.update!(email: 'different@user.email')
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(302)
expect(JSON.parse(cookies[:authentication_data])["email"]).to eq(user.email)
end
end
context 'when sso_payload cookie exist' do
before do
SiteSetting.enable_sso_provider = true

View File

@ -312,6 +312,22 @@ RSpec.describe SessionController do
end
end
end
context "if the security_key_param is provided but only TOTP is enabled" do
it "does not log in the user" do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_token: 'foo',
second_factor_method: UserSecondFactor.methods[:totp]
}
expect(response.status).to eq(200)
expect(JSON.parse(response.body)["error"]).to eq(
I18n.t("login.invalid_second_factor_code")
)
expect(session[:current_user_id]).to eq(nil)
end
end
end
context "user has only security key enabled" do
@ -343,7 +359,7 @@ RSpec.describe SessionController do
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
'login.not_enabled_second_factor_method'
))
end
end
@ -351,7 +367,7 @@ RSpec.describe SessionController do
it" shows an error message and denies login" do
post "/session/email-login/#{email_token.token}.json", params: {
security_key_credential: {
second_factor_token: {
signature: 'bad_sig',
clientData: 'bad_clientData',
credentialId: 'bad_credential_id',
@ -375,7 +391,7 @@ RSpec.describe SessionController do
post "/session/email-login/#{email_token.token}.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
@ -387,6 +403,46 @@ RSpec.describe SessionController do
end
end
end
context "user has security key and totp enabled" do
let!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
it "doesnt allow logging in if the 2fa params are garbled" do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_method: UserSecondFactor.methods[:totp],
second_factor_token: "blah"
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
it "doesnt allow login if both of the 2fa params are blank" do
post "/session/email-login/#{email_token.token}.json", params: {
second_factor_method: UserSecondFactor.methods[:totp],
second_factor_token: ""
}
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
))
end
end
end
end
@ -1190,9 +1246,8 @@ RSpec.describe SessionController do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: {},
second_factor_token: '99999999',
second_factor_method: UserSecondFactor.methods[:totp]
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(200)
@ -1200,7 +1255,7 @@ RSpec.describe SessionController do
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
'login.invalid_security_key'
))
end
end
@ -1210,7 +1265,7 @@ RSpec.describe SessionController do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: {
second_factor_token: {
signature: 'bad_sig',
clientData: 'bad_clientData',
credentialId: 'bad_credential_id',
@ -1234,7 +1289,7 @@ RSpec.describe SessionController do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
@ -1256,7 +1311,7 @@ RSpec.describe SessionController do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
security_key_credential: valid_security_key_auth_post_data,
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
@ -1265,7 +1320,7 @@ RSpec.describe SessionController do
response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED")
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
'login.not_enabled_second_factor_method'
))
end
end
@ -1279,12 +1334,12 @@ RSpec.describe SessionController do
it 'should return the right response' do
post "/session.json", params: {
login: user.username,
password: 'myawesomepassword',
password: 'myawesomepassword'
}
expect(response.status).to eq(200)
expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code'
'login.invalid_second_factor_method'
))
end
end

View File

@ -236,7 +236,7 @@ describe UsersController do
expect(response.status).to eq(200)
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"security_key_required":false,"backup_enabled":false}')
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":false,"security_key_required":false,"backup_enabled":false,"multiple_second_factor_methods":false}')
end
expect(session["password-#{token}"]).to be_blank
@ -349,7 +349,7 @@ describe UsersController do
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"security_key_required":false,"backup_enabled":false}')
expect(json['password_reset']).to include('{"is_developer":false,"admin":false,"second_factor_required":true,"security_key_required":false,"backup_enabled":false,"multiple_second_factor_methods":false}')
end
put "/u/password-reset/#{token}", params: {
@ -420,21 +420,34 @@ describe UsersController do
it 'changes password with valid security key challenge and authentication' do
put "/u/password-reset/#{token}.json", params: {
password: 'hg9ow8yHG32O',
security_key_credential: valid_security_key_auth_post_data,
second_factor_token: valid_security_key_auth_post_data,
second_factor_method: UserSecondFactor.methods[:security_key]
}
user.reload
expect(response.status).to eq(200)
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true)
expect(user.user_auth_tokens.count).to eq(1)
end
it "does not change a password if a fake TOTP token is provided" do
put "/u/password-reset/#{token}.json", params: {
password: 'hg9ow8yHG32O',
second_factor_token: 'blah',
second_factor_method: UserSecondFactor.methods[:security_key]
}
user.reload
expect(response.status).to eq(200)
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(false)
end
context "when security key authentication fails" do
it 'shows an error message and does not change password' do
put "/u/password-reset/#{token}", params: {
password: 'hg9ow8yHG32O',
security_key_credential: {
second_factor_token: {
signature: 'bad',
clientData: 'bad',
authenticatorData: 'bad',
@ -446,7 +459,7 @@ describe UsersController do
user.reload
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(false)
expect(response.status).to eq(200)
expect(JSON.parse(response.body)['errors']).to include(I18n.t("webauthn.validation.not_found_error"))
expect(response.body).to include(I18n.t("webauthn.validation.not_found_error"))
end
end
end

View File

@ -115,6 +115,88 @@ describe UsersEmailController do
expect(user.email).to eq("new.n.cool@example.com")
end
end
context "security key required" do
fab!(:user_security_key) do
Fabricate(
:user_security_key,
user: user,
credential_id: valid_security_key_data[:credential_id],
public_key: valid_security_key_data[:public_key]
)
end
before do
simulate_localhost_webauthn_challenge
end
it 'requires a security key' do
get "/u/confirm-new-email/#{user.email_tokens.last.token}"
expect(response.status).to eq(200)
response_body = response.body
expect(response_body).to include(I18n.t("login.security_key_authenticate"))
expect(response_body).to include(I18n.t("login.security_key_description"))
end
context "if the user has a TOTP enabled and wants to use that instead" do
before do
Fabricate(:user_second_factor_totp, user: user)
end
it 'allows entering the totp code instead' do
get "/u/confirm-new-email/#{user.email_tokens.last.token}?show_totp=true"
expect(response.status).to eq(200)
response_body = response.body
expect(response_body).to include(I18n.t("login.second_factor_title"))
expect(response_body).not_to include(I18n.t("login.security_key_authenticate"))
end
end
it 'adds an error on a security key attempt' do
get "/u/confirm-new-email/#{user.email_tokens.last.token}"
put "/u/confirm-new-email", params: {
token: user.email_tokens.last.token,
second_factor_token: "{}",
second_factor_method: UserSecondFactor.methods[:security_key]
}
expect(response.status).to eq(302)
expect(flash[:invalid_second_factor]).to eq(true)
end
it 'confirms with a correct security key token' do
get "/u/confirm-new-email/#{user.email_tokens.last.token}"
put "/u/confirm-new-email", params: {
second_factor_token: valid_security_key_auth_post_data.to_json,
second_factor_method: UserSecondFactor.methods[:security_key],
token: user.email_tokens.last.token
}
expect(response.status).to eq(302)
user.reload
expect(user.email).to eq("new.n.cool@example.com")
end
context "if the security key data JSON is garbled" do
it "raises an invalid parameters error" do
get "/u/confirm-new-email/#{user.email_tokens.last.token}"
put "/u/confirm-new-email", params: {
second_factor_token: "{someweird: 8notjson}",
second_factor_method: UserSecondFactor.methods[:security_key],
token: user.email_tokens.last.token
}
expect(response.status).to eq(400)
end
end
end
end
end