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: { actions: {
finishLogin() { finishLogin() {
let data = {}; let data = { second_factor_method: this.secondFactorMethod };
if (this.securityKeyCredential) { if (this.securityKeyCredential) {
data = { security_key_credential: this.securityKeyCredential }; data.second_factor_token = this.securityKeyCredential;
} else { } else {
data = { data.second_factor_token = this.secondFactorToken;
second_factor_token: this.secondFactorToken,
second_factor_method: this.secondFactorMethod
};
} }
ajax({ ajax({
url: `/session/email-login/${this.model.token}`, url: `/session/email-login/${this.model.token}`,
type: "POST", type: "POST",

View File

@@ -120,9 +120,8 @@ export default Controller.extend(ModalFunctionality, {
data: { data: {
login: this.loginName, login: this.loginName,
password: this.loginPassword, password: this.loginPassword,
second_factor_token: this.secondFactorToken, second_factor_token: this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod, second_factor_method: this.secondFactorMethod,
security_key_credential: this.securityKeyCredential,
timezone: moment.tz.guess() timezone: moment.tz.guess()
} }
}).then( }).then(
@@ -130,12 +129,9 @@ export default Controller.extend(ModalFunctionality, {
// Successful login // Successful login
if (result && result.error) { if (result && result.error) {
this.set("loggingIn", false); this.set("loggingIn", false);
const invalidSecurityKey = result.reason === "invalid_security_key";
const invalidSecondFactor =
result.reason === "invalid_second_factor";
if ( if (
(invalidSecondFactor || invalidSecurityKey) && (result.security_key_enabled || result.totp_enabled) &&
!this.secondFactorRequired !this.secondFactorRequired
) { ) {
document.getElementById("modal-alert").style.display = "none"; document.getElementById("modal-alert").style.display = "none";
@@ -145,9 +141,9 @@ export default Controller.extend(ModalFunctionality, {
secondFactorRequired: true, secondFactorRequired: true,
showLoginButtons: false, showLoginButtons: false,
backupEnabled: result.backup_enabled, backupEnabled: result.backup_enabled,
showSecondFactor: invalidSecondFactor, showSecondFactor: result.totp_enabled,
showSecurityKey: invalidSecurityKey, showSecurityKey: result.security_key_enabled,
secondFactorMethod: invalidSecurityKey secondFactorMethod: result.security_key_enabled
? SECOND_FACTOR_METHODS.SECURITY_KEY ? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP, : SECOND_FACTOR_METHODS.TOTP,
securityKeyChallenge: result.challenge, 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 Controller from "@ember/controller";
import { default as discourseComputed } from "discourse-common/utils/decorators"; import { default as discourseComputed } from "discourse-common/utils/decorators";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
@@ -18,6 +18,7 @@ export default Controller.extend(PasswordValidation, {
"model.second_factor_required", "model.second_factor_required",
"model.security_key_required" "model.security_key_required"
), ),
otherMethodAllowed: readOnly("model.multiple_second_factor_methods"),
@discourseComputed("model.security_key_required") @discourseComputed("model.security_key_required")
secondFactorMethod(security_key_required) { secondFactorMethod(security_key_required) {
return security_key_required return security_key_required
@@ -51,9 +52,8 @@ export default Controller.extend(PasswordValidation, {
type: "PUT", type: "PUT",
data: { data: {
password: this.accountPassword, password: this.accountPassword,
second_factor_token: this.secondFactorToken, second_factor_token: this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod, second_factor_method: this.secondFactorMethod
security_key_credential: this.securityKeyCredential
} }
}) })
.then(result => { .then(result => {

View File

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

View File

@@ -745,23 +745,22 @@ class ApplicationController < ActionController::Base
return return
end end
check_totp = current_user && return if !current_user
!request.format.json? && return if !should_enforce_2fa?
!is_api? &&
!current_user.anonymous? &&
((SiteSetting.enforce_second_factor == 'staff' && current_user.staff?) ||
SiteSetting.enforce_second_factor == 'all') &&
!current_user.totp_enabled?
if check_totp redirect_path = "#{GlobalSetting.relative_url_root}/u/#{current_user.username}/preferences/second-factor"
redirect_path = "#{GlobalSetting.relative_url_root}/u/#{current_user.username}/preferences/second-factor" if !request.fullpath.start_with?(redirect_path)
if !request.fullpath.start_with?(redirect_path) redirect_to path(redirect_path)
redirect_to path(redirect_path) nil
nil
end
end end
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 def block_if_readonly_mode
return if request.fullpath.start_with?(path "/admin/backups") return if request.fullpath.start_with?(path "/admin/backups")
raise Discourse::ReadOnly.new if !(request.get? || request.head?) && @readonly_mode 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 :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_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 :redirect_to_login_if_required
skip_before_action :preload_json, :check_xhr, only: %i(sso sso_login sso_provider destroy one_time_password) 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 end
def create 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(:login)
params.require(:password) params.require(:password)
@@ -293,55 +290,14 @@ class SessionController < ApplicationController
end end
if payload = login_error_check(user) if payload = login_error_check(user)
render json: payload return 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)
end end
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(user, err.message)
end
def invalid_security_key(user, err_message = nil) if !authenticate_second_factor(user)
Webauthn.stage_challenge(user, secure_session) if !params[:security_key_credential] return render(json: @second_factor_failure_payload)
render json: failed_json.merge( end
error: err_message || I18n.t("login.invalid_security_key"),
reason: "invalid_security_key", (user.active && user.email_confirmed?) ? login(user) : not_activated(user)
backup_enabled: user.backup_codes_enabled?,
multiple_second_factor_methods: user.has_multiple_second_factor_methods?
).merge(Webauthn.allowed_credentials(user, secure_session))
end end
def email_login_info def email_login_info
@@ -385,41 +341,17 @@ class SessionController < ApplicationController
end end
def email_login 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] token = params[:token]
matched_token = EmailToken.confirmable(token) matched_token = EmailToken.confirmable(token)
if !SiteSetting.enable_local_logins_via_email && 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 raise Discourse::NotFound
end end
if security_key_credential.present? user = matched_token&.user
if matched_token&.user&.security_keys_enabled? if user.present? && !authenticate_second_factor(user)
security_key_valid = ::Webauthn::SecurityKeyAuthenticationService.new( return render(json: @second_factor_failure_payload)
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
end end
if user = EmailToken.confirm(token) if user = EmailToken.confirm(token)
@@ -434,8 +366,6 @@ class SessionController < ApplicationController
end end
render json: { error: I18n.t('email_login.invalid_token') } render json: { error: I18n.t('email_login.invalid_token') }
rescue ::Webauthn::SecurityKeyError => err
invalid_security_key(matched_token&.user, err.message)
end end
def one_time_password def one_time_password
@@ -514,6 +444,21 @@ class SessionController < ApplicationController
private 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) def login_error_check(user)
return failed_to_login(user) if user.suspended? return failed_to_login(user) if user.suspended?
@@ -596,6 +541,11 @@ class SessionController < ApplicationController
).performed! ).performed!
end 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:) def render_sso_error(status:, text:)
@sso_error = text @sso_error = text
render status: status, layout: 'no_ember' render status: status, layout: 'no_ember'

View File

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

View File

@@ -14,7 +14,7 @@ class UsersController < ApplicationController
] ]
skip_before_action :check_xhr, only: [ 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, :activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary, :my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
:feature_topic, :clear_featured_topic :feature_topic, :clear_featured_topic
@@ -40,7 +40,8 @@ class UsersController < ApplicationController
:perform_account_activation, :perform_account_activation,
:send_activation_email, :send_activation_email,
:update_activation_email, :update_activation_email,
:password_reset, :password_reset_show,
:password_reset_update,
:confirm_email_token, :confirm_email_token,
:email_login, :email_login,
:admin_login, :admin_login,
@@ -504,68 +505,79 @@ class UsersController < ApplicationController
} }
end end
def password_reset def password_reset_show
expires_now expires_now
token = params[:token] token = params[:token]
password_reset_find_user(token, committing_change: false)
if EmailToken.valid_token_format?(token) if !@error
@user = if request.put? security_params = {
EmailToken.confirm(token) is_developer: UsernameCheckerService.is_developer?(@user.email),
else admin: @user.admin?,
EmailToken.confirmable(token)&.user second_factor_required: @user.totp_enabled?,
end security_key_required: @user.security_keys_enabled?,
backup_enabled: @user.backup_codes_enabled?,
if @user multiple_second_factor_methods: @user.has_multiple_second_factor_methods?
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 end
second_factor_token = params[:second_factor_token] respond_to do |format|
second_factor_method = params[:second_factor_method].to_i format.html do
security_key_credential = params[:security_key_credential] 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! 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 end
second_factor_totp_disabled = !@user&.totp_enabled? # no point doing anything else if we can't even find
if second_factor_authenticated || second_factor_totp_disabled || security_key_authenticated # a user from the token
secure_session["second-factor-#{token}"] = "true" if @user
end
security_key_disabled = !@user&.security_keys_enabled? if !secure_session["second-factor-#{token}"]
if security_key_authenticated || security_key_disabled second_factor_authentication_result = @user.authenticate_second_factor(params, secure_session)
secure_session["security-key-#{token}"] = "true" if !second_factor_authentication_result.ok
end 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" # this must be set because the first call we authenticate e.g. TOTP, and we do
valid_security_key = secure_session["security-key-#{token}"] == "true" # 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 if @invalid_password = params[:password].blank? || params[:password].size > User.max_password_length
@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
@user.errors.add(:password, :invalid) @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 = params[:password]
@user.password_required! @user.password_required!
@user.user_auth_tokens.destroy_all @user.user_auth_tokens.destroy_all
@@ -585,69 +597,45 @@ class UsersController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
if @error return render 'password_reset', layout: 'no_ember' 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 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 end
format.json do format.json do
if request.put? if @error || @user&.errors&.any?
if @error || @user&.errors&.any? render json: {
render json: { success: false,
success: false, message: @error,
message: @error, errors: @user&.errors&.to_hash,
errors: @user&.errors&.to_hash, is_developer: UsernameCheckerService.is_developer?(@user&.email),
is_developer: UsernameCheckerService.is_developer?(@user&.email), admin: @user&.admin?
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
else else
if @error || @user&.errors&.any? render json: {
render json: { success: true,
message: @error, message: @success,
errors: @user&.errors&.to_hash requires_approval: !Guardian.new(@user).can_access_forum?,
} redirect_to: Wizard.user_requires_completion?(@user) ? wizard_path : nil
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
end end
end end
end end
rescue ::Webauthn::SecurityKeyError => err
render json: {
message: err.message,
errors: [err.message]
}
end end
def confirm_email_token def confirm_email_token
@@ -1358,6 +1346,20 @@ class UsersController < ApplicationController
private 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 def respond_to_suspicious_request
if suspicious?(params) if suspicious?(params)
render json: { render json: {

View File

@@ -56,11 +56,26 @@ class UsersEmailController < ApplicationController
redirect_url = path("/u/confirm-new-email/#{params[:token]}") 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! if params[:second_factor_token].present?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
flash[:invalid_second_factor] = true if !@error
redirect_to redirect_url # this is needed becase the form posts this field as JSON and it can be a
return # 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 end
if !@error if !@error
@@ -92,15 +107,22 @@ class UsersEmailController < ApplicationController
end end
@show_invalid_second_factor_error = flash[:invalid_second_factor] @show_invalid_second_factor_error = flash[:invalid_second_factor]
@invalid_second_factor_message = flash[:invalid_second_factor_message]
if !@error if !@error
if @user.totp_enabled? @backup_codes_enabled = @user.backup_codes_enabled?
@backup_codes_enabled = @user.backup_codes_enabled? if params[:show_backup].to_s == "true" && @backup_codes_enabled
if params[:show_backup].to_s == "true" && @backup_codes_enabled @show_backup_codes = true
@show_backup_codes = true else
else if @user.totp_enabled?
@show_second_factor = true @show_second_factor = true
end 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 end
@to_email = @change_request.new_email @to_email = @change_request.new_email

View File

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

View File

@@ -21,25 +21,49 @@
<%=form_tag(u_confirm_new_email_path, method: :put) do %> <%=form_tag(u_confirm_new_email_path, method: :put) do %>
<%= hidden_field_tag 'token', @token.token %> <%= 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 %> <% if @show_backup_codes %>
<div id="backup-second-factor-form" style=""> <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> <h3><%= t('login.second_factor_backup_title') %></h3>
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %> <%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
<div><%= render 'common/second_factor_backup_input' %></div> <div><%= render 'common/second_factor_backup_input' %></div>
<%= submit_tag(t("submit"), class: "btn btn-primary") %> <%= submit_tag(t("submit"), class: "btn btn-primary") %>
</div> </div>
<br/>
<%= link_to t("login.second_factor_toggle.totp"), show_backup: "false" %> <%= 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 %> <% elsif @show_second_factor %>
<div id="primary-second-factor-form"> <div id="primary-second-factor-form">
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:totp] %>
<h3><%= t('login.second_factor_title') %></h3> <h3><%= t('login.second_factor_title') %></h3>
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %> <%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
<div><%= render 'common/second_factor_text_field' %></div> <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" %> <%= submit_tag t('submit'), class: "btn btn-primary" %>
</div> </div>
<br/>
<% if @backup_codes_enabled %> <% if @backup_codes_enabled %>
<%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %> <%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %>
<% end %> <% end %>
@@ -48,4 +72,11 @@
<% end %> <% end %>
<%end%> <%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> </div>

View File

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

View File

@@ -2319,6 +2319,8 @@ en:
auto_deleted_by_timer: "Automatically deleted by timer." auto_deleted_by_timer: "Automatically deleted by timer."
login: 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_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_alternative: "Try another way"
security_key_authenticate: "Authenticate with Security Key" 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_second_factor_code: "Invalid authentication code. Each code can only be used once."
invalid_security_key: "Invalid security key." invalid_security_key: "Invalid security key."
second_factor_toggle: 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" backup_code: "Use a backup code instead"
admin: 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/resent" => "users#account_created"
get "#{root_path}/account-created/edit-email" => "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' } 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" 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' } : {})) 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 # the steps followed here. Memoized methods are called in their
# place in the step flow to make the process clearer. # place in the step flow to make the process clearer.
def authenticate_security_key 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 # 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: # owner of the public key credential source credentialSource identified by credential.id:

View File

@@ -3,8 +3,16 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe SecondFactorManager do RSpec.describe SecondFactorManager do
fab!(:user_second_factor_totp) { Fabricate(:user_second_factor_totp) } fab!(:user) { Fabricate(:user) }
let(:user) { user_second_factor_totp.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!(:another_user) { Fabricate(:user) }
fab!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup) } 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 describe "when user's second factor record is disabled" do
it 'should return false' do it 'should return false' do
user.user_second_factors.totps.first.update!(enabled: false) disable_totp
expect(user.totp_enabled?).to eq(false) expect(user.totp_enabled?).to eq(false)
end end
end end
@@ -107,6 +115,252 @@ RSpec.describe SecondFactorManager do
end end
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 context 'backup codes' do
describe '#generate_backup_codes' do describe '#generate_backup_codes' do
it 'should generate and store 10 backup codes' do it 'should generate and store 10 backup codes' do
@@ -187,4 +441,12 @@ RSpec.describe SecondFactorManager do
end end
end 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 end

View File

@@ -62,6 +62,20 @@ describe Webauthn::SecurityKeyAuthenticationService do
expect(security_key.reload.last_used).not_to eq(nil) expect(security_key.reload.last_used).not_to eq(nil)
end 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 context 'when the credential ID does not match any user security key in the database' do
let(:credential_id) { 'badid' } let(:credential_id) { 'badid' }

View File

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

View File

@@ -153,6 +153,42 @@ RSpec.describe ApplicationController do
get "/" get "/"
expect(response.status).to eq(200) expect(response.status).to eq(200)
end 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 end
describe 'invalid request params' do describe 'invalid request params' do

View File

@@ -323,7 +323,7 @@ RSpec.describe Users::OmniauthCallbacksController do
expect(user.confirm_password?("securepassword")).to eq(false) expect(user.confirm_password?("securepassword")).to eq(false)
end end
context 'when user has second factor enabled' do context 'when user has TOTP enabled' do
before do before do
user.create_totp(enabled: true) user.create_totp(enabled: true)
end end
@@ -346,6 +346,29 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
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 context 'when sso_payload cookie exist' do
before do before do
SiteSetting.enable_sso_provider = true SiteSetting.enable_sso_provider = true

View File

@@ -312,6 +312,22 @@ RSpec.describe SessionController do
end end
end 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 end
context "user has only security key enabled" do context "user has only security key enabled" do
@@ -343,7 +359,7 @@ RSpec.describe SessionController do
expect(session[:current_user_id]).to eq(nil) expect(session[:current_user_id]).to eq(nil)
response_body = JSON.parse(response.body) response_body = JSON.parse(response.body)
expect(response_body['error']).to eq(I18n.t( expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code' 'login.not_enabled_second_factor_method'
)) ))
end end
end end
@@ -351,7 +367,7 @@ RSpec.describe SessionController do
it" shows an error message and denies login" do it" shows an error message and denies login" do
post "/session/email-login/#{email_token.token}.json", params: { post "/session/email-login/#{email_token.token}.json", params: {
security_key_credential: { second_factor_token: {
signature: 'bad_sig', signature: 'bad_sig',
clientData: 'bad_clientData', clientData: 'bad_clientData',
credentialId: 'bad_credential_id', credentialId: 'bad_credential_id',
@@ -375,7 +391,7 @@ RSpec.describe SessionController do
post "/session/email-login/#{email_token.token}.json", params: { post "/session/email-login/#{email_token.token}.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', 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] second_factor_method: UserSecondFactor.methods[:security_key]
} }
@@ -387,6 +403,46 @@ RSpec.describe SessionController do
end end
end 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
end end
@@ -1190,9 +1246,8 @@ RSpec.describe SessionController do
post "/session.json", params: { post "/session.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', password: 'myawesomepassword',
security_key_credential: {},
second_factor_token: '99999999', second_factor_token: '99999999',
second_factor_method: UserSecondFactor.methods[:totp] second_factor_method: UserSecondFactor.methods[:security_key]
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
@@ -1200,7 +1255,7 @@ RSpec.describe SessionController do
response_body = JSON.parse(response.body) response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED") expect(response_body["failed"]).to eq("FAILED")
expect(response_body['error']).to eq(I18n.t( expect(response_body['error']).to eq(I18n.t(
'login.invalid_second_factor_code' 'login.invalid_security_key'
)) ))
end end
end end
@@ -1210,7 +1265,7 @@ RSpec.describe SessionController do
post "/session.json", params: { post "/session.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', password: 'myawesomepassword',
security_key_credential: { second_factor_token: {
signature: 'bad_sig', signature: 'bad_sig',
clientData: 'bad_clientData', clientData: 'bad_clientData',
credentialId: 'bad_credential_id', credentialId: 'bad_credential_id',
@@ -1234,7 +1289,7 @@ RSpec.describe SessionController do
post "/session.json", params: { post "/session.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', 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] second_factor_method: UserSecondFactor.methods[:security_key]
} }
@@ -1256,7 +1311,7 @@ RSpec.describe SessionController do
post "/session.json", params: { post "/session.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', 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] second_factor_method: UserSecondFactor.methods[:security_key]
} }
@@ -1265,7 +1320,7 @@ RSpec.describe SessionController do
response_body = JSON.parse(response.body) response_body = JSON.parse(response.body)
expect(response_body["failed"]).to eq("FAILED") expect(response_body["failed"]).to eq("FAILED")
expect(JSON.parse(response.body)['error']).to eq(I18n.t( expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code' 'login.not_enabled_second_factor_method'
)) ))
end end
end end
@@ -1279,12 +1334,12 @@ RSpec.describe SessionController do
it 'should return the right response' do it 'should return the right response' do
post "/session.json", params: { post "/session.json", params: {
login: user.username, login: user.username,
password: 'myawesomepassword', password: 'myawesomepassword'
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(JSON.parse(response.body)['error']).to eq(I18n.t( expect(JSON.parse(response.body)['error']).to eq(I18n.t(
'login.invalid_second_factor_code' 'login.invalid_second_factor_method'
)) ))
end end
end end

View File

@@ -236,7 +236,7 @@ describe UsersController do
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.body).to have_tag("div#data-preloaded") do |element| expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value) 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 end
expect(session["password-#{token}"]).to be_blank 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| expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value) 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 end
put "/u/password-reset/#{token}", params: { put "/u/password-reset/#{token}", params: {
@@ -420,21 +420,34 @@ describe UsersController do
it 'changes password with valid security key challenge and authentication' do it 'changes password with valid security key challenge and authentication' do
put "/u/password-reset/#{token}.json", params: { put "/u/password-reset/#{token}.json", params: {
password: 'hg9ow8yHG32O', 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] second_factor_method: UserSecondFactor.methods[:security_key]
} }
user.reload user.reload
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true)
expect(user.user_auth_tokens.count).to eq(1) expect(user.user_auth_tokens.count).to eq(1)
end 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 context "when security key authentication fails" do
it 'shows an error message and does not change password' do it 'shows an error message and does not change password' do
put "/u/password-reset/#{token}", params: { put "/u/password-reset/#{token}", params: {
password: 'hg9ow8yHG32O', password: 'hg9ow8yHG32O',
security_key_credential: { second_factor_token: {
signature: 'bad', signature: 'bad',
clientData: 'bad', clientData: 'bad',
authenticatorData: 'bad', authenticatorData: 'bad',
@@ -446,7 +459,7 @@ describe UsersController do
user.reload user.reload
expect(user.confirm_password?('hg9ow8yHG32O')).to eq(false) expect(user.confirm_password?('hg9ow8yHG32O')).to eq(false)
expect(response.status).to eq(200) 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 end
end end

View File

@@ -115,6 +115,88 @@ describe UsersEmailController do
expect(user.email).to eq("new.n.cool@example.com") expect(user.email).to eq("new.n.cool@example.com")
end end
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
end end