diff --git a/.github/workflows/pipelock.yml b/.github/workflows/pipelock.yml index deef6acd9..76210a74a 100644 --- a/.github/workflows/pipelock.yml +++ b/.github/workflows/pipelock.yml @@ -24,8 +24,10 @@ jobs: test-vectors: 'false' exclude-paths: | .env.example + app/models/provider/binance.rb compose.example.yml compose.example.ai.yml config/locales/views/reports/ docs/hosting/ai.md - app/models/provider/binance.rb + docs/hosting/docker.md + test/ diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 28758d9d9..f297a46ba 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -1,6 +1,8 @@ module Authentication extend ActiveSupport::Concern + REMOTE_HEADER_SSO_PROVIDER = "remote_user_header" + included do before_action :set_request_details before_action :authenticate_user! @@ -16,7 +18,17 @@ def skip_authentication(**options) private def authenticate_user! - if session_record = find_session_by_cookie + cookie_session = find_session_by_cookie + + if cookie_session && cookie_session_disagrees_with_header?(cookie_session) + cookie_session.destroy + cookies.delete(:session_token) + cookie_session = nil + end + + if cookie_session + Current.session = cookie_session + elsif session_record = create_session_by_remote_header Current.session = session_record else if self_hosted_first_login? @@ -27,6 +39,85 @@ def authenticate_user! end end + def cookie_session_disagrees_with_header?(session) + email = trusted_remote_user_email + email.present? && session.user.email != email + end + + def create_session_by_remote_header + return unless user_email = trusted_remote_user_email + + user, created = find_or_create_remote_header_user(user_email) + if created + SsoAuditLog.log_jit_account_created!( + user: user, + provider: REMOTE_HEADER_SSO_PROVIDER, + request: request + ) + end + SsoAuditLog.log_login!( + user: user, + provider: REMOTE_HEADER_SSO_PROVIDER, + request: request + ) + create_session_for(user) + end + + # Returns the email asserted by the upstream proxy, but only when the + # request passes all configured trust gates: self-hosted mode, header + # set, source IP in the trusted-proxies allowlist, shared-secret match + # (if configured), and email shape is valid. + def trusted_remote_user_email + return nil unless Rails.application.config.app_mode.self_hosted? + + header_name = Rails.application.config.remote_user_header_email + return nil if header_name.blank? + return nil unless remote_user_proxy_trusted? + return nil unless remote_user_secret_valid? + + email = request.headers[header_name]&.strip&.downcase + return nil if email.blank? + return nil unless URI::MailTo::EMAIL_REGEXP.match?(email) + + email + end + + def remote_user_proxy_trusted? + trusted = Rails.application.config.remote_user_trusted_proxies + peer_ip = IPAddr.new(request.env["REMOTE_ADDR"]) + trusted.any? { |range| range.include?(peer_ip) } + rescue IPAddr::Error + false + end + + def remote_user_secret_valid? + expected = Rails.application.config.remote_user_shared_secret + return true if expected.blank? + + provided = request.headers[Rails.application.config.remote_user_shared_secret_header].to_s + ActiveSupport::SecurityUtils.secure_compare(expected, provided) + end + + def find_or_create_remote_header_user(user_email) + if user = User.find_by(email: user_email) + [ user, false ] + else + # Leave password_digest nil so the user can't fall back to local + # password login or password reset; the proxy is the only path in. + user = User.new + user.email = user_email + user.skip_password_validation = true + user.family = Family.new + user.role = User.role_for_new_family_creator(fallback_role: :admin) + begin + user.save! + [ user, true ] + rescue ActiveRecord::RecordNotUnique + [ User.find_by!(email: user_email), false ] + end + end + end + def find_session_by_cookie cookie_value = cookies.signed[:session_token] diff --git a/app/models/user.rb b/app/models/user.rb index 9f7f64d80..5f372d4ae 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -169,10 +169,12 @@ def self.default_ui_layout layout.in?(%w[intro dashboard]) ? layout : "dashboard" end - # SSO-only users have OIDC identities but no local password. - # They cannot use password reset or local login. + # Users without a local password — provisioned via OIDC SSO or via a + # trusted upstream proxy header. They cannot use password reset or + # local login; the only path back in is through the same external + # auth that provisioned them. def sso_only? - password_digest.nil? && oidc_identities.exists? + password_digest.nil? end # Check if user has a local password set (can authenticate locally) diff --git a/config/application.rb b/config/application.rb index 1269f1aad..ad151c5f5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,6 +29,21 @@ class Application < Rails::Application config.app_mode = (ENV["SELF_HOSTED"] == "true" || ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry + config.remote_user_header_email = ENV["REMOTE_USER_HEADER_EMAIL"] + # Default to loopback only so a misconfigured deployment fails closed + # at first login attempt rather than silently honoring the header from + # any source. Set REMOTE_USER_TRUSTED_PROXIES to widen the allowlist. + config.remote_user_trusted_proxies = (ENV["REMOTE_USER_TRUSTED_PROXIES"].presence || "127.0.0.0/8,::1/128") + .split(",") + .map(&:strip) + .reject(&:empty?) + .filter_map { |s| IPAddr.new(s) rescue nil } + # Optional shared-secret gate: when REMOTE_USER_SHARED_SECRET is set, + # the proxy must echo it in the configured sibling header. Unset means + # no shared-secret check (the IP allowlist remains the only gate). + config.remote_user_shared_secret = ENV["REMOTE_USER_SHARED_SECRET"].presence + config.remote_user_shared_secret_header = ENV.fetch("REMOTE_USER_SHARED_SECRET_HEADER", "X-Remote-User-Secret") + # Self hosters can optionally set their own encryption keys if they want to use ActiveRecord encryption. if Rails.application.credentials.active_record_encryption.present? config.active_record.encryption = Rails.application.credentials.active_record_encryption diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md index afd462752..5a1af09ac 100644 --- a/docs/hosting/docker.md +++ b/docs/hosting/docker.md @@ -316,3 +316,33 @@ docker compose exec db psql -U sure_user -d sure_development -c "SELECT 1;" # Th ### Slow `.csv` import (processing rows taking longer than expected) Importing comma-separated-value file(s) requires the `sure-worker` container to communicate with Redis. Check your worker logs for any unexpected errors, such as connection timeouts or Redis communication failures. + +## Reverse-proxy authentication + +Sure can be configured to trust a request header set by an upstream reverse proxy and use it to log a user in automatically (passwordless). This is intended to be used in conjunction with separate authorization software running in front of Sure. Self-hosted only — managed deployments ignore the configuration. + +For more information and examples, see https://doc.traefik.io/traefik/middlewares/http/forwardauth/ or similar documentation for your HTTP proxy and authentication software. + +Configure the Sure environment with the name of the header that carries the authenticated user's email: +```txt +REMOTE_USER_HEADER_EMAIL="Remote-Email" +``` + + !! NOTE!! this allows unchallenged (passwordless) login via simple HTTP headers. Only use this method if you have a proxy in front of Sure that is applying the authentication challenge, *AND THE SURE HTTP SERVER IS NOT ACCESSIBLE DIRECTLY*. + +### Source-IP allowlist + +Sure honors the header only when the immediate peer (`REMOTE_ADDR`) is in this list. The default is loopback (`127.0.0.0/8,::1/128`) — fail-closed by design, so a misconfigured deployment surfaces a broken login at first test rather than silently default-opening. If your proxy lives on a different host or container, override it: +```txt +REMOTE_USER_TRUSTED_PROXIES="10.0.0.5,172.18.0.0/16" +``` +Setting the variable replaces the default. A set-but-empty or unparseable value resolves to an empty allowlist — every request is treated as outside it and the header is ignored. + +### Shared-secret header (optional) + +Useful when the IP allowlist alone isn't sufficient — e.g. multi-tenant Docker bridge where many containers share an IP range, or a topology where the immediate peer isn't fully trusted. When `REMOTE_USER_SHARED_SECRET` is set, the proxy must echo the same value in a sibling header on every request; mismatches and missing headers are rejected with a constant-time compare: +```txt +REMOTE_USER_SHARED_SECRET="long-random-string-from-openssl-rand-hex-32" +REMOTE_USER_SHARED_SECRET_HEADER="X-Remote-User-Secret" +``` +`REMOTE_USER_SHARED_SECRET_HEADER` defaults to `X-Remote-User-Secret` and only needs setting if your proxy can't use that name. Leave `REMOTE_USER_SHARED_SECRET` unset to disable the check (the IP allowlist remains the only gate). diff --git a/test/controllers/password_resets_controller_test.rb b/test/controllers/password_resets_controller_test.rb index 288c8a57e..70b552850 100644 --- a/test/controllers/password_resets_controller_test.rb +++ b/test/controllers/password_resets_controller_test.rb @@ -87,4 +87,42 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest sso_user.reload assert_nil sso_user.password_digest, "SSO-only user should still have nil password_digest" end + + # Security: users provisioned with no local password (e.g. via the + # remote-header proxy auth path) must not be able to acquire a + # password through reset, even when they have no OIDC identity. + test "create does not send email for password-less user without OIDC identity" do + jit_user = create_jit_user + + assert_no_enqueued_emails do + post password_reset_path, params: { email: jit_user.email } + end + + assert_redirected_to new_password_reset_url(step: "pending") + end + + test "update blocks password setting for password-less user without OIDC identity" do + jit_user = create_jit_user + token = jit_user.generate_token_for(:password_reset) + + patch password_reset_path(token: token), + params: { user: { password: "NewSecure1!", password_confirmation: "NewSecure1!" } } + + assert_redirected_to new_session_path + assert_match(/SSO/, flash[:alert]) + assert_match(/authentication/, flash[:alert]) + assert_match(/contact your administrator/, flash[:alert]) + jit_user.reload + assert_nil jit_user.password_digest, "password-less user should still have nil password_digest after update attempt" + end + + private + def create_jit_user(email: "headerjit@test.example") + User.create!( + email: email, + family: Family.create!, + role: :admin, + skip_password_validation: true + ) + end end diff --git a/test/integration/remote_user_header_authentication_test.rb b/test/integration/remote_user_header_authentication_test.rb new file mode 100644 index 000000000..ad77fd60d --- /dev/null +++ b/test/integration/remote_user_header_authentication_test.rb @@ -0,0 +1,159 @@ +require "test_helper" + +class RemoteUserHeaderAuthenticationTest < ActionDispatch::IntegrationTest + HEADER_NAME = "Remote-Email" + JIT_EMAIL = "headerjit@test.example" + + setup do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) + Rails.application.config.stubs(:remote_user_header_email).returns(HEADER_NAME) + # Use the production default (loopback) so the integration test client's + # default REMOTE_ADDR of 127.0.0.1 satisfies the IP gate. Tests that want + # to exercise the gate explicitly override this. + Rails.application.config.stubs(:remote_user_trusted_proxies) + .returns([ IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128") ]) + end + + test "feature is inert in managed mode even with config set" do + Rails.application.config.app_mode.stubs(:self_hosted?).returns(false) + + assert_no_difference -> { User.count } do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + assert_redirected_to new_session_url + end + + test "feature is opt-in: with config unset, the header is ignored" do + Rails.application.config.stubs(:remote_user_header_email).returns(nil) + + assert_no_difference -> { User.count } do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + assert_redirected_to new_session_url + end + + test "JIT user has password_digest = nil and a created family" do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + + user = User.find_by(email: JIT_EMAIL) + assert_not_nil user, "JIT user should be created" + assert_nil user.password_digest, "JIT users must not have a local password" + assert_not_nil user.family, "JIT users must have an associated family" + end + + test "JIT delegates role assignment to User.role_for_new_family_creator" do + User.expects(:role_for_new_family_creator) + .with(fallback_role: :admin) + .returns(:super_admin) + + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + + assert_equal "super_admin", User.find_by!(email: JIT_EMAIL).role + end + + test "writes SsoAuditLog: jit_account_created once, login on every header-driven session" do + assert_difference -> { SsoAuditLog.count }, 2 do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + + user = User.find_by!(email: JIT_EMAIL) + + reset! # drop cookies so the next request goes through the header path again + + assert_difference -> { SsoAuditLog.count }, 1 do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + + events = SsoAuditLog.where(user: user).order(:created_at).pluck(:event_type, :provider) + assert_equal [ + [ "jit_account_created", "remote_user_header" ], + [ "login", "remote_user_header" ], + [ "login", "remote_user_header" ] + ], events + end + + test "cookie session for a different user is invalidated when the header asserts another identity" do + user_a = users(:family_admin) + sign_in(user_a) + cookie_session = user_a.sessions.order(:created_at).last + assert_not_nil cookie_session, "sign_in should have created a session" + + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + + refute Session.exists?(id: cookie_session.id), "cookie session should be destroyed when header asserts a different user" + assert_not_nil User.find_by(email: JIT_EMAIL), "header-asserted user should be JIT'd" + end + + test "IP allowlist: request from a non-allowlisted IP is ignored" do + Rails.application.config.stubs(:remote_user_trusted_proxies) + .returns([ IPAddr.new("10.0.0.0/24") ]) + + assert_no_difference -> { User.count } do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + assert_redirected_to new_session_url + end + + test "IP allowlist: request from an allowlisted CIDR is honored" do + Rails.application.config.stubs(:remote_user_trusted_proxies) + .returns([ IPAddr.new("10.0.0.0/24") ]) + + assert_difference -> { User.count }, 1 do + get root_url, + env: { "REMOTE_ADDR" => "10.0.0.5" }, + headers: { HEADER_NAME => JIT_EMAIL } + end + end + + test "default loopback allowlist honors a request from 127.0.0.1" do + # Setup mirrors the production loopback default (127.0.0.0/8 + ::1/128) + # and the integration client connects from 127.0.0.1, so JIT proceeds. + assert_difference -> { User.count }, 1 do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + end + + test "shared secret: when configured, request without the secret header is ignored" do + Rails.application.config.stubs(:remote_user_shared_secret).returns("s3cr3t") + Rails.application.config.stubs(:remote_user_shared_secret_header).returns("X-Remote-User-Secret") + + assert_no_difference -> { User.count } do + get root_url, headers: { HEADER_NAME => JIT_EMAIL } + end + assert_redirected_to new_session_url + end + + test "shared secret: when configured, mismatched secret is ignored" do + Rails.application.config.stubs(:remote_user_shared_secret).returns("s3cr3t") + Rails.application.config.stubs(:remote_user_shared_secret_header).returns("X-Remote-User-Secret") + + assert_no_difference -> { User.count } do + get root_url, headers: { + HEADER_NAME => JIT_EMAIL, + "X-Remote-User-Secret" => "wrong" + } + end + assert_redirected_to new_session_url + end + + test "shared secret: matching secret allows the request through" do + Rails.application.config.stubs(:remote_user_shared_secret).returns("s3cr3t") + Rails.application.config.stubs(:remote_user_shared_secret_header).returns("X-Remote-User-Secret") + + assert_difference -> { User.count }, 1 do + get root_url, headers: { + HEADER_NAME => JIT_EMAIL, + "X-Remote-User-Secret" => "s3cr3t" + } + end + end + + test "malformed email value fails closed without raising" do + [ "not an email", "", " ", "@", "foo@" ].each do |bad| + assert_no_difference -> { User.count }, "header value #{bad.inspect} should not JIT" do + get root_url, headers: { HEADER_NAME => bad } + end + assert_redirected_to new_session_url + end + end +end