Strip out more inline javascript code. Encrypt backup codes and treat the backup codes attribute as a json array

This commit is contained in:
Dan Milne
2025-11-04 18:46:11 +11:00
parent bf104a9983
commit fb14ce032f
14 changed files with 336 additions and 248 deletions

View File

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

View File

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

View File

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

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? %>
<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>

View File

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

View File

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

View File

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

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