DEV: Update confirm-email flows to use central 2fa and ember rendering (#25404)

These routes were previously rendered using Rails, and had a fairly fragile 2fa implementation in vanilla-js. This commit refactors the routes to be handled in the Ember app, removes the custom vanilla-js bundles, and leans on our centralized 2fa implementation. It also introduces a set of system specs for the behavior.
This commit is contained in:
David Taylor 2024-01-30 10:32:42 +00:00 committed by GitHub
parent 27301ae5c7
commit 283fe48243
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 445 additions and 521 deletions

View File

@ -1,4 +0,0 @@
// discourse-skip-module
(function () {
require("confirm-new-email/confirm-new-email");
})();

View File

@ -1,27 +0,0 @@
import { getWebauthnCredential } from "discourse/lib/webauthn";
const security = document.getElementById("submit-security-key");
if (security) {
security.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.closest("form").submit();
},
(errorMessage) => {
document.getElementById(
"security-key-error"
).innerHTML = `<div class="alert alert-error"></div>`;
document.querySelector("#security-key-error .alert").innerText =
errorMessage;
}
);
};
}

View File

@ -0,0 +1,46 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
export default class ConfirmNewEmailController extends Controller {
@service dialog;
@service router;
@tracked loading;
@action
async confirm() {
this.loading = true;
try {
await ajax(`/u/confirm-new-email/${this.model.token}.json`, {
type: "PUT",
});
} catch (error) {
const nonce = error.jqXHR?.responseJSON?.second_factor_challenge_nonce;
if (nonce) {
this.router.transitionTo("second-factor-auth", {
queryParams: { nonce },
});
} else {
popupAjaxError(error);
}
return;
} finally {
this.loading = false;
}
await new Promise((resolve) =>
this.dialog.dialog({
message: I18n.t("user.change_email.confirm_success"),
type: "alert",
didConfirm: resolve,
})
);
this.router.transitionTo("/my/preferences/account");
}
}

View File

@ -0,0 +1,39 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
export default class ConfirmOldEmailController extends Controller {
@service dialog;
@service router;
@tracked loading;
@action
async confirm() {
this.loading = true;
try {
await ajax(`/u/confirm-old-email/${this.model.token}.json`, {
type: "PUT",
});
} catch (error) {
popupAjaxError(error);
return;
} finally {
this.loading = false;
}
await new Promise((resolve) =>
this.dialog.dialog({
message: I18n.t("user.change_email.authorizing_old.confirm_success"),
type: "alert",
didConfirm: resolve,
})
);
this.router.transitionTo("/my/preferences/account");
}
}

View File

