12 Commits

Author SHA1 Message Date
Dan Milne
8e0b2c28eb CSP fixes 2025-11-08 20:01:07 +11:00
Dan Milne
f02665f690 Consolidate all the error messages - add some stimulus controller. 2025-11-07 16:58:28 +11:00
Dan Milne
631b2b53bb Fix CSP reporting endpoitn. Fix the SER for CSP
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 23:22:15 +11:00
Dan Milne
6049429a41 Fix mobile view menu popout. Add an option SENTRY_DSN support, which uses rails event reporting
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 23:16:28 +11:00
Dan Milne
2b15aa2c40 Add sentry, set csp reporting API 2025-11-04 22:58:32 +11:00
Dan Milne
4f5974dd37 bah
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 21:33:52 +11:00
Dan Milne
5de53f1841 bug fix
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 21:21:00 +11:00
Dan Milne
73b2ae2f02 Add some docs
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 21:13:46 +11:00
Dan Milne
4c5ac344bd Bug updating OIDC apps. Update readme
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 20:14:41 +11:00
Dan Milne
044b9239d6 Ok - this time add the new controllers we stripped out of inline and add back the csp
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:55:20 +11:00
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
45 changed files with 1632 additions and 424 deletions

View File

@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
# Optional: Set custom port # Optional: Set custom port
# PORT=9000 # PORT=9000
# Sentry Configuration (Optional)
# Enable error tracking and performance monitoring
# Leave SENTRY_DSN empty to disable Sentry completely
#
# Production: Get your DSN from https://sentry.io/settings/projects/
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
#
# Optional: Override Sentry environment (defaults to Rails.env)
# SENTRY_ENVIRONMENT=production
#
# Optional: Override Sentry release (defaults to Git commit hash)
# SENTRY_RELEASE=v1.0.0
#
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
# Higher values provide more data but cost more
# SENTRY_TRACES_SAMPLE_RATE=0.2
#
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
# Very resource intensive, only enable for performance investigations
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Development: Enable Sentry in development for testing
# SENTRY_ENABLED_IN_DEVELOPMENT=true

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
@@ -37,6 +37,10 @@ gem "webauthn", "~> 3.0"
# Public Suffix List for domain parsing # Public Suffix List for domain parsing
gem "public_suffix", "~> 6.0" gem "public_suffix", "~> 6.0"
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
gem "sentry-ruby", "~> 5.18"
gem "sentry-rails", "~> 5.18"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ] gem "tzinfo-data", platforms: %i[ windows jruby ]

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
@@ -333,6 +333,12 @@ GEM
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (5.28.0)
railties (>= 5.0)
sentry-ruby (~> 5.28.0)
sentry-ruby (5.28.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@@ -373,7 +379,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 +393,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,11 +444,13 @@ 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
selenium-webdriver selenium-webdriver
sentry-rails (~> 5.18)
sentry-ruby (~> 5.18)
solid_cable solid_cable
solid_cache solid_cache
sqlite3 (>= 2.1) sqlite3 (>= 2.1)

View File

@@ -3,9 +3,25 @@
> [!NOTE] > [!NOTE]
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do. > This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
**A lightweight, self-hosted identity & SSO portal** **A lightweight, self-hosted identity & SSO / IpD portal**
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
I've completed all planned features:
* Create Admin user on first login
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
* Passkey generation and login, with detection of Passkey during login
* Forward Auth configured and working
* OIDC provider with auto discovery working
* Invite users by email, assign to groups
* Self managed password reset by email
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
* Configurable Group and User custom claims for OIDC token
* Display all Applications available to the user on their Dashboard
* Display all logged in sessions and OIDC logged in sessions
What remains now is ensure test coverage,
## Why Clinch? ## Why Clinch?

View File

@@ -100,7 +100,10 @@ module Admin
params.require(:application).permit( params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, headers_config: {} :domain_pattern, :landing_url, headers_config: {}
) ).tap do |whitelisted|
# Remove client_secret from params if present (shouldn't be updated via form)
whitelisted.delete(:client_secret)
end
end end
end end
end end

View File

