Compare commits

..

2 Commits

Author SHA1 Message Date
Dan Milne
e9b1995e89 Remove unneeded stuff
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 18:47:31 +11:00
Dan Milne
fb14ce032f Strip out more inline javascript code. Encrypt backup codes and treat the backup codes attribute as a json array 2025-11-04 18:46:11 +11:00
14 changed files with 326 additions and 248 deletions

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # 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] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record

View File

@@ -3,29 +3,29 @@ GEM
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.15)
railties railties
actioncable (8.1.0) actioncable (8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.0) actionmailbox (8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
activejob (= 8.1.0) activejob (= 8.1.1)
activerecord (= 8.1.0) activerecord (= 8.1.1)
activestorage (= 8.1.0) activestorage (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.0) actionmailer (8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
actionview (= 8.1.0) actionview (= 8.1.1)
activejob (= 8.1.0) activejob (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.0) actionpack (8.1.1)
actionview (= 8.1.0) actionview (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.0) actiontext (8.1.1)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.0) actionpack (= 8.1.1)
activerecord (= 8.1.0) activerecord (= 8.1.1)
activestorage (= 8.1.0) activestorage (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.0) actionview (8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.0) activejob (8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.0) activemodel (8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
activerecord (8.1.0) activerecord (8.1.1)
activemodel (= 8.1.0) activemodel (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.0) activestorage (8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
activejob (= 8.1.0) activejob (= 8.1.1)
activerecord (= 8.1.0) activerecord (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.0) activesupport (8.1.1)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -112,14 +112,14 @@ GEM
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crass (1.0.6) crass (1.0.6)
date (3.4.1) date (3.5.0)
debug (1.11.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.8) dotenv (3.1.8)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (5.1.1) erb (5.1.3)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
@@ -140,14 +140,14 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.1) io-console (0.8.1)
irb (1.15.2) irb (1.15.3)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.15.1) json (2.15.2)
jwt (3.1.2) jwt (3.1.2)
base64 base64
kamal (2.8.1) kamal (2.8.1)
@@ -200,7 +200,7 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.18.10-aarch64-linux-musl)
@@ -238,7 +238,7 @@ GEM
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) rack (3.2.4)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -246,20 +246,20 @@ GEM
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.2.1)
rack (>= 3) rack (>= 3)
rails (8.1.0) rails (8.1.1)
actioncable (= 8.1.0) actioncable (= 8.1.1)
actionmailbox (= 8.1.0) actionmailbox (= 8.1.1)
actionmailer (= 8.1.0) actionmailer (= 8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
actiontext (= 8.1.0) actiontext (= 8.1.1)
actionview (= 8.1.0) actionview (= 8.1.1)
activejob (= 8.1.0) activejob (= 8.1.1)
activemodel (= 8.1.0) activemodel (= 8.1.1)
activerecord (= 8.1.0) activerecord (= 8.1.1)
activestorage (= 8.1.0) activestorage (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.0) railties (= 8.1.1)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -267,9 +267,9 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) 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) 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) railties (8.1.1)
actionpack (= 8.1.0) actionpack (= 8.1.1)
activesupport (= 8.1.0) activesupport (= 8.1.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -277,8 +277,8 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.0) rake (13.3.1)
rdoc (6.15.0) rdoc (6.15.1)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@@ -373,7 +373,7 @@ GEM
thruster (0.1.16-aarch64-linux) thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin) thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux) thruster (0.1.16-x86_64-linux)
timeout (0.4.3) timeout (0.4.4)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
@@ -387,7 +387,7 @@ GEM
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.1.0)
uri (1.0.4) uri (1.1.0)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
@@ -438,7 +438,7 @@ DEPENDENCIES
propshaft propshaft
public_suffix (~> 6.0) public_suffix (~> 6.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.0) rails (~> 8.1.1)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
rubocop-rails-omakase rubocop-rails-omakase

View File

@@ -24,9 +24,12 @@ class TotpController < ApplicationController
if totp.verify(code, drift_behind: 30, drift_ahead: 30) if totp.verify(code, drift_behind: 30, drift_ahead: 30)
# Save the secret and generate backup codes # Save the secret and generate backup codes
@user.totp_secret = totp_secret @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! @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 page with success message
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
else else
@@ -36,8 +39,15 @@ class TotpController < ApplicationController
# GET /totp/backup_codes - Show backup codes (requires password) # GET /totp/backup_codes - Show backup codes (requires password)
def backup_codes def backup_codes
# This will be shown after password verification # Check if we have temporary codes from TOTP setup
@backup_codes = @user.parsed_backup_codes 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 end
# POST /totp/verify_password - Verify password before showing backup codes # POST /totp/verify_password - Verify password before showing backup codes
@@ -49,6 +59,28 @@ class TotpController < ApplicationController
end end
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) # DELETE /totp - Disable TOTP (requires password)
def destroy def destroy
unless @user.authenticate(params[:password]) unless @user.authenticate(params[:password])
@@ -77,8 +109,4 @@ class TotpController < ApplicationController
redirect_to profile_path, alert: "Two-factor authentication is not enabled." redirect_to profile_path, alert: "Two-factor authentication is not enabled."
end end
end end
def generate_backup_codes
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
end
end end

