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