@ -192,7 +192,10 @@ export default Controller.extend({
); );
ajax(response.callback_path, { ajax(response.callback_path, {
type: response.callback_method, type: response.callback_method,
data: { second_factor_nonce: this.nonce }, data: {
second_factor_nonce: this.nonce,
...response.callback_params,
},
}) })
.then((callbackResponse) => { .then((callbackResponse) => {
const redirectUrl = const redirectUrl =

View File

@ -105,6 +105,8 @@ export default function () {
this.route("resent"); this.route("resent");
this.route("edit-email"); this.route("edit-email");
}); });
this.route("confirm-new-email", { path: "/u/confirm-new-email/:token" });
this.route("confirm-old-email", { path: "/u/confirm-old-email/:token" });
this.route( this.route(
"user", "user",
{ path: "/u/:username", resetNamespace: true }, { path: "/u/:username", resetNamespace: true },

View File

@ -0,0 +1,13 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
export default class ConfirmNewEmailRoute extends DiscourseRoute {
titleToken() {
return I18n.t("user.change_email.title");
}
model(params) {
return ajax(`/u/confirm-new-email/${params.token}.json`);
}
}

View File

@ -0,0 +1,13 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
export default class ConfirmOldEmailRoute extends DiscourseRoute {
titleToken() {
return I18n.t("user.change_email.title");
}
model(params) {
return ajax(`/u/confirm-old-email/${params.token}.json`);
}
}

View File

@ -0,0 +1,18 @@
<div id="simple-container">
<div class="confirm-new-email">
<h2>{{i18n "user.change_email.title"}}</h2>
<p>
{{#if this.model.old_email}}
{{i18n "user.change_email.authorizing_new.description"}}
{{else}}
{{i18n "user.change_email.authorizing_new.description_add"}}
{{/if}}
</p>
<p>{{this.model.new_email}}</p>
<DButton
@translatedLabel={{i18n "user.change_email.confirm"}}
class="btn-primary"
@action={{this.confirm}}
/>
</div>
</div>

View File

@ -0,0 +1,31 @@
<div id="simple-container">
<div class="confirm-old-email">
<h2>{{i18n "user.change_email.authorizing_old.title"}}</h2>
<p>
{{#if this.model.old_email}}
{{i18n "user.change_email.authorizing_old.description"}}
{{else}}
{{i18n "user.change_email.authorizing_old.description_add"}}
{{/if}}
</p>
{{#if this.model.old_email}}
<p>
{{i18n
"user.change_email.authorizing_old.old_email"
email=this.model.old_email
}}
</p>
{{/if}}
<p>
{{i18n
"user.change_email.authorizing_old.new_email"
email=this.model.new_email
}}
</p>
<DButton
@translatedLabel={{i18n "user.change_email.confirm"}}
class="btn-primary"
@action={{this.confirm}}
/>
</div>
</div>

View File

@ -3,24 +3,16 @@
class UsersEmailController < ApplicationController class UsersEmailController < ApplicationController
requires_login only: %i[index update] requires_login only: %i[index update]
skip_before_action :check_xhr, skip_before_action :check_xhr, only: %i[show_confirm_old_email show_confirm_new_email]
only: %i[
confirm_old_email
show_confirm_old_email
confirm_new_email
show_confirm_new_email
]
skip_before_action :redirect_to_login_if_required, skip_before_action :redirect_to_login_if_required,
only: %i[ only: %i[
confirm_old_email
show_confirm_old_email show_confirm_old_email
confirm_new_email
show_confirm_new_email show_confirm_new_email
confirm_old_email
confirm_new_email
] ]
before_action :require_login, only: %i[confirm_old_email show_confirm_old_email]
def index def index
end end
@ -61,125 +53,55 @@ class UsersEmailController < ApplicationController
end end
def confirm_new_email def confirm_new_email
load_change_request(:new) change_request = load_change_request(:new)
if @change_request&.change_state != EmailChangeRequest.states[:authorizing_new] result =
@error = I18n.t("change_email.already_done") run_second_factor!(SecondFactor::Actions::ConfirmEmail, target_user: change_request.user)
end
redirect_url = path("/u/confirm-new-email/#{params[:token]}") if result.no_second_factors_enabled? || result.second_factor_auth_completed?
rate_limit_second_factor!(@user)
if !@error
# this is needed because the form posts this field as JSON and it can be a
# hash when authenticating 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
updater = EmailUpdater.new updater = EmailUpdater.new
if updater.confirm(params[:token]) == :complete if updater.confirm(params[:token]) == :complete
updater.user.user_stat.reset_bounce_score! updater.user.user_stat.reset_bounce_score!
render json: success_json
else else
@error = I18n.t("change_email.already_done") render json: { error: I18n.t("change_email.already_done") }, status: 400
end end
end end
if @error
flash[:error] = @error
redirect_to redirect_url
else
redirect_to "#{redirect_url}?done=true"
end
end end
def show_confirm_new_email def show_confirm_new_email
load_change_request(:new) return render "default/empty" if request.format.html?
@done = true if params[:done].to_s == "true" change_request = load_change_request(:new)
if @change_request&.change_state != EmailChangeRequest.states[:authorizing_new] render json: {
@error = I18n.t("change_email.already_done") new_email: change_request.new_email,
end old_email: change_request.old_email,
token: params[:token],
@show_invalid_second_factor_error = flash[:invalid_second_factor] }
@invalid_second_factor_message = flash[:invalid_second_factor_message]
if !@error
@backup_codes_enabled = @user.backup_codes_enabled?
if params[:show_backup].to_s == "true" && @backup_codes_enabled
@show_backup_codes = true
else
@show_second_factor = true if @user.totp_enabled?
if @user.security_keys_enabled?
DiscourseWebauthn.stage_challenge(@user, secure_session)
@show_security_key = params[:show_totp].to_s == "true" ? false : true
@security_key_challenge = DiscourseWebauthn.challenge(@user, secure_session)
@security_key_allowed_credential_ids =
DiscourseWebauthn.allowed_credentials(@user, secure_session)[:allowed_credential_ids]
end
end
@to_email = @change_request.new_email
end
render layout: "no_ember"
end end
def confirm_old_email def confirm_old_email
load_change_request(:old) load_change_request(:old)
if @change_request&.change_state != EmailChangeRequest.states[:authorizing_old] updater = EmailUpdater.new
@error = I18n.t("change_email.already_done") if updater.confirm(params[:token]) == :authorizing_new
end render json: success_json
redirect_url = path("/u/confirm-old-email/#{params[:token]}")
if !@error
updater = EmailUpdater.new
if updater.confirm(params[:token]) != :authorizing_new
@error = I18n.t("change_email.already_done")
end
end
if @error
flash[:error] = @error
redirect_to redirect_url
else else
redirect_to "#{redirect_url}?done=true" render json: { error: I18n.t("change_email.already_done") }, status: 400
end end
end end
def show_confirm_old_email def show_confirm_old_email
load_change_request(:old) return render "default/empty" if request.format.html?
if @change_request&.change_state != EmailChangeRequest.states[:authorizing_old] change_request = load_change_request(:old)
@error = I18n.t("change_email.already_done")
end
@almost_done = true if params[:done].to_s == "true" render json: {
new_email: change_request.new_email,
if !@error old_email: change_request.old_email,
@from_email = @user.email token: params[:token],
@to_email = @change_request.new_email }
end
render layout: "no_ember"
end end
private private
@ -189,26 +111,19 @@ class UsersEmailController < ApplicationController
token = EmailToken.confirmable(params[:token], scope: EmailToken.scopes[:email_update]) token = EmailToken.confirmable(params[:token], scope: EmailToken.scopes[:email_update])
if token raise Discourse::NotFound if !token || !token.user
if current_user && token.user.id != current_user.id
raise Discourse::InvalidAccess.new "You are logged in, but this email change link belongs to another user account. Please log out and try again."
end
change_request_params =
if type == :old if type == :old
@change_request = { old_email_token_id: token.id, change_state: EmailChangeRequest.states[:authorizing_old] }
token.user&.email_change_requests&.where(old_email_token_id: token.id)&.first
elsif type == :new elsif type == :new
@change_request = { new_email_token_id: token.id, change_state: EmailChangeRequest.states[:authorizing_new] }
token.user&.email_change_requests&.where(new_email_token_id: token.id)&.first
end end
end
@user = token&.user token.user&.email_change_requests&.find_by!(**change_request_params)
@error = I18n.t("change_email.already_done") if (!@user || !@change_request)
if current_user && current_user.id != @user&.id
@error = I18n.t "change_email.wrong_account_error"
end
end
def require_login
redirect_to_login if !current_user
end end
end end

View File

@ -1,89 +0,0 @@
<div id="simple-container">
<% if @done %>
<h2>
<%= t 'change_email.confirmed' %>
</h2>
<p>
<a class="btn btn-primary" href="<%= path "/" %>"><%= t('change_email.please_continue', site_name: SiteSetting.title) %></a>
</p>
<% elsif @error %>
<div class='alert alert-error'>
<%= @error %>
</div>
<% else %>
<h2><%= t 'change_email.authorizing_new.title' %></h2>
<p>
<% if @change_request&.old_email %>
<%= t 'change_email.authorizing_new.description' %>
<% else %>
<%= t 'change_email.authorizing_new.description_add' %>
<% end %>
</p>
<p>
<%= @to_email %>
</p>
<%=form_tag(u_confirm_new_email_path, method: :put) do %>
<%= hidden_field_tag 'token', params[:token] %>
<%= hidden_field_tag 'second_factor_token', nil, id: 'security-key-credential' %>
<div id="security-key-error"></div>
<% if @show_invalid_second_factor_error %>
<div class='alert alert-error'><%= @invalid_second_factor_message %></div>
<% end %>
<% if @show_backup_codes %>
<div id="backup-second-factor-form" style="">
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:backup_code] %>
<h3><%= t('login.second_factor_backup_title') %></h3>
<%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %>
<div><%= render 'common/second_factor_backup_input' %></div>
<%= submit_tag(t("submit"), class: "btn btn-primary") %>
</div>
<br/>
<%= link_to t("login.second_factor_toggle.totp"), show_backup: "false" %>
<br/>
<% elsif @show_security_key %>
<%= hidden_field_tag 'security_key_challenge', @security_key_challenge, id: 'security-key-challenge' %>
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:security_key] %>
<%= hidden_field_tag 'security_key_allowed_credential_ids', @security_key_allowed_credential_ids.join(","), 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', class: 'btn btn-primary' %>
</div>
<br/>
<% if @show_second_factor %>
<%= link_to t("login.security_key_alternative"), show_totp: "true" %>
<% end %><br/>
<% if @backup_codes_enabled %>
<%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %>
<% end %>
<% elsif @show_second_factor %>
<div id="primary-second-factor-form">
<%= hidden_field_tag 'second_factor_method', UserSecondFactor.methods[:totp] %>
<h3><%= t('login.second_factor_title') %></h3>
<%= label_tag(:second_factor_token, t('login.second_factor_description')) %>
<div><%= render 'common/second_factor_text_field' %></div>
<%= submit_tag t('submit'), class: "btn btn-primary" %>
</div>
<br/>
<% if @backup_codes_enabled %>
<%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %>
<% end %>
<% else %>
<%= submit_tag t('change_email.confirm'), class: "btn btn-primary" %>
<% end %>
<%end%>
<% end%>
<%= preload_script "vendor" %>
<%= preload_script "locales/#{I18n.locale}" %>
<%- if ExtraLocalesController.client_overrides_exist? %>
<%= preload_script_url ExtraLocalesController.url('overrides') %>
<%- end %>
<% # TODO: move all this logic into the ember app %>
<%= preload_script "discourse/app/lib/webauthn" %>
<%= preload_script "confirm-new-email/confirm-new-email" %>
<%= preload_script "confirm-new-email/bootstrap" %>
</div>

View File

@ -1,34 +0,0 @@
<div id="simple-container">
<% if @almost_done %>
<h2><%= t 'change_email.authorizing_old.almost_done_title' %></h2>
<p>
<%= t 'change_email.authorizing_old.almost_done_description' %>
</p>
<% elsif @error %>
<div class='alert alert-error'>
<%= @error %>
</div>
<% else %>
<h2><%= t 'change_email.authorizing_old.title' %></h2>
<p>
<% if @change_request&.old_email %>
<%= t 'change_email.authorizing_old.description' %>
<br>
<br>
<%= t 'change_email.authorizing_old.old_email', email: @from_email %>
<br>
<%= t 'change_email.authorizing_old.new_email', email: @to_email %>
<% else %>
<%= t 'change_email.authorizing_old.description_add' %>
<br>
<br>
<%= @to_email %>
<% end %>
</p>
<%=form_tag(u_confirm_old_email_path, method: :put) do %>
<%= hidden_field_tag 'token', params[:token] %>
<%= submit_tag t('change_email.confirm'), class: "btn btn-primary" %>
<% end %>
<% end %>
</div>

View File

@ -27,9 +27,6 @@ Rails.application.config.assets.precompile += %w[
break_string.js break_string.js
service-worker.js service-worker.js
locales/i18n.js locales/i18n.js
discourse/app/lib/webauthn.js
confirm-new-email/confirm-new-email.js
confirm-new-email/bootstrap.js
scripts/discourse-test-listen-boot scripts/discourse-test-listen-boot
] ]

View File

@ -1559,6 +1559,18 @@ en:
success_via_admin: "We've sent an email to that address. The user will need to follow the confirmation instructions in the email." success_via_admin: "We've sent an email to that address. The user will need to follow the confirmation instructions in the email."
success_staff: "We've sent an email to your current address. Please follow the confirmation instructions." success_staff: "We've sent an email to your current address. Please follow the confirmation instructions."
back_to_preferences: "Back to preferences" back_to_preferences: "Back to preferences"
confirm_success: "Your email has been updated."
confirm: "Confirm"
authorizing_new:
description: "Please confirm you would like your email address changed to:"
description_add: "Please confirm you would like to add an alternate email address:"
authorizing_old:
title: "Verify old email address"
description: "Please verify your old email address to continue changing your email:"
description_add: "Please verify your existing email address to continue adding an alternate address:"
old_email: "Old email: %{email}"
new_email: "New email: %{email}"
confirm_success: "We have sent an email to your new email address to confirm the change!"
change_avatar: change_avatar:
title: "Change your profile picture" title: "Change your profile picture"

View File

@ -919,9 +919,6 @@ en:
unknown: "unknown operating system" unknown: "unknown operating system"
change_email: change_email:
wrong_account_error: "You are logged in the wrong account, please log out and try again."
confirmed: "Your email has been updated."
please_continue: "Continue to %{site_name}"
error: "There was an error changing your email address. Perhaps the address is already in use?" error: "There was an error changing your email address. Perhaps the address is already in use?"
doesnt_exist: "That email address is not associated with your account." doesnt_exist: "That email address is not associated with your account."
error_staged: "There was an error changing your email address. The address is already in use by a staged user." error_staged: "There was an error changing your email address. The address is already in use by a staged user."
@ -929,20 +926,6 @@ en:
confirm: "Confirm" confirm: "Confirm"
max_secondary_emails_error: "You have reached the maximum allowed secondary emails limit." max_secondary_emails_error: "You have reached the maximum allowed secondary emails limit."
authorizing_new:
title: "Confirm your new email"
description: "Please confirm you would like your email address changed to:"
description_add: "Please confirm you would like to add an alternate email address:"
authorizing_old:
title: "Change your email address"
description: "Please confirm your email address change"
description_add: "Please confirm you would like to add an alternate email address:"
old_email: "Old email: %{email}"
new_email: "New email: %{email}"
almost_done_title: "Confirming new email address"
almost_done_description: "We have sent an email to your new email address to confirm the change!"
associated_accounts: associated_accounts:
revoke_failed: "Failed to revoke your account with %{provider_name}." revoke_failed: "Failed to revoke your account with %{provider_name}."
connected: "(connected)" connected: "(connected)"

View File

@ -538,10 +538,10 @@ Discourse::Application.routes.draw do
) )
get "#{root_path}/confirm-old-email/:token" => "users_email#show_confirm_old_email" get "#{root_path}/confirm-old-email/:token" => "users_email#show_confirm_old_email"
put "#{root_path}/confirm-old-email" => "users_email#confirm_old_email" put "#{root_path}/confirm-old-email/:token" => "users_email#confirm_old_email"
get "#{root_path}/confirm-new-email/:token" => "users_email#show_confirm_new_email" get "#{root_path}/confirm-new-email/:token" => "users_email#show_confirm_new_email"
put "#{root_path}/confirm-new-email" => "users_email#confirm_new_email" put "#{root_path}/confirm-new-email/:token" => "users_email#confirm_new_email"
get( get(
{ {

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module SecondFactor::Actions
class ConfirmEmail < Base
def no_second_factors_enabled!(params)
# handled in controller
end
def second_factor_auth_required!(params)
{
callback_params: {
token: params[:token],
},
redirect_url:
(
if @current_user
"#{Discourse.base_path}/my/preferences/account"
else
"#{Discourse.base_path}/login"
end
),
}
end
def second_factor_auth_completed!(callback_params)
# handled in controller
end
end
end

View File

@ -24,10 +24,9 @@ RSpec.describe UsersEmailController do
it "errors out for invalid tokens" do it "errors out for invalid tokens" do
sign_in(user) sign_in(user)
get "/u/confirm-new-email/invalidtoken" get "/u/confirm-new-email/invalidtoken.json"
expect(response.status).to eq(200) expect(response.status).to eq(404)
expect(response.body).to include(I18n.t("change_email.already_done"))
end end
it "does not change email if accounts mismatch for a signed in user" do it "does not change email if accounts mismatch for a signed in user" do
@ -38,7 +37,8 @@ RSpec.describe UsersEmailController do
sign_in(moderator) sign_in(moderator)
put "/u/confirm-new-email", params: { token: "#{email_token.token}" } put "/u/confirm-new-email/#{email_token.token}.json"
expect(response.status).to eq(404)
expect(user.reload.email).to eq(old_email) expect(user.reload.email).to eq(old_email)
end end
@ -50,212 +50,17 @@ RSpec.describe UsersEmailController do
updater.change_to("bubblegum@adventuretime.ooo") updater.change_to("bubblegum@adventuretime.ooo")
end end
it "includes security_key_allowed_credential_ids in a hidden field" do
key1 = Fabricate(:user_security_key_with_random_credential, user: user)
key2 = Fabricate(:user_security_key_with_random_credential, user: user)
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}"
doc = Nokogiri.HTML5(response.body)
credential_ids = doc.css("#security-key-allowed-credential-ids").first["value"].split(",")
expect(credential_ids).to contain_exactly(key1.credential_id, key2.credential_id)
end
it "confirms with a correct token" do it "confirms with a correct token" do
user.user_stat.update_columns(bounce_score: 42, reset_bounce_score_after: 1.week.from_now) user.user_stat.update_columns(bounce_score: 42, reset_bounce_score_after: 1.week.from_now)
put "/u/confirm-new-email", params: { token: "#{updater.change_req.new_email_token.token}" } put "/u/confirm-new-email/#{updater.change_req.new_email_token.token}.json"
expect(response.status).to eq(302) expect(response.status).to eq(200)
expect(response.redirect_url).to include("done")
user.reload user.reload
expect(user.user_stat.bounce_score).to eq(0) expect(user.user_stat.bounce_score).to eq(0)
expect(user.user_stat.reset_bounce_score_after).to eq(nil) expect(user.user_stat.reset_bounce_score_after).to eq(nil)
expect(user.email).to eq("bubblegum@adventuretime.ooo") expect(user.email).to eq("bubblegum@adventuretime.ooo")
end end
context "when second factor is required" do
fab!(:second_factor) { Fabricate(:user_second_factor_totp, user: user) }
fab!(:backup_code) { Fabricate(:user_second_factor_backup, user: user) }
it "requires a second factor token" do
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}"
expect(response.status).to eq(200)
expect(response.body).to include(I18n.t("login.second_factor_title"))
expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code"))
end
it "requires a backup token" do
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}?show_backup=true"
expect(response.status).to eq(200)
expect(response.body).to include(I18n.t("login.second_factor_backup_title"))
end
it "adds an error on a second factor attempt" do
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.token,
second_factor_token: "000000",
second_factor_method: UserSecondFactor.methods[:totp],
}
expect(response.status).to eq(302)
expect(flash[:invalid_second_factor]).to eq(true)
end
it "confirms with a correct second token" do
put "/u/confirm-new-email",
params: {
second_factor_token: ROTP::TOTP.new(second_factor.data).now,
second_factor_method: UserSecondFactor.methods[:totp],
token: updater.change_req.new_email_token.token,
}
expect(response.status).to eq(302)
expect(user.reload.email).to eq("bubblegum@adventuretime.ooo")
end
context "with rate limiting" do
before { RateLimiter.enable }
use_redis_snapshotting
it "rate limits by IP" do
freeze_time
6.times do
put "/u/confirm-new-email",
params: {
token: "blah",
second_factor_token: "000000",
second_factor_method: UserSecondFactor.methods[:totp],
}
expect(response.status).to eq(302)
end
put "/u/confirm-new-email",
params: {
token: "blah",
second_factor_token: "000000",
second_factor_method: UserSecondFactor.methods[:totp],
}
expect(response.status).to eq(429)
end
it "rate limits by username" do
freeze_time
6.times do |x|
user.email_change_requests.last.update(
change_state: EmailChangeRequest.states[:complete],
)
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.token,
second_factor_token: "000000",
second_factor_method: UserSecondFactor.methods[:totp],
},
env: {
REMOTE_ADDR: "1.2.3.#{x}",
}
expect(response.status).to eq(302)
end
user.email_change_requests.last.update(
change_state: EmailChangeRequest.states[:authorizing_new],
)
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.token,
second_factor_token: "000000",
second_factor_method: UserSecondFactor.methods[:totp],
},
env: {
REMOTE_ADDR: "1.2.3.4",
}
expect(response.status).to eq(429)
end
end
end
context "when security key is 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 { simulate_localhost_webauthn_challenge }
it "requires a security key" do
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}"
expect(response.status).to eq(200)
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 { Fabricate(:user_second_factor_totp, user: user) }
it "allows entering the totp code instead" do
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}?show_totp=true"
expect(response.status).to eq(200)
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/#{updater.change_req.new_email_token.token}"
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.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/#{updater.change_req.new_email_token.token}"
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.token,
second_factor_token: valid_security_key_auth_post_data.to_json,
second_factor_method: UserSecondFactor.methods[:security_key],
}
expect(response.status).to eq(302)
expect(user.reload.email).to eq("bubblegum@adventuretime.ooo")
end
context "if the security key data JSON is garbled" do
it "raises an invalid parameters error" do
get "/u/confirm-new-email/#{updater.change_req.new_email_token.token}"
put "/u/confirm-new-email",
params: {
token: updater.change_req.new_email_token.token,
second_factor_token: "{someweird: 8notjson}",
second_factor_method: UserSecondFactor.methods[:security_key],
}
expect(response.status).to eq(400)
end
end
end
end end
it "destroys email tokens associated with the old email after the new email is confirmed" do it "destroys email tokens associated with the old email after the new email is confirmed" do
@ -268,7 +73,8 @@ RSpec.describe UsersEmailController do
updater.change_to("bubblegum@adventuretime.ooo") updater.change_to("bubblegum@adventuretime.ooo")
sign_in(user) sign_in(user)
put "/u/confirm-new-email", params: { token: "#{updater.change_req.new_email_token.token}" } put "/u/confirm-new-email/#{updater.change_req.new_email_token.token}.json"
expect(response.status).to eq(200)
new_password = SecureRandom.hex new_password = SecureRandom.hex
put "/u/password-reset/#{email_token.token}.json", params: { password: new_password } put "/u/password-reset/#{email_token.token}.json", params: { password: new_password }
@ -281,20 +87,12 @@ RSpec.describe UsersEmailController do
end end
describe "#confirm-old-email" do describe "#confirm-old-email" do
it "redirects to login for signed out accounts" do
get "/u/confirm-old-email/invalidtoken"
expect(response.status).to eq(302)
expect(response.redirect_url).to eq("http://test.localhost/login")
end
it "errors out for invalid tokens" do it "errors out for invalid tokens" do
sign_in(user) sign_in(user)
get "/u/confirm-old-email/invalidtoken" get "/u/confirm-old-email/invalidtoken.json"
expect(response.status).to eq(200) expect(response.status).to eq(404)
expect(response.body).to include(I18n.t("change_email.already_done"))
end end
it "bans change when accounts do not match" do it "bans change when accounts do not match" do
@ -302,10 +100,9 @@ RSpec.describe UsersEmailController do
updater = EmailUpdater.new(guardian: moderator.guardian, user: moderator) updater = EmailUpdater.new(guardian: moderator.guardian, user: moderator)
email_change_request = updater.change_to("bubblegum@adventuretime.ooo") email_change_request = updater.change_to("bubblegum@adventuretime.ooo")
get "/u/confirm-old-email/#{email_change_request.old_email_token.token}" get "/u/confirm-old-email/#{email_change_request.old_email_token.token}.json"
expect(response.status).to eq(200) expect(response.status).to eq(403)
expect(body).to include("alert-error")
end end
context "with valid old token" do context "with valid old token" do
@ -314,17 +111,15 @@ RSpec.describe UsersEmailController do
updater = EmailUpdater.new(guardian: moderator.guardian, user: moderator) updater = EmailUpdater.new(guardian: moderator.guardian, user: moderator)
email_change_request = updater.change_to("bubblegum@adventuretime.ooo") email_change_request = updater.change_to("bubblegum@adventuretime.ooo")
get "/u/confirm-old-email/#{email_change_request.old_email_token.token}" get "/u/confirm-old-email/#{email_change_request.old_email_token.token}.json"
expect(response.status).to eq(200) expect(response.status).to eq(200)
body = CGI.unescapeHTML(response.body) expect(response.parsed_body["old_email"]).to eq(moderator.email)
expect(body).to include(I18n.t("change_email.authorizing_old.title")) expect(response.parsed_body["new_email"]).to eq("bubblegum@adventuretime.ooo")
expect(body).to include(I18n.t("change_email.authorizing_old.description"))
put "/u/confirm-old-email", params: { token: email_change_request.old_email_token.token } put "/u/confirm-old-email/#{email_change_request.old_email_token.token}.json"
expect(response.status).to eq(302) expect(response.status).to eq(200)
expect(response.redirect_url).to include("done=true")
end end
end end
end end

