diff --git a/app/controllers/api/forward_auth_controller.rb b/app/controllers/api/forward_auth_controller.rb new file mode 100644 index 0000000..ae423c2 --- /dev/null +++ b/app/controllers/api/forward_auth_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 202e93b..15a426c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index cee6788..7d0484e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 1b5976c..3c8a9f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,11 @@ Rails.application.routes.draw do 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] diff --git a/db/migrate/20251023091355_change_user_status_to_integer.rb b/db/migrate/20251023091355_change_user_status_to_integer.rb new file mode 100644 index 0000000..c7595a1 --- /dev/null +++ b/db/migrate/20251023091355_change_user_status_to_integer.rb @@ -0,0 +1,5 @@ +class ChangeUserStatusToInteger < ActiveRecord::Migration[8.1] + def change + change_column :users, :status, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 3037e4d..eb95c44 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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