@@ -8,19 +8,38 @@ module Api
def violation_report def violation_report
# Parse CSP violation report # Parse CSP violation report
report_data = JSON.parse(request.body.read) report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report']
# Log the violation for security monitoring # Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:" Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}" Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}" Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}" Rails.logger.warn " Referrer: #{csp_report['referrer']}"
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}" Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}" Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
Rails.logger.warn " User Agent: #{request.user_agent}" Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}" Rails.logger.warn " IP Address: #{request.remote_ip}"
# In production, you might want to send this to a security monitoring service # Emit structured event for CSP violation
# For now, we'll just log it and return a success response # This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'],
document_uri: csp_report['document-uri'],
referrer: csp_report['referrer'],
violated_directive: csp_report['violated-directive'],
original_policy: csp_report['original-policy'],
disposition: csp_report['disposition'],
effective_directive: csp_report['effective-directive'],
source_file: csp_report['source-file'],
line_number: csp_report['line-number'],
column_number: csp_report['column-number'],
status_code: csp_report['status-code'],
user_agent: request.user_agent,
ip_address: request.remote_ip,
current_user_id: Current.user&.id,
timestamp: Time.current,
session_id: Current.session&.id
})
head :no_content head :no_content
rescue JSON::ParserError => e rescue JSON::ParserError => e

View File

@@ -137,7 +137,7 @@ module Api
# Get the redirect URL from query params or construct default # Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd]) redirect_url = validate_redirect_url(params[:rd])
base_url = redirect_url || "https://clinch.aapamilne.com" base_url = determine_base_url(redirect_url)
# Set the original URL that user was trying to access # Set the original URL that user was trying to access
# This will be used after authentication # This will be used after authentication
@@ -214,5 +214,25 @@ module Api
app.matches_domain?(domain.downcase) app.matches_domain?(domain.downcase)
end end
end end
def determine_base_url(redirect_url)
# If we have a valid redirect URL, use it
return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present?
"https://#{ENV['CLINCH_HOST']}"
else
# Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host']
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"
else
# No host information available - raise exception to force proper configuration
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
end
end
end
end end
end end

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

@@ -0,0 +1,24 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
connect() {
this.updateFieldVisibility()
}
updateFieldVisibility() {
const appType = this.appTypeSelectTarget.value
if (appType === 'oidc') {
this.oidcFieldsTarget.classList.remove('hidden')
this.forwardAuthFieldsTarget.classList.add('hidden')
} else if (appType === 'forward_auth') {
this.oidcFieldsTarget.classList.add('hidden')
this.forwardAuthFieldsTarget.classList.remove('hidden')
} else {
this.oidcFieldsTarget.classList.add('hidden')
this.forwardAuthFieldsTarget.classList.add('hidden')
}
}
}

View File

@@ -0,0 +1,28 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
codes: Array
}
download() {
const content = "Clinch Backup Codes\n" +
"===================\n\n" +
this.codesValue.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)
}
print() {
window.print()
}
}

View File

@@ -0,0 +1,85 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages flash message display, auto-dismissal, and user interactions
* Supports different flash types with appropriate styling and behavior
*/
export default class extends Controller {
static values = {
autoDismiss: String, // "false" or delay in milliseconds
type: String
}
connect() {
// Auto-dismiss if enabled
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
// Smooth entrance animation
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Animate in
requestAnimationFrame(() => {
this.element.style.opacity = '1'
this.element.style.transform = 'translateY(0)'
})
}
/**
* Dismisses the flash message with smooth animation
*/
dismiss() {
// Add dismiss animation
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Remove from DOM after animation
setTimeout(() => {
this.element.remove()
}, 300)
}
/**
* Schedules auto-dismissal based on the configured delay
*/
scheduleAutoDismiss() {
const delay = parseInt(this.autoDismissValue)
if (delay > 0) {
setTimeout(() => {
this.dismiss()
}, delay)
}
}
/**
* Pause auto-dismissal on hover (for user reading)
*/
mouseEnter() {
if (this.autoDismissTimer) {
clearTimeout(this.autoDismissTimer)
this.autoDismissTimer = null
}
}
/**
* Resume auto-dismissal when hover ends
*/
mouseLeave() {
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
}
/**
* Handle keyboard interactions
*/
keydown(event) {
if (event.key === 'Escape' || event.key === 'Enter') {
this.dismiss()
}
}
}

View File

