New email new me! (#151)

This commit is contained in:
nora 2026-01-01 17:47:36 -05:00 committed by GitHub
parent 34b447d4d0
commit 94858d563b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1605 additions and 9 deletions

View file

@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base
include PublicActivity::StoreController
include IsSneaky
include SessionsHelper
include StepUpAuthenticatable
helper_method :current_identity, :identity_signed_in?, :current_onboarding_step, :current_user
@ -18,6 +19,8 @@ class ApplicationController < ActionController::Base
def user_for_paper_trail = current_identity&.id
def info_for_paper_trail = { extra_data: { ip: request.remote_ip, user_agent: request.user_agent }.compact_blank }
def identity_signed_in? = !!current_identity

View file

@ -0,0 +1,25 @@
module StepUpAuthenticatable
extend ActiveSupport::Concern
included do
helper_method :step_up_required?
end
private
def require_step_up(action_type, return_to: nil)
return unless current_identity.has_two_factor_method?
return if current_session.recently_stepped_up?(for_action: action_type)
redirect_to new_step_up_path(action_type: action_type, return_to: return_to || request.fullpath)
false
end
def step_up_required?(action_type = nil)
current_identity.has_two_factor_method? && !current_session.recently_stepped_up?(for_action: action_type)
end
def consume_step_up!
current_session.clear_step_up!
end
end

View file

@ -0,0 +1,155 @@
class EmailChangesController < ApplicationController
skip_before_action :authenticate_identity!, only: [ :verify_old, :verify_new, :confirm_verify_old, :confirm_verify_new ]
before_action :set_email_change_request, only: [ :show, :cancel_confirmation, :cancel ]
before_action :require_step_up_for_email_change, only: [ :new, :create ]
before_action :require_email_change_feature_enabled, except: [ :verify_old, :verify_new, :confirm_verify_old, :confirm_verify_new ]
before_action :require_email_change_feature_enabled_for_verification, only: [ :verify_old, :verify_new, :confirm_verify_old, :confirm_verify_new ]
def new
pending_request = current_identity.email_change_requests.pending.first
if pending_request
redirect_to email_change_path(pending_request), notice: t(".pending_redirect")
return
end
@email_change_request = Identity::EmailChangeRequest.new
end
def show
end
def create
new_email = email_change_params[:new_email]&.downcase&.strip
if new_email.blank?
flash[:error] = t(".email_required")
return redirect_to new_email_change_path
end
existing_pending = current_identity.email_change_requests.pending.first
if existing_pending
existing_pending.cancel!
end
@email_change_request = current_identity.email_change_requests.build(
new_email: new_email,
old_email: current_identity.primary_email,
requested_from_ip: request.remote_ip
)
if @email_change_request.save
@email_change_request.send_verification_emails!
consume_step_up!
flash[:success] = t(".success")
redirect_to email_change_path(@email_change_request)
else
flash[:error] = @email_change_request.errors.full_messages.to_sentence
redirect_to new_email_change_path
end
end
def verify_old
@email_change_request = Identity::EmailChangeRequest.pending.find_by!(old_email_token: params[:token])
@token = params[:token]
rescue ActiveRecord::RecordNotFound
flash[:error] = t(".invalid_or_expired")
redirect_to root_path
end
def confirm_verify_old
@email_change_request = Identity::EmailChangeRequest.pending.find_by!(old_email_token: params[:token])
if @email_change_request.verify_old_email!(params[:token], verified_from_ip: request.remote_ip)
flash[:success] = t("email_changes.verify_old.success")
if @email_change_request.completed?
flash[:success] = t("email_changes.verify_old.email_changed")
end
else
flash[:error] = t("email_changes.verify_old.invalid_or_expired")
end
if identity_signed_in?
redirect_to email_change_path(@email_change_request)
else
redirect_to login_path
end
rescue ActiveRecord::RecordNotFound
flash[:error] = t("email_changes.verify_old.invalid_or_expired")
redirect_to root_path
end
def verify_new
@email_change_request = Identity::EmailChangeRequest.pending.find_by!(new_email_token: params[:token])
@token = params[:token]
rescue ActiveRecord::RecordNotFound
flash[:error] = t(".invalid_or_expired")
redirect_to root_path
end
def confirm_verify_new
@email_change_request = Identity::EmailChangeRequest.pending.find_by!(new_email_token: params[:token])
if @email_change_request.verify_new_email!(params[:token], verified_from_ip: request.remote_ip)
flash[:success] = t("email_changes.verify_new.success")
if @email_change_request.completed?
flash[:success] = t("email_changes.verify_new.email_changed")
end
else
flash[:error] = t("email_changes.verify_new.invalid_or_expired")
end
if identity_signed_in?
redirect_to email_change_path(@email_change_request)
else
redirect_to login_path
end
rescue ActiveRecord::RecordNotFound
flash[:error] = t("email_changes.verify_new.invalid_or_expired")
redirect_to root_path
end
def cancel_confirmation
end
def cancel
if @email_change_request.cancel!
flash[:success] = t(".success")
else
flash[:error] = t(".already_completed")
end
redirect_to edit_identity_path
end
private
def set_email_change_request
@email_change_request = current_identity.email_change_requests.find_by_public_id!(params[:id])
end
def email_change_params
params.require(:email_change).permit(:new_email)
end
def require_step_up_for_email_change
require_step_up("email_change", return_to: new_email_change_path)
end
def require_email_change_feature_enabled
unless Flipper.enabled?(:email_change, current_identity)
redirect_to edit_identity_path, alert: t("errors.feature_not_available")
end
end
def require_email_change_feature_enabled_for_verification
token = params[:token]
email_change_request = Identity::EmailChangeRequest.pending.find_by(old_email_token: token) ||
Identity::EmailChangeRequest.pending.find_by(new_email_token: token)
if email_change_request && !Flipper.enabled?(:email_change, email_change_request.identity)
flash[:error] = t("errors.feature_not_available")
redirect_to root_path
end
end
end

View file

@ -1,13 +1,21 @@
class StepUpController < ApplicationController
helper_method :step_up_cancel_path
def new
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth"
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
@return_to = params[:return_to]
@available_methods = current_identity.available_step_up_methods
@available_methods << :email # Email is always available as fallback
@available_methods << :email unless @action == "email_change" # Email fallback not available for email change (already verifying old email)
@code_sent = params[:code_sent].present?
end
def send_email_code
if params[:action_type] == "email_change"
flash[:error] = "Email verification is not available for this action"
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
return
end
send_step_up_email_code
flash[:notice] = "A verification code has been sent to your email"
redirect_to new_step_up_path(
@ -29,6 +37,12 @@ class StepUpController < ApplicationController
return
end
if action_type == "email_change" && method == :email
flash[:error] = "Email verification is not available for this action"
redirect_to new_step_up_path(action_type: action_type, return_to: params[:return_to])
return
end
# Verify based on the method they chose
verified = case method
when :totp
@ -62,8 +76,8 @@ class StepUpController < ApplicationController
return
end
# Mark step-up as completed on the identity session
current_session.update!(last_step_up_at: Time.current)
# Mark step-up as completed on the identity session, bound to the specific action
current_session.record_step_up!(action: action_type)
# Execute the verified action
case action_type
@ -77,11 +91,13 @@ class StepUpController < ApplicationController
current_identity.backup_codes.active.each(&:mark_discarded!)
end
consume_step_up!
redirect_to security_path, notice: "Two-factor authentication disabled"
when "disable_2fa"
current_identity.update!(use_two_factor_authentication: false)
TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later
consume_step_up!
redirect_to security_path, notice: "2FA requirement disabled"
when "oidc_reauth"
@ -89,12 +105,23 @@ class StepUpController < ApplicationController
safe_path = safe_internal_redirect(params[:return_to])
redirect_to safe_path || root_path
when "email_change"
# Email change step-up completed, redirect to the email change form
safe_path = safe_internal_redirect(params[:return_to])
redirect_to safe_path || new_email_change_path
else
redirect_to security_path, alert: "Unknown action"
end
end
def resend_email
if params[:action_type] == "email_change"
flash[:error] = "Email verification is not available for this action"
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
return
end
send_step_up_email_code
flash[:notice] = "A new code has been sent to your email"
redirect_to new_step_up_path(
@ -112,6 +139,15 @@ class StepUpController < ApplicationController
IdentityMailer.v2_login_code(login_code).deliver_later
end
def step_up_cancel_path(action_type)
case action_type
when "email_change"
edit_identity_path
else
security_path
end
end
# Prevent open redirect attacks - only allow internal paths
def safe_internal_redirect(return_to)
return nil if return_to.blank?

View file

@ -71,6 +71,7 @@ h3 { font-size: 1.5rem; }
@import "./snippets/security.scss";
@import "./snippets/identities.scss";
@import "./snippets/welcome.scss";
@import "./snippets/email_changes.scss";
input[type="text"],
input[type="email"],

View file

@ -24,6 +24,33 @@ $tool-label-position-left: 3px;
padding-top: $tool-padding-top;
}
@keyframes missing-i18n-text {
0%, 100% {
color: magenta;
}
50% {
color: red;
}
}
@keyframes missing-i18n-border {
0%, 100% {
border-color: red;
}
50% {
border-color: magenta;
}
}
.translation_missing {
@include admin-tool("NO I18N!", $admin-red);
animation: missing-i18n-border 1s infinite, missing-i18n-text 1s infinite;
&::before {
animation: missing-i18n-text 1s infinite;
}
}
.super-admin-tool {
@include admin-tool("super admin", $admin-red);
}

View file

@ -0,0 +1,88 @@
// Email change request pages
.email-change-summary {
margin: $space-5 0;
}
.email-arrow {
display: flex;
align-items: center;
gap: $space-4;
@media (max-width: 500px) {
flex-direction: column;
align-items: stretch;
gap: $space-2;
.arrow {
transform: rotate(90deg);
text-align: center;
}
}
}
.email-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted-strong);
margin-bottom: 0.25rem;
}
.email-item {
.email-value {
font-size: 1rem;
font-weight: 500;
word-break: break-all;
}
&.cancelled .email-value {
text-decoration: line-through;
color: var(--text-muted-strong);
}
}
.email-arrow .arrow {
color: var(--text-muted-strong);
font-size: 1.25rem;
flex-shrink: 0;
}
.email-verification-status {
margin-top: $space-1;
}
.verification-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
&.verified {
background: var(--success-bg);
color: var(--success-fg-strong);
}
&.pending {
background: var(--warning-bg);
color: var(--warning-fg-strong);
}
}
.request-meta {
font-size: 0.85rem;
color: var(--text-muted-strong);
margin-bottom: $space-5;
.separator {
margin: 0 0.5rem;
}
@media (max-width: 500px) {
.separator { display: none; }
span { display: block; margin-bottom: 0.25rem; }
}
}

