6 Commits

Author SHA1 Message Date
Dan Milne
831bd083c2 Update readme
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-24 12:02:38 +11:00
Dan Milne
1212e0f22e Allow redirection to 3rd party sites
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-24 11:52:58 +11:00
Dan Milne
a21b21ace2 remove unneeded action
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-24 11:43:34 +11:00
Dan Milne
ad70841689 Pass the redirect url through the forms
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-24 11:36:11 +11:00
Dan Milne
9be6ef09ff Add missing file
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-24 11:08:43 +11:00
Dan Milne
21bdc21486 Switch menu order 2025-10-24 11:08:28 +11:00
7 changed files with 86 additions and 48 deletions

View File

@@ -8,7 +8,13 @@ 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. 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](https://www.authelia.com) and [Authentik](https://goauthentik.io)**, designed for simplicity and ease of deployment. Clinch sits in a sweet spot between two excellent open-source identity solutions:
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
--- ---
@@ -45,7 +51,8 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL 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. 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
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
### SMTP Integration ### SMTP Integration
Send emails for: Send emails for:

View File

@@ -1,15 +1,14 @@
module Api module Api
class ForwardAuthController < ApplicationController class ForwardAuthController < ApplicationController
# ForwardAuth endpoints don't use sessions or CSRF # ForwardAuth endpoints need session storage for return URL
allow_unauthenticated_access allow_unauthenticated_access
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
# GET /api/verify # GET /api/verify
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx) # This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
# to verify if a user is authenticated and authorized to access an application # to verify if a user is authenticated and authorized to access a domain
def verify def verify
# Get the application slug from query params or X-Forwarded-Host header # Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
app_slug = params[:app] || extract_app_from_headers
# Get the session from cookie # Get the session from cookie
session_id = extract_session_id session_id = extract_session_id
@@ -40,24 +39,28 @@ module Api
return render_unauthorized("User account is not active") return render_unauthorized("User account is not active")
end end
# If an application is specified, check authorization # Check for forward auth rule authorization
if app_slug.present? # Get the forwarded host for domain matching
application = Application.find_by(slug: app_slug, app_type: "trusted_header", active: true) forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
unless application if forwarded_host.present?
Rails.logger.warn "ForwardAuth: Application not found or not configured for trusted_header: #{app_slug}" # Find matching forward auth rule for this domain
return render_forbidden("Application not found or not configured") rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
unless rule
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
return render_forbidden("No authentication rule configured for this domain")
end end
# Check if user is allowed to access this application # Check if user is allowed by this rule
unless application.user_allowed?(user) unless rule.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{app_slug}" Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
return render_forbidden("You do not have permission to access this application") return render_forbidden("You do not have permission to access this domain")
end end
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{app_slug}" Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
else else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no app specified)" Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end end
# User is authenticated and authorized # User is authenticated and authorized
@@ -87,22 +90,8 @@ module Api
end end
def extract_app_from_headers def extract_app_from_headers
# Try to extract application slug from forwarded headers # This method is deprecated since we now use ForwardAuthRule domain matching
# This is useful when the proxy doesn't pass ?app= param # Keeping it for backward compatibility but it's no longer used
# X-Forwarded-Host might contain the hostname
host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
# Try to match hostname to application
# Format: app-slug.domain.com -> app-slug
if host.present?
# Extract subdomain as potential app slug
parts = host.split(".")
if parts.length >= 2
return parts.first if parts.first != "www"
end
end
nil nil
end end

View File

@@ -16,6 +16,11 @@ class SessionsController < ApplicationController
return return
end end
# Store the redirect URL from forward auth if present
if params[:rd].present?
session[:return_to_after_authenticating] = params[:rd]
end
# Check if user is active # Check if user is active
unless user.active? unless user.active?
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator." redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
@@ -26,13 +31,17 @@ class SessionsController < ApplicationController
if user.totp_enabled? if user.totp_enabled?
# Store user ID in session temporarily for TOTP verification # Store user ID in session temporarily for TOTP verification
session[:pending_totp_user_id] = user.id session[:pending_totp_user_id] = user.id
redirect_to totp_verification_path # Preserve the redirect URL through TOTP verification
if params[:rd].present?
session[:totp_redirect_url] = params[:rd]
end
redirect_to totp_verification_path(rd: params[:rd])
return return
end end
# Sign in successful # Sign in successful
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url, notice: "Signed in successfully." redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
end end
def verify_totp def verify_totp
@@ -57,16 +66,24 @@ class SessionsController < ApplicationController
# Try TOTP verification first # Try TOTP verification first
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved
if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url, notice: "Signed in successfully." redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
# Try backup code verification # Try backup code verification
if user.verify_backup_code(code) if user.verify_backup_code(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved
if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end
start_new_session_for user start_new_session_for user
redirect_to after_authentication_url, notice: "Signed in successfully using backup code." redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end

View File

@@ -4,6 +4,7 @@
</div> </div>
<%= form_with url: signin_path, class: "contents" do |form| %> <%= form_with url: signin_path, class: "contents" do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
<%= form.email_field :email_address, <%= form.email_field :email_address,

View File

@@ -8,6 +8,7 @@
</div> </div>
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %> <%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div> <div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %> <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag :code, <%= text_field_tag :code,

View File

@@ -0,0 +1,23 @@
<% if form.object.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc space-y-1 pl-5">
<% form.object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -57,16 +57,6 @@
<% end %> <% end %>
</li> </li>
<!-- Admin: Groups -->
<li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Groups
<% end %>
</li>
<!-- Admin: Forward Auth Rules --> <!-- Admin: Forward Auth Rules -->
<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 #{ 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 %> <%= 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 %>
@@ -76,6 +66,16 @@
Forward Auth Rules Forward Auth Rules
<% end %> <% end %>
</li> </li>
<!-- Admin: Groups -->
<li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
Groups
<% end %>
</li>
<% end %> <% end %>
<!-- Profile --> <!-- Profile -->