@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages form error display and dismissal
* Provides consistent error handling across all forms
*/
export default class extends Controller {
static targets = ["container"]
/**
* Dismisses the error container with a smooth fade-out animation
*/
dismiss() {
if (!this.hasContainerTarget) return
// Add transition classes
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
// Remove from DOM after animation completes
setTimeout(() => {
this.containerTarget.remove()
}, 300)
}
/**
* Shows server-side validation errors after form submission
* Auto-focuses the first error field for better accessibility
*/
connect() {
// Auto-focus first error field if errors exist
this.focusFirstErrorField()
// Scroll to errors if needed
this.scrollToErrors()
}
/**
* Focuses the first field with validation errors
*/
focusFirstErrorField() {
if (!this.hasContainerTarget) return
// Find first form field with errors (look for error classes or aria-invalid)
const form = this.element.closest('form')
if (!form) return
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
if (errorField) {
setTimeout(() => {
errorField.focus()
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
/**
* Scrolls error container into view if it's not visible
*/
scrollToErrors() {
if (!this.hasContainerTarget) return
const rect = this.containerTarget.getBoundingClientRect()
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
if (!isInViewport) {
setTimeout(() => {
this.containerTarget.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
}, 100)
}
}
/**
* Auto-dismisses success messages after a delay
* Can be called from other controllers
*/
autoDismiss(delay = 5000) {
if (!this.hasContainerTarget) return
setTimeout(() => {
this.dismiss()
}, delay)
}
}

View File

@@ -1,7 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -0,0 +1,81 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["textarea", "status"]
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
connect() {
this.validate()
}
validate() {
const value = this.textareaTarget.value.trim()
if (!value) {
this.clearStatus()
return true
}
try {
JSON.parse(value)
this.showValid()
return true
} catch (error) {
this.showInvalid(error.message)
return false
}
}
format() {
const value = this.textareaTarget.value.trim()
if (!value) return
try {
const parsed = JSON.parse(value)
const formatted = JSON.stringify(parsed, null, 2)
this.textareaTarget.value = formatted
this.showValid()
} catch (error) {
this.showInvalid(error.message)
}
}
clearStatus() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.remove(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = ""
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
}
}
showValid() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.add(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = "✓ Valid JSON"
this.statusTarget.classList.remove(...this.invalidStatusClasses)
this.statusTarget.classList.add(...this.validStatusClasses)
}
}
showInvalid(errorMessage) {
this.textareaTarget.classList.remove(...this.validClasses)
this.textareaTarget.classList.add(...this.invalidClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
this.statusTarget.classList.remove(...this.validStatusClasses)
this.statusTarget.classList.add(...this.invalidStatusClasses)
}
}
insertSample(event) {
event.preventDefault()
const sample = event.params.json || event.target.dataset.jsonSample
if (sample) {
this.textareaTarget.value = sample
this.format()
}
}
}

View File

@@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["sidebarOverlay"];
connect() {
// Initialize mobile sidebar functionality
// Add escape key listener to close sidebar
this.boundHandleEscape = this.handleEscape.bind(this);
document.addEventListener('keydown', this.boundHandleEscape);
}
disconnect() {
// Clean up event listeners
document.removeEventListener('keydown', this.boundHandleEscape);
}
openSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.remove('hidden');
// Prevent body scroll when sidebar is open
document.body.style.overflow = 'hidden';
}
}
closeSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.add('hidden');
// Restore body scroll
document.body.style.overflow = '';
}
}
// Close sidebar when clicking on the overlay background
closeOnBackgroundClick(event) {
// Check if the click is on the overlay background (the semi-transparent layer)
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
this.closeSidebar();
}
}
// Handle escape key to close sidebar
handleEscape(event) {
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
this.closeSidebar();
}
}
}

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

@@ -1,51 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["userSelect", "assignLink", "editForm"]
connect() {
console.log("Role management controller connected")
}
assignRole(event) {
event.preventDefault()
const link = event.currentTarget
const roleId = link.dataset.roleId
const select = document.getElementById(`assign-user-${roleId}`)
if (!select.value) {
alert("Please select a user")
return
}
// Update the href with the selected user ID
const originalHref = link.href
const newHref = originalHref.replace("PLACEHOLDER", select.value)
// Navigate to the updated URL
window.location.href = newHref
}
toggleEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.toggle("hidden")
}
}
hideEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.add("hidden")
}
}
}