View file

@ -0,0 +1,49 @@
class EmailChangeMailer < ApplicationMailer
def verify_old_email(email_change_request)
@email_change_request = email_change_request
@identity = email_change_request.identity
@first_name = @identity.first_name
@token = email_change_request.old_email_token
@new_email = email_change_request.new_email
@verify_url = verify_old_email_changes_url(token: @token)
@cancel_url = cancel_email_change_url(email_change_request)
@env_prefix = env_prefix
mail(
to: email_change_request.old_email,
from: ACCOUNT_FROM,
subject: prefixed_subject(t(".subject"))
)
end
def verify_new_email(email_change_request)
@email_change_request = email_change_request
@identity = email_change_request.identity
@first_name = @identity.first_name
@token = email_change_request.new_email_token
@old_email = email_change_request.old_email
@verify_url = verify_new_email_changes_url(token: @token)
@env_prefix = env_prefix
mail(
to: email_change_request.new_email,
from: ACCOUNT_FROM,
subject: prefixed_subject(t(".subject"))
)
end
def email_changed_notification(email_change_request)
@email_change_request = email_change_request
@identity = email_change_request.identity
@first_name = @identity.first_name
@old_email = email_change_request.old_email
@new_email = email_change_request.new_email
@env_prefix = env_prefix
mail(
to: [ email_change_request.old_email, email_change_request.new_email ],
from: ACCOUNT_FROM,
subject: prefixed_subject(t(".subject"))
)
end
end

View file

@ -54,6 +54,7 @@ class Identity < ApplicationRecord
has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy
has_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
has_one :backend_user, class_name: "Backend::User", dependent: :destroy

View file

