diff --git a/Gemfile b/Gemfile
index 9e9924f1..d7d98fc5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -10,6 +10,8 @@ gem "devise"
gem "devise-security"
gem "devise_token_auth", "~> 1.2"
gem "omniauth-azure-activedirectory-v2", "~> 2.1.0"
+gem "rotp", "~> 6.3" # TOTP authentication
+gem "rqrcode", "~> 2.2" # QR code generation for TOTP
gem "fog-aws"
gem "jsonapi-serializer"
gem "kaminari"
diff --git a/Gemfile.lock b/Gemfile.lock
index 442ea205..da0c0b35 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -92,8 +92,8 @@ GEM
brakeman (6.1.1)
racc
builder (3.3.0)
- bundler-audit (0.9.2)
- bundler (>= 1.2.0, < 3)
+ bundler-audit (0.9.3)
+ bundler (>= 1.2.0)
thor (~> 1.0)
byebug (11.1.3)
capybara (3.39.2)
@@ -106,6 +106,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (5.0.0)
+ chunky_png (1.4.0)
clockwork (3.0.2)
activesupport
tzinfo
@@ -371,7 +372,12 @@ GEM
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.2)
+ rotp (6.3.0)
rouge (4.2.0)
+ rqrcode (2.2.0)
+ chunky_png (~> 1.0)
+ rqrcode_core (~> 1.0)
+ rqrcode_core (1.2.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -510,6 +516,8 @@ DEPENDENCIES
pundit
rack-cors
rails (~> 8.0.2)
+ rotp (~> 6.3)
+ rqrcode (~> 2.2)
rspec-rails
secure_headers (>= 7.1.0)
shoulda-matchers
@@ -521,7 +529,7 @@ DEPENDENCIES
with_advisory_lock
RUBY VERSION
- ruby 3.3.7p123
+ ruby 3.3.7p123
BUNDLED WITH
- 4.0.4
+ 4.0.4
diff --git a/app/controllers/multi_factor_controller.rb b/app/controllers/multi_factor_controller.rb
index a47e3674..661172a8 100644
--- a/app/controllers/multi_factor_controller.rb
+++ b/app/controllers/multi_factor_controller.rb
@@ -16,38 +16,17 @@ class MultiFactorController < ApplicationController
##
# Verifies a multi-factor authentication code and issues auth tokens.
#
- # After a user successfully authenticates with email/password,
- # they receive a temp_token and an OTP via email. This endpoint
- # validates the OTP and exchanges the temp_token for real auth tokens.
+ # Supports both email OTP and TOTP verification, as well as backup codes.
+ # The user's MFA method is detected automatically.
#
# @api POST /auth/verify_multi_factor
# @param temp_token [String] temporary token from initial sign-in
- # @param otp_code [String] 6-digit code from email
+ # @param otp_code [String] 6-digit code from email/app or 8-char backup code
# @return [JSON] user data and auth tokens in headers
# @status 200 OTP verified, auth tokens issued
# @status 401 Invalid or expired OTP/temp_token
+ # @status 403 Account locked due to failed attempts
# @status 422 Missing required parameters
- #
- # @example Request
- # POST /auth/verify_multi_factor
- # {
- # "temp_token": "abc123...",
- # "otp_code": "123456"
- # }
- #
- # @example Response Headers
- # access-token: "xyz789..."
- # client: "client_id"
- # uid: "user@example.com"
- #
- # @example Response Body
- # {
- # "data": {
- # "id": 1,
- # "email": "user@example.com",
- # ...
- # }
- # }
def verify
if params[:temp_token].blank? || params[:otp_code].blank?
return render json: {errors: ["temp_token and otp_code are required"]}, status: 422
@@ -57,40 +36,32 @@ def verify
return render json: {errors: ["Invalid or expired temp token"]}, status: :unauthorized unless user_id
@resource = User.find_by(id: user_id)
-
return render json: {errors: ["User not found"]}, status: :unauthorized unless @resource
- if @resource.multi_factor_email_code_expired?
- return render json: {errors: ["Multi-factor code has expired"]}, status: :unauthorized
+ # Check if account is locked due to failed MFA attempts
+ if @resource.mfa_locked?
+ return render json: {
+ errors: ["Account temporarily locked due to too many failed attempts. Try again in 30 minutes."]
+ }, status: :forbidden
end
- unless @resource.validate_multi_factor_email_code(params[:otp_code])
- return render json: {errors: ["Invalid multi-factor code"]}, status: :unauthorized
+ # Verify code based on user's MFA method
+ code_valid = case @resource.mfa_method
+ when :totp
+ verify_totp_code
+ when :email_otp
+ verify_email_otp_code
+ else
+ false
end
- # OTP verified! Generate auth tokens
- Rails.cache.delete("otp_temp_token:#{params[:temp_token]}")
- @resource.update_columns(multi_factor_email_code: nil, multi_factor_email_code_sent_at: nil)
-
- @client_id = SecureRandom.urlsafe_base64(nil, false)
- @token = SecureRandom.urlsafe_base64(nil, false)
- @expiry = (Time.current + DeviseTokenAuth.token_lifespan).to_i
-
- @resource.tokens ||= {}
- @resource.tokens[@client_id] = {
- token: BCrypt::Password.create(@token),
- expiry: @expiry
- }
-
- @resource.save!
-
- response.headers["access-token"] = @token
- response.headers["client"] = @client_id
- response.headers["uid"] = @resource.uid
- response.headers["expiry"] = @expiry.to_s
- response.headers["token-type"] = "Bearer"
+ unless code_valid
+ @resource.increment_mfa_failed_attempts!
+ return render json: {errors: ["Invalid multi-factor code"]}, status: :unauthorized
+ end
- render json: {data: @resource.token_validation_response}
+ # Code verified! Generate auth tokens and reset failed attempts
+ complete_authentication
end
##
@@ -131,4 +102,61 @@ def resend
render json: {message: "Multi-factor code re-sent to your email"}, status: :ok
end
+
+ private
+
+ ##
+ # Verifies TOTP code or backup code.
+ def verify_totp_code
+ # Check if it's a backup code (8 characters, alphanumeric)
+ if params[:otp_code].length == 8 && params[:otp_code].match?(/^[a-z0-9]+$/i)
+ return BackupCode.use_code(@resource, params[:otp_code])
+ end
+
+ # Otherwise treat as TOTP code
+ @resource.validate_totp_code(params[:otp_code])
+ end
+
+ ##
+ # Verifies email OTP code.
+ def verify_email_otp_code
+ return false if @resource.multi_factor_email_code_expired?
+ @resource.validate_multi_factor_email_code(params[:otp_code])
+ end
+
+ ##
+ # Completes authentication by issuing tokens.
+ def complete_authentication
+ # Clear temp token and OTP data
+ Rails.cache.delete("otp_temp_token:#{params[:temp_token]}")
+
+ if @resource.mfa_method == :email_otp
+ @resource.update_columns(multi_factor_email_code: nil, multi_factor_email_code_sent_at: nil)
+ end
+
+ # Reset failed attempts
+ @resource.reset_mfa_failed_attempts!
+
+ # Generate auth tokens
+ @client_id = SecureRandom.urlsafe_base64(nil, false)
+ @token = SecureRandom.urlsafe_base64(nil, false)
+ @expiry = (Time.current + DeviseTokenAuth.token_lifespan).to_i
+
+ @resource.tokens ||= {}
+ @resource.tokens[@client_id] = {
+ token: BCrypt::Password.create(@token),
+ expiry: @expiry
+ }
+
+ @resource.save!
+
+ # Set response headers
+ response.headers["access-token"] = @token
+ response.headers["client"] = @client_id
+ response.headers["uid"] = @resource.uid
+ response.headers["expiry"] = @expiry.to_s
+ response.headers["token-type"] = "Bearer"
+
+ render json: {data: @resource.token_validation_response}
+ end
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index a5dac3ee..e56915c4 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -3,7 +3,7 @@ def redirect_options
trusted_host = URI.parse(ENV["CLIENT_URL"].to_s.strip).host
redirect_host = begin
URI.parse(params[:redirect_url]).host
- rescue
+ rescue URI::InvalidURIError, ArgumentError
nil
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 2103cc45..2bdf34a8 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -21,8 +21,10 @@ class SessionsController < DeviseTokenAuth::SessionsController
#
# When a user with MFA enabled attempts to sign in, this endpoint:
# 1. Validates email/password credentials
- # 2. Generates and sends an OTP via email
- # 3. Returns a temp_token for OTP verification
+ # 2. Detects the user's MFA method (TOTP or email OTP)
+ # 3. For email OTP: generates and sends an OTP via email
+ # 4. For TOTP: prompts for authenticator app code (no email sent)
+ # 5. Returns a temp_token for OTP verification
#
# For users without MFA, it proceeds with normal token authentication.
#
@@ -30,7 +32,7 @@ class SessionsController < DeviseTokenAuth::SessionsController
# @param email [String] user's email address
# @param password [String] user's password
# @return [JSON] either temp_token (MFA) or auth tokens (no MFA)
- # @status 202 MFA required, OTP sent
+ # @status 202 MFA required, OTP sent or TOTP prompt
# @status 200 Signed in successfully (no MFA)
# @status 401 Invalid credentials
#
@@ -41,7 +43,7 @@ class SessionsController < DeviseTokenAuth::SessionsController
# "password": "password123"
# }
#
- # @example Response (MFA required)
+ # @example Response (Email OTP required)
# HTTP 202 Accepted
# {
# "otp_required": true,
@@ -49,6 +51,14 @@ class SessionsController < DeviseTokenAuth::SessionsController
# "message": "Multi-factor code sent to your email"
# }
#
+ # @example Response (TOTP required)
+ # HTTP 202 Accepted
+ # {
+ # "otp_required": true,
+ # "temp_token": "abc123...",
+ # "message": "Enter code from your authenticator app"
+ # }
+ #
# @example Response (no MFA)
# HTTP 200 OK
# Headers:
@@ -85,13 +95,29 @@ def create
# Password is valid - reset failed attempts
@resource.update_column(:failed_attempts, 0) if @resource.failed_attempts > 0
- # STEP 3: MFA FLOW - check if MFA is enabled
- if Rails.application.config.enable_mfa
- @resource.generate_and_send_multi_factor_email!
+ # STEP 3: MFA FLOW - check if MFA is required
+ if Rails.application.config.require_mfa
+ # Generate temp token for MFA verification
temp_token = SecureRandom.urlsafe_base64(32)
Rails.cache.write("otp_temp_token:#{temp_token}", @resource.id, expires_in: 5.minutes)
- render json: {otp_required: true, temp_token:, message: "Multi-factor code sent to your email"}, status: :accepted
+ case @resource.mfa_method
+ when :totp
+ # TOTP users use their authenticator app - no email sent
+ render json: {
+ otp_required: true,
+ temp_token:,
+ message: "Enter code from your authenticator app"
+ }, status: :accepted
+ else
+ # Email OTP users and users without MFA get a code via email
+ @resource.generate_and_send_multi_factor_email!
+ render json: {
+ otp_required: true,
+ temp_token:,
+ message: "Multi-factor code sent to your email"
+ }, status: :accepted
+ end
else
super
end
diff --git a/app/controllers/totp_controller.rb b/app/controllers/totp_controller.rb
new file mode 100644
index 00000000..a971ecb8
--- /dev/null
+++ b/app/controllers/totp_controller.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+##
+# Handles TOTP (Time-based One-Time Password) setup and management.
+#
+# This controller manages the enrollment, verification, and disabling
+# of TOTP authentication for users.
+class TotpController < ApplicationController
+ before_action :authenticate_user!
+
+ ##
+ # Initiates TOTP setup by generating a secret and returning QR code data.
+ #
+ # @api POST /auth/totp/setup
+ # @return [JSON] secret, QR code SVG, and provisioning URI
+ # @status 200 Setup data generated
+ # @status 403 TOTP not enabled in config
+ #
+ # @example Response
+ # {
+ # "secret": "JBSWY3DPEHPK3PXP",
+ # "qr_code": "",
+ # "provisioning_uri": "otpauth://totp/..."
+ # }
+ def setup
+ unless Rails.application.config.mfa_methods.include?(:totp)
+ return render json: {errors: ["TOTP is not enabled"]}, status: :forbidden
+ end
+
+ # Generate new secret (doesn't enable TOTP yet)
+ secret = current_user.generate_totp_secret
+ provisioning_uri = current_user.totp_provisioning_uri
+
+ # Generate QR code
+ qrcode = RQRCode::QRCode.new(provisioning_uri)
+ svg = qrcode.as_svg(
+ module_size: 4,
+ standalone: true,
+ use_path: true
+ )
+
+ render json: {
+ secret:,
+ qr_code: svg,
+ provisioning_uri:
+ }
+ end
+
+ ##
+ # Enables TOTP after verifying the user can generate valid codes.
+ #
+ # @api POST /auth/totp/enable
+ # @param code [String] TOTP code from authenticator app
+ # @return [JSON] success message and backup codes
+ # @status 200 TOTP enabled
+ # @status 400 Invalid code
+ # @status 403 TOTP not enabled in config
+ #
+ # @example Request
+ # POST /auth/totp/enable
+ # { "code": "123456" }
+ #
+ # @example Response
+ # {
+ # "message": "TOTP enabled successfully",
+ # "backup_codes": ["a1b2c3d4", "e5f6g7h8", ...]
+ # }
+ def enable
+ unless Rails.application.config.mfa_methods.include?(:totp)
+ return render json: {errors: ["TOTP is not enabled"]}, status: :forbidden
+ end
+
+ if params[:code].blank?
+ return render json: {errors: ["Code is required"]}, status: :unprocessable_entity
+ end
+
+ unless current_user.otp_secret.present?
+ return render json: {errors: ["Please set up TOTP first"]}, status: :unprocessable_entity
+ end
+
+ # Verify the code works before enabling
+ unless current_user.validate_totp_code(params[:code])
+ return render json: {errors: ["Invalid TOTP code"]}, status: :bad_request
+ end
+
+ # Enable TOTP
+ current_user.update!(otp_required_for_login: true)
+
+ # Generate backup codes
+ backup_codes = BackupCode.generate_for_user(current_user)
+
+ render json: {
+ message: "TOTP enabled successfully",
+ backup_codes:
+ }
+ end
+
+ ##
+ # Disables TOTP for the user.
+ #
+ # @api POST /auth/totp/disable
+ # @param password [String] user's current password (for verification)
+ # @return [JSON] success message
+ # @status 200 TOTP disabled
+ # @status 401 Invalid password
+ #
+ # @example Request
+ # POST /auth/totp/disable
+ # { "password": "current_password" }
+ def disable
+ unless current_user.valid_password?(params[:password])
+ return render json: {errors: ["Invalid password"]}, status: :unauthorized
+ end
+
+ current_user.update!(
+ otp_required_for_login: false,
+ otp_secret: nil
+ )
+
+ # Clear backup codes
+ current_user.backup_codes.destroy_all
+
+ render json: {message: "TOTP disabled successfully"}
+ end
+
+ ##
+ # Regenerates backup codes for TOTP.
+ #
+ # @api POST /auth/totp/backup_codes/regenerate
+ # @param password [String] user's current password (for verification)
+ # @return [JSON] new backup codes
+ # @status 200 Backup codes regenerated
+ # @status 401 Invalid password
+ # @status 403 TOTP not enabled for user
+ #
+ # @example Response
+ # {
+ # "backup_codes": ["a1b2c3d4", "e5f6g7h8", ...]
+ # }
+ def regenerate_backup_codes
+ unless current_user.totp_enabled?
+ return render json: {errors: ["TOTP is not enabled for your account"]}, status: :forbidden
+ end
+
+ unless current_user.valid_password?(params[:password])
+ return render json: {errors: ["Invalid password"]}, status: :unauthorized
+ end
+
+ backup_codes = BackupCode.generate_for_user(current_user)
+
+ render json: {
+ backup_codes:,
+ message: "Backup codes regenerated successfully"
+ }
+ end
+
+ ##
+ # Returns the count of remaining backup codes.
+ #
+ # @api GET /auth/totp/backup_codes/count
+ # @return [JSON] count of unused backup codes
+ # @status 200 Count returned
+ # @status 403 TOTP not enabled for user
+ def backup_codes_count
+ unless current_user.totp_enabled?
+ return render json: {errors: ["TOTP is not enabled for your account"]}, status: :forbidden
+ end
+
+ remaining_count = BackupCode.remaining_count(current_user)
+
+ render json: {remaining_count:}
+ end
+end
diff --git a/app/models/backup_code.rb b/app/models/backup_code.rb
new file mode 100644
index 00000000..bda095df
--- /dev/null
+++ b/app/models/backup_code.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+##
+# Backup codes for TOTP recovery.
+#
+# Backup codes allow users to regain access to their account if they lose
+# their TOTP device. Each code can only be used once.
+class BackupCode < ApplicationRecord
+ belongs_to :user
+
+ validates :code_digest, presence: true
+
+ ##
+ # Generates a set of backup codes for a user.
+ #
+ # @param user [User] the user to generate codes for
+ # @param count [Integer] number of codes to generate (default: 10)
+ # @return [Array] array of plain-text backup codes
+ def self.generate_for_user(user, count: 10)
+ # Delete existing codes
+ user.backup_codes.destroy_all
+
+ codes = []
+ count.times do
+ # Generate 8-character code (e.g., "a1b2c3d4")
+ code = SecureRandom.alphanumeric(8).downcase
+ codes << code
+
+ # Store hashed version
+ create!(
+ user: user,
+ code_digest: BCrypt::Password.create(code, cost: Devise.stretches)
+ )
+ end
+
+ codes
+ end
+
+ ##
+ # Validates and consumes a backup code.
+ #
+ # @param user [User] the user attempting to use the code
+ # @param code_attempt [String] the backup code to validate
+ # @return [Boolean] true if valid and unused, false otherwise
+ def self.use_code(user, code_attempt)
+ return false if code_attempt.blank?
+
+ user.backup_codes.where(used_at: nil).find_each do |backup_code|
+ db_pass = BCrypt::Password.new(backup_code.code_digest)
+ if db_pass == code_attempt.downcase
+ backup_code.update!(used_at: Time.current)
+ return true
+ end
+ end
+
+ false
+ end
+
+ ##
+ # Returns the count of unused backup codes for a user.
+ #
+ # @return [Integer] number of unused codes
+ def self.remaining_count(user)
+ user.backup_codes.where(used_at: nil).count
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index cb3a869f..337be789 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -18,9 +18,13 @@ class User < VersionedRecord
has_many :user_categories
has_many :categories, through: :user_categories
has_many :bookmarks
+ has_many :backup_codes, dependent: :destroy
belongs_to :relationship_updated_by, class_name: "User", required: false
+ # Encrypt TOTP secret at rest
+ encrypts :otp_secret, deterministic: false
+
validates :email, presence: true
validates :name, presence: true
@@ -132,6 +136,97 @@ def multi_factor_email_code_expired?
delay > 10.minutes.to_i
end
+ # TOTP authentication methods
+
+ ##
+ # Generates and stores a new TOTP secret for the user.
+ #
+ # @return [String] the base32-encoded secret
+ def generate_totp_secret
+ secret = ROTP::Base32.random
+ update!(otp_secret: secret)
+ secret
+ end
+
+ ##
+ # Returns the TOTP provisioning URI for QR code generation.
+ #
+ # @param issuer [String] the application name (default: "IMPACTOSS")
+ # @return [String] the provisioning URI
+ def totp_provisioning_uri(issuer: "IMPACTOSS")
+ return nil unless otp_secret.present?
+
+ ROTP::TOTP.new(otp_secret, issuer: issuer).provisioning_uri(email)
+ end
+
+ ##
+ # Validates a TOTP code.
+ #
+ # @param code [String] the 6-digit TOTP code
+ # @param drift [Integer] acceptable time drift in seconds (default: 30)
+ # @return [Boolean] true if valid, false otherwise
+ def validate_totp_code(code)
+ return false unless otp_secret.present?
+ return false if code.blank?
+
+ totp = ROTP::TOTP.new(otp_secret)
+ # Allow 30 seconds of drift in either direction
+ totp.verify(code, drift_behind: 30, drift_ahead: 30).present?
+ end
+
+ ##
+ # Checks if TOTP is enabled for this user.
+ #
+ # @return [Boolean] true if TOTP is enabled
+ def totp_enabled?
+ otp_secret.present? && otp_required_for_login
+ end
+
+ ##
+ # Determines the user's MFA method.
+ #
+ # @return [Symbol] :totp, :email_otp, or :none
+ def mfa_method
+ return :totp if totp_enabled?
+ return :email_otp if email_otp_enabled?
+ :none
+ end
+
+ ##
+ # Checks if email OTP is enabled (globally).
+ #
+ # @return [Boolean] true if email OTP is enabled
+ def email_otp_enabled?
+ Rails.application.config.mfa_methods.include?(:email_otp) &&
+ Rails.application.config.require_mfa
+ end
+
+ ##
+ # Checks if MFA is locked due to failed attempts.
+ #
+ # @return [Boolean] true if locked
+ def mfa_locked?
+ mfa_locked_until.present? && mfa_locked_until > Time.current
+ end
+
+ ##
+ # Increments failed MFA attempts and locks if threshold exceeded.
+ #
+ # @param max_attempts [Integer] maximum attempts before locking (default: 5)
+ def increment_mfa_failed_attempts!(max_attempts: 5)
+ increment!(:mfa_failed_attempts)
+
+ if mfa_failed_attempts >= max_attempts
+ update!(mfa_locked_until: 30.minutes.from_now)
+ end
+ end
+
+ ##
+ # Resets MFA failed attempts counter.
+ def reset_mfa_failed_attempts!
+ update!(mfa_failed_attempts: 0, mfa_locked_until: nil)
+ end
+
private
# Set timestamp when password changes
diff --git a/config/initializers/multi_factor_authentication.rb b/config/initializers/multi_factor_authentication.rb
index 6af96231..47cf665d 100644
--- a/config/initializers/multi_factor_authentication.rb
+++ b/config/initializers/multi_factor_authentication.rb
@@ -1,8 +1,41 @@
# frozen_string_literal: true
-# Multi-Factor Authentication Configuration
+##
+# Authentication Configuration
#
-# Set IMPACTOSS_REQUIRE_MFA=true in your environment to require MFA for all users.
-# When enabled, all users must verify their email with an OTP code when signing in.
+# Controls authentication methods and MFA requirements.
#
-Rails.application.config.enable_mfa = ENV.fetch("IMPACTOSS_REQUIRE_MFA", "false") == "true"
+# Environment Variables:
+# IMPACTOSS_ENABLE_AZURE - Enable Azure/EntraID SSO (true/false)
+# IMPACTOSS_REQUIRE_MFA - Require MFA for local auth (true/false)
+# IMPACTOSS_MFA_METHODS - Available MFA methods (comma-separated)
+# Options: email_otp, totp
+# Default: email_otp
+#
+# Examples:
+# # Email/password with email OTP
+# IMPACTOSS_REQUIRE_MFA=true
+# IMPACTOSS_MFA_METHODS=email_otp
+#
+# # Email/password with choice of TOTP or email OTP
+# IMPACTOSS_REQUIRE_MFA=true
+# IMPACTOSS_MFA_METHODS=totp,email_otp
+#
+# # Azure SSO with TOTP for local accounts
+# IMPACTOSS_ENABLE_AZURE=true
+# IMPACTOSS_REQUIRE_MFA=true
+# IMPACTOSS_MFA_METHODS=totp
+#
+Rails.application.config.tap do |config|
+ # Primary authentication
+ config.enable_azure = ENV.fetch("IMPACTOSS_ENABLE_AZURE", "false") == "true"
+
+ # Multi-factor authentication
+ config.require_mfa = ENV.fetch("IMPACTOSS_REQUIRE_MFA", "false") == "true"
+
+ mfa_methods_string = ENV.fetch("IMPACTOSS_MFA_METHODS", "email_otp")
+ config.mfa_methods = mfa_methods_string.split(",").map(&:strip).map(&:to_sym)
+
+ # Maintain backward compatibility with old config name
+ config.enable_mfa = config.require_mfa && config.mfa_methods.include?(:email_otp)
+end
diff --git a/config/routes.rb b/config/routes.rb
index 0de50790..adcdda8e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,13 @@
post "auth/verify_multi_factor", to: "multi_factor#verify"
post "auth/resend_multi_factor", to: "multi_factor#resend"
+ # TOTP authentication endpoints
+ post "auth/totp/setup", to: "totp#setup"
+ post "auth/totp/enable", to: "totp#enable"
+ post "auth/totp/disable", to: "totp#disable"
+ post "auth/totp/backup_codes/regenerate", to: "totp#regenerate_backup_codes"
+ get "auth/totp/backup_codes/count", to: "totp#backup_codes_count"
+
mount_devise_token_auth_for "User",
at: "auth",
controllers: {
diff --git a/db/migrate/20260203230048_add_mfa_rate_limiting_to_users.rb b/db/migrate/20260203230048_add_mfa_rate_limiting_to_users.rb
new file mode 100644
index 00000000..00a00d00
--- /dev/null
+++ b/db/migrate/20260203230048_add_mfa_rate_limiting_to_users.rb
@@ -0,0 +1,6 @@
+class AddMfaRateLimitingToUsers < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :mfa_failed_attempts, :integer, default: 0, null: false
+ add_column :users, :mfa_locked_until, :datetime
+ end
+end
diff --git a/db/migrate/20260203230802_create_backup_codes.rb b/db/migrate/20260203230802_create_backup_codes.rb
new file mode 100644
index 00000000..8f716b02
--- /dev/null
+++ b/db/migrate/20260203230802_create_backup_codes.rb
@@ -0,0 +1,13 @@
+class CreateBackupCodes < ActiveRecord::Migration[8.0]
+ def change
+ create_table :backup_codes do |t|
+ t.references :user, null: false, foreign_key: true, index: true
+ t.string :code_digest, null: false
+ t.datetime :used_at
+
+ t.timestamps
+ end
+
+ add_index :backup_codes, :code_digest
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9b1c823a..8e82b950 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,10 +10,20 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2026_02_03_124846) do
+ActiveRecord::Schema[8.0].define(version: 2026_02_03_230802) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
+ create_table "backup_codes", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.string "code_digest", null: false
+ t.datetime "used_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["code_digest"], name: "index_backup_codes_on_code_digest"
+ t.index ["user_id"], name: "index_backup_codes_on_user_id"
+ end
+
create_table "bookmarks", id: :serial, force: :cascade do |t|
t.integer "user_id", null: false
t.string "title", null: false
@@ -338,6 +348,8 @@
t.datetime "multi_factor_email_code_sent_at"
t.string "confirmation_token"
t.datetime "confirmation_sent_at"
+ t.integer "mfa_failed_attempts", default: 0, null: false
+ t.datetime "mfa_locked_until"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
@@ -353,6 +365,7 @@
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
end
+ add_foreign_key "backup_codes", "users"
add_foreign_key "framework_frameworks", "frameworks"
add_foreign_key "framework_frameworks", "frameworks", column: "other_framework_id"
add_foreign_key "framework_taxonomies", "frameworks"
diff --git a/spec/requests/multi_factor_spec.rb b/spec/requests/multi_factor_spec.rb
index b4116bad..68c4d6c6 100644
--- a/spec/requests/multi_factor_spec.rb
+++ b/spec/requests/multi_factor_spec.rb
@@ -9,7 +9,8 @@
before do
# Enable MFA globally for these tests
- allow(Rails.application.config).to receive(:enable_mfa).and_return(true)
+ allow(Rails.application.config).to receive(:require_mfa).and_return(true)
+ allow(Rails.application.config).to receive(:mfa_methods).and_return([:email_otp])
Rails.cache.write("otp_temp_token:#{temp_token}", user.id, expires_in: 5.minutes)
end
@@ -126,7 +127,7 @@
params: {temp_token: temp_token, otp_code: valid_otp_code}.to_json,
headers: {"CONTENT_TYPE" => "application/json"}
json = JSON.parse(response.body)
- expect(json["errors"]).to include("Multi-factor code has expired")
+ expect(json["errors"]).to include("Invalid multi-factor code")
end
end
diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb
index 1dba7b0a..4cd44a34 100644
--- a/spec/requests/sessions_spec.rb
+++ b/spec/requests/sessions_spec.rb
@@ -9,7 +9,8 @@
context "when MFA is enabled globally" do
before do
- allow(Rails.application.config).to receive(:enable_mfa).and_return(true)
+ allow(Rails.application.config).to receive(:require_mfa).and_return(true)
+ allow(Rails.application.config).to receive(:mfa_methods).and_return([:email_otp])
end
it "returns accepted status" do
@@ -43,7 +44,7 @@
context "when MFA is disabled globally" do
before do
- allow(Rails.application.config).to receive(:enable_mfa).and_return(false)
+ allow(Rails.application.config).to receive(:require_mfa).and_return(false)
end
it "returns success status" do