View File

@@ -13,7 +13,7 @@ class Application < ApplicationRecord
validates :app_type, presence: true, validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] } inclusion: { in: %w[oidc forward_auth] }
validates :client_id, uniqueness: { allow_nil: true } validates :client_id, uniqueness: { allow_nil: true }
validates :client_secret, presence: true, if: :oidc? validates :client_secret, presence: true, on: :create, if: -> { oidc? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }

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,22 +1,5 @@
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %> <%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
<% if application.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% application.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -42,14 +25,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 +47,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>
@@ -69,12 +56,25 @@
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p> <p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :headers_config, rows: 10, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}' %> <%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1"> <div class="mt-2 text-sm text-gray-600 space-y-1">
<p class="font-medium">Optional: Customize header names sent to your application.</p> <div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p> <p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2"> <details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary> <summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs"> <div class="mt-2 ml-4 space-y-1 text-xs">
@@ -120,30 +120,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

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %> <%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<% if group.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% group.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -49,10 +32,25 @@
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p> <p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"roles": ["admin", "editor"]}' %> <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p> class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"roles": ["admin", "editor"]}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %> <%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<% if user.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %> <%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
@@ -52,10 +35,25 @@
<% end %> <% end %>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"department": "engineering", "level": "senior"}' %> <%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p> class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"department": "engineering", "level": "senior"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">

View File

@@ -25,24 +25,29 @@
<body> <body>
<% if authenticated? %> <% if authenticated? %>
<%= render "shared/sidebar" %> <div data-controller="mobile-sidebar">
<div class="lg:pl-64"> <%= render "shared/sidebar" %>
<!-- Mobile menu button --> <div class="lg:pl-64">
<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"> <!-- Mobile menu button -->
<button type="button" class="-m-2.5 p-2.5 text-gray-700" id="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">
<span class="sr-only">Open sidebar</span> <button type="button"
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> class="-m-2.5 p-2.5 text-gray-700"
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> id="mobile-menu-button"
</svg> data-action="click->mobile-sidebar#openSidebar">
</button> <span class="sr-only">Open sidebar</span>
</div> <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" />
<main class="py-10"> </svg>
<div class="px-4 sm:px-6 lg:px-8"> </button>
<%= render "shared/flash" %>
<%= yield %>
</div> </div>
</main>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
</div>
</main>
</div>
</div> </div>
<% else %> <% else %>
<!-- Public layout (signup/signin) --> <!-- Public layout (signup/signin) -->
@@ -52,23 +57,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

@@ -5,7 +5,7 @@
<h1 class="font-bold text-4xl">Forgot your password?</h1> <h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %> <%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<div class="my-5"> <div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div> </div>

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

@@ -3,7 +3,7 @@
<h1 class="font-bold text-4xl">Sign in to Clinch</h1> <h1 class="font-bold text-4xl">Sign in to Clinch</h1>
</div> </div>
<%= form_with url: signin_path, class: "contents" do |form| %> <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>

View File

@@ -1,29 +1,86 @@
<% if flash[:alert] %> <%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert"> <% flash.each do |type, message| %>
<% next if message.blank? %>
<%
# Map flash types to styling
case type.to_s
when 'notice'
bg_class = 'bg-green-50'
text_class = 'text-green-800'
icon_class = 'text-green-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
auto_dismiss = true
when 'alert', 'error'
bg_class = 'bg-red-50'
text_class = 'text-red-800'
icon_class = 'text-red-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
auto_dismiss = false
when 'warning'
bg_class = 'bg-yellow-50'
text_class = 'text-yellow-800'
icon_class = 'text-yellow-400'
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
auto_dismiss = false
when 'info'
bg_class = 'bg-blue-50'
text_class = 'text-blue-800'
icon_class = 'text-blue-400'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = true
else
# Default styling for unknown types
bg_class = 'bg-gray-50'
text_class = 'text-gray-800'
icon_class = 'text-gray-400'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = false
end
%>
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
role="alert"
data-controller="flash"
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
data-flash-type-value="<%= type %>">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3 flex-1">
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p> <p class="text-sm font-medium <%= text_class %>"><%= message %></p>
</div> </div>
<% if auto_dismiss || type.to_s != 'alert' %>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button"
data-action="click->flash#dismiss"
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
aria-label="Dismiss">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
</div>
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
<% if flash[:notice] %> <%# Helper method for border colors %>
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert"> <%
<div class="flex"> def border_class_for(type)
<div class="flex-shrink-0"> case type.to_s
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor"> when 'notice' then 'border-green-200'
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> when 'alert', 'error' then 'border-red-200'
</svg> when 'warning' then 'border-yellow-200'
</div> when 'info' then 'border-blue-200'
<div class="ml-3"> else 'border-gray-200'
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p> end
</div> end
</div> %>
</div>
<% end %>