@ -0,0 +1,216 @@
# == Schema Information
#
# Table name: identity_email_change_requests
#
# id :bigint not null, primary key
# cancelled_at :datetime
# completed_at :datetime
# expires_at :datetime not null
# new_email :string not null
# new_email_token_bidx :string
# new_email_token_ciphertext :text
# new_email_verified_at :datetime
# old_email :string not null
# old_email_token_bidx :string
# old_email_token_ciphertext :text
# old_email_verified_at :datetime
# requested_from_ip :string
# created_at :datetime not null
# updated_at :datetime not null
# identity_id :bigint not null
#
# Indexes
#
# idx_email_change_requests_identity_completed (identity_id,completed_at)
# index_identity_email_change_requests_on_identity_id (identity_id)
# index_identity_email_change_requests_on_new_email_token_bidx (new_email_token_bidx)
# index_identity_email_change_requests_on_old_email_token_bidx (old_email_token_bidx)
#
# Foreign Keys
#
# fk_rails_... (identity_id => identities.id)
#
class Identity::EmailChangeRequest < ApplicationRecord
include PublicIdentifiable
EXPIRATION = 24.hours
set_public_id_prefix "emc"
has_paper_trail
belongs_to :identity
alias_method :to_param, :public_id
has_encrypted :old_email_token
blind_index :old_email_token
has_encrypted :new_email_token
blind_index :new_email_token
validates :new_email, :old_email, :expires_at, presence: true
validate :validate_new_email
validate :new_email_not_taken
validate :new_email_different_from_old
scope :pending, -> { where(completed_at: nil, cancelled_at: nil).where("expires_at > ?", Time.current) }
scope :completed, -> { where.not(completed_at: nil) }
before_validation :normalize_emails
before_validation :set_defaults, on: :create
before_create :generate_tokens
after_create :track_email_change_requested
def pending?
completed_at.nil? && cancelled_at.nil? && !expired?
end
def completed?
completed_at.present?
end
def cancelled?
cancelled_at.present?
end
def expired?
expires_at < Time.current
end
def old_email_verified?
old_email_verified_at.present?
end
def new_email_verified?
new_email_verified_at.present?
end
def both_emails_verified?
old_email_verified? && new_email_verified?
end
def verify_old_email!(token, verified_from_ip: nil)
return false unless pending?
return false unless ActiveSupport::SecurityUtils.secure_compare(old_email_token.to_s, token.to_s)
transaction do
update!(old_email_verified_at: Time.current, old_email_verified_from_ip: verified_from_ip)
identity.create_activity :email_change_verified_old,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
complete_if_ready!
end
true
end
def verify_new_email!(token, verified_from_ip: nil)
return false unless pending?
return false unless ActiveSupport::SecurityUtils.secure_compare(new_email_token.to_s, token.to_s)
transaction do
update!(new_email_verified_at: Time.current, new_email_verified_from_ip: verified_from_ip)
identity.create_activity :email_change_verified_new,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
complete_if_ready!
end
true
end
def cancel!
return false unless pending?
transaction do
update!(cancelled_at: Time.current)
identity.create_activity :email_change_cancelled,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
true
end
def complete_if_ready!
with_lock do
return unless both_emails_verified?
return unless pending?
return if completed?
identity.update!(primary_email: new_email)
update!(completed_at: Time.current)
identity.create_activity :email_changed,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
EmailChangeMailer.email_changed_notification(self).deliver_later
end
def send_verification_emails!
EmailChangeMailer.verify_old_email(self).deliver_later
EmailChangeMailer.verify_new_email(self).deliver_later
end
private
def normalize_emails
self.new_email = new_email.to_s.strip.downcase.presence
self.old_email = old_email.to_s.strip.downcase.presence
end
def set_defaults
self.expires_at ||= EXPIRATION.from_now
self.old_email ||= identity&.primary_email
end
def generate_tokens
self.old_email_token ||= SecureRandom.urlsafe_base64(32)
self.new_email_token ||= SecureRandom.urlsafe_base64(32)
end
def new_email_not_taken
return unless new_email.present?
existing = Identity.where.not(id: identity_id).find_by(primary_email: new_email)
errors.add(:new_email, "is already taken by another account") if existing
end
def new_email_different_from_old
return unless new_email.present? && old_email.present?
if new_email.downcase == old_email.downcase
errors.add(:new_email, "can't be your current email, ya goof!")
end
end
def track_email_change_requested
identity.create_activity :email_change_requested,
owner: identity,
recipient: identity,
parameters: { old_email: old_email, new_email: new_email }
end
def validate_new_email
return unless new_email.present?
address = ValidEmail2::Address.new(new_email)
unless address.valid?
errors.add(:new_email, I18n.t("errors.attributes.new_email.invalid_format", default: "is invalid"))
return
end
if address.disposable?
errors.add(:new_email, I18n.t("errors.attributes.new_email.temporary", default: "cannot be a temporary email"))
return
end
unless address.valid_mx?
errors.add(:new_email, I18n.t("errors.attributes.new_email.no_mx_record", default: "domain does not accept email"))
end
end
end

View file

@ -44,6 +44,25 @@ class IdentitySession < ApplicationRecord
update_column(:last_seen, Time.current)
end
STEP_UP_DURATION = 15.minutes
def recently_stepped_up?(for_action: nil)
return false unless last_step_up_at.present? && last_step_up_at > STEP_UP_DURATION.ago
# If a specific action is required, verify the step-up was for that action
return true if for_action.nil?
last_step_up_action == for_action.to_s
end
def record_step_up!(action:)
update!(last_step_up_at: Time.current, last_step_up_action: action.to_s)
end
def clear_step_up!
update!(last_step_up_at: nil, last_step_up_action: nil)
end
private
def identity_is_unlocked

View file

@ -0,0 +1,16 @@
<p class="greeting"><%= t(".greeting", first_name: @first_name) %></p>
<p class="body-text"><%= t(".body") %></p>
<p class="body-text" style="font-size: 18px; margin: 24px 0;">
<span style="color: #6b7280; text-decoration: line-through;"><%= @old_email %></span>
<span style="color: #9ca3af; margin: 0 8px;">→</span>
<strong><%= @new_email %></strong>
</p>
<p class="secondary-text">
<strong><%= t(".not_you_title") %></strong>
<%= t(".not_you_body_html").html_safe %>
</p>
<p class="signature"><%= simple_format(t(".signature")) %></p>

View file

@ -0,0 +1,11 @@
<%= t(".greeting", first_name: @first_name) %>
<%= t(".body") %>
<%= t(".change_summary") %>
<%= @old_email %> → <%= @new_email %>
<%= t(".not_you_title") %>
<%= t(".not_you_body_text") %>
<%= t(".signature") %>

View file

@ -0,0 +1,21 @@
<p class="greeting"><%= t(".greeting", first_name: @first_name) %></p>
<p class="body-text"><%= t(".body") %></p>
<p class="body-text" style="font-size: 18px; margin: 24px 0;">
<span style="color: #6b7280;"><%= @old_email %></span>
<span style="color: #9ca3af; margin: 0 8px;">→</span>
<strong><%= @email_change_request.new_email %></strong>
</p>
<p class="body-text"><%= t(".instruction") %></p>
<div class="button-container">
<%= link_to t(".verify_button"), @verify_url, class: "button" %>
</div>
<p class="hint left"><%= t(".expiry") %></p>
<p class="secondary-text"><%= t(".not_you") %></p>
<p class="signature"><%= simple_format(t(".signature")) %></p>

View file

