Compare commits
2 Commits
d480d7dd0a
...
7f075391c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f075391c1 | ||
|
|
91573ee2b9 |
3
Gemfile
3
Gemfile
@@ -28,6 +28,9 @@ gem "rotp", "~> 6.3"
|
||||
# QR code generation for TOTP setup
|
||||
gem "rqrcode", "~> 2.0"
|
||||
|
||||
# JWT for OIDC ID tokens
|
||||
gem "jwt", "~> 2.9"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -412,6 +414,7 @@ DEPENDENCIES
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jwt (~> 2.9)
|
||||
kamal
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
|
||||
130
app/controllers/api/forward_auth_controller.rb
Normal file
130
app/controllers/api/forward_auth_controller.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
module Api
|
||||
class ForwardAuthController < ApplicationController
|
||||
# ForwardAuth endpoints don't use sessions or CSRF
|
||||
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
|
||||
def verify
|
||||
# Get the application slug from query params or X-Forwarded-Host header
|
||||
app_slug = params[:app] || extract_app_from_headers
|
||||
|
||||
# Get the session from cookie
|
||||
session_id = extract_session_id
|
||||
unless session_id
|
||||
# No session cookie - user is not authenticated
|
||||
return render_unauthorized("No session cookie")
|
||||
end
|
||||
|
||||
# Find the session
|
||||
session = Session.find_by(id: session_id)
|
||||
unless session
|
||||
# Invalid session
|
||||
return render_unauthorized("Invalid session")
|
||||
end
|
||||
|
||||
# Check if session is expired
|
||||
if session.expired?
|
||||
session.destroy
|
||||
return render_unauthorized("Session expired")
|
||||
end
|
||||
|
||||
# Update last activity
|
||||
session.update_column(:last_activity_at, Time.current)
|
||||
|
||||
# Get the user
|
||||
user = session.user
|
||||
unless user.active?
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# If an application is specified, check authorization
|
||||
if app_slug.present?
|
||||
application = Application.find_by(slug: app_slug, app_type: "trusted_header", active: true)
|
||||
|
||||
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")
|
||||
end
|
||||
|
||||
# Check if user is allowed to access this application
|
||||
unless application.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{app_slug}"
|
||||
return render_forbidden("You do not have permission to access this application")
|
||||
end
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{app_slug}"
|
||||
else
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no app specified)"
|
||||
end
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers
|
||||
response.headers["Remote-User"] = user.email_address
|
||||
response.headers["Remote-Email"] = user.email_address
|
||||
response.headers["Remote-Name"] = user.email_address
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
||||
end
|
||||
|
||||
# Add admin flag
|
||||
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
||||
|
||||
# Return 200 OK with no body
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_session_id
|
||||
# Extract session ID from cookie
|
||||
# Rails uses signed cookies by default
|
||||
cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
# Try to extract application slug from forwarded headers
|
||||
# This is useful when the proxy doesn't pass ?app= param
|
||||
|
||||
# X-Forwarded-Host might contain the hostname
|
||||
host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
# Try to match hostname to application
|
||||
# Format: app-slug.domain.com -> app-slug
|
||||
if host.present?
|
||||
# Extract subdomain as potential app slug
|
||||
parts = host.split(".")
|
||||
if parts.length >= 2
|
||||
return parts.first if parts.first != "www"
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def render_unauthorized(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Return 401 Unauthorized
|
||||
# The reverse proxy should redirect to login
|
||||
head :unauthorized
|
||||
end
|
||||
|
||||
def render_forbidden(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class OidcController < ApplicationController
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
||||
skip_before_action :verify_authenticity_token, only: [:token]
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
|
||||
@@ -17,7 +17,7 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
# Check if user is active
|
||||
unless user.status == "active"
|
||||
unless user.active?
|
||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||
return
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def valid?
|
||||
def usable?
|
||||
!used? && !expired?
|
||||
end
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ class User < ApplicationRecord
|
||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||
validates :status, presence: true,
|
||||
inclusion: { in: %w[active disabled pending_invitation] }
|
||||
|
||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(status: "active") }
|
||||
scope :admins, -> { where(admin: true) }
|
||||
|
||||
# TOTP methods
|
||||
|
||||
@@ -18,6 +18,19 @@ Rails.application.routes.draw do
|
||||
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
||||
post "/totp-verification", to: "sessions#verify_totp"
|
||||
|
||||
# OIDC (OpenID Connect) routes
|
||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||
get "/oauth/authorize", to: "oidc#authorize"
|
||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||
post "/oauth/token", to: "oidc#token"
|
||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||
|
||||
# ForwardAuth / Trusted Header SSO
|
||||
namespace :api do
|
||||
get "/verify", to: "forward_auth#verify"
|
||||
end
|
||||
|
||||
# Authenticated routes
|
||||
root "dashboard#index"
|
||||
resource :profile, only: [:show, :update]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class ChangeUserStatusToInteger < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
change_column :users, :status, :integer
|
||||
end
|
||||
end
|
||||
4
db/schema.rb
generated
4
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_054039) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -108,7 +108,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do
|
||||
t.datetime "created_at", null: false
|
||||
t.string "email_address", null: false
|
||||
t.string "password_digest", null: false
|
||||
t.string "status", default: "active", null: false
|
||||
t.integer "status"
|
||||
t.boolean "totp_required", default: false, null: false
|
||||
t.string "totp_secret"
|
||||
t.datetime "updated_at", null: false
|
||||
|
||||
Reference in New Issue
Block a user