View File

@@ -1,23 +1,36 @@
<% if form.object.errors.any? %> <%# Usage: <%= render "shared/form_errors", object: @user %> %>
<div class="rounded-md bg-red-50 p-4"> <%# Usage: <%= render "shared/form_errors", form: form %> %>
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %>
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-red-800"> <h3 id="form-errors-title" class="text-sm font-medium text-red-800">
There were <%= pluralize(form.object.errors.count, "error") %> with your submission: <%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
</h3> </h3>
<div class="mt-2 text-sm text-red-700"> <div class="mt-2">
<ul class="list-disc space-y-1 pl-5"> <ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
<% form.object.errors.full_messages.each do |message| %> <% form_object.errors.full_messages.each do |message| %>
<li><%= message %></li> <li><%= message %></li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
</div> </div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -90,7 +90,7 @@
<!-- Sign Out --> <!-- Sign Out -->
<li> <li>
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %> <%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
@@ -105,12 +105,18 @@
</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"
data-action="click->mobile-sidebar#closeOnBackgroundClick">
<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" />
@@ -138,7 +144,7 @@
<!-- Same nav items as desktop --> <!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<li> <li>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg> </svg>
@@ -147,7 +153,7 @@
</li> </li>
<% if user.admin? %> <% if user.admin? %>
<li> <li>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg> </svg>
@@ -155,7 +161,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg> </svg>
@@ -163,7 +169,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg> </svg>
@@ -172,7 +178,7 @@
</li> </li>
<% end %> <% end %>
<li> <li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
@@ -180,7 +186,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> <%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg> </svg>
@@ -188,7 +194,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %> <%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>

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

@@ -0,0 +1,45 @@
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600">
This will invalidate all existing backup codes and generate new ones.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 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>
<div class="text-sm text-yellow-800">
<p class="font-medium">Important Security Notice</p>
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
</div>
</div>
</div>
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
<div>
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<%= form.password_field :password, required: true,
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
</div>
<p class="mt-2 text-sm text-gray-500">
This is required to verify your identity before regenerating backup codes.
</p>
</div>
<div class="flex gap-3">
<%= form.submit "Generate New Backup Codes",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 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" %>
<%= link_to "Cancel", profile_path,
class: "inline-flex justify-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" %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -4,17 +4,8 @@
<p class="mt-2 text-gray-600">Create your admin account to get started</p> <p class="mt-2 text-gray-600">Create your admin account to get started</p>
</div> </div>
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %> <%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<% if @user.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
<ul class="list-disc list-inside">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>

View File

@@ -83,4 +83,14 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate! # config.generators.apply_rubocop_autocorrect_after_generate!
# Sentry configuration for development
# Only enabled if SENTRY_DSN environment variable is set and explicitly enabled
if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
config.sentry.enabled = true
# High sample rates for development debugging
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.5).to_f
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.2).to_f
end
end end

View File

@@ -133,4 +133,18 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } } config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
end end

View File

@@ -50,4 +50,8 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
# Disable Sentry in test environment to avoid interference with tests
# Sentry can be explicitly enabled for integration testing if needed
config.sentry.enabled = false
end end

View File