@ -0,0 +1,16 @@
<%= t(".greeting", first_name: @first_name) %>
<%= t(".body") %>
<%= t(".change_summary") %>
<%= @old_email %> → <%= @email_change_request.new_email %>
<%= t(".instruction") %>
<%= @verify_url %>
<%= t(".expiry") %>
<%= t(".not_you") %>
<%= t(".signature") %>

View file

@ -0,0 +1,24 @@
<p class="greeting"><%= t(".greeting", first_name: @first_name) %></p>
<p class="body-text"><%= t(".body") %></p>
<p class="body-text" style="font-size: 18px; margin: 24px 0;">
<span style="color: #6b7280;"><%= @identity.primary_email %></span>
<span style="color: #9ca3af; margin: 0 8px;">→</span>
<strong><%= @new_email %></strong>
</p>
<p class="body-text"><%= t(".instruction") %></p>
<div class="button-container">
<%= link_to t(".verify_button"), @verify_url, class: "button" %>
<br><br>
<p class="hint left"><%= t(".expiry") %></p>
</div>
<p class="secondary-text">
<strong><%= t(".not_you_title") %></strong>
<%= t(".not_you_body_html", cancel_url: @cancel_url).html_safe %>
</p>
<p class="signature"><%= simple_format(t(".signature")) %></p>

View file

@ -0,0 +1,18 @@
<%= t(".greeting", first_name: @first_name) %>
<%= t(".body") %>
<%= t(".change_summary") %>
<%= @identity.primary_email %> → <%= @new_email %>
<%= t(".instruction_text") %>
<%= @verify_url %>
<%= t(".expiry") %>
<%= t(".not_you_title") %>
<%= t(".not_you_body_text") %>
<%= @cancel_url %>
<%= t(".signature") %>

View file

@ -0,0 +1,28 @@
<div class="page-header">
<h1><%= t(".title") %></h1>
</div>
<div class="page-sections">
<section class="section-card">
<p><%= t(".explanation") %></p>
<div class="email-change-summary">
<div class="email-arrow">
<div class="email-item">
<div class="email-value"><%= @email_change_request.old_email %></div>
</div>
<span class="arrow">→</span>
<div class="email-item cancelled">
<div class="email-value"><%= @email_change_request.new_email %></div>
</div>
</div>
</div>
<div class="form-actions">
<%= button_to t(".confirm_cancel"), cancel_email_change_path(@email_change_request), method: :post, class: "danger" %>
<%= link_to t(".nevermind"), email_change_path(@email_change_request), class: "secondary", role: "button" %>
</div>
</section>
</div>

View file

@ -0,0 +1,47 @@
<div class="page-header">
<h1><%= t(".title") %></h1>
<p><%= t(".verification_info") %></p>
</div>
<div class="page-sections">
<div class="banner warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<%= t ".slack_warning_html" %>
</div>
<%= form_with url: email_changes_path, method: :post, local: true, scope: :email_change do |f| %>
<section class="section-card">
<table style="width: 100%; border-collapse: collapse; border: none; background: none;">
<tbody>
<tr style="background: none;">
<td style="width: 40%; vertical-align: top; background: none;">
<div class="email-label"><%= t(".current_email") %></div>
</td>
<td style="width: 1%; vertical-align: top; text-align: center; background: none;"></td>
<td style="width: 59%; vertical-align: top; background: none; padding-left: 0;">
<div class="email-label"><%= t(".new_email") %></div>
</td>
</tr>
<tr style="height: 2.5rem; background: none;">
<td style="vertical-align: middle; background: none;">
<div class="email-value"><%= current_identity.primary_email %></div>
</td>
<td style="vertical-align: middle; text-align: center; background: none;">
<div class="arrow">→</div>
</td>
<td style="vertical-align: middle; padding: 0; background: none;">
<%= f.email_field :new_email, required: true, placeholder: "new@electronic.mail", autocomplete: "email", style: "margin: 0; height: 100%;" %>
</td>
</tr>
</tbody>
</table>
<div class="form-actions" style="margin-top: 2rem;">
<%= f.submit t(".submit") %>
<%= link_to t(".cancel"), edit_identity_path, class: "secondary", role: "button" %>
</div>
</section>
<% end %>
</div>

View file

@ -0,0 +1,64 @@
<div class="page-header">
<% if @email_change_request.completed? %>
<h1><%= t(".title_completed") %></h1>
<% elsif @email_change_request.cancelled? %>
<h1><%= t(".title_cancelled") %></h1>
<% elsif @email_change_request.expired? %>
<h1><%= t(".title_expired") %></h1>
<% else %>
<h1><%= t(".title_pending") %></h1>
<% end %>
</div>
<div class="page-sections">
<section class="section-card">
<% if @email_change_request.pending? %>
<p><%= t(".pending_instructions") %></p>
<% elsif @email_change_request.expired? %>
<p><%= t(".expired_instructions") %></p>
<% end %>
<div class="email-change-summary">
<div class="email-arrow">
<div class="email-item">
<div class="email-label"><%= t(".old_email") %></div>
<div class="email-value"><%= @email_change_request.old_email %></div>
<% if @email_change_request.pending? %>
<div class="email-verification-status">
<% if @email_change_request.old_email_verified? %>
<span class="verification-badge verified"><%= t(".verified") %></span>
<% else %>
<span class="verification-badge pending"><%= t(".awaiting_verification") %></span>
<% end %>
</div>
<% end %>
</div>
<span class="arrow">→</span>
<div class="email-item">
<div class="email-label"><%= t(".new_email") %></div>
<div class="email-value"><%= @email_change_request.new_email %></div>
<% if @email_change_request.pending? %>
<div class="email-verification-status">
<% if @email_change_request.new_email_verified? %>
<span class="verification-badge verified"><%= t(".verified") %></span>
<% else %>
<span class="verification-badge pending"><%= t(".awaiting_verification") %></span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<div class="form-actions">
<% if @email_change_request.pending? %>
<%= link_to t(".cancel_request"), cancel_email_change_path(@email_change_request), class: "danger", role: "button" %>
<% elsif @email_change_request.expired? || @email_change_request.cancelled? %>
<%= link_to t(".start_new"), new_email_change_path, role: "button" %>
<% end %>
<%= link_to t(".back"), edit_identity_path, class: "secondary", role: "button" %>
</div>
</section>
</div>

View file

@ -0,0 +1,27 @@
<div class="page-header">
<h1><%= t(".title") %></h1>
</div>
<div class="page-sections">
<section class="section-card">
<p><%= t(".explanation") %></p>
<div class="email-change-summary">
<div class="email-arrow">
<div class="email-item">
<div class="email-value"><%= @email_change_request.old_email %></div>
</div>
<span class="arrow">→</span>
<div class="email-item">
<div class="email-value"><%= @email_change_request.new_email %></div>
</div>
</div>
</div>
<div class="form-actions">
<%= button_to t(".confirm"), confirm_verify_new_email_changes_path(token: @token), method: :post %>
</div>
</section>
</div>