View File

@@ -22,7 +22,11 @@ export default class extends Controller {
} }
hide() { 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"); this.dialogTarget.classList.add("hidden");
} else { } else {
this.element.classList.add("hidden"); this.element.classList.add("hidden");

View File

@@ -66,19 +66,53 @@ class User < ApplicationRecord
def verify_backup_code(code) def verify_backup_code(code)
return false unless backup_codes.present? return false unless backup_codes.present?
codes = JSON.parse(backup_codes) # Rate limiting: prevent brute force attacks on backup codes
if codes.include?(code) if rate_limit_backup_code_verification?
codes.delete(code) Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
update(backup_codes: codes.to_json) 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 true
else 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 false
end end
end end
def parsed_backup_codes # Rate limiting for backup code verification to prevent brute force attacks
return [] unless backup_codes.present? def rate_limit_backup_code_verification?
JSON.parse(backup_codes) # 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 end
# WebAuthn methods # WebAuthn methods
@@ -152,6 +186,16 @@ class User < ApplicationRecord
private private
def generate_backup_codes 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
end end

View File

@@ -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? %> <% if application.errors.any? %>
<div class="rounded-md bg-red-50 p-4"> <div class="rounded-md bg-red-50 p-4">
<div class="flex"> <div class="flex">
@@ -42,14 +42,18 @@
<div> <div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> <%= 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? %> <% if application.persisted? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p> <p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %> <% end %>
</div> </div>
<!-- OIDC-specific fields --> <!-- 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> <h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
<div> <div>
@@ -60,7 +64,7 @@
</div> </div>
<!-- Forward Auth-specific fields --> <!-- 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> <h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
<div> <div>
@@ -120,30 +124,3 @@
</div> </div>
<% end %> <% 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>

View File

@@ -26,10 +26,13 @@
<body> <body>
<% if authenticated? %> <% if authenticated? %>
<%= render "shared/sidebar" %> <%= render "shared/sidebar" %>
<div class="lg:pl-64"> <div class="lg:pl-64" data-controller="mobile-sidebar">
<!-- Mobile menu button --> <!-- 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"> <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> <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"> <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" /> <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> </main>
<% end %> <% 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> </body>
</html> </html>

View File

@@ -1,4 +1,4 @@
<div class="space-y-8"> <div class="space-y-8" data-controller="modal">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1> <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> <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 --> <!-- Disable 2FA Modal -->
<div id="disable-2fa-modal" <div id="disable-2fa-modal"
data-controller="modal"
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape" 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"> 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 class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
@@ -164,18 +163,27 @@
</div> </div>
</div> </div>
<!-- View Backup Codes Modal --> <!-- Regenerate Backup Codes Modal -->
<div id="view-backup-codes-modal" <div id="view-backup-codes-modal"
data-controller="modal"
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape" 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"> 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 class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div> <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"> <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> </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> <div>
<%= password_field_tag :password, nil, <%= password_field_tag :password, nil,
placeholder: "Enter your password", 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" %> class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div> </div>
<div class="mt-4 flex gap-3"> <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" %> 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" <button type="button"
data-action="click->modal#hide" data-action="click->modal#hide"

View File

@@ -105,12 +105,15 @@
</div> </div>
<!-- Mobile sidebar overlay --> <!-- 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 bg-gray-900/80"></div>
<div class="fixed inset-0 flex"> <div class="fixed inset-0 flex">
<div class="relative mr-16 flex w-full max-w-xs flex-1"> <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"> <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> <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />

View File

@@ -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"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1> <h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600">
@@ -29,14 +29,14 @@
</div> </div>
<div class="mt-6 flex gap-3"> <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"> <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" /> <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> </svg>
Download Codes Download Codes
</button> </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"> <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" /> <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> </svg>
@@ -52,27 +52,3 @@
</div> </div>
</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>

View File

@@ -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

View File

@@ -64,6 +64,8 @@ Rails.application.routes.draw do
delete '/totp', to: 'totp#destroy' delete '/totp', to: 'totp#destroy'
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_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 # WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn

4
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", 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| create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false t.boolean "admin", default: false, null: false
t.text "backup_codes" t.json "backup_codes"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false t.string "email_address", null: false

View File

@@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase
assert_not user.valid? assert_not user.valid?
assert_includes user.errors[:password], "is too short (minimum is 8 characters)" assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
end 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 end