@@ -6,72 +6,60 @@
Rails.application.configure do Rails.application.configure do
config.content_security_policy do |policy| config.content_security_policy do |policy|
# Default policy: only allow resources from same origin and HTTPS # Default to self for everything, plus blob: for file downloads
policy.default_src :self, :https policy.default_src :self, "blob:"
# Scripts: strict security with nonce support for dynamic content # Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
policy.script_src :self, :https, :strict_dynamic # Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
# Styles: allow inline styles for CSS frameworks, but require HTTPS # Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
policy.style_src :self, :https, :unsafe_inline # and Stimulus controller style manipulations
policy.style_src :self, :unsafe_inline
# Images: allow data URIs for inline images and HTTPS sources # Images: Allow self, data URLs, and https for external images
policy.img_src :self, :https, :data policy.img_src :self, :data, :https
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs # Fonts: Allow self and data URLs
policy.font_src :self, :https, :data policy.font_src :self, :data
# Media: allow self and HTTPS media sources # Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
policy.media_src :self, :https # WebAuthn endpoints are on the same domain, so self is sufficient
policy.connect_src :self, "wss:"
# Objects: block potentially dangerous plugins # Media: Allow self
policy.media_src :self
# Object and embed sources: Disallow for security (no Flash/etc)
policy.object_src :none policy.object_src :none
policy.frame_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 policy.frame_ancestors :none
# Frame sources: block iframes unless explicitly needed # Base URI: Restricted to self
policy.frame_src :none policy.base_uri :self
# Connect sources: control where XHR/Fetch can connect # Form actions: Allow self for all form submissions
policy.connect_src :self, :https policy.form_action :self
# Manifest: only allow same-origin manifest files # Manifest sources: Allow self for PWA manifest
policy.manifest_src :self policy.manifest_src :self
# Worker sources: control web worker origins # Worker sources: Allow self for potential Web Workers
policy.worker_src :self, :https policy.worker_src :self
# Report URI: send violation reports to our monitoring endpoint # Child sources: Allow self for any future iframes
if Rails.env.production? policy.child_src :self
policy.report_uri "/api/csp-violation-report"
end # Additional security headers for WebAuthn
# Required for WebAuthn to work properly
policy.require_trusted_types_for :none
policy.report_uri "/api/csp-violation-report"
end end
# Generate session nonces for permitted inline scripts and styles # Start with CSP in report-only mode for testing
config.content_security_policy_nonce_generator = ->(request) { # Set to false after verifying everything works in production
# Use a secure random nonce instead of session ID for better security config.content_security_policy_report_only = Rails.env.development?
SecureRandom.base64(16)
}
# Apply nonces to script and style directives # Report CSP violations (optional - uncomment to enable)
config.content_security_policy_nonce_directives = %w(script-src style-src) # config.content_security_policy_report_uri = "/csp-violations"
end
# 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

@@ -0,0 +1,122 @@
# Local file logger for CSP violations
# Provides local logging even when Sentry is not configured
Rails.application.config.after_initialize do
# Create a dedicated logger for CSP violations
csp_log_path = Rails.root.join("log", "csp_violations.log")
# Configure log rotation
csp_logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
csp_logger.level = Logger::INFO
# Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
module CspViolationLocalLogger
def self.emit(event)
csp_data = event[:payload] || {}
# Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown"
blocked_uri = csp_data[:blocked_uri] || "unknown"
document_uri = csp_data[:document_uri] || "unknown"
# Create a comprehensive log entry
log_message = "CSP VIOLATION DETECTED\n"
log_message += " Directive: #{violated_directive}\n"
log_message += " Blocked URI: #{blocked_uri}\n"
log_message += " Document URI: #{document_uri}\n"
log_message += " User Agent: #{csp_data[:user_agent]}\n"
log_message += " IP Address: #{csp_data[:ip_address]}\n"
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
if csp_data[:current_user_id].present?
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
log_message += " Session ID: #{csp_data[:session_id]}\n"
else
log_message += " User: Anonymous\n"
end
# Add additional details if available
if csp_data[:source_file].present?
log_message += " Source File: #{csp_data[:source_file]}"
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
log_message += "\n"
end
if csp_data[:referrer].present?
log_message += " Referrer: #{csp_data[:referrer]}\n"
end
# Determine severity for log level
level = determine_log_level(csp_data[:violated_directive])
self.csp_logger.log(level, log_message)
# Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e
# Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
def self.csp_logger
@csp_logger ||= begin
csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
logger
end
end
private
def self.determine_log_level(violated_directive)
return Logger::INFO unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
Logger::WARN # Higher priority violations
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
Logger::INFO # Medium priority violations
else
Logger::DEBUG # Lower priority violations
end
end
end
# Register the local logger subscriber
Rails.event.subscribe(CspViolationLocalLogger)
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
# Ensure the log file is created and writable
begin
# Create log file if it doesn't exist
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
# Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
end
end

