diff --git a/app/assets/javascripts/confirm-new-email/confirm-new-email.js.es6 b/app/assets/javascripts/confirm-new-email/confirm-new-email.js.es6 new file mode 100644 index 0000000000..dbcfdd25c4 --- /dev/null +++ b/app/assets/javascripts/confirm-new-email/confirm-new-email.js.es6 @@ -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; + } + ); +}; diff --git a/app/assets/javascripts/confirm-new-email/confirm-new-email.no-module.js.es6 b/app/assets/javascripts/confirm-new-email/confirm-new-email.no-module.js.es6 new file mode 100644 index 0000000000..0dd05dd8ca --- /dev/null +++ b/app/assets/javascripts/confirm-new-email/confirm-new-email.no-module.js.es6 @@ -0,0 +1 @@ +require("confirm-new-email/confirm-new-email").default(); diff --git a/app/assets/javascripts/discourse/controllers/email-login.js.es6 b/app/assets/javascripts/discourse/controllers/email-login.js.es6 index 01612c766f..4eab7fc1ba 100644 --- a/app/assets/javascripts/discourse/controllers/email-login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/email-login.js.es6 @@ -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", diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 5eabad52fb..c90a1b07f7 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -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, diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 7f3718482e..f423f0bff9 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -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 => { diff --git a/app/assets/javascripts/discourse/templates/password-reset.hbs b/app/assets/javascripts/discourse/templates/password-reset.hbs index c7c83156c5..d2126263c4 100644 --- a/app/assets/javascripts/discourse/templates/password-reset.hbs +++ b/app/assets/javascripts/discourse/templates/password-reset.hbs @@ -28,7 +28,7 @@ showSecurityKey=model.security_key_required showSecondFactor=false secondFactorMethod=secondFactorMethod - otherMethodAllowed=secondFactorRequired + otherMethodAllowed=otherMethodAllowed action=(action "authenticateSecurityKey")}} {{/security-key-form}} {{else}} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5dcd7ae2e1..46ff0a334e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 442ca7cb6d..0338bd85a4 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -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' diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 80bb6346b8..36548585f3 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index da4b6a19bc..774b7ed82c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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: { diff --git a/app/controllers/users_email_controller.rb b/app/controllers/users_email_controller.rb index aaead3bf62..96f027880c 100644 --- a/app/controllers/users_email_controller.rb +++ b/app/controllers/users_email_controller.rb @@ -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 diff --git a/app/models/concerns/second_factor_manager.rb b/app/models/concerns/second_factor_manager.rb index 235242339f..b8362cae73 100644 --- a/app/models/concerns/second_factor_manager.rb +++ b/app/models/concerns/second_factor_manager.rb @@ -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 diff --git a/app/views/users_email/show_confirm_new_email.html.erb b/app/views/users_email/show_confirm_new_email.html.erb index fe14cf53df..31108a7ac1 100644 --- a/app/views/users_email/show_confirm_new_email.html.erb +++ b/app/views/users_email/show_confirm_new_email.html.erb @@ -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' %> +
+ + <% if @show_invalid_second_factor_error %> +<%= t('login.security_key_description') %>
+ <%= button_tag t('login.security_key_authenticate'), id: 'submit-security-key' %> +