Compare commits
6 Commits
fc9afcd1b7
...
2025.01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
831bd083c2 | ||
|
|
1212e0f22e | ||
|
|
a21b21ace2 | ||
|
|
ad70841689 | ||
|
|
9be6ef09ff | ||
|
|
21bdc21486 |
11
README.md
11
README.md
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
Send emails for:
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
module Api
|
||||
class ForwardAuthController < ApplicationController
|
||||
# ForwardAuth endpoints don't use sessions or CSRF
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
# GET /api/verify
|
||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||
# to verify if a user is authenticated and authorized to access an application
|
||||
# to verify if a user is authenticated and authorized to access a domain
|
||||
def verify
|
||||
# Get the application slug from query params or X-Forwarded-Host header
|
||||
app_slug = params[:app] || extract_app_from_headers
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
||||
|
||||
# Get the session from cookie
|
||||
session_id = extract_session_id
|
||||
@@ -40,24 +39,28 @@ module Api
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# If an application is specified, check authorization
|
||||
if app_slug.present?
|
||||
application = Application.find_by(slug: app_slug, app_type: "trusted_header", active: true)
|
||||
# Check for forward auth rule authorization
|
||||
# Get the forwarded host for domain matching
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
unless application
|
||||
Rails.logger.warn "ForwardAuth: Application not found or not configured for trusted_header: #{app_slug}"
|
||||
return render_forbidden("Application not found or not configured")
|
||||
if forwarded_host.present?
|
||||
# Find matching forward auth rule for this domain
|
||||
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
|
||||
|
||||
# Check if user is allowed to access this application
|
||||
unless application.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{app_slug}"
|
||||
return render_forbidden("You do not have permission to access this application")
|
||||
# Check if user is allowed by this rule
|
||||
unless rule.user_allowed?(user)
|
||||
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 domain")
|
||||
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
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no app specified)"
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
end
|
||||
|
||||
# User is authenticated and authorized
|
||||
@@ -87,22 +90,8 @@ module Api
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
# Try to extract application slug from forwarded headers
|
||||
# This is useful when the proxy doesn't pass ?app= param
|
||||
|
||||
# X-Forwarded-Host might contain the hostname
|
||||
host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
# Try to match hostname to application
|
||||
# Format: app-slug.domain.com -> app-slug
|
||||
if host.present?
|
||||
# Extract subdomain as potential app slug
|
||||
parts = host.split(".")
|
||||
if parts.length >= 2
|
||||
return parts.first if parts.first != "www"
|
||||
end
|
||||
end
|
||||
|
||||
# This method is deprecated since we now use ForwardAuthRule domain matching
|
||||
# Keeping it for backward compatibility but it's no longer used
|
||||
nil
|
||||
end
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
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
|
||||
unless user.active?
|
||||
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?
|
||||
# Store user ID in session temporarily for TOTP verification
|
||||
session[:pending_totp_user_id] = user.id
|
||||
redirect_to totp_verification_path
|
||||
# Preserve the redirect URL through TOTP verification
|
||||
if params[:rd].present?
|
||||
session[:totp_redirect_url] = params[:rd]
|
||||
end
|
||||
redirect_to totp_verification_path(rd: params[:rd])
|
||||
return
|
||||
end
|
||||
|
||||
# Sign in successful
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
end
|
||||
|
||||
def verify_totp
|
||||
@@ -57,16 +66,24 @@ class SessionsController < ApplicationController
|
||||
# Try TOTP verification first
|
||||
if user.verify_totp(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Try backup code verification
|
||||
if user.verify_backup_code(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code."
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
</div>
|
||||
|
||||
<%= form_with url: signin_path, class: "contents" do |form| %>
|
||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.email_field :email_address,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
|
||||
<%= 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>
|
||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :code,
|
||||
|
||||
23
app/views/shared/_form_errors.html.erb
Normal file
23
app/views/shared/_form_errors.html.erb
Normal 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 %>
|
||||
@@ -57,16 +57,6 @@
|
||||
<% end %>
|
||||
</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 -->
|
||||
<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 %>
|
||||
@@ -76,6 +66,16 @@
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</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 %>
|
||||
|
||||
<!-- Profile -->
|
||||
|
||||
Reference in New Issue
Block a user