Previously only TOTP-enabled triggered an email. Every other security-relevant change — password change, TOTP disable, passkey add/remove, API key create/revoke, email address change, backup-code regeneration — happened silently, so an attacker on a stolen session could quietly drop 2FA or hijack the email with no signal to the account holder. Add SecurityMailer with one method per event. Each email carries the request IP, user-agent, and timestamp so the user can spot unfamiliar activity. Email-address changes notify both the old and new addresses with directional language; the old-address copy explicitly warns that whoever made the change can now receive password reset emails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
6.0 KiB
Ruby
163 lines
6.0 KiB
Ruby
class TotpController < ApplicationController
|
|
before_action :set_user
|
|
before_action :redirect_if_totp_enabled, only: [:new, :create]
|
|
before_action :require_totp_enabled, only: [:backup_codes, :verify_password, :destroy]
|
|
|
|
# GET /totp/new - Show QR code to set up TOTP
|
|
def new
|
|
# Check if user is being forced to set up TOTP by admin
|
|
@totp_setup_required = session[:pending_totp_setup_user_id].present?
|
|
|
|
# Generate TOTP secret but don't save yet
|
|
@totp_secret = ROTP::Base32.random
|
|
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
|
|
|
# Hold the secret server-side until the user confirms it with a valid code,
|
|
# so an attacker with session access cannot substitute one they control.
|
|
session[:pending_totp_secret] = @totp_secret
|
|
|
|
# Generate QR code
|
|
require "rqrcode"
|
|
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
|
end
|
|
|
|
# POST /totp - Verify TOTP code and enable 2FA
|
|
def create
|
|
totp_secret = session[:pending_totp_secret]
|
|
code = params[:code]
|
|
|
|
unless totp_secret
|
|
redirect_to new_totp_path, alert: "Your TOTP setup session expired. Please start again."
|
|
return
|
|
end
|
|
|
|
# Verify the code works
|
|
totp = ROTP::TOTP.new(totp_secret)
|
|
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
|
# Save the secret and generate backup codes
|
|
@user.totp_secret = totp_secret
|
|
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
|
@user.save!
|
|
|
|
session.delete(:pending_totp_secret)
|
|
TotpMailer.enabled(@user).deliver_later
|
|
|
|
# Store plain codes temporarily in session for display after redirect
|
|
session[:temp_backup_codes] = plain_codes
|
|
|
|
# Check if this was a required setup from login
|
|
if session[:pending_totp_setup_user_id].present?
|
|
session.delete(:pending_totp_setup_user_id)
|
|
# Mark that user should be auto-signed in after viewing backup codes
|
|
session[:auto_signin_after_forced_totp] = true
|
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes, then you'll be signed in."
|
|
else
|
|
# Regular setup from profile
|
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
|
end
|
|
else
|
|
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
|
end
|
|
end
|
|
|
|
# GET /totp/backup_codes - Show backup codes (requires password)
|
|
def backup_codes
|
|
# Check if we have temporary codes from TOTP setup
|
|
if session[:temp_backup_codes].present?
|
|
@backup_codes = session[:temp_backup_codes]
|
|
session.delete(:temp_backup_codes) # Clear after use
|
|
|
|
# Check if this was a forced TOTP setup during login
|
|
@auto_signin_pending = session[:auto_signin_after_forced_totp].present?
|
|
if @auto_signin_pending
|
|
session.delete(:auto_signin_after_forced_totp)
|
|
end
|
|
else
|
|
# This will be shown after password verification for existing users
|
|
# Since we can't display BCrypt hashes, redirect to regenerate
|
|
redirect_to regenerate_backup_codes_totp_path
|
|
end
|
|
end
|
|
|
|
# POST /totp/verify_password - Verify password before showing backup codes
|
|
def verify_password
|
|
if @user.authenticate(params[:password])
|
|
redirect_to backup_codes_totp_path
|
|
else
|
|
redirect_to profile_path, alert: "Incorrect password."
|
|
end
|
|
end
|
|
|
|
# GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password)
|
|
def regenerate_backup_codes
|
|
# This will be shown after password verification
|
|
end
|
|
|
|
# POST /totp/regenerate_backup_codes - Actually regenerate backup codes
|
|
def create_new_backup_codes
|
|
unless @user.authenticate(params[:password])
|
|
redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password."
|
|
return
|
|
end
|
|
|
|
# Generate new backup codes and store BCrypt hashes
|
|
plain_codes = @user.send(:generate_backup_codes)
|
|
@user.save!
|
|
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
|
|
|
# Store plain codes temporarily in session for display
|
|
session[:temp_backup_codes] = plain_codes
|
|
|
|
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
|
end
|
|
|
|
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
|
|
def complete_setup
|
|
# Sign in the user after they've saved their backup codes
|
|
# This is only used when admin requires TOTP and user just set it up during login
|
|
if session[:totp_redirect_url].present?
|
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
|
end
|
|
|
|
start_new_session_for @user
|
|
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
|
|
end
|
|
|
|
# DELETE /totp - Disable TOTP (requires password)
|
|
def destroy
|
|
unless @user.authenticate(params[:password])
|
|
redirect_to profile_path, alert: "Incorrect password. Could not disable 2FA."
|
|
return
|
|
end
|
|
|
|
# Prevent disabling if admin requires TOTP
|
|
if @user.totp_required?
|
|
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
|
|
return
|
|
end
|
|
|
|
@user.disable_totp!
|
|
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
|
end
|
|
|
|
private
|
|
|
|
def set_user
|
|
@user = Current.session.user
|
|
end
|
|
|
|
def redirect_if_totp_enabled
|
|
# Allow setup if admin requires it, even if already enabled (for regeneration)
|
|
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
|
|
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
|
end
|
|
end
|
|
|
|
def require_totp_enabled
|
|
unless @user.totp_enabled?
|
|
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
|
end
|
|
end
|
|
end
|