Strip out more inline javascript code. Encrypt backup codes and treat the backup codes attribute as a json array
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -1,7 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", "~> 8.1.0"
|
||||
gem "rails", "~> 8.1.1"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
|
||||
128
Gemfile.lock
128
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
railties
|
||||
actioncable (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.0)
|
||||
actiontext (8.1.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activerecord (8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.0)
|
||||
activesupport (8.1.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -112,14 +112,14 @@ GEM
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
date (3.4.1)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.1)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -140,14 +140,14 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
json (2.15.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
@@ -200,7 +200,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
@@ -238,7 +238,7 @@ GEM
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.2.4)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -246,20 +246,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.0)
|
||||
actioncable (= 8.1.0)
|
||||
actionmailbox (= 8.1.0)
|
||||
actionmailer (= 8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actiontext (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
rails (8.1.1)
|
||||
actioncable (= 8.1.1)
|
||||
actionmailbox (= 8.1.1)
|
||||
actionmailer (= 8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actiontext (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.0)
|
||||
railties (= 8.1.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -267,9 +267,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
railties (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -277,8 +277,8 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.15.0)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -373,7 +373,7 @@ GEM
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
timeout (0.4.3)
|
||||
timeout (0.4.4)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
@@ -387,7 +387,7 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.0)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -438,7 +438,7 @@ DEPENDENCIES
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rails (~> 8.1.1)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
|
||||
@@ -24,9 +24,12 @@ class TotpController < ApplicationController
|
||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
# Save the secret and generate backup codes
|
||||
@user.totp_secret = totp_secret
|
||||
@user.backup_codes = generate_backup_codes
|
||||
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||
@user.save!
|
||||
|
||||
# Store plain codes temporarily in session for display after redirect
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
|
||||
# Redirect to backup codes page with success message
|
||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
||||
else
|
||||
@@ -36,8 +39,15 @@ class TotpController < ApplicationController
|
||||
|
||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||
def backup_codes
|
||||
# This will be shown after password verification
|
||||
@backup_codes = @user.parsed_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
|
||||
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
|
||||
@@ -49,6 +59,28 @@ class TotpController < ApplicationController
|
||||
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!
|
||||
|
||||
# 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
|
||||
|
||||
# DELETE /totp - Disable TOTP (requires password)
|
||||
def destroy
|
||||
unless @user.authenticate(params[:password])
|
||||
@@ -77,8 +109,4 @@ class TotpController < ApplicationController
|
||||
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
||||
end
|
||||
end
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Controller } from "@hotwired/stimulus"
|
||||
// Generic modal controller for showing/hiding modal dialogs
|
||||
export default class extends Controller {
|
||||
static targets = ["dialog"]
|
||||
static values = {
|
||||
refreshOnClose: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
show(event) {
|
||||
// If called from a button with data-modal-id, find and show that modal
|
||||
@@ -11,6 +14,8 @@ export default class extends Controller {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
// Store the refresh preference from the button
|
||||
this.refreshOnCloseValue = event.currentTarget?.dataset?.refreshOnClose === "true";
|
||||
}
|
||||
} else if (this.hasDialogTarget) {
|
||||
// Otherwise show the dialog target
|
||||
@@ -22,11 +27,20 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.hasDialogTarget) {
|
||||
// Find the currently visible modal to hide it
|
||||
const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)');
|
||||
if (visibleModal) {
|
||||
visibleModal.classList.add("hidden");
|
||||
} else if (this.hasDialogTarget) {
|
||||
this.dialogTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.element.classList.add("hidden");
|
||||
}
|
||||
|
||||
// Refresh page if requested
|
||||
if (this.refreshOnCloseValue) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking backdrop
|
||||
|
||||
@@ -66,19 +66,53 @@ class User < ApplicationRecord
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
codes = JSON.parse(backup_codes)
|
||||
if codes.include?(code)
|
||||
codes.delete(code)
|
||||
update(backup_codes: codes.to_json)
|
||||
# Rate limiting: prevent brute force attacks on backup codes
|
||||
if rate_limit_backup_code_verification?
|
||||
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
||||
return false
|
||||
end
|
||||
|
||||
# backup_codes is now an Array (JSON column), no need to parse
|
||||
# Find the matching hash by comparing with BCrypt
|
||||
matching_hash = backup_codes.find do |hashed_code|
|
||||
BCrypt::Password.new(hashed_code) == code
|
||||
end
|
||||
|
||||
if matching_hash
|
||||
# Remove the used hash from the array (single-use property)
|
||||
backup_codes.delete(matching_hash)
|
||||
save! # Save the updated array
|
||||
|
||||
# Log successful backup code usage for security monitoring
|
||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
true
|
||||
else
|
||||
# Increment failed attempt counter and log for security monitoring
|
||||
increment_backup_code_failed_attempts
|
||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_backup_codes
|
||||
return [] unless backup_codes.present?
|
||||
JSON.parse(backup_codes)
|
||||
# Rate limiting for backup code verification to prevent brute force attacks
|
||||
def rate_limit_backup_code_verification?
|
||||
# Use Rails.cache to track failed attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
||||
true
|
||||
else
|
||||
# Don't increment here - increment only on failed attempts
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Increment failed attempt counter
|
||||
def increment_backup_code_failed_attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
# WebAuthn methods
|
||||
@@ -152,6 +186,16 @@ class User < ApplicationRecord
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
# Generate plain codes for user to see/save
|
||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||
|
||||
# Store BCrypt hashes of the codes
|
||||
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
|
||||
|
||||
# Return plain codes for display (will be shown to user once)
|
||||
# Store only hashes in the database (as Array for JSON column)
|
||||
self.backup_codes = hashed_codes
|
||||
|
||||
plain_codes
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
|
||||
<% if application.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
@@ -42,14 +42,18 @@
|
||||
|
||||
<div>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
disabled: application.persisted?,
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- OIDC-specific fields -->
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -60,7 +64,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Forward Auth-specific fields -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.forward_auth? %>">
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -120,30 +124,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide type-specific fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
const forwardAuthFields = document.querySelector('#forward-auth-fields');
|
||||
|
||||
function updateFieldVisibility() {
|
||||
if (!appTypeSelect) return;
|
||||
|
||||
const appType = appTypeSelect.value;
|
||||
|
||||
if (oidcFields) {
|
||||
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (forwardAuthFields) {
|
||||
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (appTypeSelect) {
|
||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
// Initialize visibility on page load
|
||||
updateFieldVisibility();
|
||||
</script>
|
||||
|
||||
@@ -26,10 +26,13 @@
|
||||
<body>
|
||||
<% if authenticated? %>
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64">
|
||||
<div class="lg:pl-64" data-controller="mobile-sidebar">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button" class="-m-2.5 p-2.5 text-gray-700" id="mobile-menu-button">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700"
|
||||
id="mobile-menu-button"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
@@ -52,23 +55,5 @@
|
||||
</main>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
||||
const mobileSidebarOverlay = document.getElementById('mobile-sidebar-overlay');
|
||||
|
||||
if (mobileMenuButton) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileMenuClose) {
|
||||
mobileMenuClose.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-8" data-controller="modal">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||
@@ -126,7 +126,6 @@
|
||||
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div id="disable-2fa-modal"
|
||||
data-controller="modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
@@ -164,18 +163,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Backup Codes Modal -->
|
||||
<!-- Regenerate Backup Codes Modal -->
|
||||
<div id="view-backup-codes-modal"
|
||||
data-controller="modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
||||
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||
</div>
|
||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with url: create_new_backup_codes_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div>
|
||||
<%= password_field_tag :password, nil,
|
||||
placeholder: "Enter your password",
|
||||
@@ -184,7 +192,7 @@
|
||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "View Codes",
|
||||
<%= form.submit "Generate New Codes",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
<button type="button"
|
||||
data-action="click->modal#hide"
|
||||
|
||||
@@ -105,12 +105,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<div class="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
|
||||
<div class="relative z-50 lg:hidden hidden" data-mobile-sidebar-target="sidebarOverlay" id="mobile-sidebar-overlay">
|
||||
<div class="fixed inset-0 bg-gray-900/80"></div>
|
||||
<div class="fixed inset-0 flex">
|
||||
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5"
|
||||
id="mobile-menu-close"
|
||||
data-action="click->mobile-sidebar#closeSidebar">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
@@ -29,14 +29,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button onclick="downloadBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Codes
|
||||
</button>
|
||||
|
||||
<button onclick="printBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
@@ -52,27 +52,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = <%= raw @backup_codes.to_json %>;
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const content = "Clinch Backup Codes\n" +
|
||||
"===================\n\n" +
|
||||
backupCodes.join("\n") +
|
||||
"\n\nSave these codes in a secure location.";
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'clinch-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function printBackupCodes() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# Define an application-wide content security policy.
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
# Default policy: only allow resources from same origin and HTTPS
|
||||
policy.default_src :self, :https
|
||||
|
||||
# Scripts: strict security with nonce support for dynamic content
|
||||
policy.script_src :self, :https, :strict_dynamic
|
||||
|
||||
# Styles: allow inline styles for CSS frameworks, but require HTTPS
|
||||
policy.style_src :self, :https, :unsafe_inline
|
||||
|
||||
# Images: allow data URIs for inline images and HTTPS sources
|
||||
policy.img_src :self, :https, :data
|
||||
|
||||
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs
|
||||
policy.font_src :self, :https, :data
|
||||
|
||||
# Media: allow self and HTTPS media sources
|
||||
policy.media_src :self, :https
|
||||
|
||||
# Objects: block potentially dangerous plugins
|
||||
policy.object_src :none
|
||||
|
||||
# Base URI: restrict base tag to same origin
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: only allow forms to submit to same origin
|
||||
policy.form_action :self
|
||||
|
||||
# Frame ancestors: prevent clickjacking by disallowing framing
|
||||
policy.frame_ancestors :none
|
||||
|
||||
# Frame sources: block iframes unless explicitly needed
|
||||
policy.frame_src :none
|
||||
|
||||
# Connect sources: control where XHR/Fetch can connect
|
||||
policy.connect_src :self, :https
|
||||
|
||||
# Manifest: only allow same-origin manifest files
|
||||
policy.manifest_src :self
|
||||
|
||||
# Worker sources: control web worker origins
|
||||
policy.worker_src :self, :https
|
||||
|
||||
# Report URI: send violation reports to our monitoring endpoint
|
||||
if Rails.env.production?
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
end
|
||||
|
||||
# Generate session nonces for permitted inline scripts and styles
|
||||
config.content_security_policy_nonce_generator = ->(request) {
|
||||
# Use a secure random nonce instead of session ID for better security
|
||||
SecureRandom.base64(16)
|
||||
}
|
||||
|
||||
# Apply nonces to script and style directives
|
||||
config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
|
||||
# Automatically add `nonce` attributes to script/style tags
|
||||
config.content_security_policy_nonce_auto = true
|
||||
|
||||
# Enforce CSP in production, but use report-only in development for debugging
|
||||
if Rails.env.production?
|
||||
# Enforce the policy in production
|
||||
config.content_security_policy_report_only = false
|
||||
else
|
||||
# Report violations only in development (helps with debugging)
|
||||
config.content_security_policy_report_only = true
|
||||
end
|
||||
end
|
||||
@@ -64,6 +64,8 @@ Rails.application.routes.draw do
|
||||
delete '/totp', to: 'totp#destroy'
|
||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||
|
||||
# WebAuthn (Passkeys) routes
|
||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -124,7 +124,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.text "backup_codes"
|
||||
t.json "backup_codes"
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.string "email_address", null: false
|
||||
|
||||
@@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase
|
||||
assert_not user.valid?
|
||||
assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
|
||||
end
|
||||
|
||||
# Backup codes tests
|
||||
test "generate_backup_codes returns 10 plain codes and stores BCrypt hashes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
|
||||
# Should return 10 plain codes
|
||||
assert_equal 10, plain_codes.length
|
||||
assert_kind_of Array, plain_codes
|
||||
|
||||
# All codes should be 8 characters, alphanumeric, uppercase
|
||||
plain_codes.each do |code|
|
||||
assert_equal 8, code.length
|
||||
assert_match(/\A[A-Z0-9]+\z/, code)
|
||||
end
|
||||
|
||||
# Save user to persist the backup codes
|
||||
user.save!
|
||||
|
||||
# Reload user from database to check stored values
|
||||
user.reload
|
||||
stored_hashes = user.backup_codes || []
|
||||
|
||||
# Should store 10 BCrypt hashes
|
||||
assert_equal 10, stored_hashes.length
|
||||
stored_hashes.each do |hash|
|
||||
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
|
||||
end
|
||||
|
||||
# Verify each plain code matches its corresponding hash
|
||||
plain_codes.each_with_index do |code, index|
|
||||
assert BCrypt::Password.new(stored_hashes[index]) == code, "Plain code should match stored hash"
|
||||
end
|
||||
end
|
||||
|
||||
test "verify_backup_code works with BCrypt hashes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes using the new flow (simulate what happens in controller)
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Should successfully verify a valid backup code
|
||||
assert user.verify_backup_code(plain_codes.first), "Should verify first backup code"
|
||||
|
||||
# Code should be deleted after use (single-use property)
|
||||
user.reload
|
||||
assert user.verify_backup_code(plain_codes.first) == false, "Used code should not be verifiable again"
|
||||
|
||||
# Should still verify other unused codes
|
||||
assert user.verify_backup_code(plain_codes.second), "Should verify second backup code"
|
||||
end
|
||||
|
||||
test "verify_backup_code returns false for invalid codes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Should fail for invalid codes
|
||||
assert_not user.verify_backup_code("INVALID123"), "Should fail for invalid code"
|
||||
assert_not user.verify_backup_code(""), "Should fail for empty code"
|
||||
assert_not user.verify_backup_code(plain_codes.first + "X"), "Should fail for modified valid code"
|
||||
end
|
||||
|
||||
test "verify_backup_code returns false when no backup codes exist" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Should return false when user has no backup codes
|
||||
assert_not user.verify_backup_code("ANYCODE123"), "Should fail when no backup codes exist"
|
||||
end
|
||||
|
||||
test "verify_backup_code respects rate limiting" do
|
||||
# Temporarily use memory store for this test
|
||||
original_cache_store = Rails.cache
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Make 5 failed attempts to trigger rate limit
|
||||
5.times do |i|
|
||||
result = user.verify_backup_code("INVALID123")
|
||||
assert_not result, "Failed attempt #{i+1} should return false"
|
||||
end
|
||||
|
||||
# Check that the cache is tracking attempts
|
||||
attempts = Rails.cache.read("backup_code_failed_attempts_#{user.id}") || 0
|
||||
assert_equal 5, attempts, "Should have 5 failed attempts tracked"
|
||||
|
||||
# 6th attempt should be rate limited (both valid and invalid codes should fail)
|
||||
assert_not user.verify_backup_code(plain_codes.first), "Valid code should be rate limited after 5 failed attempts"
|
||||
assert_not user.verify_backup_code("INVALID123"), "Invalid code should also be rate limited"
|
||||
|
||||
# Valid code should still work if we clear the rate limit
|
||||
Rails.cache.delete("backup_code_failed_attempts_#{user.id}")
|
||||
assert user.verify_backup_code(plain_codes.first), "Should work after clearing rate limit"
|
||||
|
||||
# Restore original cache store
|
||||
Rails.cache = original_cache_store
|
||||
end
|
||||
|
||||
# Note: parsed_backup_codes method and legacy tests removed
|
||||
# All users now use BCrypt hashes stored in JSON column
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user