View file

@ -0,0 +1,27 @@
<div class="page-header">
<h1><%= t(".title") %></h1>
</div>
<div class="page-sections">
<section class="section-card">
<p><%= t(".explanation") %></p>
<div class="email-change-summary">
<div class="email-arrow">
<div class="email-item">
<div class="email-value"><%= @email_change_request.old_email %></div>
</div>
<span class="arrow">→</span>
<div class="email-item">
<div class="email-value"><%= @email_change_request.new_email %></div>
</div>
</div>
</div>
<div class="form-actions">
<%= button_to t(".confirm"), confirm_verify_old_email_changes_path(token: @token), method: :post %>
</div>
</section>
</div>

View file

@ -50,7 +50,11 @@
<section class="section-card">
<h3><%= t "identities.email" %></h3>
<%= text_field_tag :primary_email, @identity.primary_email, readonly: true, autocomplete: "off" %>
<small class="usn"><%= t ".no_email_change_yet" %></small>
<% if Flipper.enabled?(:email_change, current_identity) %>
<div class="form-actions" style="margin-top: 0.5rem;">
<%= link_to t(".change_email"), new_email_change_path, class: "button secondary" %>
</div>
<% end %>
</section>
<%= form_with model: @identity, url: identity_path, method: :patch, local: true do |f| %>

View file