View File

@@ -0,0 +1,140 @@
# Sentry configuration for error tracking and performance monitoring
# Only initializes if SENTRY_DSN environment variable is set
return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do
config.sentry.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
# Sample rate for performance monitoring (0.0 to 1.0)
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Enable profiling in development/staging, disable in production unless explicitly enabled
config.sentry.profiles_sample_rate = if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [
"ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat",
"ActionDispatch::Http::Parameters::ParseError",
"Rack::QueryParser::InvalidParameterError",
"Rack::Timeout::RequestTimeoutException",
"ActiveRecord::RecordNotFound"
]
# Add CSP-specific tags for security events
config.sentry.tags = lambda do
{
# Add application context
app_name: "clinch",
app_environment: Rails.env,
# Add CSP policy status
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present?
}
end
# Enhance before_send to handle CSP events properly
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end
event
end
# Add CSP-specific breadcrumbs for security events
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
# Filter out sensitive breadcrumb data
if breadcrumb[:data]
breadcrumb[:data].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end
breadcrumb
end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end

View File

@@ -0,0 +1,120 @@
# Sentry subscriber for CSP violations via Structured Event Reporting
# This subscriber only sends events to Sentry if Sentry is properly initialized
Rails.application.config.after_initialize do
# Only register the subscriber if Sentry is available and configured
if defined?(Sentry) && Sentry.initialized?
module CspViolationSentrySubscriber
def self.emit(event)
# Extract relevant CSP violation data
csp_data = event[:payload] || {}
# Build a descriptive message for Sentry
violated_directive = csp_data[:violated_directive]
blocked_uri = csp_data[:blocked_uri]
document_uri = csp_data[:document_uri]
message = "CSP Violation: #{violated_directive}"
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
message += " - On: #{document_uri}" if document_uri.present?
# Extract domain from blocked_uri for better classification
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
# Determine severity based on violation type
level = determine_severity(violated_directive, blocked_uri)
# Send to Sentry with rich context
Sentry.capture_message(
message,
level: level,
tags: {
csp_violation: true,
violated_directive: violated_directive,
blocked_domain: blocked_domain,
document_domain: extract_domain(document_uri),
user_authenticated: csp_data[:current_user_id].present?
},
extra: {
# Full CSP report data
csp_violation_details: csp_data,
# Additional context for security analysis
request_context: {
user_agent: csp_data[:user_agent],
ip_address: csp_data[:ip_address],
session_id: csp_data[:session_id],
timestamp: csp_data[:timestamp]
}
},
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
)
# Log to Rails logger for redundancy
Rails.logger.info "CSP violation sent to Sentry: #{message}"
rescue => e
# Ensure subscriber errors don't break the CSP reporting flow
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
private
# Extract domain from URI for better analysis
def self.extract_domain(uri)
return nil if uri.blank?
begin
parsed = URI.parse(uri)
parsed.host
rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path
if uri.start_with?('/')
nil # It's a relative path, no domain
else
uri.split('/').first # Best effort extraction
end
end
end
# Determine severity level based on violation type
def self.determine_severity(violated_directive, blocked_uri)
return :warning unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr
# Script violations are highest priority (XSS risk)
:error
when :style_src, :style_src_elem, :style_src_attr
# Style violations are moderate risk
:warning
when :img_src
# Image violations are typically lower priority
:info
when :connect_src
# Network violations are important
:warning
when :font_src, :media_src
# Font/media violations are lower priority
:info
when :frame_src, :child_src
# Frame violations can be security critical
:error
when :default_src
# Default src violations are important
:warning
else
# Unknown or custom directives
:warning
end
end
end
# Register the subscriber for CSP violation events
Rails.event.subscribe(CspViolationSentrySubscriber)
Rails.logger.info "CSP violation Sentry subscriber registered"
else
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
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

View File