View File

@ -0,0 +1,182 @@
# frozen_string_literal: true
describe "Changing email", type: :system do
fab!(:password) { "mysupersecurepassword" }
fab!(:user) { Fabricate(:user, active: true, password: password) }
let(:new_email) { "newemail@example.com" }
let(:user_preferences_security_page) { PageObjects::Pages::UserPreferencesSecurity.new }
before { Jobs.run_immediately! }
def generate_confirm_link
visit "/my/preferences/account"
email_dropdown = PageObjects::Components::SelectKit.new(".email-dropdown")
expect(email_dropdown.visible?).to eq(true)
email_dropdown.select_row_by_value("updateEmail")
find("#change-email").fill_in with: "newemail@example.com"
find(".save-button button").click
wait_for(timeout: 5) { ActionMailer::Base.deliveries.count === 1 }
if user.admin?
get_link_from_email(:old)
else
get_link_from_email(:new)
end
end
def get_link_from_email(type)
mail = ActionMailer::Base.deliveries.last
expect(mail.to).to contain_exactly(type == :new ? new_email : user.email)
mail.body.to_s[%r{/u/confirm-#{type}-email/\S+}, 0]
end
it "allows regular user to change their email" do
sign_in user
visit generate_confirm_link
find(".confirm-new-email .btn-primary").click
expect(page).to have_css(".dialog-body", text: I18n.t("js.user.change_email.confirm_success"))
find(".dialog-footer .btn-primary").click
expect(user.reload.email).to eq(new_email)
end
it "works when user has totp 2fa" do
second_factor = Fabricate(:user_second_factor_totp, user: user)
sign_in user
visit generate_confirm_link
find(".confirm-new-email .btn-primary").click
find(".second-factor-token-input").fill_in with: second_factor.totp_object.now
find("button[type=submit]").click
try_until_success { expect(current_url).to match("/u/#{user.username}/preferences/account") }
expect(user.reload.email).to eq(new_email)
end
it "works when user has webauthn 2fa" do
sign_in user
DiscourseWebauthn.stubs(:origin).returns(current_host + ":" + Capybara.server_port.to_s)
options =
::Selenium::WebDriver::VirtualAuthenticatorOptions.new(
user_verification: true,
user_verified: true,
resident_key: true,
)
authenticator = page.driver.browser.add_virtual_authenticator(options)
user_preferences_security_page.visit(user)
user_preferences_security_page.visit_second_factor(password)
find(".security-key .new-security-key").click
expect(user_preferences_security_page).to have_css("input#security-key-name")
find(".d-modal__body input#security-key-name").fill_in(with: "First Key")
find(".add-security-key").click
expect(user_preferences_security_page).to have_css(".security-key .second-factor-item")
visit generate_confirm_link
find(".confirm-new-email .btn-primary").click
find("#security-key-authenticate-button").click
try_until_success { expect(current_url).to match("/u/#{user.username}/preferences/account") }
expect(user.reload.email).to eq(new_email)
end
it "does not require login to verify" do
second_factor = Fabricate(:user_second_factor_totp, user: user)
sign_in user
confirm_link = generate_confirm_link
Capybara.reset_sessions! # log out
visit confirm_link
find(".confirm-new-email .btn-primary").click
find(".second-factor-token-input").fill_in with: second_factor.totp_object.now
find("button[type=submit]").click
try_until_success { expect(current_url).to match("/latest") }
expect(user.reload.email).to eq(new_email)
end
it "makes admins verify old email" do
user.update!(admin: true)
sign_in user
confirm_old_link = generate_confirm_link
# Confirm old email
visit confirm_old_link
find(".confirm-old-email .btn-primary").click
expect(page).to have_css(
".dialog-body",
text: I18n.t("js.user.change_email.authorizing_old.confirm_success"),
)
find(".dialog-footer .btn-primary").click
# Confirm new email
wait_for(timeout: 5) { ActionMailer::Base.deliveries.count === 2 }
confirm_new_link = get_link_from_email(:new)
visit confirm_new_link
find(".confirm-new-email .btn-primary").click
expect(page).to have_css(".dialog-body", text: I18n.t("js.user.change_email.confirm_success"))
find(".dialog-footer .btn-primary").click
expect(user.reload.email).to eq(new_email)
end
it "allows admin to verify old email while logged out" do
user.update!(admin: true)
sign_in user
confirm_old_link = generate_confirm_link
Capybara.reset_sessions! # log out
# Confirm old email
visit confirm_old_link
find(".confirm-old-email .btn-primary").click
expect(page).to have_css(
".dialog-body",
text: I18n.t("js.user.change_email.authorizing_old.confirm_success"),
)
find(".dialog-footer .btn-primary").click
# Confirm new email
wait_for(timeout: 5) { ActionMailer::Base.deliveries.count === 2 }
confirm_new_link = get_link_from_email(:new)
visit confirm_new_link
find(".confirm-new-email .btn-primary").click
expect(page).to have_css(".dialog-body", text: I18n.t("js.user.change_email.confirm_success"))
find(".dialog-footer .btn-primary").click
expect(user.reload.email).to eq(new_email)
end
end