Compare commits
45 Commits
e39721c7e6
...
v0.16.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782e197d91 | ||
|
|
020759bfb3 | ||
|
|
85f50bfc96 | ||
|
|
b55139eb1c | ||
|
|
8f578ed3f4 | ||
|
|
aa5736ddab | ||
|
|
49068aa344 | ||
|
|
07ea031b61 | ||
|
|
209c5496d8 | ||
|
|
d49e7ce4f5 | ||
|
|
44892e3301 | ||
|
|
24266872f9 | ||
|
|
cd862c7cd7 | ||
|
|
89bd5f1432 | ||
|
|
57d7d1f691 | ||
|
|
406a79d9eb | ||
|
|
f38ac2ecc8 | ||
|
|
84ed462f40 | ||
|
|
96a657e349 | ||
|
|
8a095e4939 | ||
|
|
703d24e4e4 | ||
|
|
2843790cef | ||
|
|
0e9ec71013 | ||
|
|
fe68f6e81e | ||
|
|
c5ab7dc2a5 | ||
|
|
bfad9c4e9d | ||
|
|
5b41db2c6a | ||
|
|
03dfdbd83a | ||
|
|
6b58b685c4 | ||
|
|
a399907dfd | ||
|
|
bbfb564e1c | ||
|
|
9663110938 | ||
|
|
0bca1d2bac | ||
|
|
bdb10d86fb | ||
|
|
37e6e2cc19 | ||
|
|
9648b64043 | ||
|
|
a5eba9a5cd | ||
|
|
afa90303c8 | ||
|
|
df5dbfc46c | ||
|
|
2768104c1e | ||
|
|
2e427a0520 | ||
|
|
556656d090 | ||
|
|
cc93f72f0a | ||
|
|
09e9b32e46 | ||
|
|
7d352654fd |
56
.github/workflows/build.yml
vendored
Normal file
56
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Build and publish image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
tags: [ 'v*' ]
|
||||||
|
|
||||||
|
# Only one build per ref at a time; cancel superseded main builds.
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # Required to push to GHCR
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract image metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=main
|
||||||
|
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern=v{{version}}
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -1 +1 @@
|
|||||||
4.0.1
|
4.0.5
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=4.0.1
|
ARG RUBY_VERSION=4.0.5
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|||||||
2
Gemfile
2
Gemfile
@@ -1,7 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||||
gem "rails", "~> 8.1.2"
|
gem "rails", "~> 8.1.3"
|
||||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
|
|||||||
288
Gemfile.lock
288
Gemfile.lock
@@ -1,31 +1,31 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.16)
|
action_text-trix (2.1.19)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.2)
|
actioncable (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.2)
|
actionmailbox (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.2)
|
actionmailer (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.2)
|
actionpack (8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.2)
|
actiontext (8.1.3)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.2)
|
actionview (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.2)
|
activejob (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.2)
|
activemodel (8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
activerecord (8.1.2)
|
activerecord (8.1.3)
|
||||||
activemodel (= 8.1.2)
|
activemodel (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.2)
|
activestorage (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.2)
|
activesupport (8.1.3)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -75,19 +75,19 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.8)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.21)
|
bcrypt (3.1.22)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bigdecimal (4.0.1)
|
bigdecimal (4.1.2)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.20.1)
|
bootsnap (1.24.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.2)
|
brakeman (8.0.5)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -102,11 +102,11 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
cbor (0.5.10.1)
|
cbor (0.5.10.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.6)
|
concurrent-ruby (1.3.7)
|
||||||
connection_pool (3.0.2)
|
connection_pool (3.0.2)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
@@ -120,44 +120,44 @@ GEM
|
|||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.2)
|
erb (6.0.4)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
ffi (1.17.3-aarch64-linux-gnu)
|
ffi (1.17.4-aarch64-linux-gnu)
|
||||||
ffi (1.17.3-aarch64-linux-musl)
|
ffi (1.17.4-aarch64-linux-musl)
|
||||||
ffi (1.17.3-arm-linux-gnu)
|
ffi (1.17.4-arm-linux-gnu)
|
||||||
ffi (1.17.3-arm-linux-musl)
|
ffi (1.17.4-arm-linux-musl)
|
||||||
ffi (1.17.3-arm64-darwin)
|
ffi (1.17.4-arm64-darwin)
|
||||||
ffi (1.17.3-x86_64-linux-gnu)
|
ffi (1.17.4-x86_64-linux-gnu)
|
||||||
ffi (1.17.3-x86_64-linux-musl)
|
ffi (1.17.4-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
fugit (1.12.2)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.8)
|
i18n (1.15.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
importmap-rails (2.2.2)
|
importmap-rails (2.2.3)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.2)
|
io-console (0.8.2)
|
||||||
irb (1.17.0)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
prism (>= 1.3.0)
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.14.1)
|
jbuilder (2.15.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.19.0)
|
json (2.19.9)
|
||||||
jwt (3.1.2)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kamal (2.10.1)
|
kamal (2.12.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -177,7 +177,7 @@ GEM
|
|||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.0)
|
loofah (2.25.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -186,14 +186,14 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.1.0)
|
marcel (1.2.1)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.27.0)
|
minitest (5.27.0)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.3)
|
||||||
net-imap (0.6.3)
|
net-imap (0.6.4.1)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -206,68 +206,68 @@ GEM
|
|||||||
net-ssh (>= 5.0.0, < 8.0.0)
|
net-ssh (>= 5.0.0, < 8.0.0)
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.2)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-aarch64-linux-musl)
|
nokogiri (1.19.4-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm-linux-gnu)
|
nokogiri (1.19.4-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm-linux-musl)
|
nokogiri (1.19.4-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-arm64-darwin)
|
nokogiri (1.19.4-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.1-x86_64-linux-musl)
|
nokogiri (1.19.4-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.0)
|
openssl (4.0.2)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (2.1.0)
|
||||||
parser (3.3.10.0)
|
parser (3.3.11.1)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.7.0)
|
prism (1.9.0)
|
||||||
propshaft (1.3.1)
|
propshaft (1.3.2)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
psych (5.3.1)
|
psych (5.4.0)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.0)
|
public_suffix (7.0.5)
|
||||||
puma (7.1.0)
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.5)
|
rack (3.2.6)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.2)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.2)
|
rails (8.1.3)
|
||||||
actioncable (= 8.1.2)
|
actioncable (= 8.1.3)
|
||||||
actionmailbox (= 8.1.2)
|
actionmailbox (= 8.1.3)
|
||||||
actionmailer (= 8.1.2)
|
actionmailer (= 8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
actiontext (= 8.1.2)
|
actiontext (= 8.1.3)
|
||||||
actionview (= 8.1.2)
|
actionview (= 8.1.3)
|
||||||
activejob (= 8.1.2)
|
activejob (= 8.1.3)
|
||||||
activemodel (= 8.1.2)
|
activemodel (= 8.1.3)
|
||||||
activerecord (= 8.1.2)
|
activerecord (= 8.1.3)
|
||||||
activestorage (= 8.1.2)
|
activestorage (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.2)
|
railties (= 8.1.3)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -275,9 +275,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.7.0)
|
rails-html-sanitizer (1.7.0)
|
||||||
loofah (~> 2.25)
|
loofah (~> 2.25)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (8.1.2)
|
railties (8.1.3)
|
||||||
actionpack (= 8.1.2)
|
actionpack (= 8.1.3)
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.3)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -285,32 +285,32 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (7.2.0)
|
rdoc (7.2.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.12.0)
|
||||||
reline (0.6.3)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.1.1)
|
rqrcode (3.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.1)
|
rqrcode_core (2.1.0)
|
||||||
rubocop (1.81.7)
|
rubocop (1.87.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (>= 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.49.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.0)
|
rubocop-ast (1.49.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
@@ -321,29 +321,30 @@ GEM
|
|||||||
ruby-vips (2.3.0)
|
ruby-vips (2.3.0)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.2)
|
rubyzip (3.4.0)
|
||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.39.0)
|
selenium-webdriver (4.45.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.2.0)
|
sentry-rails (6.6.2)
|
||||||
railties (>= 5.2.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 6.2.0)
|
sentry-ruby (~> 6.6.2)
|
||||||
sentry-ruby (6.2.0)
|
sentry-ruby (6.6.2)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
logger
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.2)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
solid_cable (3.0.12)
|
solid_cable (4.0.0)
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
@@ -352,20 +353,20 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.2.4)
|
solid_queue (1.4.0)
|
||||||
activejob (>= 7.1)
|
activejob (>= 7.1)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
concurrent-ruby (>= 1.3.1)
|
concurrent-ruby (>= 1.3.1)
|
||||||
fugit (~> 1.11)
|
fugit (~> 1.11)
|
||||||
railties (>= 7.1)
|
railties (>= 7.1)
|
||||||
thor (>= 1.3.1)
|
thor (>= 1.3.1)
|
||||||
sqlite3 (2.9.0-aarch64-linux-gnu)
|
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.0-aarch64-linux-musl)
|
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||||
sqlite3 (2.9.0-arm-linux-gnu)
|
sqlite3 (2.9.5-arm-linux-gnu)
|
||||||
sqlite3 (2.9.0-arm-linux-musl)
|
sqlite3 (2.9.5-arm-linux-musl)
|
||||||
sqlite3 (2.9.0-arm64-darwin)
|
sqlite3 (2.9.5-arm64-darwin)
|
||||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||||
sqlite3 (2.9.0-x86_64-linux-musl)
|
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||||
sshkit (1.25.0)
|
sshkit (1.25.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -373,10 +374,10 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
standard (1.52.0)
|
standard (1.55.0)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.0)
|
lint_roller (~> 1.0)
|
||||||
rubocop (~> 1.81.7)
|
rubocop (~> 1.87.0)
|
||||||
standard-custom (~> 1.0.0)
|
standard-custom (~> 1.0.0)
|
||||||
standard-performance (~> 1.8)
|
standard-performance (~> 1.8)
|
||||||
standard-custom (1.0.2)
|
standard-custom (1.0.2)
|
||||||
@@ -388,27 +389,27 @@ GEM
|
|||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.2.0)
|
||||||
tailwindcss-rails (4.4.0)
|
tailwindcss-rails (4.6.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.18)
|
tailwindcss-ruby (4.3.1)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
|
tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
|
tailwindcss-ruby (4.3.1-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.18-arm64-darwin)
|
tailwindcss-ruby (4.3.1-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
tailwindcss-ruby (4.3.1-x86_64-linux-musl)
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.21)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.21-aarch64-linux)
|
||||||
thruster (0.1.17-arm64-darwin)
|
thruster (0.1.21-arm64-darwin)
|
||||||
thruster (0.1.17-x86_64-linux)
|
thruster (0.1.21-x86_64-linux)
|
||||||
timeout (0.6.0)
|
timeout (0.6.1)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.20)
|
turbo-rails (2.0.23)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -418,11 +419,10 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.3.0)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 8.0.0)
|
||||||
activemodel (>= 6.0.0)
|
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 8.0.0)
|
||||||
webauthn (3.4.3)
|
webauthn (3.4.3)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
@@ -432,13 +432,13 @@ GEM
|
|||||||
safety_net_attestation (~> 0.5.0)
|
safety_net_attestation (~> 0.5.0)
|
||||||
tpm-key_attestation (~> 0.14.0)
|
tpm-key_attestation (~> 0.14.0)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.8.1)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.5)
|
zeitwerk (2.8.2)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -469,7 +469,7 @@ DEPENDENCIES
|
|||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 7.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.2)
|
rails (~> 8.1.3)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
@@ -490,4 +490,4 @@ DEPENDENCIES
|
|||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.3
|
4.0.6
|
||||||
|
|||||||
110
SECURITY_REVIEW_TODO.md
Normal file
110
SECURITY_REVIEW_TODO.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Security Review — Tracking
|
||||||
|
|
||||||
|
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
|
||||||
|
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
|
||||||
|
`security/forward-auth-and-consent-csrf`.
|
||||||
|
|
||||||
|
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
|
||||||
|
|
||||||
|
All HIGH findings are closed. Each fix has tests; suite is green.
|
||||||
|
|
||||||
|
| Commit | Fix | Sev |
|
||||||
|
|--------|-----|-----|
|
||||||
|
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
|
||||||
|
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
|
||||||
|
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
|
||||||
|
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
|
||||||
|
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
|
||||||
|
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
|
||||||
|
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
|
||||||
|
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
|
||||||
|
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
|
||||||
|
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
|
||||||
|
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
|
||||||
|
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
|
||||||
|
|
||||||
|
**Verified false positive (no change):** PKCE *is* required by default —
|
||||||
|
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
|
||||||
|
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
|
||||||
|
apps sit on `require_pkce = false`.
|
||||||
|
|
||||||
|
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
|
||||||
|
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
|
||||||
|
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
|
||||||
|
surface as warnings without breaking. Fallback if style-src surprises: keep
|
||||||
|
`style-src 'unsafe-inline'`, ship script-src only.
|
||||||
|
|
||||||
|
## ☐ Remaining — MEDIUM
|
||||||
|
|
||||||
|
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
|
||||||
|
any other registered client's post-logout URI. Validate the hint's `aud` and
|
||||||
|
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
|
||||||
|
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
|
||||||
|
minted unconditionally; gate on the granted scope.
|
||||||
|
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
|
||||||
|
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
|
||||||
|
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
|
||||||
|
`app/controllers/api/csp_controller.rb`.
|
||||||
|
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
|
||||||
|
403 outages on non-standard ports (also a correctness bug). Reuse the
|
||||||
|
port-stripping done in `check_forward_auth_token`.
|
||||||
|
`app/controllers/api/forward_auth_controller.rb`.
|
||||||
|
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
|
||||||
|
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
|
||||||
|
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
|
||||||
|
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
|
||||||
|
`app/controllers/webauthn_controller.rb`.
|
||||||
|
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
|
||||||
|
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
|
||||||
|
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
|
||||||
|
`app/models/application_user_claim.rb`.
|
||||||
|
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
|
||||||
|
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
|
||||||
|
(`start_new_session_for`).
|
||||||
|
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
|
||||||
|
— removed; it was redundant with the `192.168.0.0/16` regex already in the
|
||||||
|
`CLINCH_ALLOW_INTERNAL_IPS` block.
|
||||||
|
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
|
||||||
|
add to `form-action` if the client_id+redirect_uri is a registered pair.
|
||||||
|
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
|
||||||
|
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
|
||||||
|
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
|
||||||
|
`style` from the allowlist. `app/models/svg_scrubber.rb`.
|
||||||
|
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
|
||||||
|
log detail server-side. `app/controllers/sessions_controller.rb`,
|
||||||
|
`app/controllers/webauthn_controller.rb`.
|
||||||
|
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
|
||||||
|
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
|
||||||
|
(`webauthn_challenge`).
|
||||||
|
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
|
||||||
|
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
|
||||||
|
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
|
||||||
|
but consider a random `token` column (Rails 8 generator default).
|
||||||
|
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
|
||||||
|
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
|
||||||
|
credential stuffing). Add failed-count + `locked_until` on users.
|
||||||
|
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
|
||||||
|
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
|
||||||
|
|
||||||
|
## ☐ Remaining — LOW / INFO
|
||||||
|
|
||||||
|
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
|
||||||
|
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
|
||||||
|
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
|
||||||
|
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
|
||||||
|
downstream). Add a format validator. `app/models/group.rb`.
|
||||||
|
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
|
||||||
|
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
|
||||||
|
`app/models/application.rb`.
|
||||||
|
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
|
||||||
|
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
|
||||||
|
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
|
||||||
|
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
|
||||||
|
`config/initializers/active_record_encryption.rb`.
|
||||||
|
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
|
||||||
|
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
|
||||||
|
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
|
||||||
|
structured logging). `app/controllers/api/forward_auth_controller.rb`.
|
||||||
|
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
|
||||||
|
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
|
||||||
|
`config/initializers/webauthn.rb`.
|
||||||
20
app/controllers/admin/access_checks_controller.rb
Normal file
20
app/controllers/admin/access_checks_controller.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
module Admin
|
||||||
|
class AccessChecksController < BaseController
|
||||||
|
def new
|
||||||
|
load_options
|
||||||
|
@user = User.find_by(id: params[:user_id])
|
||||||
|
@application = Application.find_by(id: params[:application_id])
|
||||||
|
return unless @user && @application
|
||||||
|
|
||||||
|
@allowed = @application.user_allowed?(@user)
|
||||||
|
@via = @user.groups & @application.allowed_groups
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_options
|
||||||
|
@users = User.order(:email_address)
|
||||||
|
@applications = Application.order(:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,11 +3,31 @@ module Admin
|
|||||||
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@applications = Application.order(created_at: :desc)
|
@applications = Application.order(created_at: :desc).includes(:allowed_groups)
|
||||||
|
|
||||||
|
# Distinct active users that have access to each app, preloaded to avoid N+1.
|
||||||
|
@user_count_by_app = User.where(status: User.statuses[:active])
|
||||||
|
.joins(groups: :applications)
|
||||||
|
.group("applications.id")
|
||||||
|
.distinct
|
||||||
|
.count("users.id")
|
||||||
|
|
||||||
|
# Top-of-page summary
|
||||||
|
@total_users_with_access = User.where(status: User.statuses[:active])
|
||||||
|
.joins(groups: :applications)
|
||||||
|
.distinct
|
||||||
|
.count("users.id")
|
||||||
|
@total_groups_granting_access = Group.joins(:applications).distinct.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@allowed_groups = @application.allowed_groups
|
@allowed_groups = @application.allowed_groups
|
||||||
|
@users_with_access = User.where(status: User.statuses[:active])
|
||||||
|
.joins(groups: :applications)
|
||||||
|
.where(applications: {id: @application.id})
|
||||||
|
.distinct
|
||||||
|
.includes(:groups)
|
||||||
|
.order(:email_address)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -104,7 +124,7 @@ module Admin
|
|||||||
permitted = params.require(:application).permit(
|
permitted = params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
:icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle headers_config - it comes as a JSON string from the text area
|
# Handle headers_config - it comes as a JSON string from the text area
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ module Admin
|
|||||||
def new
|
def new
|
||||||
@group = Group.new
|
@group = Group.new
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -28,6 +29,7 @@ module Admin
|
|||||||
@group = Group.new
|
@group = Group.new
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -45,15 +47,23 @@ module Admin
|
|||||||
@group.users = User.where(id: user_ids)
|
@group.users = User.where(id: user_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle application assignments
|
||||||
|
if params[:group][:application_ids].present?
|
||||||
|
application_ids = params[:group][:application_ids].reject(&:blank?)
|
||||||
|
@group.applications = Application.where(id: application_ids)
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -66,6 +76,7 @@ module Admin
|
|||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -83,9 +94,18 @@ module Admin
|
|||||||
@group.users = []
|
@group.users = []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle application assignments
|
||||||
|
if params[:group][:application_ids].present?
|
||||||
|
application_ids = params[:group][:application_ids].reject(&:blank?)
|
||||||
|
@group.applications = Application.where(id: application_ids)
|
||||||
|
else
|
||||||
|
@group.applications = []
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
|
@available_applications = Application.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -102,7 +122,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description, :custom_claims)
|
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,27 +7,38 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@accessible_applications = Application.where(active: true)
|
||||||
|
.joins(:allowed_groups)
|
||||||
|
.where(groups: {id: @user.groups})
|
||||||
|
.distinct
|
||||||
|
.includes(:allowed_groups)
|
||||||
|
.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
@user.status = :pending_invitation
|
@user.status = :pending_invitation
|
||||||
|
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
assign_groups_from_params(@user)
|
||||||
InvitationsMailer.invite_user(@user).deliver_later
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -43,6 +54,7 @@ module Admin
|
|||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -52,9 +64,16 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
|
unless assign_groups_from_params(@user)
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
@applications = Application.active.order(:name)
|
@applications = Application.active.order(:name)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -122,14 +141,28 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims]
|
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
# Apply group_ids from the form, with a guard preventing self-demotion when
|
||||||
if params[:id] != Current.session.user.id.to_s
|
# the user is the last member of the last admin group. Returns true on
|
||||||
permitted << :admin
|
# success, false if a guard fired (caller should re-render).
|
||||||
|
def assign_groups_from_params(user)
|
||||||
|
return true unless params[:user].key?(:group_ids)
|
||||||
|
|
||||||
|
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
|
||||||
|
new_groups = Group.where(id: raw_ids)
|
||||||
|
|
||||||
|
if user == Current.session.user
|
||||||
|
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
|
||||||
|
if losing_admin
|
||||||
|
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
|
||||||
|
return false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
params.require(:user).permit(*permitted)
|
user.groups = new_groups
|
||||||
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,26 +64,16 @@ module Api
|
|||||||
return render_forbidden("No authentication rule configured for this domain")
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
# Fail closed: with no host we cannot resolve an application or evaluate its
|
||||||
end
|
# group policy. Emitting identity headers here would bypass all per-domain
|
||||||
|
# access control, so reject instead.
|
||||||
headers = if app
|
Rails.logger.info "ForwardAuth: Access denied - no host header present"
|
||||||
app.headers_for_user(user)
|
return render_forbidden("No host header present")
|
||||||
else
|
|
||||||
Application::DEFAULT_HEADERS.map { |key, header_name|
|
|
||||||
case key
|
|
||||||
when :user, :email, :name
|
|
||||||
[header_name, user.email_address]
|
|
||||||
when :username
|
|
||||||
[header_name, user.username] if user.username.present?
|
|
||||||
when :groups
|
|
||||||
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
|
|
||||||
when :admin
|
|
||||||
[header_name, user.admin? ? "true" : "false"]
|
|
||||||
end
|
|
||||||
}.compact.to_h
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Reaching here implies a matching, active application was resolved above
|
||||||
|
# (every other path returns forbidden), so headers are always scoped to it.
|
||||||
|
headers = app.headers_for_user(user)
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
|
||||||
|
|
||||||
@@ -148,6 +138,14 @@ module Api
|
|||||||
return render_bearer_error("Application is inactive")
|
return render_bearer_error("Application is inactive")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Re-check group membership at use-time. The ApiKey model only validates
|
||||||
|
# access on creation, so a user removed from the app's allowed groups
|
||||||
|
# afterwards must not keep access via an existing key.
|
||||||
|
unless app.user_allowed?(user)
|
||||||
|
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
|
||||||
|
return render_bearer_error("Access denied: insufficient group membership")
|
||||||
|
end
|
||||||
|
|
||||||
api_key.touch_last_used!
|
api_key.touch_last_used!
|
||||||
|
|
||||||
headers = app.headers_for_user(user)
|
headers = app.headers_for_user(user)
|
||||||
@@ -158,7 +156,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_bearer_error(message)
|
def render_bearer_error(message)
|
||||||
render json: { error: message }, status: :unauthorized
|
render json: {error: message}, status: :unauthorized
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_forward_auth_token
|
def check_forward_auth_token
|
||||||
@@ -198,15 +196,18 @@ module Api
|
|||||||
original_host = request.headers["X-Forwarded-Host"]
|
original_host = request.headers["X-Forwarded-Host"]
|
||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
original_url = if original_host
|
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
|
||||||
"https://#{original_host}#{original_uri}"
|
# URL as a post-login redirect target if it resolves to a known, active
|
||||||
else
|
# forward-auth application. Otherwise this is an open redirect: a spoofed
|
||||||
redirect_url || base_url
|
# host would be stored and reflected into the signin `rd`, then followed
|
||||||
end
|
# (with allow_other_host) after the user authenticates. Fall back to a
|
||||||
|
# validated `rd` or, failing that, the IdP's own base URL.
|
||||||
|
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
|
||||||
|
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
|
||||||
|
|
||||||
session[:return_to_after_authenticating] = original_url
|
session[:return_to_after_authenticating] = original_url
|
||||||
|
|
||||||
login_params = { rd: original_url, rm: request.method }
|
login_params = {rd: original_url, rm: request.method}
|
||||||
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
||||||
|
|
||||||
redirect_to login_url, allow_other_host: true, status: :found
|
redirect_to login_url, allow_other_host: true, status: :found
|
||||||
@@ -242,18 +243,13 @@ module Api
|
|||||||
def determine_base_url(redirect_url)
|
def determine_base_url(redirect_url)
|
||||||
return redirect_url if redirect_url.present?
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
if ENV["CLINCH_HOST"].present?
|
# CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
|
||||||
host = ENV["CLINCH_HOST"]
|
# environments (enforced at boot in config/initializers/clinch_host.rb).
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
# We never fall back to the request host: a spoofed X-Forwarded-Host would
|
||||||
else
|
# otherwise redirect the login flow to an attacker-controlled origin. The
|
||||||
request_host = request.host || request.headers["X-Forwarded-Host"]
|
# localhost default only applies to local dev/test.
|
||||||
if request_host.present?
|
host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
|
||||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
|
||||||
"https://#{request_host}"
|
|
||||||
else
|
|
||||||
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
|
|||||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
@api_key = Current.session.user.api_keys.build(api_key_params)
|
||||||
|
|
||||||
if @api_key.save
|
if @api_key.save
|
||||||
|
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
|
||||||
flash[:api_key_token] = @api_key.plaintext_token
|
flash[:api_key_token] = @api_key.plaintext_token
|
||||||
redirect_to api_key_path(@api_key)
|
redirect_to api_key_path(@api_key)
|
||||||
else
|
else
|
||||||
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@api_key.revoke!
|
@api_key.revoke!
|
||||||
|
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
|
||||||
redirect_to api_keys_path, notice: "API key revoked."
|
redirect_to api_keys_path, notice: "API key revoked."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def security_event_context
|
||||||
|
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
|
||||||
|
end
|
||||||
|
|
||||||
# Remove a query parameter from a URL using proper URI parsing
|
# Remove a query parameter from a URL using proper URI parsing
|
||||||
# More robust than regex - handles URL encoding, edge cases, etc.
|
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Session.active.for_active_user.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
@@ -43,6 +43,37 @@ module Authentication
|
|||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# When a sign-in form will eventually redirect through /oauth/authorize to an
|
||||||
|
# external client, Safari enforces CSP form-action against every hop in the
|
||||||
|
# redirect chain. With the default form-action 'self', the final cross-origin
|
||||||
|
# hop to the OAuth client's redirect_uri gets blocked. Add the redirect_uri
|
||||||
|
# host to form-action so the chain completes.
|
||||||
|
def allow_oauth_redirect_in_csp
|
||||||
|
stored = session[:return_to_after_authenticating]
|
||||||
|
return if stored.blank?
|
||||||
|
|
||||||
|
uri = URI.parse(stored)
|
||||||
|
return unless uri.path&.start_with?("/oauth/")
|
||||||
|
|
||||||
|
redirect_uri = Rack::Utils.parse_query(uri.query.to_s)["redirect_uri"]
|
||||||
|
return if redirect_uri.blank?
|
||||||
|
|
||||||
|
redirect_host = URI.parse(redirect_uri).host
|
||||||
|
return if redirect_host.blank?
|
||||||
|
|
||||||
|
csp = request.content_security_policy
|
||||||
|
return unless csp
|
||||||
|
|
||||||
|
# NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
|
||||||
|
# and returns its old value, so reading it twice yields nil. Mutate the
|
||||||
|
# underlying `directives` hash (a public reader of the real values) instead.
|
||||||
|
form_action = (csp.directives["form-action"] ||= ["'self'"])
|
||||||
|
host = "https://#{redirect_host}"
|
||||||
|
form_action << host unless form_action.include?(host)
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def start_new_session_for(user, acr: "1", remember_me: false)
|
def start_new_session_for(user, acr: "1", remember_me: false)
|
||||||
user.update!(last_sign_in_at: Time.current)
|
user.update!(last_sign_in_at: Time.current)
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
|
||||||
@@ -73,7 +104,15 @@ module Authentication
|
|||||||
# Set domain for cross-subdomain authentication if we can extract it
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
# When "Remember me" is off, issue a browser-session cookie (no Expires)
|
||||||
|
# so closing the browser signs the user out — especially important on
|
||||||
|
# shared devices. The server Session#expires_at still enforces the
|
||||||
|
# 24h / 30d window regardless.
|
||||||
|
if remember_me
|
||||||
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
|
else
|
||||||
|
cookies.signed[:session_id] = cookie_options
|
||||||
|
end
|
||||||
|
|
||||||
# Create a one-time token for immediate forward auth after authentication
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
# This solves the race condition where browser hasn't processed cookie yet
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
@@ -152,7 +191,7 @@ module Authentication
|
|||||||
token = SecureRandom.urlsafe_base64(32)
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
Rails.cache.write(
|
Rails.cache.write(
|
||||||
"forward_auth_token:#{token}",
|
"forward_auth_token:#{token}",
|
||||||
{ session_id: session_obj.id, host: bound_host },
|
{session_id: session_obj.id, host: bound_host},
|
||||||
expires_in: 60.seconds
|
expires_in: 60.seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ class OidcController < ApplicationController
|
|||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
|
# Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers
|
||||||
|
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing
|
||||||
|
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the
|
||||||
|
# consent form already embeds the token via form_with.
|
||||||
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize]
|
||||||
|
|
||||||
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
|
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
|
||||||
# other error can be reported via redirect. Failures here render a plain page.
|
# other error can be reported via redirect. Failures here render a plain page.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to signin_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(password_params)
|
if @user.update(password_params)
|
||||||
|
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Password updated successfully."
|
redirect_to profile_path, notice: "Password updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
old_email = @user.email_address
|
||||||
if @user.update(email_params)
|
if @user.update(email_params)
|
||||||
|
new_email = @user.email_address
|
||||||
|
if old_email != new_email
|
||||||
|
context = security_event_context
|
||||||
|
[old_email, new_email].uniq.each do |recipient|
|
||||||
|
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
|
||||||
|
end
|
||||||
|
end
|
||||||
redirect_to profile_path, notice: "Email updated successfully."
|
redirect_to profile_path, notice: "Email updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
allow_oauth_redirect_in_csp
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html # render HTML login page
|
format.html # render HTML login page
|
||||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||||
@@ -119,6 +121,16 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Re-check account status: active? was verified at the password step, but an
|
||||||
|
# admin may have disabled the account while the user sat on this 2FA screen.
|
||||||
|
# Without this, a disabled account could still mint a valid session here.
|
||||||
|
unless user.active?
|
||||||
|
session.delete(:pending_totp_user_id)
|
||||||
|
session.delete(:pending_remember_me)
|
||||||
|
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
remember_me = session.delete(:pending_remember_me) || false
|
remember_me = session.delete(:pending_remember_me) || false
|
||||||
|
|
||||||
# Try TOTP verification first (password + TOTP = 2FA)
|
# Try TOTP verification first (password + TOTP = 2FA)
|
||||||
@@ -154,6 +166,8 @@ class SessionsController < ApplicationController
|
|||||||
@user_has_webauthn = user&.can_authenticate_with_webauthn?
|
@user_has_webauthn = user&.can_authenticate_with_webauthn?
|
||||||
@pending_email = user&.email_address
|
@pending_email = user&.email_address
|
||||||
|
|
||||||
|
allow_oauth_redirect_in_csp
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -237,6 +251,14 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Re-check account status: an admin may have disabled the account between the
|
||||||
|
# password step and this passkey verification. Reject before creating a session.
|
||||||
|
unless user.active?
|
||||||
|
session.delete(:pending_webauthn_user_id)
|
||||||
|
render json: {error: "Your account is not active."}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Get the credential and assertion from params
|
# Get the credential and assertion from params
|
||||||
credential_data = params[:credential]
|
credential_data = params[:credential]
|
||||||
if credential_data.blank?
|
if credential_data.blank?
|
||||||
@@ -273,10 +295,14 @@ class SessionsController < ApplicationController
|
|||||||
sign_count: stored_credential.sign_count
|
sign_count: stored_credential.sign_count
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for suspicious sign count (possible clone)
|
# Clone detection: a non-advancing signature counter signals the credential
|
||||||
|
# may have been copied. Reject the sign-in (do NOT create a session or update
|
||||||
|
# the stored counter) and alert the user, per WebAuthn §6.1.1.
|
||||||
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
|
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}"
|
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id} (stored=#{stored_credential.sign_count}, presented=#{webauthn_credential.sign_count})"
|
||||||
# You might want to notify admins or temporarily disable the credential
|
SecurityMailer.suspicious_passkey_used(user, nickname: stored_credential.display_name, **security_event_context).deliver_later
|
||||||
|
render json: {error: "Passkey authentication could not be completed. Please contact support."}, status: :unprocessable_entity
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update credential usage
|
# Update credential usage
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class TotpController < ApplicationController
|
|||||||
# Generate new backup codes and store BCrypt hashes
|
# Generate new backup codes and store BCrypt hashes
|
||||||
plain_codes = @user.send(:generate_backup_codes)
|
plain_codes = @user.send(:generate_backup_codes)
|
||||||
@user.save!
|
@user.save!
|
||||||
|
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
||||||
|
|
||||||
# Store plain codes temporarily in session for display
|
# Store plain codes temporarily in session for display
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
@@ -136,6 +137,7 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
|
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
# First user becomes admin automatically
|
|
||||||
@user.admin = true if User.count.zero?
|
|
||||||
@user.status = "active"
|
@user.status = "active"
|
||||||
|
first_user = User.count.zero?
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
|
# First user automatically becomes a member of every admin group, so they
|
||||||
|
# can reach the admin panel without an existing admin to grant access.
|
||||||
|
if first_user
|
||||||
|
Group.where(admin: true).each { |g| @user.groups << g }
|
||||||
|
end
|
||||||
|
|
||||||
start_new_session_for @user
|
start_new_session_for @user
|
||||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
|
|||||||
backup_state: backup_state
|
backup_state: backup_state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
|
|||||||
# Remove a passkey
|
# Remove a passkey
|
||||||
def destroy
|
def destroy
|
||||||
nickname = @webauthn_credential.nickname
|
nickname = @webauthn_credential.nickname
|
||||||
|
user = @webauthn_credential.user
|
||||||
@webauthn_credential.destroy
|
@webauthn_credential.destroy
|
||||||
|
|
||||||
|
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html {
|
||||||
redirect_to profile_path,
|
redirect_to profile_path,
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def oidc_env_lines(application, client_secret: nil)
|
||||||
|
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
|
||||||
|
lines << if client_secret
|
||||||
|
"OIDC_CLIENT_SECRET=#{client_secret}"
|
||||||
|
elsif application.public_client?
|
||||||
|
"OIDC_CLIENT_SECRET="
|
||||||
|
else
|
||||||
|
"OIDC_CLIENT_SECRET=<your-client-secret>"
|
||||||
|
end
|
||||||
|
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
|
||||||
|
lines << "OIDC_PROVIDER_NAME='Clinch'"
|
||||||
|
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
|
||||||
|
lines
|
||||||
|
end
|
||||||
|
|
||||||
def border_class_for(type)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when "notice" then "border-green-200 dark:border-green-700"
|
when "notice" then "border-green-200 dark:border-green-700"
|
||||||
@@ -29,4 +44,49 @@ module ApplicationHelper
|
|||||||
else "border-gray-200 dark:border-gray-700"
|
else "border-gray-200 dark:border-gray-700"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Picks 1-2 character initials for a monogram fallback when an Application
|
||||||
|
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
|
||||||
|
# first two letters of the name (Audiobookshelf -> AU).
|
||||||
|
MONOGRAM_PALETTE = %w[
|
||||||
|
#4f46e5 #0891b2 #16a34a #ca8a04
|
||||||
|
#db2777 #9333ea #ea580c #475569
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def monogram_initials(name)
|
||||||
|
return "?" if name.blank?
|
||||||
|
caps = name.scan(/[A-Z]/)
|
||||||
|
initials = if caps.size >= 2
|
||||||
|
caps.first(2).join
|
||||||
|
else
|
||||||
|
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
|
||||||
|
end
|
||||||
|
initials.presence || "?"
|
||||||
|
end
|
||||||
|
|
||||||
|
def monogram_color(name)
|
||||||
|
return MONOGRAM_PALETTE.first if name.blank?
|
||||||
|
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
|
||||||
|
MONOGRAM_PALETTE[index]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renders an application icon with optional dark-mode variant. If
|
||||||
|
# `icon_dark` is attached, we render both <img> tags and Tailwind's class-
|
||||||
|
# based `dark:` modifier hides the inactive one — so it follows the in-app
|
||||||
|
# theme toggle (.dark on <html>), not the OS preference. If only `icon` is
|
||||||
|
# attached, the same image is used in both modes. Caller must ensure at
|
||||||
|
# least app.icon is attached; the monogram fallback handles no-icon.
|
||||||
|
def app_icon_picture(app, class:, alt: nil)
|
||||||
|
img_class = binding.local_variable_get(:class)
|
||||||
|
alt ||= "#{app.name} icon"
|
||||||
|
|
||||||
|
if app.icon_dark.attached?
|
||||||
|
safe_join([
|
||||||
|
image_tag(app.icon, class: "#{img_class} dark:hidden", alt: alt),
|
||||||
|
image_tag(app.icon_dark, class: "#{img_class} hidden dark:block", alt: alt)
|
||||||
|
])
|
||||||
|
else
|
||||||
|
image_tag(app.icon, class: img_class, alt: alt)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ class BackchannelLogoutJob < ApplicationJob
|
|||||||
# Send HTTP POST to the application's backchannel logout URI
|
# Send HTTP POST to the application's backchannel logout URI
|
||||||
uri = URI.parse(application.backchannel_logout_uri)
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
|
# SSRF guard: re-check at request time (with DNS resolution) in case the URI
|
||||||
|
# predates the validation, or a public hostname now resolves to an internal
|
||||||
|
# address. Abort without retrying — retries would not change the outcome.
|
||||||
|
if PrivateAddressCheck.internal_host?(uri.host) || PrivateAddressCheck.resolves_to_internal?(uri.host)
|
||||||
|
Rails.logger.error "BackchannelLogout: Refusing to send logout to #{application.name} - #{uri.host} is or resolves to a non-public address (SSRF guard)"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
||||||
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||||
|
|||||||
57
app/lib/private_address_check.rb
Normal file
57
app/lib/private_address_check.rb
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
require "ipaddr"
|
||||||
|
require "resolv"
|
||||||
|
|
||||||
|
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
|
||||||
|
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
|
||||||
|
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
|
||||||
|
# otherwise non-public address space.
|
||||||
|
module PrivateAddressCheck
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Hostnames that are internal by definition and must never be dialled.
|
||||||
|
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
|
||||||
|
|
||||||
|
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
|
||||||
|
# Suitable for model validation (deterministic, immediate admin feedback).
|
||||||
|
def internal_host?(host)
|
||||||
|
host = host.to_s.downcase
|
||||||
|
return true if host.blank?
|
||||||
|
return true if BLOCKED_HOSTNAMES.include?(host)
|
||||||
|
return true if host.end_with?(".localhost")
|
||||||
|
|
||||||
|
ip = parse_ip(host)
|
||||||
|
ip ? internal_ip?(ip) : false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Authoritative check: resolves the hostname and blocks if ANY address is
|
||||||
|
# internal. Suitable for request time — also defeats a public hostname that
|
||||||
|
# has been pointed at an internal IP (DNS rebinding to internal space).
|
||||||
|
def resolves_to_internal?(host)
|
||||||
|
addresses(host).any? { |ip| internal_ip?(ip) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def addresses(host)
|
||||||
|
ip = parse_ip(host)
|
||||||
|
return [ip] if ip
|
||||||
|
|
||||||
|
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
|
||||||
|
rescue
|
||||||
|
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
|
||||||
|
# not-provably-internal; the dial itself will then fail safely.
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def internal_ip?(ip)
|
||||||
|
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_ip(str)
|
||||||
|
IPAddr.new(str.to_s)
|
||||||
|
rescue IPAddr::Error
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def unspecified?(ip)
|
||||||
|
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
|
||||||
|
end
|
||||||
|
end
|
||||||
65
app/mailers/security_mailer.rb
Normal file
65
app/mailers/security_mailer.rb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
class SecurityMailer < ApplicationMailer
|
||||||
|
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
|
||||||
|
|
||||||
|
def password_changed(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def totp_disabled(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@nickname = nickname
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@nickname = nickname
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@api_key_name = name
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@api_key_name = name
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@nickname = nickname
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", to: user.email_address
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
|
||||||
|
assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@recipient = recipient
|
||||||
|
@old_email = old_email
|
||||||
|
@new_email = new_email
|
||||||
|
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assign_context(user, ip, user_agent, occurred_at)
|
||||||
|
@user = user
|
||||||
|
@ip = ip
|
||||||
|
@user_agent = user_agent
|
||||||
|
@occurred_at = occurred_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,6 @@ class TotpMailer < ApplicationMailer
|
|||||||
def enabled(user)
|
def enabled(user)
|
||||||
@user = user
|
@user = user
|
||||||
mail subject: "Two-factor authentication enabled on your account",
|
mail subject: "Two-factor authentication enabled on your account",
|
||||||
to: user.email_address
|
to: user.email_address
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ class Application < ApplicationRecord
|
|||||||
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
||||||
|
|
||||||
has_one_attached :icon
|
has_one_attached :icon
|
||||||
|
has_one_attached :icon_dark
|
||||||
|
|
||||||
before_validation :sanitize_svg_icon, if: -> { attachment_changes["icon"].present? }
|
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
|
||||||
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
|
||||||
|
before_validation :sanitize_svg_icons
|
||||||
|
after_save :fix_icon_content_types
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
@@ -53,9 +56,10 @@ class Application < ApplicationRecord
|
|||||||
message: "must be a valid HTTP or HTTPS URL"
|
message: "must be a valid HTTP or HTTPS URL"
|
||||||
}
|
}
|
||||||
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||||
|
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
|
||||||
|
|
||||||
# Icon validation using ActiveStorage validators
|
# Icon validation using ActiveStorage validators
|
||||||
validate :icon_validation, if: -> { icon.attached? }
|
validate :icon_validation
|
||||||
|
|
||||||
# Token TTL validations (for OIDC apps)
|
# Token TTL validations (for OIDC apps)
|
||||||
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
@@ -118,14 +122,12 @@ class Application < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
|
# Default-deny: an empty allowed_groups list means no one gets in.
|
||||||
|
# To make an app accessible to "everyone", attach the seeded auto-assign
|
||||||
|
# group (or any group every user is in).
|
||||||
def user_allowed?(user)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
return false unless user.active?
|
return false unless user.active?
|
||||||
|
|
||||||
# If no groups are specified, allow all active users
|
|
||||||
return true if allowed_groups.empty?
|
|
||||||
|
|
||||||
# Otherwise, user must be in at least one of the allowed groups
|
|
||||||
(user.groups & allowed_groups).any?
|
(user.groups & allowed_groups).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,10 +170,6 @@ class Application < ApplicationRecord
|
|||||||
return "deny" unless active?
|
return "deny" unless active?
|
||||||
return "deny" unless user.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)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||||
@@ -274,42 +272,84 @@ class Application < ApplicationRecord
|
|||||||
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
||||||
end
|
end
|
||||||
|
|
||||||
def fix_icon_content_type
|
def fix_icon_content_types
|
||||||
return unless icon.attached?
|
ICON_ATTACHMENTS.each do |attr|
|
||||||
|
attachment = public_send(attr)
|
||||||
# Fix SVG content type if it was detected incorrectly
|
next unless attachment.attached?
|
||||||
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
# Fix SVG content type if it was detected incorrectly
|
||||||
icon.blob.update(content_type: "image/svg+xml")
|
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
|
||||||
|
attachment.blob.update(content_type: "image/svg+xml")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitize_svg_icon
|
def sanitize_svg_icons
|
||||||
return unless icon.content_type == "image/svg+xml"
|
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
|
||||||
|
# this point (Active Storage uploads in before_save), so we cannot call
|
||||||
|
# download — we must read from the pending attachable.
|
||||||
|
#
|
||||||
|
# attach below re-sets attachment_changes and would re-fire this callback;
|
||||||
|
# we skip if the pending attachable is the cleaned hash we just installed
|
||||||
|
# (tracked by object identity, per-attribute).
|
||||||
|
@svg_sanitized_attachables ||= {}
|
||||||
|
|
||||||
raw_svg = icon.download
|
ICON_ATTACHMENTS.each do |attr|
|
||||||
doc = Loofah.xml_document(raw_svg)
|
change = attachment_changes[attr.to_s]
|
||||||
doc.scrub!(SvgScrubber.new)
|
next unless change
|
||||||
clean_svg = doc.to_xml
|
attachable = change.attachable
|
||||||
|
next if attachable.equal?(@svg_sanitized_attachables[attr])
|
||||||
|
|
||||||
icon.attach(
|
raw_svg, filename, content_type = read_pending_icon(attachable)
|
||||||
io: StringIO.new(clean_svg),
|
next unless raw_svg
|
||||||
filename: icon.filename.to_s,
|
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
|
||||||
content_type: "image/svg+xml"
|
|
||||||
)
|
doc = Loofah.xml_document(raw_svg)
|
||||||
|
doc.scrub!(SvgScrubber.new)
|
||||||
|
clean_svg = doc.to_xml
|
||||||
|
|
||||||
|
sanitized = {
|
||||||
|
io: StringIO.new(clean_svg),
|
||||||
|
filename: filename,
|
||||||
|
content_type: "image/svg+xml"
|
||||||
|
}
|
||||||
|
@svg_sanitized_attachables[attr] = sanitized
|
||||||
|
public_send(attr).attach(sanitized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_pending_icon(attachable)
|
||||||
|
case attachable
|
||||||
|
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
||||||
|
content = attachable.read
|
||||||
|
attachable.rewind
|
||||||
|
[content, attachable.original_filename, attachable.content_type]
|
||||||
|
when Hash
|
||||||
|
io = attachable[:io] || attachable["io"]
|
||||||
|
return [nil, nil, nil] unless io
|
||||||
|
content = io.read
|
||||||
|
io.rewind if io.respond_to?(:rewind)
|
||||||
|
[content,
|
||||||
|
attachable[:filename] || attachable["filename"],
|
||||||
|
attachable[:content_type] || attachable["content_type"]]
|
||||||
|
else
|
||||||
|
[nil, nil, nil]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def icon_validation
|
def icon_validation
|
||||||
return unless icon.attached?
|
|
||||||
|
|
||||||
# Check content type
|
|
||||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
unless allowed_types.include?(icon.content_type)
|
|
||||||
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check file size (2MB limit)
|
ICON_ATTACHMENTS.each do |attr|
|
||||||
if icon.blob.byte_size > 2.megabytes
|
attachment = public_send(attr)
|
||||||
errors.add(:icon, "must be less than 2MB")
|
next unless attachment.attached?
|
||||||
|
|
||||||
|
unless allowed_types.include?(attachment.content_type)
|
||||||
|
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
|
||||||
|
end
|
||||||
|
|
||||||
|
if attachment.blob.byte_size > 2.megabytes
|
||||||
|
errors.add(attr, "must be less than 2MB")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -351,4 +391,17 @@ class Application < ApplicationRecord
|
|||||||
# Let the format validator handle invalid URIs
|
# Let the format validator handle invalid URIs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# SSRF guard: the backchannel logout URI is dialled server-side on every user
|
||||||
|
# logout, so it must not target internal infrastructure (loopback, private
|
||||||
|
# ranges, or the link-local cloud metadata endpoint). This is the fast,
|
||||||
|
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
|
||||||
|
def backchannel_logout_uri_not_internal
|
||||||
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
|
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
|
||||||
|
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Let the format validator handle invalid URIs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class Group < ApplicationRecord
|
|||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
scope :auto_assign, -> { where(auto_assign: true) }
|
||||||
|
scope :admin, -> { where(admin: true) }
|
||||||
|
|
||||||
|
before_destroy :ensure_other_admin_group_exists
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
def parsed_custom_claims
|
||||||
return {} if custom_claims.blank?
|
return {} if custom_claims.blank?
|
||||||
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_other_admin_group_exists
|
||||||
|
return unless admin?
|
||||||
|
return if Group.where(admin: true).where.not(id: id).exists?
|
||||||
|
errors.add(:base, "cannot delete the last administrators group")
|
||||||
|
throw :abort
|
||||||
|
end
|
||||||
|
|
||||||
def no_reserved_claim_names
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
update!(revoked_at: Time.current)
|
update!(revoked_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Revoke all refresh tokens in the same family (token rotation security)
|
# Revoke all refresh tokens in the same family (token rotation security).
|
||||||
|
# Also revoke every access token issued within the family: on a detected reuse
|
||||||
|
# attack the stolen chain's access tokens must not remain usable at /userinfo
|
||||||
|
# until they expire.
|
||||||
def revoke_family!
|
def revoke_family!
|
||||||
return unless token_family_id.present?
|
return unless token_family_id.present?
|
||||||
|
|
||||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
now = Time.current
|
||||||
|
family = OidcRefreshToken.in_family(token_family_id)
|
||||||
|
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
|
||||||
|
|
||||||
|
family.update_all(revoked_at: now)
|
||||||
|
if access_token_ids.any?
|
||||||
|
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ class Session < ApplicationRecord
|
|||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
# Sessions whose owning user is currently active. Used at request time so a
|
||||||
|
# disabled account cannot continue to authenticate with an existing session.
|
||||||
|
scope :for_active_user, -> { joins(:user).where(users: {status: User.statuses[:active]}) }
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at <= Time.current
|
expires_at.present? && expires_at <= Time.current
|
||||||
|
|||||||
73
app/models/svg_scrubber.rb
Normal file
73
app/models/svg_scrubber.rb
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Loofah scrubber that strips dangerous content from SVG files
|
||||||
|
# while preserving safe SVG elements and attributes for icon display.
|
||||||
|
class SvgScrubber < Loofah::Scrubber
|
||||||
|
ALLOWED_ELEMENTS = %w[
|
||||||
|
svg g defs use symbol
|
||||||
|
circle ellipse line path polygon polyline rect
|
||||||
|
text tspan textPath
|
||||||
|
clipPath mask pattern
|
||||||
|
linearGradient radialGradient stop
|
||||||
|
filter feBlend feColorMatrix feComponentTransfer feComposite
|
||||||
|
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
|
||||||
|
feGaussianBlur feImage feMerge feMergeNode feMorphology
|
||||||
|
feOffset feSpecularLighting feTile feTurbulence
|
||||||
|
title desc metadata
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = %w[
|
||||||
|
id class style
|
||||||
|
x y x1 y1 x2 y2 cx cy r rx ry
|
||||||
|
width height viewBox preserveAspectRatio
|
||||||
|
d points
|
||||||
|
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
|
||||||
|
opacity fill-opacity stroke-opacity
|
||||||
|
transform translate rotate scale
|
||||||
|
font-family font-size font-weight text-anchor
|
||||||
|
clip-path mask filter
|
||||||
|
gradientUnits gradientTransform spreadMethod
|
||||||
|
offset stop-color stop-opacity
|
||||||
|
dx dy textLength lengthAdjust
|
||||||
|
xmlns xmlns:xlink
|
||||||
|
color display visibility overflow
|
||||||
|
fill-rule clip-rule
|
||||||
|
marker-start marker-mid marker-end
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Loofah hands attribute names back in their source case (e.g. "viewBox").
|
||||||
|
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
|
||||||
|
# stripped from legitimate icons.
|
||||||
|
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
|
||||||
|
|
||||||
|
# Event handler attributes that must always be removed
|
||||||
|
EVENT_HANDLER_PATTERN = /\Aon/i
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@direction = :top_down
|
||||||
|
end
|
||||||
|
|
||||||
|
def scrub(node)
|
||||||
|
return CONTINUE if node.text? || node.cdata?
|
||||||
|
|
||||||
|
if node.element?
|
||||||
|
if ALLOWED_ELEMENTS.include?(node.name)
|
||||||
|
# Remove disallowed and event handler attributes
|
||||||
|
node.attribute_nodes.each do |attr|
|
||||||
|
attr.remove unless safe_attribute?(attr)
|
||||||
|
end
|
||||||
|
return CONTINUE
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
node.remove
|
||||||
|
STOP
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safe_attribute?(attr)
|
||||||
|
name = attr.name.downcase
|
||||||
|
return false if name.match?(EVENT_HANDLER_PATTERN)
|
||||||
|
return false if attr.value&.match?(/javascript:|data:/i)
|
||||||
|
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -41,8 +41,24 @@ class User < ApplicationRecord
|
|||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||||
|
|
||||||
|
# When an account stops being active (e.g. an admin disables it), immediately
|
||||||
|
# terminate its sessions so access is revoked everywhere, not just on expiry.
|
||||||
|
# Defence-in-depth: session lookup also filters by active status at request time.
|
||||||
|
after_update_commit :revoke_sessions_when_deactivated
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
|
||||||
|
|
||||||
|
# Set true on a user (or on the user_params) to skip the auto-assign callback
|
||||||
|
# for that record. Used by the admin invite form (opt-out checkbox) and by
|
||||||
|
# tests that want a clean slate.
|
||||||
|
attr_accessor :skip_auto_assign
|
||||||
|
|
||||||
|
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
|
||||||
|
|
||||||
|
def admin?
|
||||||
|
groups.any?(&:admin?)
|
||||||
|
end
|
||||||
|
|
||||||
# TOTP methods
|
# TOTP methods
|
||||||
def totp_enabled?
|
def totp_enabled?
|
||||||
@@ -52,7 +68,10 @@ class User < ApplicationRecord
|
|||||||
def enable_totp!
|
def enable_totp!
|
||||||
require "rotp"
|
require "rotp"
|
||||||
self.totp_secret = ROTP::Base32.random
|
self.totp_secret = ROTP::Base32.random
|
||||||
self.backup_codes = generate_backup_codes
|
# generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
|
||||||
|
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
|
||||||
|
# return value — that would store the plaintext codes and break verification.
|
||||||
|
generate_backup_codes
|
||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -75,7 +94,13 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
require "rotp"
|
require "rotp"
|
||||||
totp = ROTP::TOTP.new(totp_secret)
|
totp = ROTP::TOTP.new(totp_secret)
|
||||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
# Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
|
||||||
|
# at or before the last accepted one, closing the ~90s drift-window replay.
|
||||||
|
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
|
||||||
|
return false unless verified_at
|
||||||
|
|
||||||
|
update_column(:last_otp_at, verified_at)
|
||||||
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Console/debug helper: get current TOTP code
|
# Console/debug helper: get current TOTP code
|
||||||
@@ -222,6 +247,17 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def add_to_auto_assign_groups
|
||||||
|
Group.auto_assign.each { |g| groups << g }
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_sessions_when_deactivated
|
||||||
|
return unless saved_change_to_status?
|
||||||
|
return if active?
|
||||||
|
|
||||||
|
sessions.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
def no_reserved_claim_names
|
def no_reserved_claim_names
|
||||||
return if custom_claims.blank?
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if sign count is suspicious (clone detection)
|
# Check if sign count is suspicious (clone detection).
|
||||||
|
#
|
||||||
|
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
|
||||||
|
# not implement a counter (true of most synced passkeys — Apple/Google report
|
||||||
|
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
|
||||||
|
# stored and presented counts are non-zero does a non-increasing value signal
|
||||||
|
# a possible clone.
|
||||||
def suspicious_sign_count?(new_sign_count)
|
def suspicious_sign_count?(new_sign_count)
|
||||||
return false if sign_count.zero? && new_sign_count > 0 # First use
|
return false if sign_count.zero? || new_sign_count.zero?
|
||||||
return false if new_sign_count > sign_count # Normal increment
|
|
||||||
|
|
||||||
# Sign count didn't increase - possible clone
|
new_sign_count <= sign_count
|
||||||
true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Format for display in UI
|
# Format for display in UI
|
||||||
|
|||||||
77
app/views/admin/access_checks/new.html.erb
Normal file
77
app/views/admin/access_checks/new.html.erb
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<%= form.select :user_id,
|
||||||
|
@users.map { |u| [u.email_address, u.id] },
|
||||||
|
{ include_blank: "Select a user…", selected: @user&.id },
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<%= form.select :application_id,
|
||||||
|
@applications.map { |a| [a.name, a.id] },
|
||||||
|
{ include_blank: "Select an application…", selected: @application&.id },
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.submit "Check access", 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @user && @application %>
|
||||||
|
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<% if @allowed %>
|
||||||
|
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<% else %>
|
||||||
|
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
<% end %>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
|
||||||
|
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
|
||||||
|
</p>
|
||||||
|
<% if @allowed %>
|
||||||
|
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
|
||||||
|
Granted via:
|
||||||
|
<% @via.each_with_index do |g, i| %>
|
||||||
|
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
|
||||||
|
<% reasons = [] %>
|
||||||
|
<% reasons << "the application is inactive" unless @application.active? %>
|
||||||
|
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
|
||||||
|
<% if @application.active? && @user.active? %>
|
||||||
|
<% if @application.allowed_groups.empty? %>
|
||||||
|
<% reasons << "the application has no allowed groups (default deny)" %>
|
||||||
|
<% else %>
|
||||||
|
<% reasons << "the user shares no group with the application's allowed groups" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
Reason: <%= reasons.join("; ") %>.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
|
||||||
|
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -36,9 +36,9 @@
|
|||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between -mb-2">
|
||||||
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Icons</span>
|
||||||
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||||
@@ -46,75 +46,22 @@
|
|||||||
Browse icons at dashboardicons.com
|
Browse icons at dashboardicons.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% if application.icon.attached? && application.persisted? %>
|
|
||||||
<% begin %>
|
|
||||||
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
|
||||||
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
|
||||||
<div class="mt-2 mb-3 flex items-center gap-4">
|
|
||||||
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700", alt: "Current icon" %>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p class="font-medium">Current icon</p>
|
|
||||||
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% rescue ArgumentError => e %>
|
|
||||||
<%# Handle case where icon attachment exists but can't generate signed_id %>
|
|
||||||
<% if e.message.include?("Cannot get a signed_id for a new record") %>
|
|
||||||
<div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p class="font-medium">Icon uploaded</p>
|
|
||||||
<p class="text-xs">File will be processed shortly</p>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%# Re-raise if it's a different error %>
|
|
||||||
<% raise e %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="mt-2" data-controller="file-drop image-paste">
|
<%= render "icon_uploader",
|
||||||
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
form: form,
|
||||||
data-file-drop-target="dropzone"
|
field: :icon,
|
||||||
data-image-paste-target="dropzone"
|
label: "Icon",
|
||||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
current_attached: (application.persisted? ? application.icon : nil),
|
||||||
tabindex="0">
|
current_label: "Current icon" %>
|
||||||
<div class="space-y-1 text-center">
|
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
<%= render "icon_uploader",
|
||||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
form: form,
|
||||||
</svg>
|
field: :icon_dark,
|
||||||
<div class="flex text-sm text-gray-600 dark:text-gray-400">
|
label: "Dark mode icon (optional)",
|
||||||
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-900 focus-within:ring-blue-500">
|
help: "Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.",
|
||||||
<span>Upload a file</span>
|
current_attached: (application.persisted? ? application.icon_dark : nil),
|
||||||
<%= form.file_field :icon,
|
current_label: "Current dark-mode icon",
|
||||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
preview_extra_class: "bg-gray-900" %>
|
||||||
class: "sr-only",
|
|
||||||
data: {
|
|
||||||
file_drop_target: "input",
|
|
||||||
image_paste_target: "input",
|
|
||||||
action: "change->file-drop#handleFiles"
|
|
||||||
} %>
|
|
||||||
</label>
|
|
||||||
<p class="pl-1">or drag and drop</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF, or SVG up to 2MB</p>
|
|
||||||
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-file-drop-target="preview" class="mt-3 hidden">
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
|
|
||||||
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
66
app/views/admin/applications/_icon_uploader.html.erb
Normal file
66
app/views/admin/applications/_icon_uploader.html.erb
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<%# Compact icon uploader. Locals:
|
||||||
|
form - the form builder
|
||||||
|
field - symbol for the file field (:icon or :icon_dark)
|
||||||
|
label - heading text
|
||||||
|
help - small helper paragraph (optional)
|
||||||
|
current_attached - the attachment to show as "current" preview
|
||||||
|
current_label - text for the preview row (e.g. "Current icon")
|
||||||
|
preview_extra_class - extra css for the preview img (e.g. "bg-gray-900")
|
||||||
|
%>
|
||||||
|
<div>
|
||||||
|
<%= form.label field, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<% if local_assigns[:help].present? %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= help %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if current_attached&.attached? && current_attached.blob&.persisted? && current_attached.blob.key.present? %>
|
||||||
|
<div class="mt-2 mb-3 flex items-center gap-3">
|
||||||
|
<%= image_tag current_attached, class: "h-12 w-12 rounded-md object-cover border border-gray-200 dark:border-gray-700 #{local_assigns[:preview_extra_class]}", alt: current_label %>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p class="font-medium"><%= current_label %></p>
|
||||||
|
<p class="text-xs"><%= number_to_human_size(current_attached.blob.byte_size) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-2" data-controller="file-drop image-paste">
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:border-blue-400 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-colors"
|
||||||
|
data-file-drop-target="dropzone"
|
||||||
|
data-image-paste-target="dropzone"
|
||||||
|
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||||
|
tabindex="0">
|
||||||
|
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 shrink-0" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 4v12m0-12l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<label for="<%= form.field_id(field) %>" class="cursor-pointer font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
|
||||||
|
<span>Upload</span>
|
||||||
|
<%= form.file_field field,
|
||||||
|
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||||
|
class: "sr-only",
|
||||||
|
data: {
|
||||||
|
file_drop_target: "input",
|
||||||
|
image_paste_target: "input",
|
||||||
|
action: "change->file-drop#handleFiles"
|
||||||
|
} %>
|
||||||
|
</label>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400"> · drag and drop · or click and paste (⌘V)</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF or SVG · max 2MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-file-drop-target="preview" class="mt-2 hidden">
|
||||||
|
<div class="flex items-center gap-3 p-2 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
|
||||||
|
<img data-file-drop-target="previewImage" class="h-10 w-10 rounded object-cover" alt="Preview">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -8,6 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 grid grid-cols-3 gap-4">
|
||||||
|
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
|
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
@@ -18,7 +33,7 @@
|
|||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</th>
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -30,13 +45,9 @@
|
|||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<% if application.icon.attached? %>
|
<% if application.icon.attached? %>
|
||||||
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0", alt: "#{application.name} icon" %>
|
<%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center flex-shrink-0">
|
<%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %>
|
||||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,10 +73,13 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<% if application.allowed_groups.empty? %>
|
<% groups_count = application.allowed_groups.size %>
|
||||||
<span class="text-gray-400 dark:text-gray-500">All users</span>
|
<% users_count = @user_count_by_app[application.id] || 0 %>
|
||||||
|
<% if groups_count.zero? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= application.allowed_groups.count %>
|
<span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
|||||||
@@ -25,6 +25,23 @@
|
|||||||
Public clients do not have a client secret. PKCE is required.
|
Public clients do not have a client secret. PKCE is required.
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
|
||||||
|
|
||||||
|
<div class="mt-4" data-controller="clipboard">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy & paste):</span>
|
||||||
|
<button type="button"
|
||||||
|
data-action="clipboard#copy"
|
||||||
|
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
|
||||||
|
<span data-clipboard-target="label">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea data-clipboard-target="source"
|
||||||
|
readonly
|
||||||
|
rows="<%= env_lines.length %>"
|
||||||
|
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,13 +49,9 @@
|
|||||||
<div class="sm:flex sm:items-start sm:justify-between">
|
<div class="sm:flex sm:items-start sm:justify-between">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<% if @application.icon.attached? %>
|
<% if @application.icon.attached? %>
|
||||||
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{@application.name} icon" %>
|
<%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-16 w-16 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center shrink-0">
|
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
|
||||||
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
|
||||||
@@ -157,6 +170,30 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Environment variables
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 py-3" data-controller="clipboard">
|
||||||
|
<% env_lines = oidc_env_lines(@application) %>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
|
||||||
|
</span>
|
||||||
|
<button type="button"
|
||||||
|
data-action="clipboard#copy"
|
||||||
|
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
|
||||||
|
<span data-clipboard-target="label">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea data-clipboard-target="source"
|
||||||
|
readonly
|
||||||
|
rows="<%= env_lines.length %>"
|
||||||
|
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
||||||
@@ -233,11 +270,11 @@
|
|||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||||
<% if @allowed_groups.empty? %>
|
<% if @allowed_groups.empty? %>
|
||||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
|
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
No groups assigned - all active users can access this application.
|
No groups assigned — no one can access this application. Attach a group to grant access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,4 +295,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Users with access -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Users with access (<%= @users_with_access.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @users_with_access.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @users_with_access.each do |user| %>
|
||||||
|
<% via = user.groups & @application.allowed_groups %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<% via.each do |g| %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||||
@@ -32,6 +48,29 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
|
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||||
|
<% if @available_applications.any? %>
|
||||||
|
<% @available_applications.each do |application| %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
<% case application.app_type %>
|
||||||
|
<% when "oidc" %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
||||||
|
<% when "trusted_header" %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</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">
|
<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 dark:text-gray-300" %>
|
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
|
||||||
|
<% if @group.auto_assign? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @group.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% if @group.description.present? %>
|
<% if @group.description.present? %>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -33,11 +33,36 @@
|
|||||||
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div>
|
||||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
|
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
||||||
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
||||||
<% if user == Current.session.user %>
|
<% if @available_groups.any? %>
|
||||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span>
|
<% @available_groups.each do |group| %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
<% if group.admin? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if group.auto_assign? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
|
||||||
|
<% unless user.persisted? %>
|
||||||
|
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
|
||||||
|
<% if auto_names.any? %>
|
||||||
|
<div class="mt-2 flex items-center">
|
||||||
|
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<% @users.each do |user| %>
|
<% @users.each do |user| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
||||||
<%= user.email_address %>
|
<%= link_to user.email_address, admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<% if user.status.present? %>
|
<% if user.status.present? %>
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
data: { turbo_method: :post },
|
data: { turbo_method: :post },
|
||||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= link_to "Delete", admin_user_path(user),
|
<%= link_to "Delete", admin_user_path(user),
|
||||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||||
|
|||||||
95
app/views/admin/users/show.html.erb
Normal file
95
app/views/admin/users/show.html.erb
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<div class="mb-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
|
||||||
|
<% if @user.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% case @user.status %>
|
||||||
|
<% when "active" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
||||||
|
<% when "disabled" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
|
||||||
|
<% when "pending_invitation" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if @user.name.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
|
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Group memberships -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Group memberships (<%= @user.groups.count %>)
|
||||||
|
</h3>
|
||||||
|
<% if @user.groups.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @user.groups.order(:name).each do |group| %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
||||||
|
<% if group.admin? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
||||||
|
<% end %>
|
||||||
|
<% if group.auto_assign? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accessible applications -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Accessible applications (<%= @accessible_applications.count %>)
|
||||||
|
</h3>
|
||||||
|
<% unless @user.active? %>
|
||||||
|
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @accessible_applications.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
||||||
|
<% @accessible_applications.each do |app| %>
|
||||||
|
<% via = app.allowed_groups & @user.groups %>
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<% via.each do |g| %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -130,13 +130,9 @@
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-start gap-3 mb-4">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<% if app.icon.attached? %>
|
<% if app.icon.attached? %>
|
||||||
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{app.name} icon" %>
|
<%= app_icon_picture app, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-12 w-12 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center shrink-0">
|
<%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %>
|
||||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
<script>
|
<script nonce="<%= content_security_policy_nonce %>">
|
||||||
(function() {
|
(function() {
|
||||||
var theme = localStorage.getItem('theme');
|
var theme = localStorage.getItem('theme');
|
||||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
|
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<% if @application.icon.attached? %>
|
<% if @application.icon.attached? %>
|
||||||
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
<div class="mx-auto h-20 w-20 mb-4">
|
||||||
|
<%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center mb-4">
|
<div class="mx-auto mb-4">
|
||||||
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<p>
|
<p>
|
||||||
You can reset your password on
|
You can reset your password on
|
||||||
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
<%= link_to "this password reset page", edit_password_url(@user.generate_token_for(:password_reset)) %>.
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
This link will expire in 1 hour.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
You can reset your password on
|
You can reset your password on
|
||||||
<%= edit_password_url(@user.password_reset_token) %>
|
<%= edit_password_url(@user.generate_token_for(:password_reset)) %>
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
This link will expire in 1 hour.
|
||||||
|
|||||||
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
11
app/views/security_mailer/_event_metadata.html.erb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
|
||||||
|
from IP <strong><%= @ip %></strong>
|
||||||
|
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you did <strong>not</strong> perform this action, reset your password
|
||||||
|
immediately and contact your administrator.
|
||||||
|
</p>
|
||||||
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
7
app/views/security_mailer/_event_metadata.text.erb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
This action was recorded at <%= @occurred_at.to_fs(:long) %>
|
||||||
|
from IP <%= @ip %>
|
||||||
|
using <%= @user_agent.presence || "an unknown client" %>.
|
||||||
|
|
||||||
|
If you did not perform this action, reset your password immediately
|
||||||
|
and contact your administrator.
|
||||||
8
app/views/security_mailer/api_key_created.html.erb
Normal file
8
app/views/security_mailer/api_key_created.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new API key (<strong><%= @api_key_name %></strong>) was just created
|
||||||
|
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/api_key_created.text.erb
Normal file
6
app/views/security_mailer/api_key_created.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new API key ("<%= @api_key_name %>") was just created on your Clinch
|
||||||
|
account (<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
8
app/views/security_mailer/api_key_revoked.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The API key <strong><%= @api_key_name %></strong> was just revoked
|
||||||
|
on your Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
6
app/views/security_mailer/api_key_revoked.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
The API key "<%= @api_key_name %>" was just revoked on your Clinch
|
||||||
|
account (<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new set of two-factor backup codes was generated on your Clinch
|
||||||
|
account (<strong><%= @user.email_address %></strong>).
|
||||||
|
Any previous backup codes are now invalid.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new set of two-factor backup codes was generated on your Clinch account
|
||||||
|
(<%= @user.email_address %>). Any previous backup codes are now invalid.
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
22
app/views/security_mailer/email_address_changed.html.erb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<% if @recipient == @new_email %>
|
||||||
|
<p>
|
||||||
|
The email address on your Clinch account is now
|
||||||
|
<strong><%= @new_email %></strong>.
|
||||||
|
It was previously <strong><%= @old_email %></strong>.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<p>
|
||||||
|
The email address on your Clinch account was changed away from this
|
||||||
|
address (<strong><%= @old_email %></strong>) to
|
||||||
|
<strong><%= @new_email %></strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If this was <strong>not</strong> you, contact your administrator
|
||||||
|
immediately — whoever made the change can now receive password
|
||||||
|
reset emails for the account.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
14
app/views/security_mailer/email_address_changed.text.erb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
<% if @recipient == @new_email %>
|
||||||
|
The email address on your Clinch account is now <%= @new_email %>.
|
||||||
|
It was previously <%= @old_email %>.
|
||||||
|
<% else %>
|
||||||
|
The email address on your Clinch account was changed away from this
|
||||||
|
address (<%= @old_email %>) to <%= @new_email %>.
|
||||||
|
|
||||||
|
If this was not you, contact your administrator immediately — whoever
|
||||||
|
made the change can now receive password reset emails for the account.
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/passkey_added.html.erb
Normal file
8
app/views/security_mailer/passkey_added.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A new passkey (<strong><%= @nickname %></strong>) was just added to your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/passkey_added.text.erb
Normal file
6
app/views/security_mailer/passkey_added.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A new passkey ("<%= @nickname %>") was just added to your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
8
app/views/security_mailer/passkey_removed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A passkey (<strong><%= @nickname %></strong>) was just removed from your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
6
app/views/security_mailer/passkey_removed.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A passkey ("<%= @nickname %>") was just removed from your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/password_changed.html.erb
Normal file
8
app/views/security_mailer/password_changed.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The password on your Clinch account
|
||||||
|
(<strong><%= @user.email_address %></strong>) was just changed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
5
app/views/security_mailer/password_changed.text.erb
Normal file
5
app/views/security_mailer/password_changed.text.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
The password on your Clinch account (<%= @user.email_address %>) was just changed.
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
16
app/views/security_mailer/suspicious_passkey_used.html.erb
Normal file
16
app/views/security_mailer/suspicious_passkey_used.html.erb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A sign-in to your Clinch account (<strong><%= @user.email_address %></strong>)
|
||||||
|
using your passkey (<strong><%= @nickname %></strong>) was <strong>blocked</strong>
|
||||||
|
because its security counter did not advance as expected. This can indicate the
|
||||||
|
passkey has been copied (cloned).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If this was you and you are unable to sign in, remove this passkey and register
|
||||||
|
a new one. If you do not recognise this activity, treat it as a compromise:
|
||||||
|
remove the passkey and review your account security.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
11
app/views/security_mailer/suspicious_passkey_used.text.erb
Normal file
11
app/views/security_mailer/suspicious_passkey_used.text.erb
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A sign-in to your Clinch account (<%= @user.email_address %>) using your passkey
|
||||||
|
("<%= @nickname %>") was BLOCKED because its security counter did not advance as
|
||||||
|
expected. This can indicate the passkey has been copied (cloned).
|
||||||
|
|
||||||
|
If this was you and you are unable to sign in, remove this passkey and register a
|
||||||
|
new one. If you do not recognise this activity, treat it as a compromise: remove
|
||||||
|
the passkey and review your account security.
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
8
app/views/security_mailer/totp_disabled.html.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Two-factor authentication was just <strong>disabled</strong> on your
|
||||||
|
Clinch account (<strong><%= @user.email_address %></strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
6
app/views/security_mailer/totp_disabled.text.erb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Two-factor authentication was just disabled on your Clinch account
|
||||||
|
(<%= @user.email_address %>).
|
||||||
|
|
||||||
|
<%= render "event_metadata" %>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Continue with Passkey
|
Continue with Passkey
|
||||||
</button>
|
</button>
|
||||||
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
|
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Use Passkey Instead
|
Use Passkey Instead
|
||||||
</button>
|
</button>
|
||||||
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
|
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
18
app/views/shared/_app_monogram.html.erb
Normal file
18
app/views/shared/_app_monogram.html.erb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<%# Renders a deterministic monogram SVG for an Application that has no icon.
|
||||||
|
Locals:
|
||||||
|
name - the application name (required)
|
||||||
|
class - css classes for the <svg> element (e.g. "h-12 w-12 rounded-lg")
|
||||||
|
%>
|
||||||
|
<% initials = monogram_initials(name) %>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"
|
||||||
|
class="<%= local_assigns[:class] || "h-12 w-12 rounded-lg" %>"
|
||||||
|
role="img" aria-label="<%= name %> icon">
|
||||||
|
<rect width="40" height="40" fill="<%= monogram_color(name) %>" />
|
||||||
|
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle"
|
||||||
|
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
font-weight="600" fill="#ffffff"
|
||||||
|
font-size="<%= initials.length == 1 ? 22 : 17 %>"
|
||||||
|
letter-spacing="-0.5">
|
||||||
|
<%= initials %>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
@@ -66,6 +66,16 @@
|
|||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Admin: Access check -->
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Access check
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
@@ -115,6 +125,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<%= render "shared/version_info" %>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,6 +206,14 @@
|
|||||||
Groups
|
Groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Access check
|
||||||
|
<% end %>
|
||||||
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } 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 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
@@ -233,6 +255,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<%= render "shared/version_info" %>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
app/views/shared/_version_info.html.erb
Normal file
4
app/views/shared/_version_info.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="px-2 text-xs text-gray-500 dark:text-gray-500 space-y-0.5">
|
||||||
|
<div>Clinch <%= Clinch::VERSION %></div>
|
||||||
|
<div>Rails <%= Rails.version %> · Ruby <%= RUBY_VERSION %></div>
|
||||||
|
</div>
|
||||||
@@ -38,10 +38,6 @@ env:
|
|||||||
secret:
|
secret:
|
||||||
- RAILS_MASTER_KEY
|
- RAILS_MASTER_KEY
|
||||||
clear:
|
clear:
|
||||||
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
|
|
||||||
# When you start using multiple servers, you should split out job processing to a dedicated machine.
|
|
||||||
SOLID_QUEUE_IN_PUMA: true
|
|
||||||
|
|
||||||
# Set number of processes dedicated to Solid Queue (default: 1)
|
# Set number of processes dedicated to Solid Queue (default: 1)
|
||||||
# JOB_CONCURRENCY: 3
|
# JOB_CONCURRENCY: 3
|
||||||
|
|
||||||
|
|||||||
@@ -118,14 +118,17 @@ Rails.application.configure do
|
|||||||
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
|
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
|
||||||
|
|
||||||
if registrable_domain.present?
|
if registrable_domain.present?
|
||||||
# Create regex to allow any subdomain of the registrable domain
|
# Allow the registrable domain and any subdomain of it. The pattern is
|
||||||
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/
|
# anchored (\A...\z) with a mandatory dot before the domain so that
|
||||||
|
# look-alikes such as "evil-example.com" or "example.com.attacker.com"
|
||||||
|
# do NOT match — an unanchored /.*example\.com/ would allow both.
|
||||||
|
allowed_hosts << /\A(.+\.)?#{Regexp.escape(registrable_domain)}\z/i
|
||||||
end
|
end
|
||||||
rescue PublicSuffix::DomainInvalid
|
rescue PublicSuffix::DomainInvalid
|
||||||
# Fallback to simple domain extraction if PublicSuffix fails
|
# Fallback to simple domain extraction if PublicSuffix fails
|
||||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||||
base_domain = host_domain.split(".").last(2).join(".")
|
base_domain = host_domain.split(".").last(2).join(".")
|
||||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
allowed_hosts << /\A(.+\.)?#{Regexp.escape(base_domain)}\z/i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -136,9 +139,6 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Allow internal IP access for cross-compose or host networking
|
# Allow internal IP access for cross-compose or host networking
|
||||||
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
|
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
|
||||||
# Specific host IP
|
|
||||||
allowed_hosts << "192.168.2.246"
|
|
||||||
|
|
||||||
# Private IP ranges for internal network access
|
# Private IP ranges for internal network access
|
||||||
allowed_hosts += [
|
allowed_hosts += [
|
||||||
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
|
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
|
||||||
@@ -157,17 +157,5 @@ Rails.application.configure do
|
|||||||
# Skip DNS rebinding protection for the default health check endpoint.
|
# Skip DNS rebinding protection for the default health check endpoint.
|
||||||
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
|
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
|
||||||
|
|
||||||
# Sentry configuration for production
|
# Sentry is configured in config/initializers/sentry.rb, gated on SENTRY_DSN.
|
||||||
# Only enabled if SENTRY_DSN environment variable is set
|
|
||||||
if ENV["SENTRY_DSN"].present?
|
|
||||||
config.sentry.enabled = true
|
|
||||||
|
|
||||||
# Performance monitoring: sample 20% of transactions for traces
|
|
||||||
# Adjust based on your traffic volume and Sentry plan limits
|
|
||||||
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
|
|
||||||
|
|
||||||
# Continuous profiling: disabled by default in production due to cost
|
|
||||||
# Enable temporarily for performance investigations if needed
|
|
||||||
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
16
config/initializers/clinch_host.rb
Normal file
16
config/initializers/clinch_host.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# CLINCH_HOST is this IdP's canonical external origin, e.g. https://auth.example.com.
|
||||||
|
# It anchors the OIDC issuer, the WebAuthn RP ID, and the forward-auth login
|
||||||
|
# redirect. In deployed (non-local) environments it MUST be set explicitly and
|
||||||
|
# never inferred from request headers — X-Forwarded-Host is attacker-influenceable,
|
||||||
|
# so inferring the origin from it would allow host-header phishing and open
|
||||||
|
# redirects. Fail fast at boot rather than start in an unsafe configuration.
|
||||||
|
#
|
||||||
|
# Skipped during asset precompilation (e.g. the Docker build step, which sets
|
||||||
|
# SECRET_KEY_BASE_DUMMY): no real CLINCH_HOST exists yet and assets don't need it.
|
||||||
|
unless Rails.env.local? || ENV["SECRET_KEY_BASE_DUMMY"].present?
|
||||||
|
if ENV["CLINCH_HOST"].blank?
|
||||||
|
raise "CLINCH_HOST must be set (e.g. https://auth.example.com). It is the " \
|
||||||
|
"canonical origin of this Clinch instance and must not be inferred " \
|
||||||
|
"from request headers."
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,13 +9,15 @@ Rails.application.configure do
|
|||||||
# Default to self for everything, plus blob: for file downloads
|
# Default to self for everything, plus blob: for file downloads
|
||||||
policy.default_src :self, "blob:"
|
policy.default_src :self, "blob:"
|
||||||
|
|
||||||
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
# Scripts: self + per-response nonce (see nonce config below) + blob: for
|
||||||
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
# downloads. No unsafe-inline — importmap/Turbo/Stimulus inline tags carry the
|
||||||
policy.script_src :self, :unsafe_inline, "blob:"
|
# nonce automatically, and the one hand-written inline script is nonced.
|
||||||
|
policy.script_src :self, "blob:"
|
||||||
|
|
||||||
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
# Styles: self + per-response nonce. No unsafe-inline — Tailwind ships as an
|
||||||
# and Stimulus controller style manipulations
|
# external stylesheet, Turbo's injected <style> carries the nonce, and Stimulus
|
||||||
policy.style_src :self, :unsafe_inline
|
# sets styles via the CSSOM (not governed by CSP).
|
||||||
|
policy.style_src :self
|
||||||
|
|
||||||
# Images: Allow self, data URLs, and https for external images
|
# Images: Allow self, data URLs, and https for external images
|
||||||
policy.img_src :self, :data, :https
|
policy.img_src :self, :data, :https
|
||||||
@@ -51,14 +53,22 @@ Rails.application.configure do
|
|||||||
# Child sources: Allow self for any future iframes
|
# Child sources: Allow self for any future iframes
|
||||||
policy.child_src :self
|
policy.child_src :self
|
||||||
|
|
||||||
# Additional security headers for WebAuthn
|
# Do not enforce Trusted Types. The only valid value for
|
||||||
# Required for WebAuthn to work properly
|
# require-trusted-types-for is 'script'; there is no 'none' token, so
|
||||||
policy.require_trusted_types_for :none
|
# emitting it produces an invalid directive that browsers reject. To leave
|
||||||
|
# Trusted Types unenforced (needed for WebAuthn), omit the directive entirely.
|
||||||
|
|
||||||
# CSP reporting using report_uri (supported method)
|
# CSP reporting using report_uri (supported method)
|
||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Per-response random nonce applied to script-src and style-src. The app does
|
||||||
|
# not page-cache HTML, so a fresh random nonce per response is the most secure
|
||||||
|
# choice (no reuse across responses). csp_meta_tag (in the layout) and
|
||||||
|
# importmap-rails read this nonce automatically.
|
||||||
|
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
|
||||||
|
config.content_security_policy_nonce_directives = %w[script-src style-src]
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
# Use this to limit dissemination of sensitive information.
|
# Use this to limit dissemination of sensitive information.
|
||||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||||
Rails.application.config.filter_parameters += [
|
Rails.application.config.filter_parameters += [
|
||||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup
|
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup,
|
||||||
|
# :code partially matches the TOTP/backup `code` param, the OAuth authorization
|
||||||
|
# `code`, and the PKCE `code_verifier`/`code_challenge` — all sensitive in logs.
|
||||||
|
:code
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,62 +1,44 @@
|
|||||||
# Sentry configuration for error tracking and performance monitoring
|
# Sentry configuration for error tracking and performance monitoring.
|
||||||
# Only initializes if SENTRY_DSN environment variable is set
|
# Only initializes if the SENTRY_DSN environment variable is set.
|
||||||
|
|
||||||
return unless ENV["SENTRY_DSN"].present?
|
return unless ENV["SENTRY_DSN"].present?
|
||||||
|
|
||||||
Rails.application.configure do
|
Sentry.init do |config|
|
||||||
config.sentry.dsn = ENV["SENTRY_DSN"]
|
config.dsn = ENV["SENTRY_DSN"]
|
||||||
|
|
||||||
# Set environment (defaults to Rails.env)
|
# Environment label (defaults to Rails.env)
|
||||||
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
|
config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
|
||||||
|
|
||||||
# Set release version from Git or environment variable
|
# Release version from an env var or the current Git SHA
|
||||||
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
|
config.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence
|
||||||
|
|
||||||
# Sample rate for performance monitoring (0.0 to 1.0)
|
# Only report from production unless explicitly enabled elsewhere.
|
||||||
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
|
config.enabled_environments =
|
||||||
|
if ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||||
# Enable profiling in development/staging, disable in production unless explicitly enabled
|
%w[production development]
|
||||||
config.sentry.profiles_sample_rate = if Rails.env.production?
|
else
|
||||||
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
%w[production]
|
||||||
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
|
end
|
||||||
|
|
||||||
# Filter sensitive parameters
|
# Don't send cookies, request bodies, or user IPs by default.
|
||||||
if event.context[:request]
|
config.send_default_pii = false
|
||||||
event.context[:request].reject! { |key, value|
|
|
||||||
key.to_s.match?(/password|secret|token|key|authorization/i)
|
# Breadcrumbs for debugging
|
||||||
}
|
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||||
|
|
||||||
|
# Performance monitoring sample rate (0.0 to 1.0)
|
||||||
|
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
|
||||||
|
|
||||||
|
# Profiling: disabled in production by default due to cost.
|
||||||
|
config.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
|
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
|
# Ignore common non-critical exceptions
|
||||||
config.sentry.excluded_exceptions += [
|
config.excluded_exceptions += [
|
||||||
"ActionController::RoutingError",
|
"ActionController::RoutingError",
|
||||||
"ActionController::InvalidAuthenticityToken",
|
"ActionController::InvalidAuthenticityToken",
|
||||||
"ActionController::UnknownFormat",
|
"ActionController::UnknownFormat",
|
||||||
@@ -66,75 +48,38 @@ Rails.application.configure do
|
|||||||
"ActiveRecord::RecordNotFound"
|
"ActiveRecord::RecordNotFound"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add CSP-specific tags for security events
|
# Attach application/user context and scrub anything sensitive before sending.
|
||||||
config.sentry.tags = lambda do
|
config.before_send = lambda do |event, _hint|
|
||||||
{
|
event.tags = (event.tags || {}).merge(
|
||||||
# Add application context
|
|
||||||
app_name: "clinch",
|
app_name: "clinch",
|
||||||
app_environment: Rails.env,
|
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
|
if defined?(Current) && Current.respond_to?(:user) && Current.user
|
||||||
config.sentry.before_send = lambda do |event, hint|
|
event.user = (event.user || {}).merge(
|
||||||
# Filter out sensitive information
|
id: Current.user.id,
|
||||||
if event.context[:extra]
|
email: Current.user.email_address,
|
||||||
event.context[:extra].reject! { |key, value|
|
admin: Current.user.admin?
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if event.extra.is_a?(Hash)
|
||||||
|
event.extra.reject! do |key, value|
|
||||||
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||||
}
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
event
|
event
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add CSP-specific breadcrumbs for security events
|
# Scrub sensitive data out of breadcrumbs.
|
||||||
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
|
config.before_breadcrumb = lambda do |breadcrumb, _hint|
|
||||||
# Filter out sensitive breadcrumb data
|
if breadcrumb.data.is_a?(Hash)
|
||||||
if breadcrumb[:data]
|
breadcrumb.data.reject! do |key, value|
|
||||||
breadcrumb[:data].reject! { |key, value|
|
key.to_s.match?(/password|secret|token|key|authorization/i) || value.to_s.match?(/password|secret/i)
|
||||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
breadcrumb
|
breadcrumb
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only send errors in production unless explicitly enabled
|
|
||||||
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.9.0"
|
VERSION = "0.16.2"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ port ENV.fetch("PORT", 3000)
|
|||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Solid Queue plugin removed - now using async processor
|
# Run the Solid Queue supervisor inside Puma. Clinch ships as a single
|
||||||
|
# container, so the web process is always the worker too.
|
||||||
|
plugin :solid_queue if ENV.fetch("RAILS_ENV", "development") == "production"
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# In other environments, only set the PID file if requested.
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :groups
|
resources :groups
|
||||||
|
get "access", to: "access_checks#new"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class AddOidcAuthorizationCodeIdToTokens < ActiveRecord::Migration[8.1]
|
class AddOidcAuthorizationCodeIdToTokens < ActiveRecord::Migration[8.1]
|
||||||
def change
|
def change
|
||||||
add_reference :oidc_access_tokens, :oidc_authorization_code,
|
add_reference :oidc_access_tokens, :oidc_authorization_code,
|
||||||
null: true, foreign_key: true, index: true
|
null: true, foreign_key: true, index: true
|
||||||
add_reference :oidc_refresh_tokens, :oidc_authorization_code,
|
add_reference :oidc_refresh_tokens, :oidc_authorization_code,
|
||||||
null: true, foreign_key: true, index: true
|
null: true, foreign_key: true, index: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class AddAutoAssignAndAdminToGroups < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :groups, :auto_assign, :boolean, default: false, null: false
|
||||||
|
add_column :groups, :admin, :boolean, default: false, null: false
|
||||||
|
add_index :groups, :auto_assign, where: "auto_assign"
|
||||||
|
add_index :groups, :admin, where: "admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
class SeedDefaultGroupsAndMigrateAdmins < ActiveRecord::Migration[8.1]
|
||||||
|
# Data migration: seed "everyone" (auto_assign) and "admins" (admin) groups,
|
||||||
|
# backfill memberships from existing data, attach "everyone" to previously
|
||||||
|
# group-less applications. Idempotent.
|
||||||
|
#
|
||||||
|
# Must run before RemoveAdminFromUsers, because it reads the legacy
|
||||||
|
# users.admin column.
|
||||||
|
|
||||||
|
def up
|
||||||
|
unless Group.exists?(auto_assign: true)
|
||||||
|
everyone = Group.create!(
|
||||||
|
name: "everyone",
|
||||||
|
description: "Auto-assigned to new users. Safe to rename or remove.",
|
||||||
|
auto_assign: true
|
||||||
|
)
|
||||||
|
|
||||||
|
User.where(status: 0).find_each do |u|
|
||||||
|
UserGroup.find_or_create_by!(user_id: u.id, group_id: everyone.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
Application.left_joins(:application_groups)
|
||||||
|
.where(application_groups: {id: nil})
|
||||||
|
.find_each do |app|
|
||||||
|
ApplicationGroup.find_or_create_by!(application_id: app.id, group_id: everyone.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless Group.exists?(admin: true)
|
||||||
|
admins = Group.create!(
|
||||||
|
name: "admins",
|
||||||
|
description: "Members can access the admin panel.",
|
||||||
|
admin: true
|
||||||
|
)
|
||||||
|
|
||||||
|
User.where(admin: true).find_each do |u|
|
||||||
|
UserGroup.find_or_create_by!(user_id: u.id, group_id: admins.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
Group.where(name: ["everyone", "admins"]).destroy_all
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20260607000003_remove_admin_from_users.rb
Normal file
5
db/migrate/20260607000003_remove_admin_from_users.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class RemoveAdminFromUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
remove_column :users, :admin, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
7
db/migrate/20260611000001_add_last_otp_at_to_users.rb
Normal file
7
db/migrate/20260611000001_add_last_otp_at_to_users.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class AddLastOtpAtToUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Unix timestamp of the most recently accepted TOTP timestep, used to reject
|
||||||
|
# replay of a code within its drift window (passed to ROTP's `after:`).
|
||||||
|
add_column :users, :last_otp_at, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
8
db/schema.rb
generated
8
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -106,11 +106,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "groups", force: :cascade do |t|
|
create_table "groups", force: :cascade do |t|
|
||||||
|
t.boolean "admin", default: false, null: false
|
||||||
|
t.boolean "auto_assign", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.json "custom_claims", default: {}, null: false
|
t.json "custom_claims", default: {}, null: false
|
||||||
t.text "description"
|
t.text "description"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["admin"], name: "index_groups_on_admin", where: "admin"
|
||||||
|
t.index ["auto_assign"], name: "index_groups_on_auto_assign", where: "auto_assign"
|
||||||
t.index ["name"], name: "index_groups_on_name", unique: true
|
t.index ["name"], name: "index_groups_on_name", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -225,11 +229,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.boolean "admin", default: false, null: false
|
|
||||||
t.json "backup_codes"
|
t.json "backup_codes"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.json "custom_claims", default: {}, null: false
|
t.json "custom_claims", default: {}, null: false
|
||||||
t.string "email_address", null: false
|
t.string "email_address", null: false
|
||||||
|
t.integer "last_otp_at"
|
||||||
t.datetime "last_sign_in_at"
|
t.datetime "last_sign_in_at"
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
|
|||||||
47
test/controllers/admin/access_checks_controller_test.rb
Normal file
47
test/controllers/admin/access_checks_controller_test.rb
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class AccessChecksControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin = users(:two)
|
||||||
|
sign_in_as(@admin)
|
||||||
|
@kavita = applications(:kavita_app)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "new renders the form with users and applications" do
|
||||||
|
get admin_access_path
|
||||||
|
assert_response :success
|
||||||
|
assert_match @kavita.name, response.body
|
||||||
|
assert_match "alice@example.com", response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 'can access' with via group when user is in an allowed group" do
|
||||||
|
get admin_access_path, params: {
|
||||||
|
user_id: users(:alice).id,
|
||||||
|
application_id: @kavita.id
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
assert_match "can access", response.body
|
||||||
|
assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 'cannot access' with reason when user shares no group with the app" do
|
||||||
|
lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true)
|
||||||
|
get admin_access_path, params: {
|
||||||
|
user_id: lonely.id,
|
||||||
|
application_id: @kavita.id
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
assert_match "cannot access", response.body
|
||||||
|
assert_match "shares no group", response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders form unchanged when ids are missing" do
|
||||||
|
get admin_access_path, params: {user_id: "", application_id: ""}
|
||||||
|
assert_response :success
|
||||||
|
# No result panel should render. The panel-only phrases:
|
||||||
|
refute_match "Granted via", response.body
|
||||||
|
refute_match "Reason:", response.body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
70
test/controllers/admin/groups_controller_test.rb
Normal file
70
test/controllers/admin/groups_controller_test.rb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class GroupsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin = users(:two)
|
||||||
|
sign_in_as(@admin)
|
||||||
|
@group = groups(:one)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update assigns applications from application_ids" do
|
||||||
|
app_a = applications(:kavita_app)
|
||||||
|
app_b = applications(:another_app)
|
||||||
|
|
||||||
|
patch admin_group_path(@group), params: {
|
||||||
|
group: {
|
||||||
|
name: @group.name,
|
||||||
|
application_ids: [app_a.id, app_b.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to admin_group_path(@group)
|
||||||
|
assert_equal [app_a, app_b].sort, @group.reload.applications.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update with no application_ids clears assigned applications" do
|
||||||
|
@group.applications = [applications(:kavita_app)]
|
||||||
|
|
||||||
|
patch admin_group_path(@group), params: {
|
||||||
|
group: {name: @group.name}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to admin_group_path(@group)
|
||||||
|
assert_empty @group.reload.applications
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create assigns applications from application_ids" do
|
||||||
|
app = applications(:audiobookshelf_app)
|
||||||
|
|
||||||
|
assert_difference -> { Group.count }, 1 do
|
||||||
|
post admin_groups_path, params: {
|
||||||
|
group: {
|
||||||
|
name: "New Group",
|
||||||
|
application_ids: [app.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal [app], Group.find_by(name: "new group").applications
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can mark a group as auto_assign and admin" do
|
||||||
|
patch admin_group_path(@group), params: {
|
||||||
|
group: {name: @group.name, auto_assign: "1", admin: "1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
@group.reload
|
||||||
|
assert @group.auto_assign?
|
||||||
|
assert @group.admin?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot delete the last admin group" do
|
||||||
|
admins = groups(:admin_group)
|
||||||
|
|
||||||
|
delete admin_group_path(admins)
|
||||||
|
# Destroy was aborted by the before_destroy guard
|
||||||
|
assert Group.exists?(admins.id), "admin group should not have been deleted"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
69
test/controllers/admin/users_controller_test.rb
Normal file
69
test/controllers/admin/users_controller_test.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin = users(:two) # in admin_group via fixtures
|
||||||
|
sign_in_as(@admin)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "show loads accessible applications via the user's groups" do
|
||||||
|
kavita = applications(:kavita_app)
|
||||||
|
# alice is in admin_group via fixtures; kavita is attached to admin_group via app_groups
|
||||||
|
get admin_user_path(users(:alice))
|
||||||
|
assert_response :success
|
||||||
|
assert_match kavita.name, response.body
|
||||||
|
# The "via" badge mentions the granting group name
|
||||||
|
assert_match groups(:admin_group).name, response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update assigns group memberships from group_ids" do
|
||||||
|
target = users(:bob)
|
||||||
|
editors = groups(:editor_group)
|
||||||
|
one = groups(:one)
|
||||||
|
|
||||||
|
patch admin_user_path(target), params: {
|
||||||
|
user: {email_address: target.email_address, group_ids: [editors.id, one.id]}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to admin_users_path
|
||||||
|
assert_equal [editors, one].sort, target.reload.groups.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot remove yourself from the last admin group" do
|
||||||
|
# @admin (users:two) is in admin_group. Removing them via the user form
|
||||||
|
# while no other admin exists is blocked.
|
||||||
|
sole_admin = users(:two)
|
||||||
|
# Strip alice (the other admin) so @admin is the last one.
|
||||||
|
users(:alice).groups.delete(groups(:admin_group))
|
||||||
|
|
||||||
|
patch admin_user_path(sole_admin), params: {
|
||||||
|
user: {email_address: sole_admin.email_address, group_ids: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
assert sole_admin.reload.admin?, "should still be admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create with auto_assign=0 skips the auto-assign callback" do
|
||||||
|
post admin_users_path, params: {
|
||||||
|
user: {email_address: "restricted@example.com"},
|
||||||
|
auto_assign: "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
created = User.find_by(email_address: "restricted@example.com")
|
||||||
|
assert_not_includes created.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create without auto_assign param auto-joins everyone" do
|
||||||
|
post admin_users_path, params: {
|
||||||
|
user: {email_address: "newbie@example.com"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
created = User.find_by(email_address: "newbie@example.com")
|
||||||
|
assert_includes created.groups, groups(:everyone)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,6 +11,7 @@ module Api
|
|||||||
domain_pattern: "webdav.example.com",
|
domain_pattern: "webdav.example.com",
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
grant_everyone_access(@app)
|
||||||
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
|
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
|
||||||
@token = @api_key.plaintext_token
|
@token = @api_key.plaintext_token
|
||||||
end
|
end
|
||||||
@@ -112,6 +113,42 @@ module Api
|
|||||||
assert_equal "Application is inactive", json["error"]
|
assert_equal "Application is inactive", json["error"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "bearer token returns 401 once user is removed from allowed groups" do
|
||||||
|
# App restricted to a specific group; user is a member when the key is made.
|
||||||
|
group = Group.create!(name: "webdav-users")
|
||||||
|
restricted_app = Application.create!(
|
||||||
|
name: "Restricted WebDAV",
|
||||||
|
slug: "restricted-webdav",
|
||||||
|
app_type: "forward_auth",
|
||||||
|
domain_pattern: "restricted.example.com",
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
restricted_app.allowed_groups << group
|
||||||
|
@user.groups << group
|
||||||
|
|
||||||
|
key = @user.api_keys.create!(name: "Restricted Key", application: restricted_app)
|
||||||
|
token = key.plaintext_token
|
||||||
|
|
||||||
|
# Sanity: access works while membership stands.
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{token}",
|
||||||
|
"X-Forwarded-Host" => "restricted.example.com"
|
||||||
|
}
|
||||||
|
assert_response :ok
|
||||||
|
|
||||||
|
# Revoke group membership; the existing key must stop working.
|
||||||
|
@user.groups.destroy(group)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"Authorization" => "Bearer #{token}",
|
||||||
|
"X-Forwarded-Host" => "restricted.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "Access denied: insufficient group membership", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
test "no bearer token falls through to cookie auth" do
|
test "no bearer token falls through to cookie auth" do
|
||||||
# No auth header, no session -> should redirect (cookie flow)
|
# No auth header, no session -> should redirect (cookie flow)
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ module Api
|
|||||||
@admin_user = users(:alice)
|
@admin_user = users(:alice)
|
||||||
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
|
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
|
||||||
@group = groups(:admin_group)
|
@group = groups(:admin_group)
|
||||||
@rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
|
@rule = grant_everyone_access(Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true))
|
||||||
@inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
|
@inactive_rule = grant_everyone_access(Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Authentication Tests
|
# Authentication Tests
|
||||||
@@ -65,7 +65,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return 403 when rule exists but user not in allowed groups" do
|
test "should return 403 when rule exists but user not in allowed groups" do
|
||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups = [@group]
|
||||||
sign_in_as(@user) # User not in group
|
sign_in_as(@user) # User not in group
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
@@ -75,7 +75,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return 200 when user is in allowed groups" do
|
test "should return 200 when user is in allowed groups" do
|
||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups = [@group]
|
||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ module Api
|
|||||||
|
|
||||||
# Domain Pattern Tests
|
# Domain Pattern Tests
|
||||||
test "should match wildcard domains correctly" do
|
test "should match wildcard domains correctly" do
|
||||||
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
@@ -101,7 +101,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should match exact domains correctly" do
|
test "should match exact domains correctly" do
|
||||||
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||||
@@ -126,7 +126,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return custom headers when configured" do
|
test "should return custom headers when configured" do
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "Custom App",
|
name: "Custom App",
|
||||||
slug: "custom-app",
|
slug: "custom-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -151,7 +151,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return no headers when all headers disabled" do
|
test "should return no headers when all headers disabled" do
|
||||||
Application.create!(
|
grant_everyone_access Application.create!(
|
||||||
name: "No Headers App",
|
name: "No Headers App",
|
||||||
slug: "no-headers-app",
|
slug: "no-headers-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -182,11 +182,19 @@ module Api
|
|||||||
assert_includes groups_header, "Editors"
|
assert_includes groups_header, "Editors"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should not include groups header when user has no groups" do
|
test "should not include groups header when user has no groups beyond the granting one and groups header empty" do
|
||||||
@user.groups.clear # Remove fixture groups
|
# Under default-deny the user must be in at least one group to access the app.
|
||||||
|
# This rewritten test verifies that when an app's headers_config disables the
|
||||||
|
# groups header, no x-remote-groups is sent regardless of memberships.
|
||||||
|
grant_everyone_access Application.create!(
|
||||||
|
name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth",
|
||||||
|
domain_pattern: "hidden.example.com",
|
||||||
|
active: true,
|
||||||
|
headers_config: {groups: ""}
|
||||||
|
)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "hidden.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_nil response.headers["x-remote-groups"]
|
assert_nil response.headers["x-remote-groups"]
|
||||||
@@ -234,6 +242,20 @@ module Api
|
|||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fail closed when no host can be determined: emitting identity headers without
|
||||||
|
# an application would bypass all per-domain group access control.
|
||||||
|
test "should fail closed and emit no identity headers when host is absent" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Blank both host sources so forwarded_host is not present.
|
||||||
|
get "/api/verify", headers: {"X-Forwarded-Host" => "", "Host" => ""}
|
||||||
|
|
||||||
|
assert_response 403
|
||||||
|
assert_equal "No host header present", response.headers["x-auth-reason"]
|
||||||
|
assert_nil response.headers["X-Remote-User"]
|
||||||
|
assert_nil response.headers["X-Remote-Groups"]
|
||||||
|
end
|
||||||
|
|
||||||
# Security Tests
|
# Security Tests
|
||||||
test "should handle very long domain names" do
|
test "should handle very long domain names" do
|
||||||
long_domain = "a" * 250 + ".example.com"
|
long_domain = "a" * 250 + ".example.com"
|
||||||
@@ -537,7 +559,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should track failed attempts and eventually rate limit" do
|
test "should track failed attempts and eventually rate limit" do
|
||||||
cache = Rails.application.config.forward_auth_cache
|
Rails.application.config.forward_auth_cache
|
||||||
|
|
||||||
# Make 50 failed requests (no session = unauthorized)
|
# Make 50 failed requests (no session = unauthorized)
|
||||||
50.times do
|
50.times do
|
||||||
@@ -705,7 +727,7 @@ module Api
|
|||||||
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
|
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
@user = users(:bob)
|
@user = users(:bob)
|
||||||
Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
grant_everyone_access Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
@original_cache = Rails.cache
|
@original_cache = Rails.cache
|
||||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
@application.generate_new_client_secret!
|
@application.generate_new_client_secret!
|
||||||
@plain_client_secret = @application.client_secret
|
@plain_client_secret = @application.client_secret
|
||||||
@application.save!
|
@application.save!
|
||||||
|
grant_everyone_access(@application)
|
||||||
end
|
end
|
||||||
|
|
||||||
def teardown
|
def teardown
|
||||||
@@ -33,6 +34,25 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
# CRITICAL SECURITY TESTS
|
# CRITICAL SECURITY TESTS
|
||||||
# ====================
|
# ====================
|
||||||
|
|
||||||
|
test "consent endpoint rejects cross-site POST without a CSRF token" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Forgery protection is disabled in the test env by default; enable it so the
|
||||||
|
# before_action actually runs, mirroring production behaviour.
|
||||||
|
original = ActionController::Base.allow_forgery_protection
|
||||||
|
ActionController::Base.allow_forgery_protection = true
|
||||||
|
begin
|
||||||
|
# No authenticity_token param: a forged cross-site submission. Because
|
||||||
|
# :consent is NOT in the verify_authenticity_token skip list, this must be
|
||||||
|
# rejected before the action can grant any OAuth scopes.
|
||||||
|
post "/oauth/authorize/consent", params: {approve: "true"}
|
||||||
|
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
ensure
|
||||||
|
ActionController::Base.allow_forgery_protection = original
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "prevents authorization code reuse - sequential attempts" do
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
# Create consent
|
# Create consent
|
||||||
OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
grant_everyone_access(@application)
|
||||||
|
|
||||||
# Sign in the user using the test helper
|
# Sign in the user using the test helper
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|||||||
@@ -213,6 +213,50 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal family_id, new_refresh_token.token_family_id
|
assert_equal family_id, new_refresh_token.token_family_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "reusing a revoked refresh token revokes every access token in the family" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
family_id = refresh_token.token_family_id
|
||||||
|
old_plaintext = refresh_token.token
|
||||||
|
|
||||||
|
# Rotate once: the old refresh token is revoked; a new access + refresh token
|
||||||
|
# are issued into the same family.
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: old_plaintext,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
new_refresh = OidcRefreshToken.in_family(family_id).where.not(id: refresh_token.id).first
|
||||||
|
new_access_token = new_refresh.oidc_access_token
|
||||||
|
refute new_access_token.reload.revoked?, "rotated-in access token should start active"
|
||||||
|
|
||||||
|
# Reuse the OLD (now revoked) refresh token -> rotation-attack detection.
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: old_plaintext,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
assert_response :bad_request
|
||||||
|
|
||||||
|
# Both the original and the rotated-in access token must now be revoked, so a
|
||||||
|
# stolen access token from anywhere in the chain stops working at /userinfo.
|
||||||
|
assert access_token.reload.revoked?, "original access token should be revoked"
|
||||||
|
assert new_access_token.reload.revoked?, "rotated-in access token should be revoked"
|
||||||
|
end
|
||||||
|
|
||||||
test "userinfo endpoint works with hashed access token" do
|
test "userinfo endpoint works with hashed access token" do
|
||||||
access_token = OidcAccessToken.create!(
|
access_token = OidcAccessToken.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
|
|||||||
@@ -30,4 +30,22 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
assert_empty cookies[:session_id]
|
assert_empty cookies[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "session cookie has no Expires attribute when remember_me is off" do
|
||||||
|
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "0"}
|
||||||
|
|
||||||
|
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
|
||||||
|
assert set_cookie, "session_id cookie should be set"
|
||||||
|
refute_match(/expires=/i, set_cookie,
|
||||||
|
"without Remember me, the session cookie must be a browser-session cookie (no Expires)")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "session cookie has long-lived Expires attribute when remember_me is on" do
|
||||||
|
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "1"}
|
||||||
|
|
||||||
|
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
|
||||||
|
assert set_cookie, "session_id cookie should be set"
|
||||||
|
assert_match(/expires=/i, set_cookie,
|
||||||
|
"with Remember me, the cookie should have an Expires attribute")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,16 +19,21 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# First use of the code should succeed
|
# First use of the code should succeed
|
||||||
post totp_verification_path, params: {code: valid_code}
|
post totp_verification_path, params: {code: valid_code}
|
||||||
assert_response :redirect
|
|
||||||
assert_redirected_to root_path
|
assert_redirected_to root_path
|
||||||
|
|
||||||
# Sign out
|
# Sign out
|
||||||
delete session_path
|
delete session_path
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Note: In the current implementation, TOTP codes CAN be reused within the 60-second time window
|
# Replay the SAME code in a fresh sign-in attempt. Because verify_totp records
|
||||||
# This is standard TOTP behavior. For enhanced security, you could implement used code tracking.
|
# the accepted timestep (ROTP `after:`), the code is now rejected even though
|
||||||
# This test documents the current behavior - codes work within their time window
|
# it is still within its drift window — so we stay on the verification step.
|
||||||
|
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
|
||||||
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
|
post totp_verification_path, params: {code: valid_code}
|
||||||
|
assert_redirected_to totp_verification_path
|
||||||
|
assert_equal "Invalid verification code. Please try again.", flash[:alert]
|
||||||
|
|
||||||
user.sessions.delete_all
|
user.sessions.delete_all
|
||||||
user.destroy
|
user.destroy
|
||||||
|
|||||||
6
test/fixtures/groups.yml
vendored
6
test/fixtures/groups.yml
vendored
@@ -11,7 +11,13 @@ two:
|
|||||||
admin_group:
|
admin_group:
|
||||||
name: Administrators
|
name: Administrators
|
||||||
description: System administrators with full access
|
description: System administrators with full access
|
||||||
|
admin: true
|
||||||
|
|
||||||
editor_group:
|
editor_group:
|
||||||
name: Editors
|
name: Editors
|
||||||
description: Content editors with limited access
|
description: Content editors with limited access
|
||||||
|
|
||||||
|
everyone:
|
||||||
|
name: everyone
|
||||||
|
description: Auto-assigned to new users.
|
||||||
|
auto_assign: true
|
||||||
|
|||||||
19
test/fixtures/user_groups.yml
vendored
19
test/fixtures/user_groups.yml
vendored
@@ -1,9 +1,28 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
# All users belong to "everyone" so existing tests that create group-less apps
|
||||||
|
# can be made compatible by attaching that group.
|
||||||
|
|
||||||
|
one_everyone:
|
||||||
|
user: one
|
||||||
|
group: everyone
|
||||||
|
two_everyone:
|
||||||
|
user: two
|
||||||
|
group: everyone
|
||||||
|
alice_everyone:
|
||||||
|
user: alice
|
||||||
|
group: everyone
|
||||||
|
bob_everyone:
|
||||||
|
user: bob
|
||||||
|
group: everyone
|
||||||
|
|
||||||
alice_admin_group:
|
alice_admin_group:
|
||||||
user: alice
|
user: alice
|
||||||
group: admin_group
|
group: admin_group
|
||||||
|
|
||||||
|
two_admin_group:
|
||||||
|
user: two
|
||||||
|
group: admin_group
|
||||||
|
|
||||||
bob_editor_group:
|
bob_editor_group:
|
||||||
user: bob
|
user: bob
|
||||||
group: editor_group
|
group: editor_group
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user