Separate Forward auth into it's own models + controller
This commit is contained in:
@@ -8,7 +8,7 @@ Clinch gives you one place to manage users and lets any web app authenticate aga
|
||||
|
||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||
|
||||
**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment.
|
||||
**Clinch is a lightweight alternative to [Authelia](https://www.authelia.com) and [Authentik](https://goauthentik.io)**, designed for simplicity and ease of deployment.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +45,7 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL
|
||||
|
||||
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
||||
Forward Auth works only on the same domain as Clinch runs
|
||||
|
||||
### SMTP Integration
|
||||
Send emails for:
|
||||
|
||||
71
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
71
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
module Admin
|
||||
class ForwardAuthRulesController < BaseController
|
||||
before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@forward_auth_rules = ForwardAuthRule.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
@allowed_groups = @forward_auth_rule.allowed_groups
|
||||
end
|
||||
|
||||
def new
|
||||
@forward_auth_rule = ForwardAuthRule.new
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
||||
|
||||
if @forward_auth_rule.save
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @forward_auth_rule.update(forward_auth_rule_params)
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
else
|
||||
@forward_auth_rule.allowed_groups = []
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@forward_auth_rule.destroy
|
||||
redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_forward_auth_rule
|
||||
@forward_auth_rule = ForwardAuthRule.find(params[:id])
|
||||
end
|
||||
|
||||
def forward_auth_rule_params
|
||||
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,21 @@ module Authentication
|
||||
def start_new_session_for(user)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,4 +63,37 @@ module Authentication
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies
|
||||
# Examples:
|
||||
# - clinch.aapamilne.com -> .aapamilne.com
|
||||
# - app.example.co.uk -> .example.co.uk
|
||||
# - localhost -> nil (no domain setting for local development)
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Split hostname into parts
|
||||
parts = host.split('.')
|
||||
|
||||
# For normal domains like example.com, we need at least 2 parts
|
||||
# For complex domains like co.uk, we need at least 3 parts
|
||||
return nil if parts.length < 2
|
||||
|
||||
# Extract root domain with leading dot for cross-subdomain cookies
|
||||
if parts.length >= 3
|
||||
# Check if it's a known complex TLD
|
||||
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
||||
second_level = "#{parts[-2]}.#{parts[-1]}"
|
||||
|
||||
if complex_tlds.include?(second_level)
|
||||
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
||||
root_parts = parts[-3..-1]
|
||||
return ".#{root_parts.join('.')}"
|
||||
end
|
||||
end
|
||||
|
||||
# For regular domains: app.example.com -> .example.com
|
||||
root_parts = parts[-2..-1]
|
||||
".#{root_parts.join('.')}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ class Application < ApplicationRecord
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc trusted_header saml] }
|
||||
inclusion: { in: %w[oidc saml] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
@@ -18,7 +18,6 @@ class Application < ApplicationRecord
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :oidc, -> { where(app_type: "oidc") }
|
||||
scope :trusted_header, -> { where(app_type: "trusted_header") }
|
||||
scope :saml, -> { where(app_type: "saml") }
|
||||
|
||||
# Type checks
|
||||
@@ -26,10 +25,6 @@ class Application < ApplicationRecord
|
||||
app_type == "oidc"
|
||||
end
|
||||
|
||||
def trusted_header?
|
||||
app_type == "trusted_header"
|
||||
end
|
||||
|
||||
def saml?
|
||||
app_type == "saml"
|
||||
end
|
||||
|
||||
53
app/models/forward_auth_rule.rb
Normal file
53
app/models/forward_auth_rule.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
class ForwardAuthRule < ApplicationRecord
|
||||
has_many :forward_auth_rule_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
|
||||
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :active, inclusion: { in: [true, false] }
|
||||
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
|
||||
# Check if a domain matches this rule
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
end
|
||||
|
||||
# Access control for forward auth
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users (bypass)
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
# Policy determination based on user status and rule configuration
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
end
|
||||
end
|
||||
6
app/models/forward_auth_rule_group.rb
Normal file
6
app/models/forward_auth_rule_group.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class ForwardAuthRuleGroup < ApplicationRecord
|
||||
belongs_to :forward_auth_rule
|
||||
belongs_to :group
|
||||
|
||||
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<div>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["ForwardAuth (Trusted Headers)", "trusted_header"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC and ForwardAuth applications.</p>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC applications.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
@@ -37,8 +37,6 @@
|
||||
<% case application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "trusted_header" %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-1 text-xs font-medium text-indigo-700">ForwardAuth</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% end %>
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
<% case @application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "trusted_header" %>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-100 px-2 py-1 text-xs font-medium text-indigo-700">ForwardAuth</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% end %>
|
||||
|
||||
57
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
57
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
@@ -0,0 +1,57 @@
|
||||
<% content_for :title, "Edit Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
Edit Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Update Rule", 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 %>
|
||||
</div>
|
||||
89
app/views/admin/forward_auth_rules/index.html.erb
Normal file
89
app/views/admin/forward_auth_rules/index.html.erb
Normal file
@@ -0,0 +1,89 @@
|
||||
<% content_for :title, "Forward Auth Rules" %>
|
||||
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<% if @forward_auth_rules.any? %>
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Domain Pattern</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
<%= rule.domain_pattern %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.allowed_groups.any? %>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% rule.allowed_groups.each do |group| %>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass (All Users)
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.active? %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= link_to "Delete", admin_forward_auth_rule_path(rule),
|
||||
data: {
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||
},
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No forward auth rules</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new forward authentication rule.</p>
|
||||
<div class="mt-6">
|
||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "inline-flex items-center 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>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
57
app/views/admin/forward_auth_rules/new.html.erb
Normal file
57
app/views/admin/forward_auth_rules/new.html.erb
Normal file
@@ -0,0 +1,57 @@
|
||||
<% content_for :title, "New Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
New Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Create Rule", 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 %>
|
||||
</div>
|
||||
111
app/views/admin/forward_auth_rules/show.html.erb
Normal file
111
app/views/admin/forward_auth_rules/show.html.erb
Normal file
@@ -0,0 +1,111 @@
|
||||
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
<%= @forward_auth_rule.domain_pattern %>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
|
||||
data: {
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||
},
|
||||
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<% if @forward_auth_rule.active? %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Access Policy</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<% if @allowed_groups.any? %>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm">Only users in these groups are allowed access:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @allowed_groups.each do |group| %>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass - All authenticated users allowed
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
||||
<% if @allowed_groups.any? %>
|
||||
<li>Only users belonging to the specified groups will be granted access</li>
|
||||
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
||||
<% else %>
|
||||
<li>All authenticated users will be granted access (bypass mode)</li>
|
||||
<% end %>
|
||||
<li>Inactive rules are ignored during authentication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,6 +66,16 @@
|
||||
Groups
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Forward Auth Rules -->
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<!-- Profile -->
|
||||
@@ -160,6 +170,14 @@
|
||||
Groups
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
|
||||
@@ -57,6 +57,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
resources :groups
|
||||
resources :forward_auth_rules
|
||||
end
|
||||
|
||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||
|
||||
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
11
db/migrate/20251023210508_create_forward_auth_rules.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateForwardAuthRules < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :forward_auth_rules do |t|
|
||||
t.string :domain_pattern
|
||||
t.integer :policy
|
||||
t.boolean :active
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
10
db/migrate/20251023234744_create_forward_auth_rule_groups.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class CreateForwardAuthRuleGroups < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :forward_auth_rule_groups do |t|
|
||||
t.references :forward_auth_rule, null: false, foreign_key: true
|
||||
t.references :group, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
23
db/schema.rb
generated
23
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -37,6 +37,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.index ["slug"], name: "index_applications_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "forward_auth_rule_groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "forward_auth_rule_id", null: false
|
||||
t.integer "group_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["forward_auth_rule_id"], name: "index_forward_auth_rule_groups_on_forward_auth_rule_id"
|
||||
t.index ["group_id"], name: "index_forward_auth_rule_groups_on_group_id"
|
||||
end
|
||||
|
||||
create_table "forward_auth_rules", force: :cascade do |t|
|
||||
t.boolean "active"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "domain_pattern"
|
||||
t.integer "policy"
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "description"
|
||||
@@ -108,7 +125,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
t.datetime "created_at", null: false
|
||||
t.string "email_address", null: false
|
||||
t.string "password_digest", null: false
|
||||
t.integer "status"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.boolean "totp_required", default: false, null: false
|
||||
t.string "totp_secret"
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -118,6 +135,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
|
||||
add_foreign_key "application_groups", "applications"
|
||||
add_foreign_key "application_groups", "groups"
|
||||
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
|
||||
add_foreign_key "forward_auth_rule_groups", "groups"
|
||||
add_foreign_key "oidc_access_tokens", "applications"
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
|
||||
11
test/fixtures/forward_auth_rules.yml
vendored
Normal file
11
test/fixtures/forward_auth_rules.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
|
||||
two:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
127
test/models/forward_auth_rule_test.rb
Normal file
127
test/models/forward_auth_rule_test.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
require "test_helper"
|
||||
|
||||
class ForwardAuthRuleTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@rule = ForwardAuthRule.new(
|
||||
domain_pattern: "*.example.com",
|
||||
active: true
|
||||
)
|
||||
end
|
||||
|
||||
test "should be valid with valid attributes" do
|
||||
assert @rule.valid?
|
||||
end
|
||||
|
||||
test "should require domain_pattern" do
|
||||
@rule.domain_pattern = ""
|
||||
assert_not @rule.valid?
|
||||
assert_includes @rule.errors[:domain_pattern], "can't be blank"
|
||||
end
|
||||
|
||||
test "should require active to be boolean" do
|
||||
@rule.active = nil
|
||||
assert_not @rule.valid?
|
||||
assert_includes @rule.errors[:active], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should normalize domain_pattern to lowercase" do
|
||||
@rule.domain_pattern = "*.EXAMPLE.COM"
|
||||
@rule.save!
|
||||
assert_equal "*.example.com", @rule.reload.domain_pattern
|
||||
end
|
||||
|
||||
test "should enforce unique domain_pattern" do
|
||||
@rule.save!
|
||||
duplicate = ForwardAuthRule.new(
|
||||
domain_pattern: "*.example.com",
|
||||
active: true
|
||||
)
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:domain_pattern], "has already been taken"
|
||||
end
|
||||
|
||||
test "should match domain patterns correctly" do
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("app.example.com")
|
||||
assert @rule.matches_domain?("api.example.com")
|
||||
assert @rule.matches_domain?("sub.app.example.com")
|
||||
assert_not @rule.matches_domain?("example.org")
|
||||
assert_not @rule.matches_domain?("otherexample.com")
|
||||
end
|
||||
|
||||
test "should handle exact domain matches" do
|
||||
@rule.domain_pattern = "api.example.com"
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("api.example.com")
|
||||
assert_not @rule.matches_domain?("app.example.com")
|
||||
assert_not @rule.matches_domain?("sub.api.example.com")
|
||||
end
|
||||
|
||||
test "policy_for_user should return bypass when no groups assigned" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
assert_equal "bypass", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return deny for inactive rule" do
|
||||
user = users(:one)
|
||||
@rule.active = false
|
||||
@rule.save!
|
||||
|
||||
assert_equal "deny", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return deny for inactive user" do
|
||||
user = users(:one)
|
||||
user.update!(active: false)
|
||||
@rule.save!
|
||||
|
||||
assert_equal "deny", @rule.policy_for_user(user)
|
||||
end
|
||||
|
||||
test "policy_for_user should return correct policy based on user groups and TOTP" do
|
||||
group = groups(:one)
|
||||
user_with_totp = users(:two)
|
||||
user_without_totp = users(:one)
|
||||
|
||||
user_with_totp.totp_secret = "test_secret"
|
||||
user_with_totp.save!
|
||||
|
||||
@rule.allowed_groups << group
|
||||
user_with_totp.groups << group
|
||||
user_without_totp.groups << group
|
||||
@rule.save!
|
||||
|
||||
assert_equal "two_factor", @rule.policy_for_user(user_with_totp)
|
||||
assert_equal "one_factor", @rule.policy_for_user(user_without_totp)
|
||||
end
|
||||
|
||||
test "user_allowed? should return true when no groups assigned" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should return true when user in allowed groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
user.groups << group
|
||||
@rule.allowed_groups << group
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should return false when user not in allowed groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
@rule.allowed_groups << group
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.user_allowed?(user)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user