@ -86,6 +86,10 @@
margin: 0 0 28px;
}
.hint.left {
text-align: left;
}
.code-block {
background-color: #f0f0f0;
border-radius: 10px;

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
requested email change from <%= activity.parameters[:old_email] %> to <%= activity.parameters[:new_email] %>.
<% end %>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
verified new email (<%= activity.parameters[:new_email] %>) for email change.
<% end %>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
verified old email (<%= activity.parameters[:old_email] %>) for email change.
<% end %>

View file

@ -0,0 +1,3 @@
<%= render Components::PublicActivity::Snippet.new(activity) do %>
changed email from <%= activity.parameters[:old_email] %> to <%= activity.parameters[:new_email] %>.
<% end %>

View file

@ -76,7 +76,7 @@
</style>
<footer>
<p><%= link_to t(".cancel"), security_path %></p>
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
</footer>
<% else %>

View file

@ -35,6 +35,18 @@ class Rack::Attack
end
end
throttle("email_change/ip", limit: 3, period: 1.hour) do |req|
if req.path == "/email_changes" && req.post?
req.ip
end
end
throttle("email_change_verify/ip", limit: 10, period: 5.minutes) do |req|
if req.path.match?(%r{^/email_changes/verify/(old|new)$}) && %w[GET POST].include?(req.request_method)
req.ip
end
end
self.throttled_responder = lambda do |env|
headers = {
"Content-Type" => "text/html",

View file

@ -50,6 +50,36 @@ en:
subject: "[Hack Club] Two-factor authentication requirement removed"
greeting: "Hey %{first_name},"
body: "The two-factor authentication requirement has been removed from your Hack Club account. Your authentication methods are still available if you want to re-enable this requirement later."
email_change_mailer:
verify_old_email:
subject: "[Hack Club] Confirm your email change request"
greeting: "Hey %{first_name},"
body: "We received a request to change the email address on your Hack Club account:"
instruction: "To approve this change, click the button below."
instruction_text: "To approve this change, use the link below."
verify_button: "Yes, change my email"
expiry: "This link expires in 24 hours."
not_you_title: "Didn't request this?"
not_you_body_html: "Someone may have access to your account.<br/> <a href='%{cancel_url}'>Cancel this request</a>, review your sessions, and contact <a href='mailto:auth@hackclub.com'>auth@hackclub.com</a>."
not_you_body_text: "Someone may have access to your account. Cancel this request using the link below, review your sessions, and contact auth@hackclub.com."
signature: " Hack Club"
verify_new_email:
subject: "[Hack Club] Verify your new email address"
greeting: "Hey %{first_name},"
body: "You're almost done changing your email address:"
instruction: "Click the button below to verify you have access to this email."
verify_button: "Verify this email"
expiry: "This link expires in 24 hours."
not_you: "Didn't request this? You can safely ignore this email."
signature: " Hack Club"
email_changed_notification:
subject: "[Hack Club] Your email address has been changed"
greeting: "Hey %{first_name},"
body: "Your Hack Club account email was successfully changed:"
not_you_title: "Didn't make this change?"
not_you_body_html: "Your account may be compromised.<br/> Contact us at <a href='mailto:auth@hackclub.com'>auth@hackclub.com</a> and review your sessions."
not_you_body_text: "Your account may be compromised.\n Contact us at auth@hackclub.com and review your sessions."
signature: " Hack Club"
verification_mailer:
approved:
subject: "[Hack Club] Your identity verification has been approved!"
@ -78,6 +108,7 @@ en:
body: "We got your documents and they're in the queue for review. We'll let you know as soon as we've had a look!"
signature: "Best,\nthe Hack Club team"
errors:
feature_not_available: "This feature is not currently available."
attributes:
primary_email:
temporary: "seems to be a disposable email address. Please use a permanent email address to create your account (or if this is a false positive email nora@hackclub.com!)"
@ -117,6 +148,7 @@ en:
developer: Developer
docs: Docs
logout: Sign out
backend: Admin
developer_apps:
index:
title: Developers' corner
@ -281,7 +313,7 @@ en:
developer_mode: Developer mode
developer_mode_description: Wanna OAuth some OAuth? Check this box.
save: Save changes
no_email_change_yet: Changing your email will be available in a future release.
change_email: Change email
super_secret_settings: "Super secret settings"
saml_debug: "debug your SAML assertions?"
new:
@ -537,6 +569,56 @@ en:
title: Get verified for YSWS
subtitle: Verify your age and identity to participate in programs and compete for prizes
start_verification: Start Verification
email_changes:
new:
title: Change your email
current_email: Current email
new_email: New email
new_email_label: New email address
submit: Request email change
cancel: Cancel
verification_info: We'll send verification emails to both your current email and your new email. You must confirm both to complete the change.
slack_warning_html: Changing your email may affect signing into Slack. Be forewarned.
pending_redirect: You already have a pending email change request.
show:
title_pending: Check your email
title_completed: Email changed!
title_cancelled: Request cancelled
title_expired: Request expired
old_email: From
new_email: To
verified: Verified
awaiting_verification: Waiting...
pending_instructions: We sent verification links to both addresses. Click the link in each email to confirm the change.
expired_instructions: This request has expired. You can start a new one if you still want to change your email.
cancel_request: Cancel this request
start_new: Start a new request
back: Back to profile
create:
email_required: Please enter a new email address.
success: Email change request created. Check both email addresses for verification links.
verify_old:
title: Confirm email change
explanation: You're approving a change to your account's email address. Click the button below to confirm.
confirm: Approve email change
success: Current email verified!
email_changed: Your email has been changed successfully!
invalid_or_expired: This verification link is invalid or has expired.
verify_new:
title: Verify your new email
explanation: Click the button below to verify you have access to this email address.
confirm: Verify email
success: New email verified!
email_changed: Your email has been changed successfully!
invalid_or_expired: This verification link is invalid or has expired.
cancel_confirmation:
title: Cancel email change?
explanation: This will stop the pending email change. You can always start a new request later.
confirm_cancel: Yes, cancel it
nevermind: Nevermind
cancel:
success: Email change request cancelled.
already_completed: This request has already been completed or cancelled.
auth:
sign_in_or_create: "Sign in or create a Hack Club Auth account to continue"
portal:

View file

@ -254,7 +254,20 @@ Rails.application.routes.draw do
resource :identity, only: [ :edit, :update ] do
collection do
post :toggle_2fa
get :confirm_disable_2fa
get :confirm_disable_2fa
end
end
resources :email_changes, only: [ :new, :create, :show ] do
member do
get :cancel, action: :cancel_confirmation
post :cancel
end
collection do
get "verify/old", to: "email_changes#verify_old", as: :verify_old
post "verify/old", to: "email_changes#confirm_verify_old", as: :confirm_verify_old
get "verify/new", to: "email_changes#verify_new", as: :verify_new
post "verify/new", to: "email_changes#confirm_verify_new", as: :confirm_verify_new
end
end

View file

@ -0,0 +1,16 @@
class AddEmailUniquenessIndexes < ActiveRecord::Migration[8.0]
def change
# Ensure primary_email uniqueness at DB level (case-insensitive, excluding soft-deleted)
# Rails validation alone has TOCTTOU race conditions
add_index :identities,
"LOWER(primary_email)",
unique: true,
where: "deleted_at IS NULL",
name: "idx_identities_unique_primary_email"
# Note: Partial unique index for pending email change requests is not possible
# because PostgreSQL requires immutable functions in index predicates.
# The expires_at > NOW() condition uses a non-immutable function.
# We handle this at the application layer by cancelling existing pending requests.
end
end

View file

@ -0,0 +1,25 @@
class CreateIdentityEmailChangeRequests < ActiveRecord::Migration[8.0]
def change
create_table :identity_email_change_requests do |t|
t.references :identity, null: false, foreign_key: true
t.string :new_email, null: false
t.string :old_email, null: false
t.datetime :old_email_verified_at
t.datetime :new_email_verified_at
t.text :old_email_token_ciphertext
t.string :old_email_token_bidx
t.text :new_email_token_ciphertext
t.string :new_email_token_bidx
t.datetime :completed_at
t.datetime :expires_at, null: false
t.datetime :cancelled_at
t.string :requested_from_ip
t.timestamps
end
add_index :identity_email_change_requests, :old_email_token_bidx
add_index :identity_email_change_requests, :new_email_token_bidx
add_index :identity_email_change_requests, [ :identity_id, :completed_at ], name: "idx_email_change_requests_identity_completed"
end
end

View file

@ -0,0 +1,19 @@
class AddEmailChangeSecurityEnhancements < ActiveRecord::Migration[8.0]
def change
# Add columns for tracking verification IPs (forensic logging)
add_column :identity_email_change_requests, :old_email_verified_from_ip, :string
add_column :identity_email_change_requests, :new_email_verified_from_ip, :string
# Add step-up action binding to sessions
add_column :identity_sessions, :last_step_up_action, :string
# Add partial unique index to prevent concurrent pending requests per identity
# Note: Using a function-based approach since expires_at comparison needs current time
# This index ensures at most one non-completed, non-cancelled request per identity
add_index :identity_email_change_requests,
:identity_id,
unique: true,
where: "completed_at IS NULL AND cancelled_at IS NULL",
name: "idx_unique_pending_email_change_per_identity"
end
end

30
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pgcrypto"
@ -301,6 +301,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
t.boolean "saml_debug"
t.boolean "is_in_workspace", default: false, null: false
t.string "slack_dm_channel_id"
t.index "lower((primary_email)::text)", name: "idx_identities_unique_primary_email", unique: true, where: "(deleted_at IS NULL)"
t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true
t.index ["deleted_at"], name: "index_identities_on_deleted_at"
t.index ["legacy_migrated_at"], name: "index_identities_on_legacy_migrated_at"
@ -338,6 +339,31 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
t.index ["identity_id"], name: "index_identity_documents_on_identity_id"
end
create_table "identity_email_change_requests", force: :cascade do |t|
t.bigint "identity_id", null: false
t.string "new_email", null: false
t.string "old_email", null: false
t.datetime "old_email_verified_at"
t.datetime "new_email_verified_at"
t.text "old_email_token_ciphertext"
t.string "old_email_token_bidx"
t.text "new_email_token_ciphertext"
t.string "new_email_token_bidx"
t.datetime "completed_at"
t.datetime "expires_at", null: false
t.datetime "cancelled_at"
t.string "requested_from_ip"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "old_email_verified_from_ip"
t.string "new_email_verified_from_ip"
t.index ["identity_id", "completed_at"], name: "idx_email_change_requests_identity_completed"
t.index ["identity_id"], name: "idx_unique_pending_email_change_per_identity", unique: true, where: "((completed_at IS NULL) AND (cancelled_at IS NULL))"
t.index ["identity_id"], name: "index_identity_email_change_requests_on_identity_id"
t.index ["new_email_token_bidx"], name: "index_identity_email_change_requests_on_new_email_token_bidx"
t.index ["old_email_token_bidx"], name: "index_identity_email_change_requests_on_old_email_token_bidx"
end
create_table "identity_login_codes", force: :cascade do |t|
t.datetime "expires_at"
t.string "token_bidx"
@ -381,6 +407,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "last_step_up_at"
t.string "last_step_up_action"
t.index ["identity_id"], name: "index_identity_sessions_on_identity_id"
end
@ -557,6 +584,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_001813) do
add_foreign_key "identity_aadhaar_records", "identities"
add_foreign_key "identity_backup_codes", "identities"
add_foreign_key "identity_documents", "identities"
add_foreign_key "identity_email_change_requests", "identities"
add_foreign_key "identity_login_codes", "identities"
add_foreign_key "identity_resemblances", "identities"
add_foreign_key "identity_resemblances", "identities", column: "past_identity_id"

View file

@ -0,0 +1,41 @@
FactoryBot.define do
factory :email_change_request, class: "Identity::EmailChangeRequest" do
identity
sequence(:new_email) { |n| "new#{n}@example.com" }
old_email { identity&.primary_email || "old@example.com" }
expires_at { 24.hours.from_now }
trait :with_tokens do
after(:create) do |request|
request.generate_tokens!
end
end
trait :old_verified do
old_email_verified_at { Time.current }
end
trait :new_verified do
new_email_verified_at { Time.current }
end
trait :both_verified do
old_email_verified_at { Time.current }
new_email_verified_at { Time.current }
end
trait :completed do
old_email_verified_at { 1.hour.ago }
new_email_verified_at { 1.hour.ago }
completed_at { Time.current }
end
trait :cancelled do
cancelled_at { Time.current }
end
trait :expired do
expires_at { 1.hour.ago }
end
end
end

View file

@ -0,0 +1,43 @@
class EmailChangeMailerPreview < ActionMailer::Preview
def verify_old_email
EmailChangeMailer.verify_old_email(email_change_request)
end
def verify_new_email
EmailChangeMailer.verify_new_email(email_change_request)
end
def email_changed_notification
request = email_change_request
request.completed_at = Time.current
EmailChangeMailer.email_changed_notification(request)
end
private
def email_change_request
identity = Identity.last || build_fake_identity
request = identity.email_change_requests.first
return request if request
Identity::EmailChangeRequest.new(
id: 1,
identity: identity,
old_email: identity.primary_email,
new_email: "newemail@example.com",
old_email_token: SecureRandom.urlsafe_base64(32),
new_email_token: SecureRandom.urlsafe_base64(32),
expires_at: 24.hours.from_now
)
end
def build_fake_identity
Identity.new(
id: 1,
first_name: "Orpheus",
last_name: "Mascot",
primary_email: "orpheus@hackclub.com"
)
end
end

View file

@ -0,0 +1,282 @@
require "rails_helper"
RSpec.describe Identity::EmailChangeRequest do
let(:identity) { create(:identity) }
describe "validations" do
it "requires new_email" do
request = build(:email_change_request, identity: identity, new_email: nil)
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("can't be blank")
end
it "requires a valid email format" do
request = build(:email_change_request, identity: identity, new_email: "not-an-email")
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("is invalid")
end
it "requires new_email to be different from old_email" do
request = build(:email_change_request, identity: identity, new_email: identity.primary_email)
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("can't be your current email, ya goof!")
end
it "rejects email already taken by another identity" do
other_identity = create(:identity, primary_email: "taken@hackclub.com")
request = build(:email_change_request, identity: identity, new_email: "taken@hackclub.com")
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("is already taken by another account")
end
it "allows valid new email" do
request = build(:email_change_request, identity: identity, new_email: "newemail@hackclub.com")
expect(request).to be_valid
end
it "rejects disposable email addresses" do
request = build(:email_change_request, identity: identity, new_email: "test@mailinator.com")
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("cannot be a temporary email")
end
it "rejects email with invalid MX records" do
request = build(:email_change_request, identity: identity, new_email: "test@thisisnotarealdomain12345.com")
expect(request).not_to be_valid
expect(request.errors[:new_email]).to include("domain does not accept email")
end
end
describe "defaults" do
it "sets expires_at on create" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com")
expect(request.expires_at).to be_within(1.minute).of(24.hours.from_now)
end
it "sets old_email from identity on create" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com")
expect(request.old_email).to eq(identity.primary_email)
end
end
describe "#pending?" do
it "returns true for pending request" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com")
expect(request).to be_pending
end
it "returns false for completed request" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com", completed_at: Time.current)
expect(request).not_to be_pending
end
it "returns false for cancelled request" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com", cancelled_at: Time.current)
expect(request).not_to be_pending
end
it "returns false for expired request" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com", expires_at: 1.hour.ago)
expect(request).not_to be_pending
end
end
describe "automatic token generation" do
it "generates tokens on create" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com")
expect(request.old_email_token).to be_present
expect(request.new_email_token).to be_present
expect(request.old_email_token).not_to eq(request.new_email_token)
end
end
describe "#verify_old_email!" do
let(:request) { create(:email_change_request, identity: identity, new_email: "new@hackclub.com") }
it "verifies old email with correct token" do
expect(request.verify_old_email!(request.old_email_token)).to be true
expect(request.reload.old_email_verified?).to be true
end
it "returns false for incorrect token" do
expect(request.verify_old_email!("wrong-token")).to be false
expect(request.old_email_verified?).to be false
end
it "returns false if request is not pending" do
request.cancel!
expect(request.verify_old_email!(request.old_email_token)).to be false
end
it "records verification IP address" do
request.verify_old_email!(request.old_email_token, verified_from_ip: "192.168.1.100")
expect(request.reload.old_email_verified_from_ip).to eq("192.168.1.100")
end
end
describe "#verify_new_email!" do
let(:request) { create(:email_change_request, identity: identity, new_email: "new@hackclub.com") }
it "verifies new email with correct token" do
expect(request.verify_new_email!(request.new_email_token)).to be true
expect(request.reload.new_email_verified?).to be true
end
it "returns false for incorrect token" do
expect(request.verify_new_email!("wrong-token")).to be false
expect(request.new_email_verified?).to be false
end
it "returns false if request is not pending" do
request.cancel!
expect(request.verify_new_email!(request.new_email_token)).to be false
end
it "records verification IP address" do
request.verify_new_email!(request.new_email_token, verified_from_ip: "10.0.0.50")
expect(request.reload.new_email_verified_from_ip).to eq("10.0.0.50")
end
end
describe "#complete_if_ready!" do
let(:request) { create(:email_change_request, identity: identity, new_email: "new@hackclub.com") }
it "completes when both emails are verified" do
request.verify_old_email!(request.old_email_token)
request.verify_new_email!(request.new_email_token)
expect(request.reload).to be_completed
expect(identity.reload.primary_email).to eq("new@hackclub.com")
end
it "does not complete with only old email verified" do
original_email = request.old_email
request.verify_old_email!(request.old_email_token)
expect(request.reload).not_to be_completed
expect(identity.reload.primary_email).to eq(original_email)
end
it "does not complete with only new email verified" do
original_email = request.old_email
request.verify_new_email!(request.new_email_token)
expect(request.reload).not_to be_completed
expect(identity.reload.primary_email).to eq(original_email)
end
it "sends notification email after completion" do
expect {
request.verify_old_email!(request.old_email_token)
request.verify_new_email!(request.new_email_token)
}.to have_enqueued_mail(EmailChangeMailer, :email_changed_notification)
end
it "creates an activity record" do
original_email = request.old_email
request.verify_old_email!(request.old_email_token)
request.verify_new_email!(request.new_email_token)
activity = identity.activities.last
expect(activity.key).to eq("identity.email_changed")
expect(activity.parameters[:old_email]).to eq(original_email)
expect(activity.parameters[:new_email]).to eq("new@hackclub.com")
end
end
describe "#cancel!" do
let(:request) { create(:email_change_request, identity: identity, new_email: "new@hackclub.com") }
it "cancels a pending request" do
expect(request.cancel!).to be true
expect(request.reload).to be_cancelled
end
it "returns false for already completed request" do
request.update!(completed_at: Time.current)
expect(request.cancel!).to be false
end
it "creates a cancellation activity record" do
request.cancel!
activity = identity.activities.find_by(key: "identity.email_change_cancelled")
expect(activity).to be_present
expect(activity.parameters[:old_email]).to eq(request.old_email)
expect(activity.parameters[:new_email]).to eq(request.new_email)
end
end
describe "#complete_if_ready! race condition protection" do
let(:request) { create(:email_change_request, identity: identity, new_email: "new@hackclub.com") }
it "does not complete if request was cancelled" do
request.verify_old_email!(request.old_email_token)
request.update!(new_email_verified_at: Time.current)
request.update!(cancelled_at: Time.current)
original_email = identity.primary_email
request.complete_if_ready!
expect(request.reload).not_to be_completed
expect(identity.reload.primary_email).to eq(original_email)
end
it "does not complete if request expired" do
request.verify_old_email!(request.old_email_token)
request.update!(new_email_verified_at: Time.current)
request.update!(expires_at: 1.hour.ago)
original_email = identity.primary_email
request.complete_if_ready!
expect(request.reload).not_to be_completed
expect(identity.reload.primary_email).to eq(original_email)
end
end
describe "scopes" do
let(:identity2) { create(:identity) }
let(:identity3) { create(:identity) }
let(:identity4) { create(:identity) }
let!(:pending_request) { create(:email_change_request, identity: identity, new_email: "pending@hackclub.com") }
let!(:completed_request) { create(:email_change_request, identity: identity2, new_email: "completed@hackclub.com", completed_at: Time.current) }
let!(:cancelled_request) { create(:email_change_request, identity: identity3, new_email: "cancelled@hackclub.com", cancelled_at: Time.current) }
let!(:expired_request) { create(:email_change_request, identity: identity4, new_email: "expired@hackclub.com", expires_at: 1.hour.ago) }
describe ".pending" do
it "returns only pending requests" do
expect(Identity::EmailChangeRequest.pending).to contain_exactly(pending_request)
end
end
describe ".completed" do
it "returns only completed requests" do
expect(Identity::EmailChangeRequest.completed).to contain_exactly(completed_request)
end
end
end
describe "paper_trail" do
it "tracks changes" do
request = create(:email_change_request, identity: identity, new_email: "new@hackclub.com")
expect(request.versions.count).to eq(1)
request.update!(cancelled_at: Time.current)
expect(request.versions.count).to eq(2)
end
end
describe "email normalization" do
it "normalizes new_email to lowercase and strips whitespace" do
request = build(:email_change_request, identity: identity, new_email: " NEW@HACKCLUB.COM ")
request.valid?
expect(request.new_email).to eq("new@hackclub.com")
end
it "normalizes old_email to lowercase and strips whitespace" do
request = build(:email_change_request, identity: identity, new_email: "new@hackclub.com", old_email: " OLD@HACKCLUB.COM ")
request.valid?
expect(request.old_email).to eq("old@hackclub.com")
end
end
end

View file

@ -0,0 +1,96 @@
require "rails_helper"
RSpec.describe "StepUp", type: :request do
let(:identity) { create(:identity) }
let(:session) do
identity.sessions.create!(
session_token: SecureRandom.hex(32),
expires_at: 1.week.from_now
)
end
before do
allow_any_instance_of(ApplicationController).to receive(:current_identity).and_return(identity)
allow_any_instance_of(ApplicationController).to receive(:current_session).and_return(session)
allow_any_instance_of(ApplicationController).to receive(:identity_signed_in?).and_return(true)
end
describe "email step-up method blocked for email_change action" do
describe "POST /step_up/send_email_code" do
it "rejects email code request for email_change action" do
post "/step_up/send_email_code", params: { action_type: "email_change", return_to: "/email_changes/new" }
expect(response).to redirect_to(new_step_up_path(action_type: "email_change", return_to: "/email_changes/new"))
expect(flash[:error]).to eq("Email verification is not available for this action")
end
it "allows email code request for other actions" do
post "/step_up/send_email_code", params: { action_type: "remove_totp", return_to: "/security" }
expect(response).to redirect_to(new_step_up_path(action_type: "remove_totp", method: :email, return_to: "/security", code_sent: true))
expect(flash[:notice]).to include("verification code has been sent")
end
end
describe "POST /step_up/verify" do
let!(:totp) do
t = identity.totps.create!
t.mark_verified!
t
end
it "rejects email method verification for email_change action" do
login_code = identity.v2_login_codes.create!
post "/step_up/verify", params: {
action_type: "email_change",
method: "email",
code: login_code.code,
return_to: "/email_changes/new"
}
expect(response).to redirect_to(new_step_up_path(action_type: "email_change", return_to: "/email_changes/new"))
expect(flash[:error]).to eq("Email verification is not available for this action")
end
it "allows TOTP verification for email_change action" do
code = ROTP::TOTP.new(totp.secret).now
post "/step_up/verify", params: {
action_type: "email_change",
method: "totp",
code: code,
return_to: "/email_changes/new"
}
expect(response).to redirect_to("/email_changes/new")
expect(session.reload.last_step_up_at).to be_within(5.seconds).of(Time.current)
expect(session.reload.last_step_up_action).to eq("email_change")
end
it "binds step-up to specific action type" do
code = ROTP::TOTP.new(totp.secret).now
post "/step_up/verify", params: {
action_type: "oidc_reauth",
method: "totp",
code: code,
return_to: "/oauth/authorize"
}
expect(session.reload.last_step_up_action).to eq("oidc_reauth")
expect(session.recently_stepped_up?(for_action: "oidc_reauth")).to be true
expect(session.recently_stepped_up?(for_action: "email_change")).to be false
end
end
describe "POST /step_up/resend_email" do
it "rejects resend for email_change action" do
post "/step_up/resend_email", params: { action_type: "email_change", return_to: "/email_changes/new" }
expect(response).to redirect_to(new_step_up_path(action_type: "email_change", return_to: "/email_changes/new"))
expect(flash[:error]).to eq("Email verification is not available for this action")
end
end
end
end