Compare commits
71 Commits
7200a6735f
...
2025.02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb | ||
|
|
6049429a41 | ||
|
|
2b15aa2c40 | ||
|
|
4f5974dd37 | ||
|
|
5de53f1841 | ||
|
|
73b2ae2f02 | ||
|
|
4c5ac344bd | ||
|
|
044b9239d6 | ||
|
|
e9b1995e89 | ||
|
|
fb14ce032f | ||
|
|
bf104a9983 | ||
|
|
ec13dd2b60 | ||
|
|
57abc0b804 | ||
|
|
19bfc21f11 | ||
|
|
ef15db77f9 | ||
|
|
4d1bc1ab66 | ||
|
|
517029247d | ||
|
|
bfcc5cdc84 | ||
|
|
81871426e9 | ||
|
|
ddcb297c74 | ||
|
|
6f7de94623 | ||
|
|
baa75a3456 | ||
|
|
c3205abffa | ||
|
|
a2008d0750 | ||
|
|
810561d74b | ||
|
|
2ee895888d | ||
|
|
6c9fc429f1 | ||
|
|
7d200b849e | ||
|
|
7074242907 | ||
|
|
da6fd5b800 | ||
|
|
cfab21b130 | ||
|
|
c80bcafdb7 | ||
|
|
f050541e14 | ||
|
|
431e947a4c | ||
|
|
8dd3e60071 | ||
|
|
e4e7a0873e | ||
|
|
b5b1d94d47 | ||
|
|
52cfd6122c | ||
|
|
87796e0478 | ||
|
|
227e29ce0a | ||
|
|
d98f777e7d | ||
|
|
88428bfd97 | ||
|
|
2679634a2b | ||
|
|
2d5823213c | ||
|
|
5921cf82c2 | ||
|
|
df834b6e57 | ||
|
|
471c16890b | ||
|
|
39757a43dc | ||
|
|
5463723455 | ||
|
|
e36850f8ba | ||
|
|
0af3dbefed | ||
|
|
d6c24e50df | ||
|
|
8c80343b89 | ||
|
|
2db7f6a9df | ||
|
|
e3f202f574 | ||
|
|
c7f391541a | ||
|
|
8e56210b74 | ||
|
|
056c69e002 | ||
|
|
225b6b0bb6 | ||
|
|
fbda018065 | ||
|
|
12e0ef66ed | ||
|
|
831bd083c2 | ||
|
|
1212e0f22e | ||
|
|
a21b21ace2 | ||
|
|
ad70841689 | ||
|
|
9be6ef09ff | ||
|
|
21bdc21486 | ||
|
|
fc9afcd1b7 | ||
|
|
ee4af20000 |
60
.env.example
60
.env.example
@@ -16,9 +16,43 @@ SMTP_AUTHENTICATION=plain
|
||||
SMTP_ENABLE_STARTTLS=true
|
||||
|
||||
# Application Configuration
|
||||
CLINCH_HOST=http://localhost:9000
|
||||
CLINCH_HOST=http://localhost:3000
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# WebAuthn / Passkey Configuration
|
||||
# Required for passkeys to work in production (HTTPS required)
|
||||
#
|
||||
# CLINCH_RP_ID is the Relying Party Identifier - the domain that owns the passkeys
|
||||
# - If your site is auth.example.com, use either "auth.example.com" or "example.com"
|
||||
# - Using parent domain (e.g., "example.com") allows passkeys to work across all subdomains
|
||||
# - Using subdomain (e.g., "auth.example.com") restricts passkeys to that specific subdomain
|
||||
#
|
||||
# CLINCH_RP_NAME is shown to users when creating/using passkeys
|
||||
#
|
||||
# Examples:
|
||||
# For https://auth.example.com:
|
||||
# CLINCH_HOST=https://auth.example.com
|
||||
# CLINCH_RP_ID=example.com
|
||||
# CLINCH_RP_NAME="Example Company"
|
||||
#
|
||||
# For https://sso.mycompany.com:
|
||||
# CLINCH_HOST=https://sso.mycompany.com
|
||||
# CLINCH_RP_ID=mycompany.com
|
||||
# CLINCH_RP_NAME="My Company Identity"
|
||||
#
|
||||
CLINCH_RP_ID=localhost
|
||||
CLINCH_RP_NAME="Clinch Identity Provider"
|
||||
|
||||
# DNS Rebinding Protection Configuration
|
||||
# Set to service name (e.g., 'clinch') if running in same Docker compose as Caddy
|
||||
CLINCH_DOCKER_SERVICE_NAME=
|
||||
|
||||
# Allow internal IP access for cross-compose deployments (true/false)
|
||||
CLINCH_ALLOW_INTERNAL_IPS=true
|
||||
|
||||
# Allow localhost access for development (true/false)
|
||||
CLINCH_ALLOW_LOCALHOST=true
|
||||
|
||||
# OIDC Configuration
|
||||
# RSA private key for signing ID tokens (JWT)
|
||||
# Generate with: openssl genrsa 2048
|
||||
@@ -34,3 +68,27 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# Optional: Set custom port
|
||||
# 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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
||||
run: bin/rails db:test:prepare test:system
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
|
||||
@@ -32,7 +32,7 @@ FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
|
||||
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Install application gems
|
||||
|
||||
22
Gemfile
22
Gemfile
@@ -1,7 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", "~> 8.1.0"
|
||||
gem "rails", "~> 8.1.1"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
@@ -26,17 +26,26 @@ gem "bcrypt", "~> 3.1.7"
|
||||
gem "rotp", "~> 6.3"
|
||||
|
||||
# QR code generation for TOTP setup
|
||||
gem "rqrcode", "~> 2.0"
|
||||
gem "rqrcode", "~> 3.1"
|
||||
|
||||
# JWT for OIDC ID tokens
|
||||
gem "jwt", "~> 2.9"
|
||||
gem "jwt", "~> 3.1"
|
||||
|
||||
# WebAuthn for passkey support
|
||||
gem "webauthn", "~> 3.0"
|
||||
|
||||
# Public Suffix List for domain parsing
|
||||
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
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||
gem "solid_cache"
|
||||
gem "solid_queue"
|
||||
gem "solid_cable"
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
@@ -68,6 +77,9 @@ end
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
|
||||
# Preview emails in browser instead of sending them
|
||||
gem "letter_opener"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
200
Gemfile.lock
200
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
railties
|
||||
actioncable (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.0)
|
||||
actiontext (8.1.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activerecord (8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.0)
|
||||
activesupport (8.1.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -77,11 +77,13 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
android_key_attestation (0.3.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
@@ -100,21 +102,25 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cbor (0.5.10.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
date (3.4.1)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.1)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
@@ -122,9 +128,6 @@ GEM
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
@@ -137,15 +140,15 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
jwt (2.10.2)
|
||||
json (2.15.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
activesupport (>= 7.0)
|
||||
@@ -159,6 +162,12 @@ GEM
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
@@ -191,7 +200,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
@@ -206,6 +215,9 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
openssl (3.3.2)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
@@ -225,9 +237,8 @@ GEM
|
||||
public_suffix (6.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.2.4)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -235,20 +246,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.0)
|
||||
actioncable (= 8.1.0)
|
||||
actionmailbox (= 8.1.0)
|
||||
actionmailer (= 8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actiontext (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
rails (8.1.1)
|
||||
actioncable (= 8.1.1)
|
||||
actionmailbox (= 8.1.1)
|
||||
actionmailer (= 8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actiontext (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.0)
|
||||
railties (= 8.1.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -256,9 +267,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
railties (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -266,8 +277,8 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.15.0)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -276,10 +287,10 @@ GEM
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.81.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
@@ -312,14 +323,22 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.0)
|
||||
rubyzip (3.2.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.37.0)
|
||||
selenium-webdriver (4.38.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.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)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -329,13 +348,6 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.2.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||
sqlite3 (2.7.4-arm-linux-gnu)
|
||||
@@ -367,7 +379,11 @@ GEM
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
timeout (0.4.3)
|
||||
timeout (0.4.4)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.17)
|
||||
actionpack (>= 7.1.0)
|
||||
@@ -377,13 +393,21 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.0)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
openssl (>= 2.2)
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
@@ -414,18 +438,21 @@ DEPENDENCIES
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jwt (~> 2.9)
|
||||
jwt (~> 3.1)
|
||||
kamal
|
||||
letter_opener
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rails (~> 8.1.1)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.0)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 5.18)
|
||||
sentry-ruby (~> 5.18)
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3 (>= 2.1)
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
@@ -433,6 +460,7 @@ DEPENDENCIES
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
web-console
|
||||
webauthn (~> 3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.2
|
||||
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Dan Milne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
86
README.md
86
README.md
@@ -1,17 +1,71 @@
|
||||
# Clinch
|
||||
|
||||
**A lightweight, self-hosted identity & SSO portal**
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||
**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.
|
||||
|
||||
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?
|
||||
|
||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||
|
||||
**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment.
|
||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||
|
||||
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
||||
|
||||
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
||||
|
||||
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### User Dashboard
|
||||
[](docs/screenshots/0-dashboard.png)
|
||||
|
||||
### Sign In
|
||||
[](docs/screenshots/1-signin.png)
|
||||
|
||||
### Sign In with 2FA
|
||||
[](docs/screenshots/2-signin.png)
|
||||
|
||||
### Users Management
|
||||
[](docs/screenshots/3-users.png)
|
||||
|
||||
### Welcome Screen
|
||||
[](docs/screenshots/4-welcome.png)
|
||||
|
||||
### Welcome Setup
|
||||
[](docs/screenshots/5-welcome-2.png)
|
||||
|
||||
### Setup 2FA
|
||||
[](docs/screenshots/6-setup-2fa.png)
|
||||
|
||||
### Forward Auth Example 1
|
||||
[](docs/screenshots/7-forward-auth-1.png)
|
||||
|
||||
### Forward Auth Example 2
|
||||
[](docs/screenshots/8-forward-auth-2.png)
|
||||
|
||||
## Features
|
||||
|
||||
### User Management
|
||||
@@ -46,6 +100,8 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
|
||||
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
||||
|
||||
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||
|
||||
### SMTP Integration
|
||||
Send emails for:
|
||||
- Invitation links (one-time token, 7-day expiry)
|
||||
@@ -61,6 +117,7 @@ Send emails for:
|
||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
||||
- **Per-application access** - Each app defines which groups can access it
|
||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
||||
|
||||
---
|
||||
|
||||
@@ -75,11 +132,13 @@ Send emails for:
|
||||
- TOTP secret and backup codes (encrypted)
|
||||
- TOTP enforcement flag
|
||||
- Status (active, disabled, pending_invitation)
|
||||
- Custom claims (JSON) - arbitrary key-value pairs added to OIDC tokens
|
||||
- Token generation for invitations, password resets, and magic logins
|
||||
|
||||
**Group**
|
||||
- Name (unique, normalized to lowercase)
|
||||
- Description
|
||||
- Custom claims (JSON) - shared claims for all members (merged with user claims)
|
||||
- Many-to-many with Users and Applications
|
||||
|
||||
**Session**
|
||||
@@ -92,9 +151,11 @@ Send emails for:
|
||||
|
||||
**Application**
|
||||
- Name and slug (URL-safe identifier)
|
||||
- Type (oidc, trusted_header, saml)
|
||||
- Client ID and secret (for OIDC)
|
||||
- Redirect URIs (JSON array)
|
||||
- Type (oidc or forward_auth)
|
||||
- Client ID and secret (for OIDC apps)
|
||||
- Redirect URIs (for OIDC apps)
|
||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
||||
- Metadata (flexible JSON storage)
|
||||
- Active flag
|
||||
- Many-to-many with Groups (allowlist)
|
||||
@@ -159,7 +220,7 @@ bin/dev
|
||||
docker build -t clinch .
|
||||
|
||||
# Run container
|
||||
docker run -p 9000:9000 \
|
||||
docker run -p 3000:3000 \
|
||||
-v clinch-storage:/rails/storage \
|
||||
-e SECRET_KEY_BASE=your-secret-key \
|
||||
-e SMTP_ADDRESS=smtp.example.com \
|
||||
@@ -200,7 +261,7 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
```
|
||||
|
||||
### First Run
|
||||
1. Visit Clinch at `http://localhost:9000` (or your configured domain)
|
||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||
2. First-run wizard creates initial admin user
|
||||
3. Admin can then:
|
||||
- Create groups
|
||||
@@ -219,12 +280,14 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
- First-run wizard
|
||||
|
||||
### Planned Features
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
|
||||
#### Maybe
|
||||
- **SAML support** - SAML 2.0 identity provider
|
||||
- **Policy engine** - Rule-based access control
|
||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||
- Stored as JSON, evaluated after auth but before consent
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||
|
||||
---
|
||||
@@ -242,5 +305,4 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
MIT
|
||||
35
app/controllers/active_sessions_controller.rb
Normal file
35
app/controllers/active_sessions_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class ActiveSessionsController < ApplicationController
|
||||
def show
|
||||
@user = Current.session.user
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
|
||||
end
|
||||
|
||||
def revoke_consent
|
||||
@user = Current.session.user
|
||||
application = Application.find(params[:application_id])
|
||||
|
||||
# Check if user has consent for this application
|
||||
consent = @user.oidc_user_consents.find_by(application: application)
|
||||
unless consent
|
||||
redirect_to active_sessions_path, alert: "No consent found for this application."
|
||||
return
|
||||
end
|
||||
|
||||
# Revoke the consent
|
||||
consent.destroy
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||
end
|
||||
|
||||
def revoke_all_consents
|
||||
@user = Current.session.user
|
||||
count = @user.oidc_user_consents.count
|
||||
|
||||
if count > 0
|
||||
@user.oidc_user_consents.destroy_all
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
||||
else
|
||||
redirect_to active_sessions_path, alert: "No applications to revoke."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -17,6 +17,7 @@ module Admin
|
||||
|
||||
def create
|
||||
@application = Application.new(application_params)
|
||||
@available_groups = Group.order(:name)
|
||||
|
||||
if @application.save
|
||||
# Handle group assignments
|
||||
@@ -25,9 +26,22 @@ module Admin
|
||||
@application.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application), notice: "Application created successfully."
|
||||
# Get the plain text client secret to show one time
|
||||
client_secret = nil
|
||||
if @application.oidc?
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
end
|
||||
|
||||
if @application.oidc? && client_secret
|
||||
flash[:notice] = "Application created successfully."
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret
|
||||
else
|
||||
flash[:notice] = "Application created successfully."
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -60,11 +74,17 @@ module Admin
|
||||
|
||||
def regenerate_credentials
|
||||
if @application.oidc?
|
||||
@application.update!(
|
||||
client_id: SecureRandom.urlsafe_base64(32),
|
||||
client_secret: SecureRandom.urlsafe_base64(48)
|
||||
)
|
||||
redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
|
||||
# Generate new client ID and secret
|
||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
|
||||
@application.update!(client_id: new_client_id)
|
||||
|
||||
flash[:notice] = "Credentials regenerated successfully."
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
else
|
||||
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
||||
end
|
||||
@@ -77,7 +97,13 @@ module Admin
|
||||
end
|
||||
|
||||
def application_params
|
||||
params.require(:application).permit(:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata)
|
||||
params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
: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
|
||||
|
||||
@@ -67,7 +67,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description)
|
||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Admin
|
||||
class UsersController < BaseController
|
||||
before_action :set_user, only: [:show, :edit, :update, :destroy]
|
||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
@@ -16,9 +16,11 @@ module Admin
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||
@user.status = :pending_invitation
|
||||
|
||||
if @user.save
|
||||
redirect_to admin_users_path, notice: "User created successfully."
|
||||
InvitationsMailer.invite_user(@user).deliver_later
|
||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -46,6 +48,16 @@ module Admin
|
||||
end
|
||||
end
|
||||
|
||||
def resend_invitation
|
||||
unless @user.pending_invitation?
|
||||
redirect_to admin_users_path, alert: "Cannot send invitation. User is not pending invitation."
|
||||
return
|
||||
end
|
||||
|
||||
InvitationsMailer.invite_user(@user).deliver_later
|
||||
redirect_to admin_users_path, notice: "Invitation email resent to #{@user.email_address}."
|
||||
end
|
||||
|
||||
def destroy
|
||||
# Prevent admin from deleting themselves
|
||||
if @user == Current.session.user
|
||||
@@ -64,7 +76,7 @@ module Admin
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email_address, :password, :admin, :status)
|
||||
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
50
app/controllers/api/csp_controller.rb
Normal file
50
app/controllers/api/csp_controller.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
module Api
|
||||
class CspController < ApplicationController
|
||||
# CSP violation reports don't need authentication
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access
|
||||
|
||||
# POST /api/csp-violation-report
|
||||
def violation_report
|
||||
# Parse CSP violation report
|
||||
report_data = JSON.parse(request.body.read)
|
||||
csp_report = report_data['csp-report']
|
||||
|
||||
# Log the violation for security monitoring
|
||||
Rails.logger.warn "CSP Violation Report:"
|
||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||
|
||||
# Emit structured event for CSP violation
|
||||
# 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
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Invalid CSP violation report: #{e.message}"
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,25 +1,29 @@
|
||||
module Api
|
||||
class ForwardAuthController < ApplicationController
|
||||
# ForwardAuth endpoints don't use sessions or CSRF
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
||||
|
||||
# GET /api/verify
|
||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||
# to verify if a user is authenticated and authorized to access an application
|
||||
# to verify if a user is authenticated and authorized to access a domain
|
||||
def verify
|
||||
# Get the application slug from query params or X-Forwarded-Host header
|
||||
app_slug = params[:app] || extract_app_from_headers
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
||||
|
||||
# Check for one-time forward auth token first (to handle race condition)
|
||||
session_id = check_forward_auth_token
|
||||
|
||||
# If no token found, try to get session from cookie
|
||||
session_id ||= extract_session_id
|
||||
|
||||
# Get the session from cookie
|
||||
session_id = extract_session_id
|
||||
unless session_id
|
||||
# No session cookie - user is not authenticated
|
||||
# No session cookie or token - user is not authenticated
|
||||
return render_unauthorized("No session cookie")
|
||||
end
|
||||
|
||||
# Find the session
|
||||
session = Session.find_by(id: session_id)
|
||||
# Find the session with user association (eager loading for performance)
|
||||
session = Session.includes(:user).find_by(id: session_id)
|
||||
unless session
|
||||
# Invalid session
|
||||
return render_unauthorized("Invalid session")
|
||||
@@ -31,89 +35,109 @@ module Api
|
||||
return render_unauthorized("Session expired")
|
||||
end
|
||||
|
||||
# Update last activity
|
||||
# Update last activity (skip validations for performance)
|
||||
session.update_column(:last_activity_at, Time.current)
|
||||
|
||||
# Get the user
|
||||
# Get the user (already loaded via includes(:user))
|
||||
user = session.user
|
||||
unless user.active?
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# If an application is specified, check authorization
|
||||
if app_slug.present?
|
||||
application = Application.find_by(slug: app_slug, app_type: "trusted_header", active: true)
|
||||
# Check for forward auth application authorization
|
||||
# Get the forwarded host for domain matching
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
unless application
|
||||
Rails.logger.warn "ForwardAuth: Application not found or not configured for trusted_header: #{app_slug}"
|
||||
return render_forbidden("Application not found or not configured")
|
||||
if forwarded_host.present?
|
||||
# Load active forward auth applications with their associations for better performance
|
||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||
apps = Application.forward_auth.includes(:allowed_groups).active
|
||||
|
||||
# Find matching forward auth application for this domain
|
||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||
|
||||
if app
|
||||
# Check if user is allowed by this application
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
||||
return render_forbidden("You do not have permission to access this domain")
|
||||
end
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - allow access with default headers (original behavior)
|
||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
||||
end
|
||||
|
||||
# Check if user is allowed to access this application
|
||||
unless application.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{app_slug}"
|
||||
return render_forbidden("You do not have permission to access this application")
|
||||
end
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{app_slug}"
|
||||
else
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no app specified)"
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
end
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers
|
||||
response.headers["Remote-User"] = user.email_address
|
||||
response.headers["Remote-Email"] = user.email_address
|
||||
response.headers["Remote-Name"] = user.email_address
|
||||
# Return 200 with user information headers using app-specific configuration
|
||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
# Log what headers we're sending (helpful for debugging)
|
||||
if headers.any?
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||
else
|
||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||
end
|
||||
|
||||
# Add admin flag
|
||||
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
||||
|
||||
# Return 200 OK with no body
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_forward_auth_token
|
||||
# Check for one-time token in query parameters (for race condition handling)
|
||||
token = params[:fa_token]
|
||||
return nil unless token.present?
|
||||
|
||||
# Try to get session ID from cache
|
||||
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||
return nil unless session_id
|
||||
|
||||
# Verify the session exists and is valid
|
||||
session = Session.find_by(id: session_id)
|
||||
return nil unless session && !session.expired?
|
||||
|
||||
# Delete the token immediately (one-time use)
|
||||
Rails.cache.delete("forward_auth_token:#{token}")
|
||||
|
||||
session_id
|
||||
end
|
||||
|
||||
def extract_session_id
|
||||
# Extract session ID from cookie
|
||||
# Rails uses signed cookies by default
|
||||
cookies.signed[:session_id]
|
||||
session_id = cookies.signed[:session_id]
|
||||
session_id
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
# Try to extract application slug from forwarded headers
|
||||
# This is useful when the proxy doesn't pass ?app= param
|
||||
|
||||
# X-Forwarded-Host might contain the hostname
|
||||
host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
# Try to match hostname to application
|
||||
# Format: app-slug.domain.com -> app-slug
|
||||
if host.present?
|
||||
# Extract subdomain as potential app slug
|
||||
parts = host.split(".")
|
||||
if parts.length >= 2
|
||||
return parts.first if parts.first != "www"
|
||||
end
|
||||
end
|
||||
|
||||
# This method is deprecated since we now use Application (forward_auth type) domain matching
|
||||
# Keeping it for backward compatibility but it's no longer used
|
||||
nil
|
||||
end
|
||||
|
||||
def render_unauthorized(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Get the redirect URL from query params or construct default
|
||||
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
||||
redirect_url = validate_redirect_url(params[:rd])
|
||||
base_url = determine_base_url(redirect_url)
|
||||
|
||||
# Set the original URL that user was trying to access
|
||||
# This will be used after authentication
|
||||
@@ -124,11 +148,11 @@ module Api
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||
|
||||
original_url = if original_host
|
||||
# Use the forwarded host and URI
|
||||
# Use the forwarded host and URI (original behavior)
|
||||
"https://#{original_host}#{original_uri}"
|
||||
else
|
||||
# Fallback: just redirect to the root of the original host
|
||||
"https://#{request.headers['Host']}"
|
||||
# Fallback: use the validated redirect URL or default
|
||||
redirect_url || "https://clinch.aapamilne.com"
|
||||
end
|
||||
|
||||
# Debug: log what we're redirecting to after login
|
||||
@@ -137,26 +161,78 @@ module Api
|
||||
session[:return_to_after_authenticating] = original_url
|
||||
|
||||
# Build login URL with redirect parameters like Authelia
|
||||
login_url = URI.parse("#{base_url}/signin")
|
||||
login_url.query_params = {
|
||||
login_params = {
|
||||
rd: original_url,
|
||||
rm: request.method
|
||||
}.to_query
|
||||
}
|
||||
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
||||
|
||||
# Return 302 Found directly to login page (matching Authelia)
|
||||
# This is the same as Authelia's StatusFound response
|
||||
Rails.logger.info "Setting 302 redirect to: #{login_url}"
|
||||
redirect_to login_url.to_s, allow_other_host: true, status: :found
|
||||
redirect_to login_url, allow_other_host: true, status: :found
|
||||
end
|
||||
|
||||
def render_forbidden(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def validate_redirect_url(url)
|
||||
return nil unless url.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Only allow HTTP/HTTPS schemes
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
|
||||
# Check against our ForwardAuth applications
|
||||
matching_app = Application.forward_auth.active.find do |app|
|
||||
app.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def domain_has_forward_auth_rule?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
Application.forward_auth.active.any? do |app|
|
||||
app.matches_domain?(domain.downcase)
|
||||
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
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
require 'uri'
|
||||
require 'public_suffix'
|
||||
require 'ipaddr'
|
||||
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -31,17 +35,38 @@ module Authentication
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to new_session_path
|
||||
redirect_to signin_path
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
return_url = session[:return_to_after_authenticating]
|
||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||
final_url
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
create_forward_auth_token(session)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,4 +74,73 @@ module Authentication
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
return nil if IPAddr.new(host_without_port) rescue false
|
||||
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
end
|
||||
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store it with an expiry of 30 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 30.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,5 +8,10 @@ class DashboardController < ApplicationController
|
||||
|
||||
# User must be authenticated
|
||||
@user = Current.session.user
|
||||
|
||||
# Load user's accessible applications
|
||||
@applications = Application.active.select do |app|
|
||||
app.user_allowed?(@user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
50
app/controllers/invitations_controller.rb
Normal file
50
app/controllers/invitations_controller.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
class InvitationsController < ApplicationController
|
||||
include Authentication
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||
|
||||
def show
|
||||
# Show the password setup form
|
||||
end
|
||||
|
||||
def update
|
||||
# Validate password manually since empty passwords might not trigger validation
|
||||
password = params[:password]
|
||||
password_confirmation = params[:password_confirmation]
|
||||
|
||||
if password.blank? || password_confirmation.blank? || password != password_confirmation || password.length < 8
|
||||
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||
return
|
||||
end
|
||||
|
||||
if @user.update(password: password, password_confirmation: password_confirmation)
|
||||
@user.update!(status: :active)
|
||||
@user.sessions.destroy_all
|
||||
start_new_session_for @user
|
||||
redirect_to root_path, notice: "Your account has been set up successfully. Welcome!"
|
||||
else
|
||||
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user_by_invitation_token
|
||||
@user = User.find_by_token_for(:invitation_login, params[:token])
|
||||
|
||||
# Check if user is still pending invitation
|
||||
if @user.nil?
|
||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||
return false
|
||||
elsif @user.pending_invitation?
|
||||
# User is valid and pending - proceed
|
||||
return true
|
||||
else
|
||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||
return false
|
||||
end
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||
return false
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
class OidcController < ApplicationController
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
||||
skip_before_action :verify_authenticity_token, only: [:token]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
def discovery
|
||||
@@ -13,6 +13,7 @@ class OidcController < ApplicationController
|
||||
token_endpoint: "#{base_url}/oauth/token",
|
||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||
end_session_endpoint: "#{base_url}/logout",
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
@@ -81,6 +82,30 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
requested_scopes = scope.split(" ")
|
||||
|
||||
# Check if user has already granted consent for these scopes
|
||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||
if existing_consent
|
||||
# User has already consented, generate authorization code directly
|
||||
code = SecureRandom.urlsafe_base64(32)
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: user,
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
scope: scope,
|
||||
nonce: nonce,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{redirect_uri}?code=#{code}"
|
||||
redirect_uri += "&state=#{state}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Store OAuth parameters for consent page
|
||||
session[:oauth_params] = {
|
||||
client_id: client_id,
|
||||
@@ -92,7 +117,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Render consent page
|
||||
@redirect_uri = redirect_uri
|
||||
@scopes = scope.split(" ")
|
||||
@scopes = requested_scopes
|
||||
render :consent
|
||||
end
|
||||
|
||||
@@ -108,36 +133,47 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: oauth_params[:client_id])
|
||||
client_id = oauth_params['client_id']
|
||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
user = Current.session.user
|
||||
|
||||
# Record user consent
|
||||
requested_scopes = oauth_params['scope'].split(' ')
|
||||
OidcUserConsent.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
application_id: application.id,
|
||||
scopes_granted: requested_scopes.join(' '),
|
||||
granted_at: Time.current
|
||||
},
|
||||
unique_by: [:user_id, :application_id]
|
||||
)
|
||||
|
||||
# Generate authorization code
|
||||
code = SecureRandom.urlsafe_base64(32)
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
code: code,
|
||||
redirect_uri: oauth_params[:redirect_uri],
|
||||
scope: oauth_params[:scope],
|
||||
redirect_uri: oauth_params['redirect_uri'],
|
||||
scope: oauth_params['scope'],
|
||||
nonce: oauth_params['nonce'],
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Store nonce in the authorization code metadata if needed
|
||||
# For now, we'll pass it through the code itself
|
||||
|
||||
# Clear OAuth params from session
|
||||
session.delete(:oauth_params)
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
end
|
||||
@@ -161,7 +197,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Find and validate the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.client_secret == client_secret
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
@@ -210,7 +246,7 @@ class OidcController < ApplicationController
|
||||
)
|
||||
|
||||
# Generate ID token
|
||||
id_token = OidcJwtService.generate_id_token(user, application)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
@@ -255,7 +291,7 @@ class OidcController < ApplicationController
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
name: user.email_address
|
||||
name: user.name.presence || user.email_address
|
||||
}
|
||||
|
||||
# Add groups if user has any
|
||||
@@ -266,9 +302,44 @@ class OidcController < ApplicationController
|
||||
# Add admin claim if user is admin
|
||||
claims[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
claims.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
claims.merge!(user.parsed_custom_claims)
|
||||
|
||||
render json: claims
|
||||
end
|
||||
|
||||
# GET /logout
|
||||
def logout
|
||||
# OpenID Connect RP-Initiated Logout
|
||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||
|
||||
id_token_hint = params[:id_token_hint]
|
||||
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||
state = params[:state]
|
||||
|
||||
# If user is authenticated, log them out
|
||||
if authenticated?
|
||||
# Invalidate the current session
|
||||
Current.session&.destroy
|
||||
reset_session
|
||||
end
|
||||
|
||||
# If post_logout_redirect_uri is provided, redirect there
|
||||
if post_logout_redirect_uri.present?
|
||||
redirect_uri = post_logout_redirect_uri
|
||||
redirect_uri += "?state=#{state}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
else
|
||||
# Default redirect to home page
|
||||
redirect_to root_path
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_client_credentials
|
||||
|
||||
@@ -28,7 +28,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_password_reset_token!(params[:token])
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class ProfilesController < ApplicationController
|
||||
def show
|
||||
@user = Current.session.user
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -11,7 +10,6 @@ class ProfilesController < ApplicationController
|
||||
# Updating password - requires current password
|
||||
unless @user.authenticate(params[:user][:current_password])
|
||||
@user.errors.add(:current_password, "is incorrect")
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -19,7 +17,6 @@ class ProfilesController < ApplicationController
|
||||
if @user.update(password_params)
|
||||
redirect_to profile_path, notice: "Password updated successfully."
|
||||
else
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
@@ -27,7 +24,6 @@ class ProfilesController < ApplicationController
|
||||
if @user.update(email_params)
|
||||
redirect_to profile_path, notice: "Email updated successfully."
|
||||
else
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create verify_totp ]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 5, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
||||
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
|
||||
|
||||
def new
|
||||
# Redirect to signup if this is first run
|
||||
@@ -16,9 +17,19 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Store the redirect URL from forward auth if present (after validation)
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:return_to_after_authenticating] = validated_url if validated_url
|
||||
end
|
||||
|
||||
# Check if user is active
|
||||
unless user.active?
|
||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||
if user.pending_invitation?
|
||||
redirect_to signin_path, alert: "Please check your email for an invitation to set up your account."
|
||||
else
|
||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -26,13 +37,18 @@ class SessionsController < ApplicationController
|
||||
if user.totp_enabled?
|
||||
# Store user ID in session temporarily for TOTP verification
|
||||
session[:pending_totp_user_id] = user.id
|
||||
redirect_to totp_verification_path
|
||||
# Preserve the redirect URL through TOTP verification (after validation)
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:totp_redirect_url] = validated_url if validated_url
|
||||
end
|
||||
redirect_to totp_verification_path(rd: params[:rd])
|
||||
return
|
||||
end
|
||||
|
||||
# Sign in successful
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
end
|
||||
|
||||
def verify_totp
|
||||
@@ -54,19 +70,33 @@ class SessionsController < ApplicationController
|
||||
if request.post?
|
||||
code = params[:code]&.strip
|
||||
|
||||
# Check if user is already authenticated (prevent duplicate submissions)
|
||||
if authenticated?
|
||||
redirect_to root_path, notice: "Already signed in."
|
||||
return
|
||||
end
|
||||
|
||||
# Try TOTP verification first
|
||||
if user.verify_totp(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Try backup code verification
|
||||
if user.verify_backup_code(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
@@ -86,6 +116,174 @@ class SessionsController < ApplicationController
|
||||
def destroy_other
|
||||
session = Current.session.user.sessions.find(params[:id])
|
||||
session.destroy
|
||||
redirect_to profile_path, notice: "Session revoked successfully."
|
||||
redirect_to active_sessions_path, notice: "Session revoked successfully."
|
||||
end
|
||||
|
||||
# WebAuthn authentication methods
|
||||
def webauthn_challenge
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
||||
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Store user ID in session for verification
|
||||
session[:pending_webauthn_user_id] = user.id
|
||||
|
||||
# Store redirect URL if present
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:webauthn_redirect_url] = validated_url if validated_url
|
||||
end
|
||||
|
||||
begin
|
||||
# Generate authentication options
|
||||
# Decode the stored base64url credential IDs before passing to the gem
|
||||
credential_ids = user.webauthn_credentials.pluck(:external_id).map do |encoded_id|
|
||||
Base64.urlsafe_decode64(encoded_id)
|
||||
end
|
||||
|
||||
options = WebAuthn::Credential.options_for_get(
|
||||
allow: credential_ids,
|
||||
user_verification: "preferred"
|
||||
)
|
||||
|
||||
# Store challenge in session
|
||||
session[:webauthn_challenge] = options.challenge
|
||||
|
||||
render json: options
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
||||
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_verify
|
||||
# Get pending user from session
|
||||
user_id = session[:pending_webauthn_user_id]
|
||||
unless user_id
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(id: user_id)
|
||||
unless user
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Get the credential and assertion from params
|
||||
credential_data = params[:credential]
|
||||
if credential_data.blank?
|
||||
render json: { error: "Credential data is required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Get the challenge from session
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Decode the credential response
|
||||
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
|
||||
|
||||
# Find the stored credential
|
||||
external_id = Base64.urlsafe_encode64(webauthn_credential.id)
|
||||
stored_credential = user.webauthn_credential_for(external_id)
|
||||
|
||||
if stored_credential.nil?
|
||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Verify the assertion
|
||||
stored_public_key = Base64.urlsafe_decode64(stored_credential.public_key)
|
||||
webauthn_credential.verify(
|
||||
challenge,
|
||||
public_key: stored_public_key,
|
||||
sign_count: stored_credential.sign_count
|
||||
)
|
||||
|
||||
# Check for suspicious sign count (possible clone)
|
||||
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
|
||||
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
|
||||
# You might want to notify admins or temporarily disable the credential
|
||||
end
|
||||
|
||||
# Update credential usage
|
||||
stored_credential.update_usage!(
|
||||
sign_count: webauthn_credential.sign_count,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
# Clean up session
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
if session[:webauthn_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||
end
|
||||
|
||||
# Create session
|
||||
start_new_session_for user
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_to: after_authentication_url,
|
||||
message: "Signed in successfully with passkey"
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
||||
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
||||
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_redirect_url(url)
|
||||
return nil unless url.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Only allow HTTP/HTTPS schemes
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
|
||||
# Check against our ForwardAuthRules
|
||||
matching_rule = ForwardAuthRule.active.find do |rule|
|
||||
rule.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_rule ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,9 +24,12 @@ class TotpController < ApplicationController
|
||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
# Save the secret and generate backup codes
|
||||
@user.totp_secret = totp_secret
|
||||
@user.backup_codes = generate_backup_codes
|
||||
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||
@user.save!
|
||||
|
||||
# Store plain codes temporarily in session for display after redirect
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
|
||||
# Redirect to backup codes page with success message
|
||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
||||
else
|
||||
@@ -36,8 +39,15 @@ class TotpController < ApplicationController
|
||||
|
||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||
def backup_codes
|
||||
# This will be shown after password verification
|
||||
@backup_codes = @user.parsed_backup_codes
|
||||
# Check if we have temporary codes from TOTP setup
|
||||
if session[:temp_backup_codes].present?
|
||||
@backup_codes = session[:temp_backup_codes]
|
||||
session.delete(:temp_backup_codes) # Clear after use
|
||||
else
|
||||
# This will be shown after password verification for existing users
|
||||
# Since we can't display BCrypt hashes, redirect to regenerate
|
||||
redirect_to regenerate_backup_codes_totp_path
|
||||
end
|
||||
end
|
||||
|
||||
# POST /totp/verify_password - Verify password before showing backup codes
|
||||
@@ -49,6 +59,28 @@ class TotpController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password)
|
||||
def regenerate_backup_codes
|
||||
# This will be shown after password verification
|
||||
end
|
||||
|
||||
# POST /totp/regenerate_backup_codes - Actually regenerate backup codes
|
||||
def create_new_backup_codes
|
||||
unless @user.authenticate(params[:password])
|
||||
redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password."
|
||||
return
|
||||
end
|
||||
|
||||
# Generate new backup codes and store BCrypt hashes
|
||||
plain_codes = @user.send(:generate_backup_codes)
|
||||
@user.save!
|
||||
|
||||
# Store plain codes temporarily in session for display
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
|
||||
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
||||
end
|
||||
|
||||
# DELETE /totp - Disable TOTP (requires password)
|
||||
def destroy
|
||||
unless @user.authenticate(params[:password])
|
||||
@@ -77,8 +109,4 @@ class TotpController < ApplicationController
|
||||
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
||||
end
|
||||
end
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
198
app/controllers/webauthn_controller.rb
Normal file
198
app/controllers/webauthn_controller.rb
Normal file
@@ -0,0 +1,198 @@
|
||||
class WebauthnController < ApplicationController
|
||||
before_action :set_webauthn_credential, only: [:destroy]
|
||||
skip_before_action :require_authentication, only: [:check]
|
||||
|
||||
# GET /webauthn/new
|
||||
def new
|
||||
@webauthn_credential = WebauthnCredential.new
|
||||
end
|
||||
|
||||
# POST /webauthn/challenge
|
||||
# Generate registration challenge for creating a new passkey
|
||||
def challenge
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
registration_options = WebAuthn::Credential.options_for_create(
|
||||
user: {
|
||||
id: user.webauthn_user_handle,
|
||||
name: user.email_address,
|
||||
display_name: user.name || user.email_address
|
||||
},
|
||||
exclude: user.webauthn_credentials.pluck(:external_id),
|
||||
authenticator_selection: {
|
||||
userVerification: "preferred",
|
||||
residentKey: "preferred",
|
||||
authenticatorAttachment: "platform" # Prefer platform authenticators first
|
||||
}
|
||||
)
|
||||
|
||||
# Store challenge in session for verification
|
||||
session[:webauthn_challenge] = registration_options.challenge
|
||||
|
||||
render json: registration_options
|
||||
end
|
||||
|
||||
# POST /webauthn/create
|
||||
# Verify and store the new credential
|
||||
def create
|
||||
credential_data, nickname = extract_credential_params
|
||||
|
||||
if credential_data.blank? || nickname.blank?
|
||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Retrieve the challenge from session
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Pass the credential hash directly to WebAuthn gem
|
||||
webauthn_credential = WebAuthn::Credential.from_create(credential_data.to_h)
|
||||
|
||||
# Verify the credential against the challenge
|
||||
webauthn_credential.verify(challenge)
|
||||
|
||||
# Extract credential metadata from the hash
|
||||
response = credential_data.to_h
|
||||
client_extension_results = response["clientExtensionResults"] || {}
|
||||
|
||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
|
||||
# Determine if this is a backup/synced credential
|
||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||
backup_state = client_extension_results["credProps"]&.dig("backup") || false
|
||||
|
||||
# Store the credential
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
@webauthn_credential = user.webauthn_credentials.create!(
|
||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||
public_key: Base64.urlsafe_encode64(webauthn_credential.public_key),
|
||||
sign_count: webauthn_credential.sign_count,
|
||||
nickname: nickname,
|
||||
authenticator_type: authenticator_type,
|
||||
backup_eligible: backup_eligible,
|
||||
backup_state: backup_state
|
||||
)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: "Passkey '#{nickname}' registered successfully",
|
||||
credential_id: @webauthn_credential.id
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /webauthn/:id
|
||||
# Remove a passkey
|
||||
def destroy
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
if @webauthn_credential.user != user
|
||||
render json: { error: "Unauthorized" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
nickname = @webauthn_credential.nickname
|
||||
@webauthn_credential.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
format.json {
|
||||
render json: {
|
||||
success: true,
|
||||
message: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# GET /webauthn/check
|
||||
# Check if user has WebAuthn credentials (for login page detection)
|
||||
def check
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { has_webauthn: false, error: "Email is required" }
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
if user.nil?
|
||||
render json: { has_webauthn: false, message: "User not found" }
|
||||
return
|
||||
end
|
||||
|
||||
render json: {
|
||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||
user_id: user.id,
|
||||
preferred_method: user.preferred_authentication_method,
|
||||
requires_webauthn: user.require_webauthn?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_credential_params
|
||||
# Use require.permit which is working and reliable
|
||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||
begin
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
end
|
||||
|
||||
def set_webauthn_credential
|
||||
@webauthn_credential = WebauthnCredential.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
alert: "Passkey not found"
|
||||
}
|
||||
format.json {
|
||||
render json: { error: "Passkey not found" }, status: :not_found
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper method to convert Base64 to Base64URL if needed
|
||||
def base64_to_base64url(str)
|
||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
||||
end
|
||||
|
||||
# Helper method to convert Base64URL to Base64 if needed
|
||||
def base64url_to_base64(str)
|
||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
||||
end
|
||||
end
|
||||
@@ -1,2 +1,22 @@
|
||||
module ApplicationHelper
|
||||
def smtp_configured?
|
||||
return true if Rails.env.test?
|
||||
|
||||
smtp_address = ENV["SMTP_ADDRESS"]
|
||||
smtp_port = ENV["SMTP_PORT"]
|
||||
|
||||
smtp_address.present? &&
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
end
|
||||
|
||||
def email_delivery_method
|
||||
if Rails.env.development?
|
||||
ActionMailer::Base.delivery_method
|
||||
else
|
||||
:smtp
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
app/javascript/controllers/application_form_controller.js
Normal file
24
app/javascript/controllers/application_form_controller.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/javascript/controllers/backup_codes_controller.js
Normal file
28
app/javascript/controllers/backup_codes_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "submit" ]
|
||||
|
||||
connect() {
|
||||
// Prevent form auto-submission when browser autofills TOTP
|
||||
this.preventAutoSubmit()
|
||||
|
||||
// Add double-click protection
|
||||
this.submitTarget.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault()
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.submitTarget.disabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Disable submit button and show loading state
|
||||
this.submitTarget.disabled = true
|
||||
this.submitTarget.textContent = 'Verifying...'
|
||||
this.submitTarget.classList.add('opacity-75', 'cursor-not-allowed')
|
||||
|
||||
// Re-enable after 10 seconds in case of network issues
|
||||
setTimeout(() => {
|
||||
this.submitTarget.disabled = false
|
||||
this.submitTarget.textContent = 'Verify'
|
||||
this.submitTarget.classList.remove('opacity-75', 'cursor-not-allowed')
|
||||
}, 10000)
|
||||
|
||||
// Allow the form to submit normally
|
||||
return true
|
||||
}
|
||||
|
||||
preventAutoSubmit() {
|
||||
// Some browsers auto-submit forms when TOTP fields are autofilled
|
||||
// This prevents that behavior while still allowing manual submission
|
||||
const codeInput = this.element.querySelector('input[name="code"]')
|
||||
|
||||
if (codeInput) {
|
||||
let hasAutoSubmitted = false
|
||||
|
||||
codeInput.addEventListener('input', (e) => {
|
||||
// Check if this looks like an auto-fill event
|
||||
// Auto-fill typically fills the entire field at once
|
||||
if (e.target.value.length >= 6 && !hasAutoSubmitted) {
|
||||
// Don't auto-submit, let user click the button manually
|
||||
hasAutoSubmitted = true
|
||||
|
||||
// Optionally, focus the submit button to make it obvious
|
||||
this.submitTarget.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Also prevent Enter key submission on TOTP field
|
||||
codeInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.submitTarget.click()
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/javascript/controllers/login_form_controller.js
Normal file
92
app/javascript/controllers/login_form_controller.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Handles login form UI changes based on WebAuthn availability
|
||||
export default class extends Controller {
|
||||
static targets = ["webauthnSection", "passwordSection", "statusMessage", "loadingOverlay"]
|
||||
|
||||
connect() {
|
||||
// Listen for WebAuthn availability events from the webauthn controller
|
||||
this.element.addEventListener('webauthn:webauthn-available', this.handleWebAuthnAvailable.bind(this));
|
||||
|
||||
// Listen for WebAuthn registration events (from profile page)
|
||||
this.element.addEventListener('webauthn:passkey-registered', this.handlePasskeyRegistered.bind(this));
|
||||
|
||||
// Listen for authentication start/end to show/hide loading
|
||||
document.addEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
|
||||
document.addEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
|
||||
document.removeEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
|
||||
}
|
||||
|
||||
handleWebAuthnAvailable(event) {
|
||||
const detail = event.detail;
|
||||
|
||||
if (!this.hasWebauthnSectionTarget || !this.hasPasswordSectionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.hasWebauthn) {
|
||||
this.webauthnSectionTarget.classList.remove('hidden');
|
||||
|
||||
// If WebAuthn is required, hide password section
|
||||
if (detail.requiresWebauthn) {
|
||||
this.passwordSectionTarget.classList.add('hidden');
|
||||
} else {
|
||||
// Show both options with a divider
|
||||
this.passwordSectionTarget.classList.add('border-t', 'pt-4', 'mt-4');
|
||||
this.addOrDivider();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePasskeyRegistered(event) {
|
||||
if (!this.hasStatusMessageTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
this.statusMessageTarget.className = 'mt-4 p-3 rounded-md bg-green-50 text-green-800 border border-green-200';
|
||||
this.statusMessageTarget.textContent = 'Passkey registered successfully!';
|
||||
this.statusMessageTarget.classList.remove('hidden');
|
||||
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.statusMessageTarget.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
if (this.hasLoadingOverlayTarget) {
|
||||
this.loadingOverlayTarget.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
if (this.hasLoadingOverlayTarget) {
|
||||
this.loadingOverlayTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addOrDivider() {
|
||||
// Check if divider already exists
|
||||
if (this.element.querySelector('.login-divider')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orDiv = document.createElement('div');
|
||||
orDiv.className = 'relative my-4 login-divider';
|
||||
orDiv.innerHTML = `
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">Or</span>
|
||||
</div>
|
||||
`;
|
||||
this.webauthnSectionTarget.parentNode.insertBefore(orDiv, this.passwordSectionTarget);
|
||||
}
|
||||
}
|
||||
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/javascript/controllers/modal_controller.js
Normal file
50
app/javascript/controllers/modal_controller.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Generic modal controller for showing/hiding modal dialogs
|
||||
export default class extends Controller {
|
||||
static targets = ["dialog"]
|
||||
|
||||
show(event) {
|
||||
// If called from a button with data-modal-id, find and show that modal
|
||||
const modalId = event.currentTarget?.dataset?.modalId;
|
||||
if (modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
} else if (this.hasDialogTarget) {
|
||||
// Otherwise show the dialog target
|
||||
this.dialogTarget.classList.remove("hidden");
|
||||
} else {
|
||||
// Or show this element itself
|
||||
this.element.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Find the currently visible modal to hide it
|
||||
const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)');
|
||||
if (visibleModal) {
|
||||
visibleModal.classList.add("hidden");
|
||||
} else if (this.hasDialogTarget) {
|
||||
this.dialogTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.element.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking backdrop
|
||||
closeOnBackdrop(event) {
|
||||
// Only close if clicking directly on the backdrop (not child elements)
|
||||
if (event.target === this.element || event.target.classList.contains('modal-backdrop')) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
closeOnEscape(event) {
|
||||
if (event.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
317
app/javascript/controllers/webauthn_controller.js
Normal file
317
app/javascript/controllers/webauthn_controller.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["nickname", "submitButton", "status", "error"];
|
||||
static values = {
|
||||
challengeUrl: String,
|
||||
createUrl: String,
|
||||
checkUrl: String
|
||||
};
|
||||
|
||||
connect() {
|
||||
// Check if WebAuthn is supported
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
console.warn("WebAuthn is not supported in this browser");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if browser supports WebAuthn
|
||||
isWebAuthnSupported() {
|
||||
return (
|
||||
window.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === "function"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has passkeys (for login page)
|
||||
async checkWebAuthnSupport(event) {
|
||||
const email = event.target.value.trim();
|
||||
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.checkUrlValue}?email=${encodeURIComponent(email)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.debug("WebAuthn check response:", data);
|
||||
|
||||
if (data.has_webauthn) {
|
||||
console.debug("Dispatching webauthn-available event");
|
||||
// Trigger custom event for login form to show passkey option
|
||||
this.dispatch("webauthn-available", {
|
||||
detail: {
|
||||
hasWebauthn: data.has_webauthn,
|
||||
requiresWebauthn: data.requires_webauthn,
|
||||
preferredMethod: data.preferred_method
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-trigger passkey authentication if required
|
||||
if (data.requires_webauthn) {
|
||||
setTimeout(() => this.authenticate(), 100);
|
||||
}
|
||||
} else {
|
||||
console.debug("No WebAuthn credentials found for this email");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking WebAuthn support:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start registration ceremony
|
||||
async register(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
this.showError("WebAuthn is not supported in your browser");
|
||||
return;
|
||||
}
|
||||
|
||||
const nickname = this.nicknameTarget.value.trim();
|
||||
if (!nickname) {
|
||||
this.showError("Please enter a nickname for this passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearMessages();
|
||||
|
||||
try {
|
||||
// Get registration challenge from server
|
||||
const challengeResponse = await fetch(this.challengeUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
}
|
||||
});
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
throw new Error("Failed to get registration challenge");
|
||||
}
|
||||
|
||||
const credentialCreationOptions = await challengeResponse.json();
|
||||
|
||||
// Use modern Web Authentication API Level 3 to parse options
|
||||
// This automatically handles all base64url encoding/decoding
|
||||
const publicKeyOptions = PublicKeyCredential.parseCreationOptionsFromJSON(
|
||||
credentialCreationOptions
|
||||
);
|
||||
|
||||
// Create credential via WebAuthn API
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Failed to create credential");
|
||||
}
|
||||
|
||||
// Send credential to server for verification
|
||||
// Use toJSON() to properly serialize the credential
|
||||
const credentialResponse = await fetch(this.createUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: credential.toJSON(),
|
||||
nickname: nickname
|
||||
})
|
||||
});
|
||||
|
||||
const result = await credentialResponse.json();
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess(result.message);
|
||||
|
||||
// Clear the form
|
||||
this.nicknameTarget.value = "";
|
||||
|
||||
// Dispatch event to refresh the passkey list
|
||||
this.dispatch("passkey-registered", {
|
||||
detail: {
|
||||
nickname: nickname,
|
||||
credentialId: result.credential_id
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally close modal or redirect
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname === "/webauthn/new") {
|
||||
window.location.href = "/profile";
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
this.showError(result.error || "Failed to register passkey");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("WebAuthn registration error:", error);
|
||||
this.showError(this.getErrorMessage(error));
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Start authentication ceremony
|
||||
async authenticate(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
this.showError("WebAuthn is not supported in your browser");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearMessages();
|
||||
|
||||
try {
|
||||
// Get authentication challenge from server
|
||||
const response = await fetch("/sessions/webauthn/challenge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: this.getUserEmail()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get authentication challenge");
|
||||
}
|
||||
|
||||
const credentialRequestOptions = await response.json();
|
||||
|
||||
// Use modern Web Authentication API Level 3 to parse options
|
||||
// This automatically handles all base64url encoding/decoding
|
||||
const publicKeyOptions = PublicKeyCredential.parseRequestOptionsFromJSON(
|
||||
credentialRequestOptions
|
||||
);
|
||||
|
||||
// Get credential via WebAuthn API
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Failed to get credential");
|
||||
}
|
||||
|
||||
// Send assertion to server for verification
|
||||
// Use toJSON() to properly serialize the credential
|
||||
const authResponse = await fetch("/sessions/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: credential.toJSON(),
|
||||
email: this.getUserEmail()
|
||||
})
|
||||
});
|
||||
|
||||
const result = await authResponse.json();
|
||||
|
||||
if (result.success) {
|
||||
// Redirect to dashboard or intended URL
|
||||
window.location.href = result.redirect_to || "/";
|
||||
} else {
|
||||
this.showError(result.error || "Authentication failed");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("WebAuthn authentication error:", error);
|
||||
this.showError(this.getErrorMessage(error));
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// UI helper methods
|
||||
setLoading(isLoading) {
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.disabled = isLoading;
|
||||
this.submitButtonTarget.textContent = isLoading ? "Registering..." : "Register Passkey";
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.textContent = message;
|
||||
this.statusTarget.className = "mt-2 text-sm text-green-600";
|
||||
this.statusTarget.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = message;
|
||||
this.errorTarget.className = "mt-2 text-sm text-red-600";
|
||||
this.errorTarget.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.style.display = "none";
|
||||
this.statusTarget.textContent = "";
|
||||
}
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.style.display = "none";
|
||||
this.errorTarget.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
getCSRFToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
}
|
||||
|
||||
getUserEmail() {
|
||||
// Try multiple ways to get the user email from login form
|
||||
let emailInput = document.querySelector('input[type="email"]');
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="email"]');
|
||||
}
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="session[email_address]"]');
|
||||
}
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="user[email_address]"]');
|
||||
}
|
||||
return emailInput ? emailInput.value.trim() : "";
|
||||
}
|
||||
|
||||
isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
getErrorMessage(error) {
|
||||
// Common WebAuthn errors
|
||||
if (error.name === "NotAllowedError") {
|
||||
return "Authentication was cancelled or timed out. Please try again.";
|
||||
}
|
||||
if (error.name === "SecurityError") {
|
||||
return "Security requirements not met. Make sure you're using HTTPS.";
|
||||
}
|
||||
if (error.name === "NotSupportedError") {
|
||||
return "This device doesn't support the requested authentication method.";
|
||||
}
|
||||
if (error.name === "InvalidStateError") {
|
||||
return "This authenticator has already been registered.";
|
||||
}
|
||||
|
||||
// Fallback to error message
|
||||
return error.message || "An unexpected error occurred";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
6
app/mailers/invitations_mailer.rb
Normal file
6
app/mailers/invitations_mailer.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class InvitationsMailer < ApplicationMailer
|
||||
def invite_user(user)
|
||||
@user = user
|
||||
mail subject: "You're invited to join Clinch", to: user.email_address
|
||||
end
|
||||
end
|
||||
@@ -1,37 +1,49 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
has_many :oidc_authorization_codes, dependent: :destroy
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc trusted_header saml] }
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||
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" }
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
|
||||
|
||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||
|
||||
# Default header configuration for ForwardAuth
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :oidc, -> { where(app_type: "oidc") }
|
||||
scope :trusted_header, -> { where(app_type: "trusted_header") }
|
||||
scope :saml, -> { where(app_type: "saml") }
|
||||
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
|
||||
# Type checks
|
||||
def oidc?
|
||||
app_type == "oidc"
|
||||
end
|
||||
|
||||
def trusted_header?
|
||||
app_type == "trusted_header"
|
||||
end
|
||||
|
||||
def saml?
|
||||
app_type == "saml"
|
||||
def forward_auth?
|
||||
app_type == "forward_auth"
|
||||
end
|
||||
|
||||
# Access control
|
||||
@@ -61,10 +73,92 @@ class Application < ApplicationRecord
|
||||
{}
|
||||
end
|
||||
|
||||
# ForwardAuth helpers
|
||||
def parsed_headers_config
|
||||
return {} unless headers_config.present?
|
||||
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
# Check if a domain matches this application's pattern (for ForwardAuth)
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank? || !forward_auth?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
end
|
||||
|
||||
# Policy determination based on user status (for ForwardAuth)
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
end
|
||||
|
||||
# Get effective header configuration (for ForwardAuth)
|
||||
def effective_headers
|
||||
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
||||
end
|
||||
|
||||
# Generate headers for a specific user (for ForwardAuth)
|
||||
def headers_for_user(user)
|
||||
headers = {}
|
||||
effective = effective_headers
|
||||
|
||||
# Only generate headers that are configured (not set to nil/false)
|
||||
effective.each do |key, header_name|
|
||||
next unless header_name.present? # Skip disabled headers
|
||||
|
||||
case key
|
||||
when :user, :email
|
||||
headers[header_name] = user.email_address
|
||||
when :name
|
||||
headers[header_name] = user.name.presence || user.email_address
|
||||
when :groups
|
||||
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||
when :admin
|
||||
headers[header_name] = user.admin? ? "true" : "false"
|
||||
end
|
||||
end
|
||||
|
||||
headers
|
||||
end
|
||||
|
||||
# Check if all headers are disabled (for ForwardAuth)
|
||||
def headers_disabled?
|
||||
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||
end
|
||||
|
||||
# Generate and return a new client secret
|
||||
def generate_new_client_secret!
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
self.save!
|
||||
secret
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_client_credentials
|
||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||
self.client_secret ||= SecureRandom.urlsafe_base64(48)
|
||||
# Generate and hash the client secret
|
||||
if new_record? && client_secret.blank?
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,4 +6,9 @@ class Group < ApplicationRecord
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
end
|
||||
end
|
||||
|
||||
52
app/models/oidc_user_consent.rb
Normal file
52
app/models/oidc_user_consent.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class OidcUserConsent < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :application
|
||||
|
||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||
validates :user_id, uniqueness: { scope: :application_id }
|
||||
|
||||
before_validation :set_granted_at, on: :create
|
||||
|
||||
# Parse scopes_granted into an array
|
||||
def scopes
|
||||
scopes_granted.split(' ')
|
||||
end
|
||||
|
||||
# Set scopes from an array
|
||||
def scopes=(scope_array)
|
||||
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
||||
end
|
||||
|
||||
# Check if this consent covers the requested scopes
|
||||
def covers_scopes?(requested_scopes)
|
||||
requested = Array(requested_scopes).map(&:to_s)
|
||||
granted = scopes
|
||||
|
||||
# All requested scopes must be included in granted scopes
|
||||
(requested - granted).empty?
|
||||
end
|
||||
|
||||
# Get a human-readable list of scopes
|
||||
def formatted_scopes
|
||||
scopes.map do |scope|
|
||||
case scope
|
||||
when 'openid'
|
||||
'Basic authentication'
|
||||
when 'profile'
|
||||
'Profile information'
|
||||
when 'email'
|
||||
'Email address'
|
||||
when 'groups'
|
||||
'Group membership'
|
||||
else
|
||||
scope.humanize
|
||||
end
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_granted_at
|
||||
self.granted_at ||= Time.current
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,21 @@ class User < ApplicationRecord
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :groups, through: :user_groups
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation, expires_in: 7.days
|
||||
generates_token_for :password_reset, expires_in: 1.hour
|
||||
generates_token_for :magic_login, expires_in: 15.minutes
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :password_reset, expires_in: 1.hour do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||
last_sign_in_at
|
||||
end
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
|
||||
@@ -56,24 +66,136 @@ class User < ApplicationRecord
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
codes = JSON.parse(backup_codes)
|
||||
if codes.include?(code)
|
||||
codes.delete(code)
|
||||
update(backup_codes: codes.to_json)
|
||||
# Rate limiting: prevent brute force attacks on backup codes
|
||||
if rate_limit_backup_code_verification?
|
||||
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
||||
return false
|
||||
end
|
||||
|
||||
# backup_codes is now an Array (JSON column), no need to parse
|
||||
# Find the matching hash by comparing with BCrypt
|
||||
matching_hash = backup_codes.find do |hashed_code|
|
||||
BCrypt::Password.new(hashed_code) == code
|
||||
end
|
||||
|
||||
if matching_hash
|
||||
# Remove the used hash from the array (single-use property)
|
||||
backup_codes.delete(matching_hash)
|
||||
save! # Save the updated array
|
||||
|
||||
# Log successful backup code usage for security monitoring
|
||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
true
|
||||
else
|
||||
# Increment failed attempt counter and log for security monitoring
|
||||
increment_backup_code_failed_attempts
|
||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_backup_codes
|
||||
return [] unless backup_codes.present?
|
||||
JSON.parse(backup_codes)
|
||||
# Rate limiting for backup code verification to prevent brute force attacks
|
||||
def rate_limit_backup_code_verification?
|
||||
# Use Rails.cache to track failed attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
||||
true
|
||||
else
|
||||
# Don't increment here - increment only on failed attempts
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Increment failed attempt counter
|
||||
def increment_backup_code_failed_attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
# WebAuthn methods
|
||||
def webauthn_enabled?
|
||||
webauthn_credentials.exists?
|
||||
end
|
||||
|
||||
def can_authenticate_with_webauthn?
|
||||
webauthn_enabled? && active?
|
||||
end
|
||||
|
||||
def require_webauthn?
|
||||
webauthn_required? || (webauthn_enabled? && !password_digest.present?)
|
||||
end
|
||||
|
||||
# Generate stable WebAuthn user handle on first use
|
||||
def webauthn_user_handle
|
||||
return webauthn_id if webauthn_id.present?
|
||||
|
||||
# Generate random 64-byte opaque identifier (base64url encoded)
|
||||
handle = SecureRandom.urlsafe_base64(64)
|
||||
update_column(:webauthn_id, handle)
|
||||
handle
|
||||
end
|
||||
|
||||
def platform_authenticators
|
||||
webauthn_credentials.platform_authenticators
|
||||
end
|
||||
|
||||
def roaming_authenticators
|
||||
webauthn_credentials.roaming_authenticators
|
||||
end
|
||||
|
||||
def webauthn_credential_for(external_id)
|
||||
webauthn_credentials.find_by(external_id: external_id)
|
||||
end
|
||||
|
||||
# Check if user has any backed up (synced) passkeys
|
||||
def has_synced_passkeys?
|
||||
webauthn_credentials.exists?(backup_eligible: true, backup_state: true)
|
||||
end
|
||||
|
||||
# Preferred authentication method for login flow
|
||||
def preferred_authentication_method
|
||||
return :webauthn if require_webauthn?
|
||||
return :webauthn if can_authenticate_with_webauthn? && preferred_2fa_method == "webauthn"
|
||||
return :password if password_digest.present?
|
||||
:webauthn
|
||||
end
|
||||
|
||||
def has_oidc_consent?(application, requested_scopes)
|
||||
oidc_user_consents
|
||||
.where(application: application)
|
||||
.find { |consent| consent.covers_scopes?(requested_scopes) }
|
||||
end
|
||||
|
||||
def revoke_consent!(application)
|
||||
consent = oidc_user_consents.find_by(application: application)
|
||||
consent&.destroy
|
||||
end
|
||||
|
||||
def revoke_all_consents!
|
||||
oidc_user_consents.destroy_all
|
||||
end
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
# Generate plain codes for user to see/save
|
||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||
|
||||
# Store BCrypt hashes of the codes
|
||||
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
|
||||
|
||||
# Return plain codes for display (will be shown to user once)
|
||||
# Store only hashes in the database (as Array for JSON column)
|
||||
self.backup_codes = hashed_codes
|
||||
|
||||
plain_codes
|
||||
end
|
||||
end
|
||||
|
||||
96
app/models/webauthn_credential.rb
Normal file
96
app/models/webauthn_credential.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
class WebauthnCredential < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
# Validations
|
||||
validates :external_id, presence: true, uniqueness: true
|
||||
validates :public_key, presence: true
|
||||
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
||||
validates :nickname, presence: true
|
||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
||||
|
||||
# Scopes for querying
|
||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
||||
scope :platform_authenticators, -> { where(authenticator_type: "platform") }
|
||||
scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") }
|
||||
scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) }
|
||||
scope :never_used, -> { where(last_used_at: nil) }
|
||||
|
||||
# Update last used timestamp and sign count after successful authentication
|
||||
def update_usage!(sign_count:, ip_address: nil, user_agent: nil)
|
||||
update!(
|
||||
last_used_at: Time.current,
|
||||
last_used_ip: ip_address,
|
||||
sign_count: sign_count,
|
||||
user_agent: user_agent
|
||||
)
|
||||
end
|
||||
|
||||
# Check if this is a platform authenticator (built-in device)
|
||||
def platform_authenticator?
|
||||
authenticator_type == "platform"
|
||||
end
|
||||
|
||||
# Check if this is a roaming authenticator (USB/NFC/Bluetooth key)
|
||||
def roaming_authenticator?
|
||||
authenticator_type == "cross-platform"
|
||||
end
|
||||
|
||||
# Check if this credential is backed up (synced passkeys)
|
||||
def backed_up?
|
||||
backup_eligible? && backup_state?
|
||||
end
|
||||
|
||||
# Human readable description
|
||||
def description
|
||||
if nickname.present?
|
||||
"#{nickname} (#{authenticator_type.humanize})"
|
||||
else
|
||||
"#{authenticator_type.humanize} Authenticator"
|
||||
end
|
||||
end
|
||||
|
||||
# Check if sign count is suspicious (clone detection)
|
||||
def suspicious_sign_count?(new_sign_count)
|
||||
return false if sign_count.zero? && new_sign_count > 0 # First use
|
||||
return false if new_sign_count > sign_count # Normal increment
|
||||
|
||||
# Sign count didn't increase - possible clone
|
||||
true
|
||||
end
|
||||
|
||||
# Format for display in UI
|
||||
def display_name
|
||||
nickname || "#{authenticator_type&.humanize} Authenticator"
|
||||
end
|
||||
|
||||
# When was this credential created?
|
||||
def created_recently?
|
||||
created_at > 1.week.ago
|
||||
end
|
||||
|
||||
# How long ago was this last used?
|
||||
def last_used_ago
|
||||
return "Never" unless last_used_at
|
||||
|
||||
time_ago_in_words(last_used_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def time_ago_in_words(time)
|
||||
seconds = Time.current - time
|
||||
minutes = seconds / 60
|
||||
hours = minutes / 60
|
||||
days = hours / 24
|
||||
|
||||
if days > 0
|
||||
"#{days.floor} day#{'s' if days > 1} ago"
|
||||
elsif hours > 0
|
||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
||||
elsif minutes > 0
|
||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
||||
else
|
||||
"Just now"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,7 +13,7 @@ class OidcJwtService
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
name: user.email_address
|
||||
name: user.name.presence || user.email_address
|
||||
}
|
||||
|
||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||
@@ -27,6 +27,14 @@ class OidcJwtService
|
||||
# Add admin claim if user is admin
|
||||
payload[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
payload.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
payload.merge!(user.parsed_custom_claims)
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
end
|
||||
|
||||
@@ -55,7 +63,7 @@ class OidcJwtService
|
||||
def issuer_url
|
||||
# In production, this should come from ENV or config
|
||||
# For now, we'll use a placeholder that can be overridden
|
||||
ENV.fetch("CLINCH_HOST", "http://localhost:3000")
|
||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
114
app/views/active_sessions/show.html.erb
Normal file
114
app/views/active_sessions/show.html.erb
Normal file
@@ -0,0 +1,114 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Connected Applications -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @connected_applications.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @connected_applications.each do |consent| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= consent.application.name %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Access to: <%= consent.formatted_scopes %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to "Revoke Access", revoke_consent_active_sessions_path(application_id: consent.application.id), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No connected applications.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @connected_applications.any? %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @active_sessions.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @active_sessions.each do |session| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= session.device_name || "Unknown Device" %>
|
||||
<% if session.id == Current.session.id %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
This device
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= session.ip_address %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<% if session.id != Current.session.id %>
|
||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 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",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @active_sessions.count > 1 %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
||||
<% if application.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -34,16 +17,26 @@
|
||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["ForwardAuth (Trusted Headers)", "trusted_header"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
disabled: application.persisted?,
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- OIDC-specific fields -->
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -53,6 +46,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward Auth-specific fields -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :domain_pattern, 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: "*.example.com or app.example.com" %>
|
||||
<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 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.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="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>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
<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>
|
||||
<div class="mt-2 ml-4 space-y-1 text-xs">
|
||||
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
|
||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||
@@ -82,18 +120,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide OIDC fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
|
||||
if (appTypeSelect && oidcFields) {
|
||||
appTypeSelect.addEventListener('change', function() {
|
||||
if (this.value === 'oidc') {
|
||||
oidcFields.style.display = 'block';
|
||||
} else {
|
||||
oidcFields.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC and ForwardAuth applications.</p>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
@@ -37,8 +37,6 @@
|
||||
<% case application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "trusted_header" %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-1 text-xs font-medium text-indigo-700">ForwardAuth</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% end %>
|
||||
@@ -58,9 +56,11 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
<div class="mb-6">
|
||||
<% if flash[:client_id] && flash[:client_secret] %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||
@@ -27,10 +44,8 @@
|
||||
<% case @application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "trusted_header" %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-1 text-xs font-medium text-indigo-700">ForwardAuth</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -44,6 +59,16 @@
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.landing_url.present? %>
|
||||
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Not configured</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +91,12 @@
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_secret %></code>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||
🔒 Client secret is stored securely and cannot be displayed
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -86,6 +116,35 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||
<% if @application.forward_auth? %>
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Forward Auth Configuration</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<% else %>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||
</div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
||||
<% if group.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -49,6 +32,27 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||
</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.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"]}',
|
||||
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 class="flex gap-3">
|
||||
<%= form.submit group.persisted? ? "Update Group" : "Create Group", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
||||
<% if user.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div>
|
||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
|
||||
@@ -46,6 +35,27 @@
|
||||
<% end %>
|
||||
</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.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"}',
|
||||
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 class="flex gap-3">
|
||||
<%= form.submit user.persisted? ? "Update User" : "Create User", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
|
||||
@@ -8,6 +8,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% unless smtp_configured? %>
|
||||
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Email delivery not configured
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
<% if Rails.env.development? %>
|
||||
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||
<% else %>
|
||||
SMTP settings are not configured. Invitation emails and other notifications will not be sent.
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
<% if Rails.env.development? %>
|
||||
To configure SMTP for production, set environment variables like <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, etc.
|
||||
<% else %>
|
||||
Configure SMTP settings by setting environment variables: <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, <span class="font-mono">SMTP_PASSWORD</span>, etc.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
@@ -66,8 +99,17 @@
|
||||
<%= user.groups.count %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<% if user.pending_invitation? %>
|
||||
<%= link_to "Resend", resend_invitation_admin_user_path(user),
|
||||
data: { turbo_method: :post },
|
||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||
<% end %>
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= link_to "Delete", admin_user_path(user),
|
||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -93,6 +93,64 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Your Applications Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Your Applications</h2>
|
||||
|
||||
<% if @applications.any? %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<% @applications.each do |app| %>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||
<%= app.name %>
|
||||
</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
<% if app.oidc? %>
|
||||
bg-blue-100 text-blue-800
|
||||
<% else %>
|
||||
bg-green-100 text-green-800
|
||||
<% end %>">
|
||||
<%= app.app_type.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<% if app.oidc? %>
|
||||
OIDC Application
|
||||
<% else %>
|
||||
ForwardAuth Protected Application
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<% if app.landing_url.present? %>
|
||||
<%= link_to "Open Application", app.landing_url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
|
||||
<% else %>
|
||||
<div class="text-sm text-gray-500 italic">
|
||||
No landing URL configured
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
You don't have access to any applications yet. Contact your administrator if you think this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @user.admin? %>
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2>
|
||||
|
||||
22
app/views/invitations/show.html.erb
Normal file
22
app/views/invitations/show.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<% if alert = flash[:alert] %>
|
||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||
<% end %>
|
||||
|
||||
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
|
||||
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
|
||||
|
||||
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||
<div class="my-5">
|
||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, 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 class="my-5">
|
||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, 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 class="inline">
|
||||
<%= form.submit "Create Account", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
12
app/views/invitations_mailer/invite_user.html.erb
Normal file
12
app/views/invitations_mailer/invite_user.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<p>
|
||||
You've been invited to join Clinch! To set up your account and create your password, please visit
|
||||
<%= link_to "this invitation page", invitation_url(@user.generate_token_for(:invitation_login)) %>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This invitation link will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
8
app/views/invitations_mailer/invite_user.text.erb
Normal file
8
app/views/invitations_mailer/invite_user.text.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
You've been invited to join Clinch!
|
||||
|
||||
To set up your account and create your password, please visit:
|
||||
#{invite_url(@user.invitation_login_token)}
|
||||
|
||||
This invitation link will expire in #{distance_of_time_in_words(0, @user.invitation_login_token_expires_in)}.
|
||||
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
@@ -25,50 +25,37 @@
|
||||
|
||||
<body>
|
||||
<% if authenticated? %>
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button" class="-m-2.5 p-2.5 text-gray-700" id="mobile-menu-button">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
<div data-controller="mobile-sidebar">
|
||||
<%= render "shared/sidebar" %>
|
||||
<div class="lg:pl-64">
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700"
|
||||
id="mobile-menu-button"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
<main class="container mx-auto mt-28 px-5 flex">
|
||||
<main class="container mx-auto mt-28 px-5">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
||||
const mobileSidebarOverlay = document.getElementById('mobile-sidebar-overlay');
|
||||
|
||||
if (mobileMenuButton) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileMenuClose) {
|
||||
mobileMenuClose.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3" do |form| %>
|
||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
|
||||
<%= form.submit "Authorize",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<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">
|
||||
<%= 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-8" data-controller="modal">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Profile & Settings</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings and security preferences.</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
@@ -102,10 +102,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button type="button" onclick="showDisable2FAModal()" class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="disable-2fa-modal"
|
||||
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button type="button" onclick="showViewBackupCodesModal()" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 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 type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="view-backup-codes-modal"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 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">
|
||||
View Backup Codes
|
||||
</button>
|
||||
</div>
|
||||
@@ -119,7 +125,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div id="disable-2fa-modal" class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div id="disable-2fa-modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
@@ -143,7 +151,9 @@
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "Disable 2FA",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %>
|
||||
<button type="button" onclick="hideDisable2FAModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 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 type="button"
|
||||
data-action="click->modal#hide"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 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">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -153,15 +163,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Backup Codes Modal -->
|
||||
<div id="view-backup-codes-modal" class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<!-- Regenerate Backup Codes Modal -->
|
||||
<div id="view-backup-codes-modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
||||
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||
</div>
|
||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with url: create_new_backup_codes_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div>
|
||||
<%= password_field_tag :password, nil,
|
||||
placeholder: "Enter your password",
|
||||
@@ -170,9 +192,11 @@
|
||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "View Codes",
|
||||
<%= form.submit "Generate New Codes",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
<button type="button" onclick="hideViewBackupCodesModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 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 type="button"
|
||||
data-action="click->modal#hide"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 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">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -181,64 +205,123 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDisable2FAModal() {
|
||||
document.getElementById('disable-2fa-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDisable2FAModal() {
|
||||
document.getElementById('disable-2fa-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showViewBackupCodesModal() {
|
||||
document.getElementById('view-backup-codes-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideViewBackupCodesModal() {
|
||||
document.getElementById('view-backup-codes-modal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<!-- Passkeys (WebAuthn) -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
||||
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Passkey Form -->
|
||||
<div class="mt-5">
|
||||
<% if @active_sessions.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @active_sessions.each do |session| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= session.device_name || "Unknown Device" %>
|
||||
<% if session.id == Current.session.id %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
This device
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= session.ip_address %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||
</p>
|
||||
<div id="add-passkey-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
|
||||
<input type="text"
|
||||
id="passkey-nickname"
|
||||
data-webauthn-target="nickname"
|
||||
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button"
|
||||
data-action="click->webauthn#register"
|
||||
data-webauthn-target="submitButton"
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add New Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div data-webauthn-target="status" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||
<div data-webauthn-target="error" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Passkeys List -->
|
||||
<div class="mt-8">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
|
||||
<% if @user.webauthn_credentials.exists? %>
|
||||
<div class="space-y-3">
|
||||
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<% if credential.platform_authenticator? %>
|
||||
<!-- Platform authenticator icon -->
|
||||
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<% else %>
|
||||
<!-- Roaming authenticator icon -->
|
||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if session.id != Current.session.id %>
|
||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 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",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= credential.nickname %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= credential.authenticator_type.humanize %> •
|
||||
Last used <%= credential.last_used_ago %>
|
||||
<% if credential.backed_up? %>
|
||||
• <span class="text-green-600">Synced</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<% if credential.created_recently? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
New
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to webauthn_credential_path(credential),
|
||||
method: :delete,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete '#{credential.nickname}'? You'll need to set it up again to sign in with this device.",
|
||||
turbo_method: :delete
|
||||
},
|
||||
class: "text-red-600 hover:text-red-800 text-sm font-medium" do %>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
||||
<div class="mb-8">
|
||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||
</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? %>
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.email_field :email_address,
|
||||
@@ -12,26 +13,67 @@
|
||||
autocomplete: "username",
|
||||
placeholder: "your@email.com",
|
||||
value: params[:email_address],
|
||||
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.password_field :password,
|
||||
required: true,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter your password",
|
||||
maxlength: 72,
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
<!-- WebAuthn section - initially hidden -->
|
||||
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm text-green-800">
|
||||
<strong>Passkey detected!</strong> You can sign in without a password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-action="click->webauthn#authenticate"
|
||||
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
Continue with Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.submit "Sign in",
|
||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||
<div id="password-section" data-login-form-target="passwordSection">
|
||||
<div class="my-5">
|
||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.password_field :password,
|
||||
required: true,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter your password",
|
||||
maxlength: 72,
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.submit "Sign in",
|
||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600 text-center">
|
||||
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay" data-login-form-target="loadingOverlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Authenticating...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div id="status-message" data-login-form-target="statusMessage" class="hidden mt-4 p-3 rounded-md"></div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
|
||||
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6", data: {
|
||||
controller: "form-submit-protection",
|
||||
turbo: false
|
||||
} do |form| %>
|
||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||
<div>
|
||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :code,
|
||||
@@ -25,6 +29,7 @@
|
||||
|
||||
<div>
|
||||
<%= form.submit "Verify",
|
||||
data: { form_submit_protection_target: "submit" },
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,29 +1,86 @@
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% 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-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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"/>
|
||||
<div class="shrink-0">
|
||||
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
<% if flash[:notice] %>
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Helper method for border colors %>
|
||||
<%
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when 'notice' then 'border-green-200'
|
||||
when 'alert', 'error' then 'border-red-200'
|
||||
when 'warning' then 'border-yellow-200'
|
||||
when 'info' then 'border-blue-200'
|
||||
else 'border-gray-200'
|
||||
end
|
||||
end
|
||||
%>
|
||||
|
||||
36
app/views/shared/_form_errors.html.erb
Normal file
36
app/views/shared/_form_errors.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<%# Usage: <%= render "shared/form_errors", object: @user %> %>
|
||||
<%# 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-shrink-0">
|
||||
<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.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>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||
<% form_object.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</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>
|
||||
<% end %>
|
||||
@@ -78,9 +78,19 @@
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Sessions -->
|
||||
<li>
|
||||
<%= 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' }" do %>
|
||||
<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" />
|
||||
</svg>
|
||||
Sessions
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<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">
|
||||
<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>
|
||||
@@ -95,12 +105,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<div class="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
|
||||
<div class="relative z-50 lg:hidden hidden"
|
||||
data-mobile-sidebar-target="sidebarOverlay"
|
||||
id="mobile-sidebar-overlay"
|
||||
data-action="click->mobile-sidebar#closeOnBackgroundClick">
|
||||
<div class="fixed inset-0 bg-gray-900/80"></div>
|
||||
<div class="fixed inset-0 flex">
|
||||
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5"
|
||||
id="mobile-menu-close"
|
||||
data-action="click->mobile-sidebar#closeSidebar">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -128,7 +144,7 @@
|
||||
<!-- Same nav items as desktop -->
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
<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">
|
||||
<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>
|
||||
@@ -137,7 +153,7 @@
|
||||
</li>
|
||||
<% if user.admin? %>
|
||||
<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">
|
||||
<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>
|
||||
@@ -145,7 +161,7 @@
|
||||
<% end %>
|
||||
</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">
|
||||
<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>
|
||||
@@ -153,7 +169,7 @@
|
||||
<% end %>
|
||||
</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">
|
||||
<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>
|
||||
@@ -162,7 +178,7 @@
|
||||
</li>
|
||||
<% end %>
|
||||
<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">
|
||||
<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>
|
||||
@@ -170,7 +186,15 @@
|
||||
<% end %>
|
||||
</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 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">
|
||||
<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>
|
||||
Sessions
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= 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">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
@@ -29,14 +29,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button onclick="downloadBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Codes
|
||||
</button>
|
||||
|
||||
<button onclick="printBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
@@ -52,27 +52,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = <%= raw @backup_codes.to_json %>;
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const content = "Clinch Backup Codes\n" +
|
||||
"===================\n\n" +
|
||||
backupCodes.join("\n") +
|
||||
"\n\nSave these codes in a secure location.";
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'clinch-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function printBackupCodes() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
45
app/views/totp/regenerate_backup_codes.html.erb
Normal 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>
|
||||
@@ -4,17 +4,8 @@
|
||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
|
||||
<% if @user.errors.any? %>
|
||||
<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 %>
|
||||
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -23,5 +23,18 @@ module Clinch
|
||||
#
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
# Configure SMTP settings using environment variables
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||
port: ENV.fetch('SMTP_PORT', 587),
|
||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
# Preview emails in browser using letter_opener
|
||||
config.action_mailer.delivery_method = :letter_opener
|
||||
config.action_mailer.perform_deliveries = true
|
||||
|
||||
# Make template changes take effect immediately.
|
||||
config.action_mailer.perform_caching = false
|
||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
||||
# Highlight code that enqueued background job in logs.
|
||||
config.active_job.verbose_enqueue_logs = true
|
||||
|
||||
# Use Solid Queue for background jobs (same as production).
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs in development
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
|
||||
# Highlight code that triggered redirect in logs.
|
||||
@@ -83,4 +83,14 @@ Rails.application.configure do
|
||||
|
||||
# Apply autocorrection by RuboCop to files generated by `bin/rails 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
|
||||
|
||||
@@ -49,16 +49,17 @@ Rails.application.configure do
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs (modify as needed for production)
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
config.action_mailer.default_url_options = {
|
||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
||||
}
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
@@ -80,11 +81,70 @@ Rails.application.configure do
|
||||
config.active_record.attributes_for_inspect = [ :id ]
|
||||
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# config.hosts = [
|
||||
# "example.com", # Allow requests from example.com
|
||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
||||
# ]
|
||||
#
|
||||
# Configure allowed hosts based on deployment scenario
|
||||
allowed_hosts = [
|
||||
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
|
||||
]
|
||||
|
||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com')
|
||||
if host_domain.present?
|
||||
begin
|
||||
# Use PublicSuffix to properly extract the domain
|
||||
domain = PublicSuffix.parse(host_domain)
|
||||
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
|
||||
|
||||
if registrable_domain.present?
|
||||
# Create regex to allow any subdomain of the registrable domain
|
||||
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/
|
||||
end
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback to simple domain extraction if PublicSuffix fails
|
||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||
base_domain = host_domain.split('.').last(2).join('.')
|
||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||
end
|
||||
end
|
||||
|
||||
# Allow Docker service names if running in same compose
|
||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
end
|
||||
|
||||
# Allow internal IP access for cross-compose or host networking
|
||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
||||
# Specific host IP
|
||||
allowed_hosts << '192.168.2.246'
|
||||
|
||||
# Private IP ranges for internal network access
|
||||
allowed_hosts += [
|
||||
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
|
||||
/10\.\d+\.\d+\.\d+/, # 10.0.0.0/8 private network
|
||||
/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+/ # 172.16.0.0/12 private network
|
||||
]
|
||||
end
|
||||
|
||||
# Local development fallbacks
|
||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
||||
end
|
||||
|
||||
config.hosts = allowed_hosts
|
||||
|
||||
# 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
|
||||
|
||||
@@ -50,4 +50,8 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
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
|
||||
|
||||
@@ -4,26 +4,62 @@
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
|
||||
# Rails.application.configure do
|
||||
# config.content_security_policy do |policy|
|
||||
# policy.default_src :self, :https
|
||||
# policy.font_src :self, :https, :data
|
||||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# end
|
||||
#
|
||||
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
#
|
||||
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
|
||||
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
|
||||
# # config.content_security_policy_nonce_auto = true
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
# Default to self for everything, plus blob: for file downloads
|
||||
policy.default_src :self, "blob:"
|
||||
|
||||
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
||||
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
||||
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
|
||||
|
||||
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
||||
# and Stimulus controller style manipulations
|
||||
policy.style_src :self, :unsafe_inline
|
||||
|
||||
# Images: Allow self, data URLs, and https for external images
|
||||
policy.img_src :self, :data, :https
|
||||
|
||||
# Fonts: Allow self and data URLs
|
||||
policy.font_src :self, :data
|
||||
|
||||
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
|
||||
# WebAuthn endpoints are on the same domain, so self is sufficient
|
||||
policy.connect_src :self, "wss:"
|
||||
|
||||
# Media: Allow self
|
||||
policy.media_src :self
|
||||
|
||||
# Object and embed sources: Disallow for security (no Flash/etc)
|
||||
policy.object_src :none
|
||||
policy.frame_src :none
|
||||
policy.frame_ancestors :none
|
||||
|
||||
# Base URI: Restricted to self
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: Allow self for all form submissions
|
||||
policy.form_action :self
|
||||
|
||||
# Manifest sources: Allow self for PWA manifest
|
||||
policy.manifest_src :self
|
||||
|
||||
# Worker sources: Allow self for potential Web Workers
|
||||
policy.worker_src :self
|
||||
|
||||
# Child sources: Allow self for any future iframes
|
||||
policy.child_src :self
|
||||
|
||||
# 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
|
||||
|
||||
# Start with CSP in report-only mode for testing
|
||||
# Set to false after verifying everything works in production
|
||||
config.content_security_policy_report_only = Rails.env.development?
|
||||
|
||||
# Report CSP violations (optional - uncomment to enable)
|
||||
# config.content_security_policy_report_uri = "/csp-violations"
|
||||
end
|
||||
122
config/initializers/csp_local_logger.rb
Normal file
122
config/initializers/csp_local_logger.rb
Normal 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
|
||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal 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
|
||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal 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
|
||||
54
config/initializers/webauthn.rb
Normal file
54
config/initializers/webauthn.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
# WebAuthn configuration for Clinch Identity Provider
|
||||
WebAuthn.configure do |config|
|
||||
# Relying Party name (displayed in authenticator prompts)
|
||||
# For development, use http://localhost to match passkey in Passwords app
|
||||
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
||||
config.allowed_origins = [origin_host]
|
||||
|
||||
# Relying Party ID (must match origin domain)
|
||||
# Extract domain from origin for RP ID
|
||||
origin_uri = URI.parse(origin_host)
|
||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
||||
|
||||
# For development, we also allow localhost with common ports and without port
|
||||
if Rails.env.development?
|
||||
config.allowed_origins += [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3035",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3035"
|
||||
]
|
||||
end
|
||||
|
||||
# Relying Party name shown in authenticator prompts
|
||||
config.rp_name = ENV.fetch("CLINCH_RP_NAME", "Clinch Identity Provider")
|
||||
|
||||
# Credential timeout in milliseconds (60 seconds)
|
||||
# Users have 60 seconds to complete the authentication ceremony
|
||||
config.credential_options_timeout = 60_000
|
||||
|
||||
# Supported algorithms for credential creation
|
||||
# ES256: ECDSA with P-256 and SHA-256 (most common, secure)
|
||||
# RS256: RSASSA-PKCS1-v1_5 with SHA-256 (hardware keys often use this)
|
||||
config.algorithms = ["ES256", "RS256"]
|
||||
|
||||
# Encoding for credential IDs and other data
|
||||
config.encoding = :base64url
|
||||
|
||||
# Custom verifier for additional security checks if needed
|
||||
# config.verifier = MyCustomVerifier.new
|
||||
end
|
||||
|
||||
# Security note: WebAuthn requires HTTPS in production
|
||||
# The WebAuthn API will not work on non-secure origins in production browsers
|
||||
# Ensure CLINCH_HOST uses https:// in production environments
|
||||
|
||||
# Example environment variables:
|
||||
# CLINCH_HOST=https://auth.example.com
|
||||
# CLINCH_RP_ID=example.com
|
||||
# CLINCH_RP_NAME="Example Company Identity Provider"
|
||||
# CLINCH_WEBAUTHN_ATTESTATION=none
|
||||
# CLINCH_WEBAUTHN_USER_VERIFICATION=preferred
|
||||
# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred
|
||||
@@ -31,11 +31,11 @@ threads threads_count, threads_count
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
port ENV.fetch("PORT", 3000)
|
||||
|
||||
|
||||
# Allow puma to be restarted by `bin/rails restart` command.
|
||||
plugin :tmp_restart
|
||||
|
||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
||||
# Solid Queue plugin removed - now using async processor
|
||||
|
||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||
# In other environments, only set the PID file if requested.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# examples:
|
||||
# periodic_cleanup:
|
||||
# class: CleanSoftDeletedRecordsJob
|
||||
# queue: background
|
||||
# args: [ 1000, { batch_size: 500 } ]
|
||||
# schedule: every hour
|
||||
# periodic_cleanup_with_command:
|
||||
# command: "SoftDeletedRecord.due.delete_all"
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
@@ -1,6 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
resource :session
|
||||
resources :passwords, param: :token
|
||||
resources :invitations, param: :token, only: [:show, :update]
|
||||
mount ActionCable.server => "/cable"
|
||||
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
@@ -18,6 +19,10 @@ Rails.application.routes.draw do
|
||||
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
||||
post "/totp-verification", to: "sessions#verify_totp"
|
||||
|
||||
# WebAuthn authentication routes
|
||||
post "/sessions/webauthn/challenge", to: "sessions#webauthn_challenge"
|
||||
post "/sessions/webauthn/verify", to: "sessions#webauthn_verify"
|
||||
|
||||
# OIDC (OpenID Connect) routes
|
||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||
@@ -25,15 +30,28 @@ Rails.application.routes.draw do
|
||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||
post "/oauth/token", to: "oidc#token"
|
||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||
get "/logout", to: "oidc#logout"
|
||||
|
||||
# ForwardAuth / Trusted Header SSO
|
||||
namespace :api do
|
||||
get "/verify", to: "forward_auth#verify"
|
||||
post "/csp-violation-report", to: "csp#violation_report"
|
||||
end
|
||||
|
||||
# Authenticated routes
|
||||
root "dashboard#index"
|
||||
resource :profile, only: [:show, :update]
|
||||
resource :profile, only: [:show, :update] do
|
||||
member do
|
||||
delete :revoke_consent
|
||||
delete :revoke_all_consents
|
||||
end
|
||||
end
|
||||
resource :active_sessions, only: [:show] do
|
||||
member do
|
||||
delete :revoke_consent
|
||||
delete :revoke_all_consents
|
||||
end
|
||||
end
|
||||
resources :sessions, only: [] do
|
||||
member do
|
||||
delete :destroy, action: :destroy_other
|
||||
@@ -46,11 +64,24 @@ Rails.application.routes.draw do
|
||||
delete '/totp', to: 'totp#destroy'
|
||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||
|
||||
# WebAuthn (Passkeys) routes
|
||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
||||
post '/webauthn/create', to: 'webauthn#create'
|
||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
||||
get '/webauthn/check', to: 'webauthn#check'
|
||||
|
||||
# Admin routes
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
resources :users
|
||||
resources :users do
|
||||
member do
|
||||
post :resend_invitation
|
||||
end
|
||||
end
|
||||
resources :applications do
|
||||
member do
|
||||
post :regenerate_credentials
|
||||
|
||||
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateForwardAuthRules < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :forward_auth_rules do |t|
|
||||
t.string :domain_pattern
|
||||
t.integer :policy
|
||||
t.boolean :active
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateForwardAuthRuleGroups < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :forward_auth_rule_groups do |t|
|
||||
t.references :forward_auth_rule, null: false, foreign_key: true
|
||||
t.references :group, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
|
||||
add_column :applications, :role_prefix, :string
|
||||
add_column :applications, :managed_permissions, :json, default: {}
|
||||
add_column :applications, :role_claim_name, :string, default: 'roles'
|
||||
|
||||
create_table :application_roles do |t|
|
||||
t.references :application, null: false, foreign_key: true
|
||||
t.string :name, null: false
|
||||
t.string :display_name
|
||||
t.text :description
|
||||
t.json :permissions, default: {}
|
||||
t.boolean :active, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :application_roles, [:application_id, :name], unique: true
|
||||
|
||||
create_table :user_role_assignments do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :application_role, null: false, foreign_key: true
|
||||
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
|
||||
t.json :metadata, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_role_assignments, [:user_id, :application_role_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddDescriptionToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :description, :text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddClientSecretHashToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :client_secret_hash, :string
|
||||
remove_column :applications, :client_secret, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameClientSecretHashToClientSecretDigest < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
rename_column :applications, :client_secret_hash, :client_secret_digest
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddNonceToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :oidc_authorization_codes, :nonce, :string
|
||||
end
|
||||
end
|
||||
17
db/migrate/20251024055739_create_oidc_user_consents.rb
Normal file
17
db/migrate/20251024055739_create_oidc_user_consents.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class CreateOidcUserConsents < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :oidc_user_consents do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :application, null: false, foreign_key: true
|
||||
t.text :scopes_granted, null: false
|
||||
t.datetime :granted_at, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Add unique index to prevent duplicate consent records
|
||||
add_index :oidc_user_consents, [:user_id, :application_id], unique: true
|
||||
# Add index for querying recent consents
|
||||
add_index :oidc_user_consents, :granted_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddHeadersConfigToForwardAuthRule < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :forward_auth_rules, :headers_config, :json, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSignInAtToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :last_sign_in_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddCustomClaimsToGroupsAndUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :groups, :custom_claims, :json, default: {}, null: false
|
||||
add_column :users, :custom_claims, :json, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class AddForwardAuthFieldsToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add ForwardAuth-specific fields
|
||||
add_column :applications, :domain_pattern, :string
|
||||
add_column :applications, :headers_config, :json, default: {}, null: false
|
||||
|
||||
# Add index on domain_pattern for lookup performance
|
||||
add_index :applications, :domain_pattern, unique: true, where: "domain_pattern IS NOT NULL"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Temporarily define models for migration
|
||||
forward_auth_rule_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rules"
|
||||
has_many :forward_auth_rule_groups, foreign_key: :forward_auth_rule_id, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
forward_auth_rule_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rule_groups"
|
||||
belongs_to :forward_auth_rule, class_name: "MigrateForwardAuthRulesToApplications::ForwardAuthRule"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "groups"
|
||||
end
|
||||
|
||||
application_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "applications"
|
||||
has_many :application_groups, foreign_key: :application_id, dependent: :destroy
|
||||
end
|
||||
|
||||
application_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "application_groups"
|
||||
belongs_to :application, class_name: "MigrateForwardAuthRulesToApplications::Application"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
# Assign to constants so we can reference them
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRule", forward_auth_rule_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRuleGroup", forward_auth_rule_group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Group", group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Application", application_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ApplicationGroup", application_group_class)
|
||||
|
||||
# Migrate each ForwardAuthRule to an Application
|
||||
forward_auth_rule_class.find_each do |rule|
|
||||
# Create Application from ForwardAuthRule
|
||||
app = application_class.create!(
|
||||
name: rule.domain_pattern.titleize,
|
||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||
app_type: 'forward_auth',
|
||||
domain_pattern: rule.domain_pattern,
|
||||
headers_config: rule.headers_config || {},
|
||||
active: rule.active
|
||||
)
|
||||
|
||||
# Migrate group associations
|
||||
forward_auth_rule_group_class.where(forward_auth_rule_id: rule.id).find_each do |far_group|
|
||||
application_group_class.create!(
|
||||
application_id: app.id,
|
||||
group_id: far_group.group_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# Remove all forward_auth applications created by this migration
|
||||
Application.where(app_type: 'forward_auth').destroy_all
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_const(name, value)
|
||||
parts = name.split("::")
|
||||
parts[0..-2].inject(Object) { |mod, part| mod.const_get(part) }.const_set(parts.last, value)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class RemoveRoleRelatedTablesAndColumns < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :user_role_assignments if table_exists?(:user_role_assignments)
|
||||
|
||||
# Remove application_roles table
|
||||
drop_table :application_roles if table_exists?(:application_roles)
|
||||
|
||||
# Remove role-related columns from applications
|
||||
remove_column :applications, :role_mapping_mode, :string if column_exists?(:applications, :role_mapping_mode)
|
||||
remove_column :applications, :role_prefix, :string if column_exists?(:applications, :role_prefix)
|
||||
remove_column :applications, :role_claim_name, :string if column_exists?(:applications, :role_claim_name)
|
||||
remove_column :applications, :managed_permissions, :json if column_exists?(:applications, :managed_permissions)
|
||||
end
|
||||
end
|
||||
9
db/migrate/20251104015104_remove_forward_auth_tables.rb
Normal file
9
db/migrate/20251104015104_remove_forward_auth_tables.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class RemoveForwardAuthTables < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :forward_auth_rule_groups if table_exists?(:forward_auth_rule_groups)
|
||||
|
||||
# Remove forward_auth_rules table
|
||||
drop_table :forward_auth_rules if table_exists?(:forward_auth_rules)
|
||||
end
|
||||
end
|
||||
5
db/migrate/20251104022439_add_name_to_users.rb
Normal file
5
db/migrate/20251104022439_add_name_to_users.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AddNameToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :name, :string
|
||||
end
|
||||
end
|
||||
32
db/migrate/20251104042155_create_webauthn_credentials.rb
Normal file
32
db/migrate/20251104042155_create_webauthn_credentials.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :webauthn_credentials do |t|
|
||||
# Reference to the user who owns this credential
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
|
||||
# WebAuthn specification fields
|
||||
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
|
||||
t.string :public_key, null: false # public key (base64)
|
||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
||||
|
||||
# Metadata
|
||||
t.string :nickname # User-friendly name ("MacBook Touch ID")
|
||||
t.string :authenticator_type # "platform" or "cross-platform"
|
||||
t.boolean :backup_eligible, default: false # Can be backed up (passkey sync)
|
||||
t.boolean :backup_state, default: false # Currently backed up
|
||||
|
||||
# Tracking
|
||||
t.datetime :last_used_at
|
||||
t.string :last_used_ip
|
||||
t.string :user_agent # Browser/OS info
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Add composite index for user-specific queries
|
||||
add_index :webauthn_credentials, [:user_id, :external_id], unique: true
|
||||
add_index :webauthn_credentials, [:user_id, :last_used_at]
|
||||
add_index :webauthn_credentials, :authenticator_type
|
||||
add_index :webauthn_credentials, :last_used_at
|
||||
end
|
||||
end
|
||||
16
db/migrate/20251104042206_add_webauthn_to_users.rb
Normal file
16
db/migrate/20251104042206_add_webauthn_to_users.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class AddWebauthnToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# WebAuthn user handle - stable, opaque identifier for the user
|
||||
# Must be unique and never change once assigned
|
||||
add_column :users, :webauthn_id, :string
|
||||
add_index :users, :webauthn_id, unique: true
|
||||
|
||||
# Policy enforcement - whether this user MUST use WebAuthn
|
||||
# Can be set by admins for high-security accounts
|
||||
add_column :users, :webauthn_required, :boolean, default: false, null: false
|
||||
|
||||
# User preference for 2FA method (if both TOTP and WebAuthn are available)
|
||||
# :totp, :webauthn, or nil for system default
|
||||
add_column :users, :preferred_2fa_method, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLandingUrlToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :landing_url, :string
|
||||
end
|
||||
end
|
||||
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal file
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal 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
|
||||
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal file
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal 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
|
||||
60
db/schema.rb
generated
60
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -25,8 +25,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "app_type", null: false
|
||||
t.string "client_id"
|
||||
t.string "client_secret"
|
||||
t.string "client_secret_digest"
|
||||
t.datetime "created_at", null: false
|
||||
t.text "description"
|
||||
t.string "domain_pattern"
|
||||
t.json "headers_config", default: {}, null: false
|
||||
t.string "landing_url"
|
||||
t.text "metadata"
|
||||
t.string "name", null: false
|
||||
t.text "redirect_uris"
|
||||
@@ -34,11 +38,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active"], name: "index_applications_on_active"
|
||||
t.index ["client_id"], name: "index_applications_on_client_id", unique: true
|
||||
t.index ["domain_pattern"], name: "index_applications_on_domain_pattern", unique: true, where: "domain_pattern IS NOT NULL"
|
||||
t.index ["slug"], name: "index_applications_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.text "description"
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -65,6 +71,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.string "code", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "nonce"
|
||||
t.string "redirect_uri", null: false
|
||||
t.string "scope"
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -77,6 +84,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_user_consents", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "granted_at", null: false
|
||||
t.text "scopes_granted", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
|
||||
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
|
||||
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
|
||||
end
|
||||
|
||||
create_table "sessions", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "device_name"
|
||||
@@ -104,16 +124,45 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.text "backup_codes"
|
||||
t.json "backup_codes"
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.string "email_address", null: false
|
||||
t.datetime "last_sign_in_at"
|
||||
t.string "name"
|
||||
t.string "password_digest", null: false
|
||||
t.integer "status"
|
||||
t.string "preferred_2fa_method"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.boolean "totp_required", default: false, null: false
|
||||
t.string "totp_secret"
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "webauthn_id"
|
||||
t.boolean "webauthn_required", default: false, null: false
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
t.index ["status"], name: "index_users_on_status"
|
||||
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
||||
end
|
||||
|
||||
create_table "webauthn_credentials", force: :cascade do |t|
|
||||
t.string "authenticator_type"
|
||||
t.boolean "backup_eligible", default: false
|
||||
t.boolean "backup_state", default: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "external_id", null: false
|
||||
t.datetime "last_used_at"
|
||||
t.string "last_used_ip"
|
||||
t.string "nickname"
|
||||
t.string "public_key", null: false
|
||||
t.integer "sign_count", default: 0, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "user_agent"
|
||||
t.integer "user_id", null: false
|
||||
t.index ["authenticator_type"], name: "index_webauthn_credentials_on_authenticator_type"
|
||||
t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true
|
||||
t.index ["last_used_at"], name: "index_webauthn_credentials_on_last_used_at"
|
||||
t.index ["user_id", "external_id"], name: "index_webauthn_credentials_on_user_id_and_external_id", unique: true
|
||||
t.index ["user_id", "last_used_at"], name: "index_webauthn_credentials_on_user_id_and_last_used_at"
|
||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "application_groups", "applications"
|
||||
@@ -122,7 +171,10 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
add_foreign_key "oidc_authorization_codes", "users"
|
||||
add_foreign_key "oidc_user_consents", "applications"
|
||||
add_foreign_key "oidc_user_consents", "users"
|
||||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "user_groups", "groups"
|
||||
add_foreign_key "user_groups", "users"
|
||||
add_foreign_key "webauthn_credentials", "users"
|
||||
end
|
||||
|
||||
393
docs/forward-auth.md
Normal file
393
docs/forward-auth.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Forward Authentication
|
||||
|
||||
## Overview
|
||||
|
||||
Forward authentication allows a reverse proxy (like Caddy, Nginx, Traefik) to delegate authentication decisions to a separate service. Clinch implements this pattern to provide SSO for multiple applications.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Tip 1: Forward URL Configuration ✅
|
||||
|
||||
Clinch includes the original destination URL in the redirect parameters:
|
||||
|
||||
```ruby
|
||||
login_params = {
|
||||
rd: original_url, # redirect destination
|
||||
rm: request.method # request method
|
||||
}
|
||||
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
||||
```
|
||||
|
||||
Example: `https://clinch.example.com/signin?rd=https://metube.example.com/&rm=GET`
|
||||
|
||||
### Tip 2: Root Domain Cookies ✅
|
||||
|
||||
Clinch sets authentication cookies on the root domain to enable cross-subdomain authentication:
|
||||
|
||||
```ruby
|
||||
def extract_root_domain(host)
|
||||
# clinch.example.com -> .example.com
|
||||
# app.example.co.uk -> .example.co.uk
|
||||
# localhost -> nil (no domain restriction)
|
||||
end
|
||||
|
||||
cookies.signed.permanent[:session_id] = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?,
|
||||
domain: ".example.com" # Available to all subdomains
|
||||
}
|
||||
```
|
||||
|
||||
This allows the same session cookie to work across:
|
||||
- `clinch.example.com` (auth service)
|
||||
- `metube.example.com` (protected app)
|
||||
- `sonarr.example.com` (protected app)
|
||||
|
||||
### Tip 3: Race Condition Solution with One-Time Tokens ✅
|
||||
|
||||
**Problem**: After successful authentication, there's a race condition where the browser immediately follows the redirect to the protected application, but the reverse proxy makes a forward auth request before the browser has processed and started sending the new session cookie.
|
||||
|
||||
**Solution**: Clinch uses a one-time token system to bridge this timing gap:
|
||||
|
||||
```ruby
|
||||
# During authentication (authentication.rb)
|
||||
def create_forward_auth_token(session_obj)
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store token for 30 seconds
|
||||
Rails.cache.write("forward_auth_token:#{token}", session_obj.id, expires_in: 30.seconds)
|
||||
|
||||
# Add token to redirect URL
|
||||
if session[:return_to_after_authenticating].present?
|
||||
original_url = session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
# In forward auth verification (forward_auth_controller.rb)
|
||||
def check_forward_auth_token
|
||||
token = params[:fa_token]
|
||||
return nil unless token.present?
|
||||
|
||||
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||
return nil unless session_id
|
||||
|
||||
session = Session.find_by(id: session_id)
|
||||
return nil unless session && !session.expired?
|
||||
|
||||
# Delete token immediately (one-time use)
|
||||
Rails.cache.delete("forward_auth_token:#{token}")
|
||||
|
||||
Rails.logger.info "ForwardAuth: Valid one-time token used for session #{session_id}"
|
||||
session_id
|
||||
end
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. User authenticates → Rails sets session cookie + generates one-time token
|
||||
2. Token gets appended to redirect URL: `https://metube.example.com/?fa_token=abc123...`
|
||||
3. Browser follows redirect → Caddy makes forward auth request with token
|
||||
4. Forward auth validates token → authenticates user immediately
|
||||
5. Token is deleted (one-time use) → subsequent requests use normal cookies
|
||||
|
||||
**Security Features:**
|
||||
- Tokens expire after 30 seconds
|
||||
- One-time use (deleted after validation)
|
||||
- Secure random generation
|
||||
- Session validation before token acceptance
|
||||
|
||||
## Implementation Overview
|
||||
|
||||
### Forward Auth Pattern
|
||||
|
||||
**Standard Forward Auth Approach:**
|
||||
- Returns `302 Found` or `303 See Other` with `Location` header
|
||||
- Direct browser redirects to authentication service
|
||||
- Uses HTTP status codes to communicate authentication state
|
||||
|
||||
**Clinch Current Implementation:**
|
||||
- Returns `302 Found` directly to login URL
|
||||
- Includes `rd` (redirect destination) and `rm` (request method) parameters
|
||||
- Uses root domain cookies for cross-subdomain authentication
|
||||
|
||||
## How Clinch Forward Auth Works
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. **User visits** `https://metube.example.com/`
|
||||
2. **Caddy forwards** to `http://clinch:3000/api/verify?rd=https://clinch.example.com`
|
||||
3. **Clinch checks session**:
|
||||
- **If authenticated**: Returns `200 OK` with user headers
|
||||
- **If not authenticated**: Returns `302 Found` to login URL with redirect parameters
|
||||
4. **Browser follows redirect** to Clinch login page
|
||||
5. **User logs in** (with TOTP if enabled):
|
||||
- Rails creates session and sets cross-domain cookie
|
||||
- **Rails generates one-time token** and appends to redirect URL
|
||||
- User is redirected to: `https://metube.example.com/?fa_token=abc123...`
|
||||
6. **Browser follows redirect** → Caddy makes forward auth request with token
|
||||
7. **Clinch validates one-time token** → authenticates user immediately
|
||||
8. **Token is deleted** → subsequent requests use normal session cookies
|
||||
9. **Caddy forwards to MEtube** with proper authentication headers
|
||||
|
||||
### Response Headers
|
||||
|
||||
**Successful Authentication (200 OK):**
|
||||
```
|
||||
Remote-User: user@example.com
|
||||
Remote-Email: user@example.com
|
||||
Remote-Groups: media-managers,users
|
||||
Remote-Admin: false
|
||||
```
|
||||
|
||||
**Redirect to Login (302 Found):**
|
||||
```
|
||||
Location: https://clinch.example.com/signin?rd=https://metube.example.com/&rm=GET
|
||||
```
|
||||
|
||||
## Caddy Configuration
|
||||
|
||||
```caddyfile
|
||||
# Clinch SSO (main authentication server)
|
||||
clinch.example.com {
|
||||
reverse_proxy clinch:3000
|
||||
}
|
||||
|
||||
# MEtube (protected by Clinch)
|
||||
metube.example.com {
|
||||
forward_auth clinch:3000 {
|
||||
uri /api/verify?rd=https://clinch.example.com
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy * {
|
||||
to http://192.168.2.223:8081
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- **Forward Auth Controller**: `app/controllers/api/forward_auth_controller.rb`
|
||||
- **Authentication Logic**: `app/controllers/concerns/authentication.rb`
|
||||
- **Caddy Examples**: `docs/caddy-example.md`
|
||||
- **Implementation Details**: See technical documentation below
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test forward auth endpoint directly
|
||||
curl -v http://localhost:3000/api/verify?rd=https://clinch.example.com
|
||||
|
||||
# Should return 302 redirect to login page
|
||||
# Or 200 OK if you have a valid session cookie
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Content Security Policy (CSP)
|
||||
|
||||
Clinch includes a comprehensive Content Security Policy to prevent Cross-Site Scripting (XSS) attacks by controlling which resources can be loaded by the browser.
|
||||
|
||||
**What CSP Prevents:**
|
||||
- Malicious script injection attacks
|
||||
- Unauthorized resource loading
|
||||
- Clickjacking through iframe protection
|
||||
- Data exfiltration through unauthorized connections
|
||||
|
||||
**CSP Features:**
|
||||
- **Strict script control**: Only allows scripts from same origin or HTTPS
|
||||
- **Nonce support**: Allows specific inline scripts with cryptographic nonces
|
||||
- **Frame protection**: Prevents clickjacking attacks
|
||||
- **Resource restrictions**: Controls images, fonts, styles, and media sources
|
||||
- **Violation reporting**: Monitors and logs attempted XSS attacks
|
||||
|
||||
**Development vs Production:**
|
||||
- **Development**: Report-only mode for debugging CSP violations
|
||||
- **Production**: Full enforcement with violation logging
|
||||
|
||||
### DNS Rebinding Protection
|
||||
|
||||
Clinch includes built-in DNS rebinding protection for enhanced security in all deployment scenarios.
|
||||
|
||||
**What is DNS Rebinding?**
|
||||
DNS rebinding attacks trick a victim's browser into accessing internal network resources by manipulating DNS responses, potentially allowing attackers to probe your authentication system.
|
||||
|
||||
**Clinch's Protection Layers:**
|
||||
1. **Rails Host Validation**: Blocks unauthorized domains at the application level
|
||||
2. **Infrastructure Security**: Caddy/Reverse proxy provides additional protection
|
||||
3. **Environment-Specific Configuration**: Adapts to your deployment scenario
|
||||
|
||||
### Deployment Scenarios
|
||||
|
||||
#### Scenario 1: Same Docker Compose (Recommended)
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
caddy:
|
||||
# ... caddy configuration
|
||||
|
||||
clinch:
|
||||
image: reg.tbdb.info/clinch:latest
|
||||
environment:
|
||||
- CLINCH_HOST=auth.aapamilne.com
|
||||
- CLINCH_DOCKER_SERVICE_NAME=clinch # Enable service name access
|
||||
- CLINCH_ALLOW_INTERNAL_IPS=true # Allow backup IP access
|
||||
- CLINCH_ALLOW_LOCALHOST=false
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
metube.aapamilne.com {
|
||||
forward_auth clinch:3000 { # Docker service name (preferred)
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy * {
|
||||
to http://192.168.2.223:8081
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Docker network isolation prevents external access
|
||||
- ✅ Service names resolve to unpredictable internal IPs
|
||||
- ✅ Natural DNS rebinding protection
|
||||
- ✅ Application-level host validation as backup
|
||||
|
||||
#### Scenario 2: Separate Docker Composes (Current Setup)
|
||||
```yaml
|
||||
# clinch-compose/.env
|
||||
CLINCH_HOST=auth.aapamilne.com
|
||||
CLINCH_ALLOW_INTERNAL_IPS=true
|
||||
CLINCH_ALLOW_LOCALHOST=false
|
||||
CLINCH_DOCKER_SERVICE_NAME=
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
metube.aapamilne.com {
|
||||
forward_auth 192.168.2.246:3000 { # IP access across composes
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Rails host validation blocks unauthorized domains
|
||||
- ✅ Only allows private IP ranges and your domain
|
||||
- ✅ Defense in depth (application + infrastructure security)
|
||||
|
||||
#### Scenario 3: External Deployment
|
||||
```yaml
|
||||
# Production environment
|
||||
environment:
|
||||
- CLINCH_HOST=auth.example.com
|
||||
- CLINCH_ALLOW_INTERNAL_IPS=false # Stricter for external
|
||||
- CLINCH_ALLOW_LOCALHOST=false
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
app.example.com {
|
||||
forward_auth auth.example.com:3000 { # External domain only
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Only allows your external domain
|
||||
- ✅ Blocks internal IP access
|
||||
- ✅ Maximum security for public deployments
|
||||
|
||||
### Host Validation Environment Variables
|
||||
|
||||
| Variable | Default | Purpose | Recommended Setting |
|
||||
|----------|---------|---------|-------------------|
|
||||
| `CLINCH_HOST` | `auth.aapamilne.com` | Primary domain | Always set to your auth domain |
|
||||
| `CLINCH_DOCKER_SERVICE_NAME` | `nil` | Docker service name | Set to service name in same compose |
|
||||
| `CLINCH_ALLOW_INTERNAL_IPS` | `true` | Allow private IPs | `true` for internal, `false` for external |
|
||||
| `CLINCH_ALLOW_LOCALHOST` | `false` | Allow localhost access | `true` for development only |
|
||||
|
||||
### Security Architecture
|
||||
|
||||
Clinch provides **defense in depth** security with multiple protection layers:
|
||||
|
||||
**Application-Level Security:**
|
||||
- Host validation prevents unauthorized domain access
|
||||
- Session-based authentication with secure cookies
|
||||
- Rate limiting on sensitive endpoints
|
||||
- Input validation and sanitization
|
||||
- Content Security Policy (CSP) prevents XSS attacks
|
||||
|
||||
**Infrastructure Security:**
|
||||
- Docker network isolation
|
||||
- Reverse proxy access control
|
||||
- SSL/TLS encryption
|
||||
- Private network restrictions
|
||||
|
||||
**Benefits of Multi-Layer Security:**
|
||||
- If infrastructure security fails, application security still protects
|
||||
- Flexible deployment options without compromising security
|
||||
- Environment-specific configuration for different threat models
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Loop**: Check that cookies are set on the root domain
|
||||
2. **Session Not Shared**: Verify `extract_root_domain` is working correctly
|
||||
3. **Caddy Connection**: Ensure service name/IP resolves from your Caddy container
|
||||
4. **Race Condition After Authentication**:
|
||||
- **Problem**: Forward auth fails immediately after login due to cookie timing
|
||||
- **Solution**: One-time tokens automatically bridge this gap
|
||||
- **Debug**: Look for "ForwardAuth: Valid one-time token used" in logs
|
||||
5. **Host Validation Errors**:
|
||||
- **Problem**: "Blocked host: [host]" errors in logs
|
||||
- **Solution**: Check `CLINCH_HOST` and other environment variables
|
||||
- **Debug**: Verify your Caddy configuration matches allowed hosts
|
||||
6. **DNS Rebinding Protection**:
|
||||
- **Problem**: Legitimate requests blocked as "unauthorized host"
|
||||
- **Solution**: Ensure your deployment scenario matches environment variables
|
||||
- **Debug**: Check Rails logs for host validation messages
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable debug logging in `forward_auth_controller.rb` to see:
|
||||
- Headers received from Caddy
|
||||
- Domain extraction results
|
||||
- Redirect URLs being generated
|
||||
- Token validation during race condition resolution
|
||||
|
||||
```ruby
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{host}, X-Forwarded-Host=#{original_host}"
|
||||
Rails.logger.info "Setting 302 redirect to: #{login_url}"
|
||||
Rails.logger.info "ForwardAuth: Valid one-time token used for session #{session_id}"
|
||||
Rails.logger.info "Authentication: Added forward auth token to redirect URL: #{url}"
|
||||
```
|
||||
|
||||
**Key log messages to watch for:**
|
||||
- `"Authentication: Added forward auth token to redirect URL"` - Token generation during login
|
||||
- `"ForwardAuth: Valid one-time token used for session X"` - Successful race condition resolution
|
||||
- `"ForwardAuth: Session cookie present: false"` - Cookie timing issue (should be resolved by token)
|
||||
|
||||
## Other References
|
||||
|
||||
- https://www.reddit.com/r/selfhosted/comments/1hybe81/i_wanted_to_implement_my_own_forward_auth_proxy/
|
||||
- https://www.kevinsimper.dk/posts/implementing-a-forward_auth-proxy-tips-and-details
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user