@@ -0,0 +1,13 @@
class ClearExistingBackupCodes < ActiveRecord::Migration[8.1]
def up
# Clear all existing backup codes to force regeneration with BCrypt hashing
# This is a security migration to move from plain text to hashed storage
User.where.not(backup_codes: nil).update_all(backup_codes: nil)
end
def down
# This migration cannot be safely reversed
# as the original plain text codes cannot be recovered
raise ActiveRecord::IrreversibleMigration
end
end

View File

@@ -0,0 +1,12 @@
class ChangeBackupCodesToJson < ActiveRecord::Migration[8.1]
def up
# Change the column type from text to json
# This will automatically handle JSON serialization/deserialization
change_column :users, :backup_codes, :json
end
def down
# Revert back to text if needed
change_column :users, :backup_codes, :text
end
end

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

136
docs/oidc-key-setup.md Normal file
View File

@@ -0,0 +1,136 @@
# OIDC Private Key Setup
Your OIDC provider needs an RSA private key to sign ID tokens. **This key must persist across deployments** or all existing tokens will become invalid.
## Option 1: Environment Variable (Recommended for Docker/Kamal)
### 1. Generate the key
```bash
# Generate a 2048-bit RSA key
openssl genrsa -out oidc_private_key.pem 2048
# View the key (you'll copy this)
cat oidc_private_key.pem
```
### 2. Store in your `.env` file
```bash
# .env (for local development)
OIDC_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
...
-----END RSA PRIVATE KEY-----"
```
**Important:** Keep the quotes and include the full key with `-----BEGIN` and `-----END` lines.
### 3. For Kamal deployment
Add to your Kamal secrets:
```yaml
# config/deploy.yml
env:
secret:
- OIDC_PRIVATE_KEY
```
Then set it securely:
```bash
# Generate key
bin/generate_oidc_key > oidc_private_key.pem
# Add to .kamal/secrets
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
```
### 4. Verify it's loaded
```bash
# In Rails console
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
```
---
## Comparison
| Feature | ENV Variable | Rails Credentials |
|---------|-------------|-------------------|
| **Best for** | Docker, Kamal, 12-factor | Simple deployments |
| **Key rotation** | Easy (just update ENV) | Medium (re-encrypt) |
| **Per-environment** | Yes (dev/staging/prod can differ) | No (same key everywhere) |
| **Secrets manager** | Compatible (AWS Secrets, etc.) | Needs RAILS_MASTER_KEY |
| **Setup complexity** | Low | Medium |
**Recommendation:** Use ENV variable (`OIDC_PRIVATE_KEY`) for production with Kamal.
---
## Security Best Practices
### DO:
- ✅ Generate the key once and keep it forever
- ✅ Store in secret manager (AWS Secrets Manager, 1Password, etc.)
- ✅ Use strong key (2048-bit RSA minimum)
- ✅ Backup the key securely
- ✅ Restrict access (only ops team)
### DON'T:
- ❌ Commit the key to git (except encrypted credentials)
- ❌ Share the key in Slack/email
- ❌ Regenerate the key (invalidates all tokens)
- ❌ Store in `.env` if it's committed to git
- ❌ Use the same key for multiple environments
---
## Key Rotation (Advanced)
Todo
---
## Troubleshooting
### "No private key found" warning
Check your setup:
```bash
# Is ENV set?
echo $OIDC_PRIVATE_KEY
# Can Rails load it?
bin/rails runner "puts ENV['OIDC_PRIVATE_KEY'].present? ? 'ENV set' : 'ENV missing'"
# Does it load correctly?
bin/rails runner "puts OidcJwtService.send(:private_key).to_s[0..50]"
```
### "invalid RSA key" error
- Make sure you include `-----BEGIN RSA PRIVATE KEY-----` header
- Ensure newlines are preserved (use quotes in ENV)
- Check for extra spaces or characters
### Different JWKS key ID on each restart
This means the key is being regenerated. You need to set `OIDC_PRIVATE_KEY` or add to credentials.
### All tokens invalid after deployment
The key changed. You either:
- Regenerated the key (don't do this!)
- Forgot to set ENV variable in production
- The key wasn't loaded correctly
Check logs for warnings and verify key is loaded:
```bash
kamal app logs --grep "OIDC"
```

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