diff --git a/Gemfile b/Gemfile index 8ff21b104..70c29cd45 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gem 'rails', '7.2.2.2' gem 'active_model_serializers' gem 'activerecord-session_store' +gem 'addressable' gem 'ahoy_matey' gem 'auto_strip_attributes' gem 'bootsnap', require: false diff --git a/Gemfile.lock b/Gemfile.lock index ccc25b00d..793c350d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -825,6 +825,7 @@ PLATFORMS DEPENDENCIES active_model_serializers activerecord-session_store + addressable ahoy_matey auto_strip_attributes better_errors diff --git a/app/controllers/callbacks_controller.rb b/app/controllers/callbacks_controller.rb index 2c3916d9e..3ab71ed5b 100644 --- a/app/controllers/callbacks_controller.rb +++ b/app/controllers/callbacks_controller.rb @@ -1,5 +1,6 @@ # The controller for callback actions class CallbacksController < Devise::OmniauthCallbacksController + include SpaceRedirect Devise.omniauth_configs.each do |provider, config| define_method(provider) do @@ -11,6 +12,9 @@ class CallbacksController < Devise::OmniauthCallbacksController def handle_callback(provider, config) @user = User.from_omniauth(request.env["omniauth.auth"]) + if request.env['omniauth.params'] && request.env['omniauth.params']['space_id'] + space = Space.find_by_id(request.env['omniauth.params']['space_id']) + end if @user.new_record? # new user @@ -27,14 +31,15 @@ def handle_callback(provider, config) sign_in @user flash[:notice] = "#{I18n.t('devise.registrations.signed_up')} Please ensure your profile is correct." - redirect_to edit_user_path(@user) + redirect_to_space(edit_user_path(@user), space) rescue Exception => e flash[:notice] = "Login failed: #{e.message.to_s}" - redirect_to new_user_session_path + redirect_to_space(new_user_session_path, space) end else - sign_in_and_redirect @user + scope = Devise::Mapping.find_scope!(@user) + sign_in(scope, resource, {}) + redirect_to_space(after_sign_in_path_for(@user), space) end end - end \ No newline at end of file diff --git a/app/controllers/concerns/space_redirect.rb b/app/controllers/concerns/space_redirect.rb new file mode 100644 index 000000000..24f6783e8 --- /dev/null +++ b/app/controllers/concerns/space_redirect.rb @@ -0,0 +1,16 @@ +module SpaceRedirect + extend ActiveSupport::Concern + + private + + def redirect_to_space(path, space) + if space&.is_subdomain? + port_part = '' + port_part = ":#{request.port}" if (request.protocol == "http://" && request.port != 80) || + (request.protocol == "https://" && request.port != 443) + redirect_to URI.join("#{request.protocol}#{space.host}#{port_part}", path).to_s, allow_other_host: true + else + redirect_to path + end + end +end diff --git a/app/controllers/orcid_controller.rb b/app/controllers/orcid_controller.rb index 485a1347a..a0332dbce 100644 --- a/app/controllers/orcid_controller.rb +++ b/app/controllers/orcid_controller.rb @@ -1,4 +1,6 @@ class OrcidController < ApplicationController + include SpaceRedirect + before_action :orcid_auth_enabled before_action :authenticate_user! before_action :set_oauth_client, only: [:authenticate, :callback] @@ -9,12 +11,17 @@ class OrcidController < ApplicationController end def authenticate - redirect_to @oauth2_client.authorization_uri(scope: '/authenticate'), allow_other_host: true + params = Space.current_space&.default? ? {} : { state: "space_id:#{Space.current_space.id}" } + redirect_to @oauth2_client.authorization_uri(scope: '/authenticate', **params), allow_other_host: true end def callback @oauth2_client.authorization_code = params[:code] token = Rack::OAuth2::AccessToken::Bearer.new(access_token: @oauth2_client.access_token!) + if params[:state].present? + m = params[:state].match(/space_id:(\d+)/) + space = Space.find_by_id(m[1]) if m + end orcid = token.access_token&.raw_attributes['orcid'] respond_to do |format| profile = current_user.profile @@ -27,7 +34,7 @@ def callback else flash[:error] = t('orcid.authentication_failure') end - format.html { redirect_to current_user } + format.html { redirect_to_space(user_path(current_user), space) } end end @@ -38,7 +45,7 @@ def set_oauth_client @oauth2_client ||= Rack::OAuth2::Client.new( identifier: config[:client_id], secret: config[:secret], - redirect_uri: config[:redirect_uri].presence || orcid_callback_url, + redirect_uri: config[:redirect_uri].presence || orcid_callback_url(host: TeSS::Config.base_uri.host), authorization_endpoint: '/oauth/authorize', token_endpoint: '/oauth/token', host: config[:host].presence || (Rails.env.production? ? 'orcid.org' : 'sandbox.orcid.org') diff --git a/app/controllers/tess_devise/sessions_controller.rb b/app/controllers/tess_devise/sessions_controller.rb new file mode 100644 index 000000000..c208f33b5 --- /dev/null +++ b/app/controllers/tess_devise/sessions_controller.rb @@ -0,0 +1,35 @@ +class TessDevise::SessionsController < Devise::SessionsController + + def create + clear_legacy_cookie + + super + end + + def destroy + super + + clear_legacy_cookie + end + + private + + def clear_legacy_cookie + # Clean up legacy host-only session cookie + key = Rails.application.config.session_options[:key] + append_set_cookie("#{key}=; path=/; Max-Age=0; HttpOnly; SameSite=Lax") + end + + def append_set_cookie(value) + existing = response.headers['Set-Cookie'] + + case existing + when nil + response.headers['Set-Cookie'] = value + when String + response.headers['Set-Cookie'] = [existing, value] + when Array + existing << value + end + end +end \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9cc2a3718..79db6327a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -700,11 +700,12 @@ def theme_path end def omniauth_login_link(provider, config) + params = Space.current_space&.default? ? {} : { space_id: Space.current_space.id } link_to( t('authentication.omniauth.log_in_with', provider: config.options[:label] || t("authentication.omniauth.providers.#{provider}", default: provider.to_s.titleize)), - omniauth_authorize_path('user', provider), + omniauth_authorize_path('user', provider, **params), method: :post ) end diff --git a/app/helpers/spaces_helper.rb b/app/helpers/spaces_helper.rb index 9317ade62..4a57101f3 100644 --- a/app/helpers/spaces_helper.rb +++ b/app/helpers/spaces_helper.rb @@ -11,4 +11,8 @@ def space_feature_options [t("features.#{f}.short"), f] end end + + def space_supports_omniauth?(space = current_space) + space.nil? || space.default? || space.is_subdomain?(TeSS::Config.base_uri.domain) + end end diff --git a/app/models/space.rb b/app/models/space.rb index 45dabfaba..277c0ff49 100644 --- a/app/models/space.rb +++ b/app/models/space.rb @@ -16,6 +16,10 @@ class Space < ApplicationRecord has_many :administrator_roles, -> { where(key: :admin) }, class_name: 'SpaceRole' has_many :administrators, through: :administrator_roles, source: :user, class_name: 'User' + auto_strip_attributes :title, :description, :host + + validates :title, presence: true + validates :host, presence: true, uniqueness: true, format: /\A[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*\z/i validates :theme, inclusion: { in: TeSS::Config.themes.keys, allow_blank: true } validate :disabled_features_valid? @@ -65,6 +69,10 @@ def enabled_features (FEATURES - disabled_features) end + def is_subdomain?(domain = TeSS::Config.base_uri.domain) + (host == domain || host.ends_with?(".#{domain}")) + end + private def disabled_features_valid? diff --git a/app/views/layouts/_login_menu.html.erb b/app/views/layouts/_login_menu.html.erb index f857983a5..f216f5b2e 100644 --- a/app/views/layouts/_login_menu.html.erb +++ b/app/views/layouts/_login_menu.html.erb @@ -9,8 +9,10 @@ Log In