diff --git a/dpc-load-testing/yarn.lock b/dpc-load-testing/yarn.lock index 3d59474625..b5f39f6bdf 100644 --- a/dpc-load-testing/yarn.lock +++ b/dpc-load-testing/yarn.lock @@ -1258,9 +1258,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.4.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" - integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== fs.realpath@^1.0.0: version "1.0.0" diff --git a/dpc-portal/Gemfile b/dpc-portal/Gemfile index 1eeca4942f..fcd1105ea9 100644 --- a/dpc-portal/Gemfile +++ b/dpc-portal/Gemfile @@ -23,9 +23,6 @@ gem 'auto-session-timeout' gem 'aws-sdk-cloudwatch' gem 'bootsnap', '>= 1.4.2', require: false gem 'bundler', '>= 1.15.0' -gem 'devise', '>= 5.0.3' -gem 'devise-async' -gem 'devise-security' gem 'dotenv-rails', groups: %i[development test] gem 'fhir_models' gem 'health_check' diff --git a/dpc-portal/app/components/core/table/header_component.rb b/dpc-portal/app/components/core/table/header_component.rb index 6d5460c977..705dd1c73c 100644 --- a/dpc-portal/app/components/core/table/header_component.rb +++ b/dpc-portal/app/components/core/table/header_component.rb @@ -6,8 +6,7 @@ module Table class HeaderComponent < ViewComponent::Base Column = Struct.new( :label, - :sortable, - keyword_init: true + :sortable ) attr_reader :caption, :columns diff --git a/dpc-portal/app/components/page/invitations/ao_flow_fail_component.html.erb b/dpc-portal/app/components/page/invitations/ao_flow_fail_component.html.erb index 1376730bdc..73e9913a20 100644 --- a/dpc-portal/app/components/page/invitations/ao_flow_fail_component.html.erb +++ b/dpc-portal/app/components/page/invitations/ao_flow_fail_component.html.erb @@ -10,6 +10,6 @@

<%=raw t(key=@text, org_name: @org_name) %>

<% 'have to put statement here, as do not have route helper in ViewComponent' if @reason == :fail_to_proof %> - <%= link_to 'Go to DPC Portal', new_user_session_path, class: 'usa-button margin-bottom-3' %> + <%= link_to 'Go to DPC Portal', sign_in_path, class: 'usa-button margin-bottom-3' %> <% end %> diff --git a/dpc-portal/app/components/page/invitations/success_component.html.erb b/dpc-portal/app/components/page/invitations/success_component.html.erb index 46579faf67..a4418797a9 100644 --- a/dpc-portal/app/components/page/invitations/success_component.html.erb +++ b/dpc-portal/app/components/page/invitations/success_component.html.erb @@ -23,5 +23,5 @@ <% end %> <% end %> - <%= link_to 'Go to DPC Portal', new_user_session_path, class: 'usa-button margin-right-0' %> + <%= link_to 'Go to DPC Portal', sign_in_path, class: 'usa-button margin-right-0' %> diff --git a/dpc-portal/app/components/page/utility/error_component.html.erb b/dpc-portal/app/components/page/utility/error_component.html.erb index 13c529404b..94190c36c4 100644 --- a/dpc-portal/app/components/page/utility/error_component.html.erb +++ b/dpc-portal/app/components/page/utility/error_component.html.erb @@ -12,7 +12,7 @@ disabled: @invitation&.renewed?, destination: renew_organization_invitation_path(@invitation.provider_organization, @invitation)) %> <% when :ao_accepted %> - <%= link_to new_user_session_path, class: 'usa-button', data: { turbo: false } do %> + <%= link_to sign_in_path, class: 'usa-button', data: { turbo: false } do %> Sign in with <% end %> <% when :cd_accepted %> @@ -30,8 +30,8 @@ destination: login_dot_gov_logout_path, method: :delete) %> <% when :login_gov_signin_cancel %> - <%= link_to 'Back to portal home', new_user_session_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> + <%= link_to 'Back to portal home', sign_in_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> <% when :login_gov_signin_fail %> - <%= link_to 'Back to portal home', new_user_session_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> + <%= link_to 'Back to portal home', sign_in_path, class: 'usa-button usa-button--outline', data: { turbo: false }%> <% end %> diff --git a/dpc-portal/app/controllers/application_controller.rb b/dpc-portal/app/controllers/application_controller.rb index e47488dc32..69f2003f66 100644 --- a/dpc-portal/app/controllers/application_controller.rb +++ b/dpc-portal/app/controllers/application_controller.rb @@ -11,6 +11,26 @@ class ApplicationController < ActionController::Base auto_session_timeout User.timeout_in + def active_url + '/active' + end + + def current_user + @current_user ||= User.where(id: session['user']).first + end + + def authenticate_user! + return if current_user + + flash[:alert] = t('devise.failure.unauthenticated') + session[:user_return_to] = request.path + redirect_to sign_in_path + end + + def sign_in(user) + session['user'] = user.id + end + private def check_user_verification @@ -37,7 +57,7 @@ def url_for_login_dot_gov_logout URI::HTTPS.build(host: IDP_HOST, path: '/openid_connect/logout', query: { client_id: IDP_CLIENT_ID, - post_logout_redirect_uri: "#{root_url}users/auth/logged_out", + post_logout_redirect_uri: "#{root_url}auth/logged_out", state: }.to_query) end diff --git a/dpc-portal/app/controllers/invitations_controller.rb b/dpc-portal/app/controllers/invitations_controller.rb index 140b0f3c83..6973c85102 100644 --- a/dpc-portal/app/controllers/invitations_controller.rb +++ b/dpc-portal/app/controllers/invitations_controller.rb @@ -62,7 +62,7 @@ def register return unless create_link session.delete("invitation_status_#{@invitation.id}") - sign_in(:user, @user) + sign_in(@user) Rails.logger.info(['User logged in', { actionContext: LoggingConstants::ActionContext::Registration, actionType: LoggingConstants::ActionType::UserLoggedIn, @@ -82,7 +82,7 @@ def login path: '/openid_connect/authorize', query: { acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', client_id: IDP_CLIENT_ID, - redirect_uri: "#{my_protocol_host}/users/auth/openid_connect/callback", + redirect_uri: "#{my_protocol_host}/users/auth/login_dot_gov/callback", response_type: 'code', scope: 'openid email all_emails profile social_security_number', nonce: @nonce, @@ -179,6 +179,12 @@ def create_link status: :unprocessable_entity) false end + rescue MultiUserMatchError => e + logger.error(['User matches too many existing users', + { actionContext: LoggingConstants::ActionContext::Registration, error: e.message }]) + + render(Page::Utility::ErrorComponent.new(@invitation, 'multi_user_match')) + nil end def create_cd_org_link @@ -203,14 +209,33 @@ def create_ao_org_link def user user_info = UserInfoService.new.user_info(session) - @user = User.find_or_create_by!(provider: :openid_connect, uid: user_info['sub']) do |user_to_create| - assign_user_attributes(user_to_create, user_info) - log_create_user - end + # Unique PacIds only available in prod + @user = if @invitation.authorized_official? && (ENV['ENV'] == 'prod' || Rails.env.test?) + ao_user(user_info) + else + User.find_or_create_by(email: @invitation.invited_email) do |user_to_create| + assign_user_attributes(user_to_create, user_info) + log_create_user + end + end + IdpUid.find_or_create_by!(user: @user, provider: :login_dot_gov, uid: user_info['sub']) update_user(user_info) @user end + def ao_user(user_info) + matching_users = User.where('email = ? OR pac_id = ?', @invitation.invited_email, session[:user_pac_id]) + raise MultiUserMatchError, "too many matching users | pac_id: #{session[:user_pac_id]}" if matching_users.size > 1 + + return matching_users.first if matching_users.present? + + user = User.new + assign_user_attributes(user, user_info) + user.save! + log_create_user + user + end + def assign_user_attributes(user_to_create, user_info) user_to_create.email = @invitation.invited_email user_to_create.given_name = user_info['given_name'] @@ -357,4 +382,6 @@ def log_waivers(role_and_waivers) actionType: LoggingConstants::ActionType::AoHasWaiver, invitation: @invitation.id }]) end + + class MultiUserMatchError < StandardError; end end diff --git a/dpc-portal/app/controllers/login_dot_gov_controller.rb b/dpc-portal/app/controllers/login_dot_gov_controller.rb index 3a6c29ac37..fc3e996d10 100644 --- a/dpc-portal/app/controllers/login_dot_gov_controller.rb +++ b/dpc-portal/app/controllers/login_dot_gov_controller.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true # Handles interactions with login.gov -class LoginDotGovController < Devise::OmniauthCallbacksController +class LoginDotGovController < ApplicationController skip_before_action :verify_authenticity_token, only: :openid_connect def openid_connect auth = request.env['omniauth.auth'] - user = User.find_by(provider: auth.provider, uid: auth.uid) + user = IdpUid.find_by(provider: auth.provider, uid: auth.uid)&.user if user - sign_in(:user, user) + sign_in(user) session[:logged_in_at] = Time.now Rails.logger.info(['User logged in', { actionContext: LoggingConstants::ActionContext::Authentication, @@ -48,11 +48,6 @@ def logout redirect_to url_for_login_dot_gov_logout, allow_other_host: true end - # Return from login.gov - def logged_out - redirect_to session.delete(:user_return_to) || new_user_session_path - end - private def handle_invitation_flow_failure(invitation_id) diff --git a/dpc-portal/app/controllers/organizations_controller.rb b/dpc-portal/app/controllers/organizations_controller.rb index 03cedcf792..fb2d94f477 100644 --- a/dpc-portal/app/controllers/organizations_controller.rb +++ b/dpc-portal/app/controllers/organizations_controller.rb @@ -14,7 +14,7 @@ class OrganizationsController < ApplicationController def index @links = current_user.provider_links - ao_or_cd = @links.any? { |link| link.is_a?(AoOrgLink) } + ao_or_cd = @links.any?(AoOrgLink) render(Page::Organization::OrganizationListComponent.new(ao_or_cd:, links: @links)) end diff --git a/dpc-portal/app/controllers/users/sessions_controller.rb b/dpc-portal/app/controllers/users/sessions_controller.rb index b79ab1c66c..c82b22b26f 100644 --- a/dpc-portal/app/controllers/users/sessions_controller.rb +++ b/dpc-portal/app/controllers/users/sessions_controller.rb @@ -1,16 +1,20 @@ # frozen_string_literal: true module Users - # Adds functionality to devise session controller - class SessionsController < Devise::SessionsController + # Handles session destruction + class SessionsController < ApplicationController auto_session_timeout_actions def destroy Rails.logger.info(['User logged out', { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::UserLoggedOut }]) - sign_out(current_user) + session.delete('user') redirect_to url_for_login_dot_gov_logout, allow_other_host: true end + + def logged_out + redirect_to session.delete(:user_return_to) || sign_in_path + end end end diff --git a/dpc-portal/app/helpers/application_helper.rb b/dpc-portal/app/helpers/application_helper.rb new file mode 100644 index 0000000000..d1813bb99b --- /dev/null +++ b/dpc-portal/app/helpers/application_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Utility methods for views +module ApplicationHelper + def current_user + @current_user + end + + def omniauth_authorize_path(service) + "/portal/auth/#{service}" + end +end diff --git a/dpc-portal/app/models/idp_uid.rb b/dpc-portal/app/models/idp_uid.rb new file mode 100644 index 0000000000..e99683af5e --- /dev/null +++ b/dpc-portal/app/models/idp_uid.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Simple class for holding OIDC information linked to user +class IdpUid < ApplicationRecord + belongs_to :user +end diff --git a/dpc-portal/app/models/invitation.rb b/dpc-portal/app/models/invitation.rb index 6e48f76c4a..48f5910dd6 100644 --- a/dpc-portal/app/models/invitation.rb +++ b/dpc-portal/app/models/invitation.rb @@ -4,7 +4,7 @@ class Invitation < ApplicationRecord validates :invited_by, :invited_given_name, :invited_family_name, presence: true, if: :needs_validation? validates :invited_email, :invited_email_confirmation, presence: true, if: :new_record? - validates :invited_email, format: Devise.email_regexp, confirmation: true, if: :new_record? + validates :invited_email, format: URI::MailTo::EMAIL_REGEXP, confirmation: true, if: :new_record? validates :invitation_type, presence: true validate :cannot_cancel_accepted validate :check_if_duplicate, if: :new_record? diff --git a/dpc-portal/app/models/user.rb b/dpc-portal/app/models/user.rb index 8d1edcc031..ced291c76b 100644 --- a/dpc-portal/app/models/user.rb +++ b/dpc-portal/app/models/user.rb @@ -2,12 +2,6 @@ # Base user class class User < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, and :trackable - devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, - :timeoutable, :omniauthable, omniauth_providers: [:openid_connect] - audited only: %i[verification_reason verification_status], on: :update validates :verification_reason, allow_nil: true, allow_blank: true, @@ -21,10 +15,16 @@ class User < ApplicationRecord enum :verification_reason, %i[ao_med_sanction_waived ao_med_sanctions] enum :verification_status, %i[approved rejected] - before_validation(on: :create) do - # Assign random, acceptable password to keep Devise happy. - # User should log in only through IdP - self.password = Devise.friendly_token[0, 20] unless password.present? + def self.remember_for + 12.hours + end + + def self.timeout_in + 30.minutes + end + + def timeout_in + self.class.timeout_in end def provider_links diff --git a/dpc-portal/app/views/layouts/application.html.erb b/dpc-portal/app/views/layouts/application.html.erb index edaa440f11..abd97bcccb 100644 --- a/dpc-portal/app/views/layouts/application.html.erb +++ b/dpc-portal/app/views/layouts/application.html.erb @@ -26,7 +26,7 @@ - <% if user_signed_in? %> + <% if !!current_user %> <%= auto_session_timeout_js %> <% end %> diff --git a/dpc-portal/app/views/login_dot_gov/failure.html.erb b/dpc-portal/app/views/login_dot_gov/failure.html.erb index d32a17cbc9..e93aed0ef5 100644 --- a/dpc-portal/app/views/login_dot_gov/failure.html.erb +++ b/dpc-portal/app/views/login_dot_gov/failure.html.erb @@ -1 +1 @@ -

<%= @message %> <%= link_to 'Try again?', new_user_session_path %>

+

<%= @message %> <%= link_to 'Try again?', sign_in_path %>

diff --git a/dpc-portal/app/views/login_dot_gov/openid_connect.html.erb b/dpc-portal/app/views/login_dot_gov/openid_connect.html.erb index 60742beaaa..5cf6cb6485 100644 --- a/dpc-portal/app/views/login_dot_gov/openid_connect.html.erb +++ b/dpc-portal/app/views/login_dot_gov/openid_connect.html.erb @@ -1,2 +1,2 @@

You logged in, <%= current_user.given_name %> <%= current_user.family_name %>!

-

<%= link_to 'Try again?', new_user_session_path %>

+

<%= link_to 'Try again?', sign_in_path %>

diff --git a/dpc-portal/app/views/devise/sessions/new.html.erb b/dpc-portal/app/views/users/sessions/new.html.erb similarity index 65% rename from dpc-portal/app/views/devise/sessions/new.html.erb rename to dpc-portal/app/views/users/sessions/new.html.erb index 85a247e2b0..3949df9114 100644 --- a/dpc-portal/app/views/devise/sessions/new.html.erb +++ b/dpc-portal/app/views/users/sessions/new.html.erb @@ -1 +1 @@ -<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(:user, :openid_connect))) %> +<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(:login_dot_gov))) %> diff --git a/dpc-portal/config/environments/test.rb b/dpc-portal/config/environments/test.rb index 0d31929fb0..fa685743a9 100644 --- a/dpc-portal/config/environments/test.rb +++ b/dpc-portal/config/environments/test.rb @@ -8,6 +8,7 @@ # and recreated between test runs. Don't rely on the data there! Rails.application.configure do + config.colorize_logging = false # Settings specified here will take precedence over those in config/application.rb. # Turn false under Spring and add config.action_view.cache_template_loading = true. diff --git a/dpc-portal/config/initializers/devise.rb b/dpc-portal/config/initializers/devise.rb deleted file mode 100644 index 73227be795..0000000000 --- a/dpc-portal/config/initializers/devise.rb +++ /dev/null @@ -1,342 +0,0 @@ -# frozen_string_literal: true - -# Assuming you have not yet modified this file, each configuration option below -# is set to its default value. Note that some are commented out while others -# are not: uncommented lines are intended to protect your configuration from -# breaking changes in upgrades (i.e., in the event that future versions of -# Devise change the default values for those options). -# -# Use this hook to configure devise mailer, warden hooks and so forth. -# Many of these configuration options can be set straight in your model. - -require "dpc_portal_utils" - -Devise.setup do |config| - include DpcPortalUtils - begin - private_key = OpenSSL::PKey::RSA.new(ENV['LOGIN_GOV_PRIVATE_KEY']) - rescue TypeError, OpenSSL::PKey::RSAError => e - Rails.logger.error("Unable to create private key for omniauth: #{e}") - private_key = OpenSSL::PKey::RSA.new(1024) - end - idp_host = ENV.fetch('IDP_HOST', 'idp.int.identitysandbox.gov') - config.omniauth :openid_connect, { - name: :openid_connect, - issuer: "https://#{idp_host}/", - discovery: true, - scope: %i[openid email all_emails], - response_type: :code, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', - client_auth_method: :jwt_bearer, - client_options: { - port: 443, - scheme: 'https', - host: idp_host, - identifier: "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV['ENV']}", - private_key: private_key, - redirect_uri: "#{my_protocol_host}/users/auth/openid_connect/callback" - } - } - # The secret key used by Devise. Devise uses this key to generate - # random tokens. Changing this key will render invalid all existing - # confirmation, reset password and unlock tokens in the database. - # Devise will use the `secret_key_base` as its `secret_key` - # by default. You can change it below and use your own secret key. - # config.secret_key = '7c6c088e32429add964776c6270e102769c71e4be2817e0e198857372f78142dfde431b4c94fdda416eaf8b5b958710f5f7fbb9cec18944159d76ec1b3b23d80' - config.secret_key = Rails.application.secret_key_base - - # ==> Controller configuration - # Configure the parent class to the devise controllers. - # config.parent_controller = 'DeviseController' - - # ==> Mailer Configuration - # Configure the e-mail address which will be shown in Devise::Mailer, - # note that it will be overwritten if you use your own mailer class - # with default "from" parameter. - config.mailer_sender = 'dpcinfo@cms.hhs.gov' - - # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' - - # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' - - # ==> ORM configuration - # Load and configure the ORM. Supports :active_record (default) and - # :mongoid (bson_ext recommended) by default. Other ORMs may be - # available as additional gems. - require 'devise/orm/active_record' - - # ==> Configuration for any authentication mechanism - # Configure which keys are used when authenticating a user. The default is - # just :email. You can configure it to use [:username, :subdomain], so for - # authenticating a user, both parameters are required. Remember that those - # parameters are used only when authenticating and not when retrieving from - # session. If you need permissions, you should implement that in a before filter. - # You can also supply a hash where the value is a boolean determining whether - # or not authentication should be aborted when the value is not present. - # config.authentication_keys = [:email] - - # Configure parameters from the request object used for authentication. Each entry - # given should be a request method and it will automatically be passed to the - # find_for_authentication method and considered in your model lookup. For instance, - # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. - # The same considerations mentioned for authentication_keys also apply to request_keys. - # config.request_keys = [] - - # Configure which authentication keys should be case-insensitive. - # These keys will be downcased upon creating or modifying a user and when used - # to authenticate or find a user. Default is :email. - config.case_insensitive_keys = [:email] - - # Configure which authentication keys should have whitespace stripped. - # These keys will have whitespace before and after removed upon creating or - # modifying a user and when used to authenticate or find a user. Default is :email. - config.strip_whitespace_keys = [:email] - - # Tell if authentication through request.params is enabled. True by default. - # It can be set to an array that will enable params authentication only for the - # given strategies, for example, `config.params_authenticatable = [:database]` will - # enable it only for database (email + password) authentication. - # config.params_authenticatable = true - - # Tell if authentication through HTTP Auth is enabled. False by default. - # It can be set to an array that will enable http authentication only for the - # given strategies, for example, `config.http_authenticatable = [:database]` will - # enable it only for database authentication. - # For API-only applications to support authentication "out-of-the-box", you will likely want to - # enable this with :database unless you are using a custom strategy. - # The supported strategies are: - # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false - - # If 401 status code should be returned for AJAX requests. True by default. - # config.http_authenticatable_on_xhr = true - - # The realm used in Http Basic Authentication. 'Application' by default. - # config.http_authentication_realm = 'Application' - - # It will change confirmation, password recovery and other workflows - # to behave the same regardless if the e-mail provided was right or wrong. - # Does not affect registerable. - # config.paranoid = true - - # By default Devise will store the user in session. You can skip storage for - # particular strategies by setting this option. - # Notice that if you are skipping storage for all authentication paths, you - # may want to disable generating routes to Devise's sessions controller by - # passing skip: :sessions to `devise_for` in your config/routes.rb - config.skip_session_storage = [:http_auth] - - # By default, Devise cleans up the CSRF token on authentication to - # avoid CSRF token fixation attacks. This means that, when using AJAX - # requests for sign in and sign up, you need to get a new CSRF token - # from the server. You can disable this option at your own risk. - # config.clean_up_csrf_token_on_authentication = true - - # When false, Devise will not attempt to reload routes on eager load. - # This can reduce the time taken to boot the app but if your application - # requires the Devise mappings to be loaded during boot time the application - # won't boot properly. - # config.reload_routes = true - - # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 12. If - # using other algorithms, it sets how many times you want the password to be hashed. - # The number of stretches used for generating the hashed password are stored - # with the hashed password. This allows you to change the stretches without - # invalidating existing passwords. - # - # Limiting the stretches to just one in testing will increase the performance of - # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use - # a value less than 10 in other environments. Note that, for bcrypt (the default - # algorithm), the cost increases exponentially with the number of stretches (e.g. - # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). - config.stretches = Rails.env.test? ? 1 : 12 - - # Set up a pepper to generate the hashed password. - # config.pepper = '7ce8080788d624d2ae1b2a8a5487f6cf9231aea379e6c8bd8c83c8e25f7cd268f72429a8170edcd324fab1af5aa3a5215faaa7f407e1db155408e6e7fe88dc0e' - - # Send a notification to the original email when the user's email is changed. - # config.send_email_changed_notification = false - - # Send a notification email when the user's password is changed. - # config.send_password_change_notification = false - - # ==> Configuration for :confirmable - # A period that the user is allowed to access the website even without - # confirming their account. For instance, if set to 2.days, the user will be - # able to access the website for two days without confirming their account, - # access will be blocked just in the third day. - # You can also set it to nil, which will allow the user to access the website - # without confirming their account. - # Default is 0.days, meaning the user cannot access the website without - # confirming their account. - # config.allow_unconfirmed_access_for = 2.days - - # A period that the user is allowed to confirm their account before their - # token becomes invalid. For example, if set to 3.days, the user can confirm - # their account within 3 days after the mail was sent, but on the fourth day - # their account can't be confirmed with the token any more. - # Default is nil, meaning there is no restriction on how long a user can take - # before confirming their account. - # config.confirm_within = 3.days - - # If true, requires any email changes to be confirmed (exactly the same way as - # initial account confirmation) to be applied. Requires additional unconfirmed_email - # db field (see migrations). Until confirmed, new email is stored in - # unconfirmed_email column, and copied to email column on successful confirmation. - config.reconfirmable = true - - # Defines which key will be used when confirming an account - # config.confirmation_keys = [:email] - - # ==> Configuration for :rememberable - # The time the user will be remembered without asking for credentials again. - config.remember_for = 12.hours - - # Invalidates all the remember me tokens when the user signs out. - config.expire_all_remember_me_on_sign_out = true - - # If true, extends the user's remember period when remembered via cookie. - # config.extend_remember_period = false - - # Options to be passed to the created cookie. For instance, you can set - # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} - - # ==> Configuration for :validatable - # Range for password length. - config.password_length = 6..128 - - # Email regex used to validate email formats. It simply asserts that - # one (and only one) @ exists in the given string. This is mainly - # to give user feedback and not to assert the e-mail validity. - config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ - - # ==> Configuration for :timeoutable - # The time you want to timeout the user session without activity. After this - # time the user will be asked for credentials again. Default is 30 minutes. - config.timeout_in = 30.minutes - - # ==> Configuration for :lockable - # Defines which strategy will be used to lock an account. - # :failed_attempts = Locks an account after a number of failed attempts to sign in. - # :none = No lock strategy. You should handle locking by yourself. - # config.lock_strategy = :failed_attempts - - # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [:email] - - # Defines which strategy will be used to unlock an account. - # :email = Sends an unlock link to the user email - # :time = Re-enables login after a certain amount of time (see :unlock_in below) - # :both = Enables both strategies - # :none = No unlock strategy. You should handle unlocking by yourself. - # config.unlock_strategy = :both - - # Number of authentication tries before locking an account if lock_strategy - # is failed attempts. - # config.maximum_attempts = 20 - - # Time interval to unlock the account if :time is enabled as unlock_strategy. - # config.unlock_in = 1.hour - - # Warn on the last attempt before the account is locked. - # config.last_attempt_warning = true - - # ==> Configuration for :recoverable - # - # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [:email] - - # Time interval you can reset your password with a reset password key. - # Don't put a too small interval or your users won't have the time to - # change their passwords. - config.reset_password_within = 6.hours - - # When set to false, does not sign a user in automatically after their password is - # reset. Defaults to true, so a user is signed in automatically after a reset. - # config.sign_in_after_reset_password = true - - # ==> Configuration for :encryptable - # Allow you to use another hashing or encryption algorithm besides bcrypt (default). - # You can use :sha1, :sha512 or algorithms from others authentication tools as - # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 - # for default behavior) and :restful_authentication_sha1 (then you should set - # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). - # - # Require the `devise-encryptable` gem when using anything other than bcrypt - # config.encryptor = :sha512 - - # ==> Scopes configuration - # Turn scoped views on. Before rendering "sessions/new", it will first check for - # "users/sessions/new". It's turned off by default because it's slower if you - # are using only default views. - # config.scoped_views = false - - # Configure the default scope given to Warden. By default it's the first - # devise role declared in your routes (usually :user). - # config.default_scope = :user - - # Set this configuration to false if you want /users/sign_out to sign out - # only the current scope. By default, Devise signs out all scopes. - # config.sign_out_all_scopes = true - - # ==> Navigation configuration - # Lists the formats that should be treated as navigational. Formats like - # :html should redirect to the sign in page when the user does not have - # access, but formats like :xml or :json, should return 401. - # - # If you have any extra navigational formats, like :iphone or :mobile, you - # should add them to the navigational formats lists. - # - # The "*/*" below is required to match Internet Explorer requests. - config.navigational_formats = ['*/*', :html, :turbo_stream] - - # The default HTTP method used to sign out a resource. Default is :delete. - config.sign_out_via = :delete - - # ==> OmniAuth - # Add a new OmniAuth provider. Check the wiki for more information on setting - # up on your models and hooks. - # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' - - # ==> Warden configuration - # If you want to use other strategies, that are not supported by Devise, or - # change the failure app, you can configure them inside the config.warden block. - # - # config.warden do |manager| - # manager.intercept_401 = false - # manager.default_strategies(scope: :user).unshift :some_external_strategy - # end - - # ==> Mountable engine configurations - # When using Devise inside an engine, let's call it `MyEngine`, and this engine - # is mountable, there are some extra configurations to be taken into account. - # The following options are available, assuming the engine is mounted as: - # - # mount MyEngine, at: '/my_engine' - # - # The router that invoked `devise_for`, in the example above, would be: - # config.router_name = :my_engine - # - # When using OmniAuth, Devise cannot automatically set OmniAuth path, - # so you need to do it manually. For the users scope, it would be: - # config.omniauth_path_prefix = '/my_engine/users/auth' - - # ==> Hotwire/Turbo configuration - # When using Devise with Hotwire/Turbo, the http status for error responses - # and some redirects must match the following. The default in Devise for existing - # apps is `200 OK` and `302 Found respectively`, but new apps are generated with - # these new defaults that match Hotwire/Turbo behavior. - # Note: These might become the new default in future versions of Devise. - config.responder.error_status = :unprocessable_entity - config.responder.redirect_status = :see_other - - # ==> Configuration for :registerable - - # When set to false, does not sign a user in automatically after their password is - # changed. Defaults to true, so a user is signed in automatically after changing a password. - # config.sign_in_after_change_password = true -end diff --git a/dpc-portal/config/initializers/omniauth.rb b/dpc-portal/config/initializers/omniauth.rb new file mode 100644 index 0000000000..51b0beb5aa --- /dev/null +++ b/dpc-portal/config/initializers/omniauth.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Configure omniauth providers + +require "dpc_portal_utils" + +include DpcPortalUtils + +Rails.application.config.middleware.use OmniAuth::Builder do + OmniAuth.config.logger = Rails.logger + begin + private_key = OpenSSL::PKey::RSA.new(ENV['LOGIN_GOV_PRIVATE_KEY']) + rescue TypeError, OpenSSL::PKey::RSAError => e + Rails.logger.error("Unable to create private key for omniauth: #{e}") + private_key = OpenSSL::PKey::RSA.new(1024) + end + idp_host = ENV.fetch('IDP_HOST', 'idp.int.identitysandbox.gov') + provider :openid_connect, { + name: :login_dot_gov, + issuer: "https://#{idp_host}/", + discovery: true, + scope: %i[openid email all_emails], + response_type: :code, + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + client_auth_method: :jwt_bearer, + client_options: { + port: 443, + scheme: 'https', + host: idp_host, + identifier: "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV['ENV']}", + private_key: private_key, + redirect_uri: "#{my_protocol_host}/portal/auth/login_dot_gov/callback" + } + } +end diff --git a/dpc-portal/config/locales/en.yml b/dpc-portal/config/locales/en.yml index 76fba17573..1ecd679ff3 100644 --- a/dpc-portal/config/locales/en.yml +++ b/dpc-portal/config/locales/en.yml @@ -38,6 +38,8 @@ en: missing_info_text: Something happened on our end and we're unable to continue. Please contact dpcinfo@cms.hhs.gov. server_error_status: "Registration unavailable: external system error." server_error_text: We're unable to complete your request right now because a required external system is unavailable. Please try again later. + multi_user_match_status: multi_user_match_status + multi_user_match_text: multi_user_match_text manage_org: Manage your organization. tos_not_signed: You must sign DPC Terms of Service. sign_tos: Sign Terms of Service diff --git a/dpc-portal/config/routes.rb b/dpc-portal/config/routes.rb index fe97cbd87d..996dc19bc0 100644 --- a/dpc-portal/config/routes.rb +++ b/dpc-portal/config/routes.rb @@ -5,15 +5,16 @@ # and config.ru via config.relative_url_root. # Rails.application.routes.draw do - devise_for :users, controllers: { sessions: 'users/sessions', omniauth_callbacks: 'login_dot_gov' } - devise_scope :user do - get '/users/auth/failure', to: 'login_dot_gov#failure', as: 'login_dot_gov_failure' - get '/users/auth/logged_out', to: 'login_dot_gov#logged_out' - get '/users/auth/no_account', to: 'login_dot_gov#no_account', as: 'no_account' - delete '/logout', to: 'login_dot_gov#logout', as: 'login_dot_gov_logout' - get 'active', to: 'users/sessions#active' - get 'timeout', to: 'users/sessions#timeout' - end + # Former devise routes + get '/users/auth/failure', to: 'login_dot_gov#failure', as: 'login_dot_gov_failure' + get '/auth/logged_out', to: 'users/sessions#logged_out' + get '/auth/no_account', to: 'login_dot_gov#no_account', as: 'no_account' + delete '/logout', to: 'login_dot_gov#logout', as: 'login_dot_gov_logout' + get 'active', to: 'users/sessions#active', as: 'active' + get 'timeout', to: 'users/sessions#timeout', as: 'timeout' + get '/users/sign_in', to: 'users/sessions#new', as: 'sign_in' + delete '/users/sign_out', to: 'users/sessions#destroy', as: 'destroy_user_session' + get '/auth/login_dot_gov/callback', to: 'login_dot_gov#openid_connect' # Defines the root path route ("/") root 'organizations#index' diff --git a/dpc-portal/db/migrate/20260129150757_create_idp_uids.rb b/dpc-portal/db/migrate/20260129150757_create_idp_uids.rb new file mode 100644 index 0000000000..3ee54a45fe --- /dev/null +++ b/dpc-portal/db/migrate/20260129150757_create_idp_uids.rb @@ -0,0 +1,18 @@ +class CreateIdpUids < ActiveRecord::Migration[8.0] + def up + create_table :idp_uids do |t| + t.string :provider + t.string :uid + t.integer :user_id + + t.timestamps + end + User.all.each do |user| + IdpUid.create!(user:, uid: user.uid, provider: user.provider) + end + add_index(:idp_uids, %i[provider uid]) + end + def down + drop_table :idp_uids + end +end diff --git a/dpc-portal/db/schema.rb b/dpc-portal/db/schema.rb index 26caccf043..f231474001 100644 --- a/dpc-portal/db/schema.rb +++ b/dpc-portal/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_19_165916) do +ActiveRecord::Schema[8.0].define(version: 2026_01_29_150757) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -69,6 +69,15 @@ t.index ["user_id"], name: "index_credential_audit_logs_on_user_id" end + create_table "idp_uids", force: :cascade do |t| + t.string "provider" + t.string "uid" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["provider", "uid"], name: "index_idp_uids_on_provider_and_uid" + end + create_table "invitations", force: :cascade do |t| t.bigint "provider_organization_id", null: false t.bigint "invited_by_id" diff --git a/dpc-portal/spec/factories/idp_uids.rb b/dpc-portal/spec/factories/idp_uids.rb new file mode 100644 index 0000000000..02d236648f --- /dev/null +++ b/dpc-portal/spec/factories/idp_uids.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :idp_uid do + provider { 'MyString' } + uid { 'MyString' } + end +end diff --git a/dpc-portal/spec/factories/users.rb b/dpc-portal/spec/factories/users.rb index 50d36cd18e..cc09470447 100644 --- a/dpc-portal/spec/factories/users.rb +++ b/dpc-portal/spec/factories/users.rb @@ -3,6 +3,7 @@ FactoryBot.define do factory :user, aliases: %i[invited_by] do sequence(:uid) { |n| n } + provider { :login_dot_gov } email { "user#{rand(0..100_000)}@example.com" } end end diff --git a/dpc-portal/spec/helpers/application_helper_spec.rb b/dpc-portal/spec/helpers/application_helper_spec.rb new file mode 100644 index 0000000000..a9cb10be3c --- /dev/null +++ b/dpc-portal/spec/helpers/application_helper_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApplicationHelper, type: :helper do + describe 'current_user' do + let(:user) { create(:user) } + it 'should return nil if not set' do + expect(helper.current_user).to be_nil + end + it 'should return if set' do + assign(:current_user, user) + expect(helper.current_user).to eq user + end + end + describe 'omniauth_authorize_path' do + it 'should return path to service' do + expect(omniauth_authorize_path(:foo)).to eq '/portal/auth/foo' + end + end +end diff --git a/dpc-portal/spec/integration/client_tokens_spec.rb b/dpc-portal/spec/integration/client_tokens_spec.rb index 94a40ea062..d9675b2be2 100644 --- a/dpc-portal/spec/integration/client_tokens_spec.rb +++ b/dpc-portal/spec/integration/client_tokens_spec.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'ClientTokens', type: :request do + include LoginSupport + before(:example) { WebMock.disable_net_connect!(allow_localhost: true, allow: ['api']) } after(:example) { WebMock.disable_net_connect!(allow_localhost: true) } diff --git a/dpc-portal/spec/integration/ip_addresses_spec.rb b/dpc-portal/spec/integration/ip_addresses_spec.rb index 6b5a40fcb3..76e1fc7a8e 100644 --- a/dpc-portal/spec/integration/ip_addresses_spec.rb +++ b/dpc-portal/spec/integration/ip_addresses_spec.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'IpAddresses', type: :request do + include LoginSupport + before(:example) { WebMock.disable_net_connect!(allow_localhost: true, allow: ['api']) } after(:example) { WebMock.disable_net_connect!(allow_localhost: true) } diff --git a/dpc-portal/spec/integration/public_keys_spec.rb b/dpc-portal/spec/integration/public_keys_spec.rb index 96e6c2067c..1736cd23e8 100644 --- a/dpc-portal/spec/integration/public_keys_spec.rb +++ b/dpc-portal/spec/integration/public_keys_spec.rb @@ -3,8 +3,11 @@ require 'base64' require 'openssl' require 'rails_helper' +require 'support/login_support' RSpec.describe 'PublicKeys', type: :request do + include LoginSupport + before(:example) { WebMock.disable_net_connect!(allow_localhost: true, allow: ['api']) } after(:example) { WebMock.disable_net_connect!(allow_localhost: true) } diff --git a/dpc-portal/spec/models/idp_uid_spec.rb b/dpc-portal/spec/models/idp_uid_spec.rb new file mode 100644 index 0000000000..38a9ed2048 --- /dev/null +++ b/dpc-portal/spec/models/idp_uid_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe IdpUid, type: :model do + it 'has a user' do + user = create(:user) + idp_uid = create(:idp_uid, user_id: user.id) + expect(idp_uid.user).to eq user + end +end diff --git a/dpc-portal/spec/rails_helper.rb b/dpc-portal/spec/rails_helper.rb index 37f790bfb7..4796e09ff7 100644 --- a/dpc-portal/spec/rails_helper.rb +++ b/dpc-portal/spec/rails_helper.rb @@ -38,9 +38,6 @@ RSpec.configure do |config| config.include FactoryBot::Syntax::Methods - # Devise test helpers - config.include Devise::Test::IntegrationHelpers, type: :request - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_paths = ["#{Rails.root}/spec/fixtures"] diff --git a/dpc-portal/spec/requests/application_spec.rb b/dpc-portal/spec/requests/application_spec.rb index c68c3b7e35..e5a83c3537 100644 --- a/dpc-portal/spec/requests/application_spec.rb +++ b/dpc-portal/spec/requests/application_spec.rb @@ -1,37 +1,33 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'Application', type: :request do - before(:all) do - Rails.application.routes.disable_clear_and_finalize = true - Rails.application.routes.draw do - match '/test', to: 'test#index', via: :get - end - end + include LoginSupport let!(:user) { create(:user) } before { sign_in user } it 'sets cache control to no-store' do - get '/test' - expect(response.body).to eq('foo') + get '/' + expect(response).to be_ok expect(response.headers['cache-control']).to eq 'no-store' end it 'logs user_id to new relic' do expect(NewRelic::Agent).to receive(:add_custom_attributes).with({ user_id: user.id }) - get '/test' - expect(response.body).to eq('foo') + get '/' + expect(response).to be_ok end describe 'timed out' do after { Timecop.return } it 'redirects to login after inactivity' do - get '/test' - expect(response.body).to eq('foo') + get '/' + expect(response).to be_ok Timecop.travel(31.minutes.from_now) - get '/test' + get '/' expect(response).to redirect_to('/users/sign_in') expect(flash[:notice] = 'Your session expired. Please sign in again to continue.') end @@ -42,23 +38,16 @@ { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::SessionTimedOut }]) logged_in_at = Time.now - get '/test' - expect(response.body).to eq('foo') + get '/' + expect(response).to be_ok until Time.now > logged_in_at + 12.hours - get '/test' - expect(response.body).to eq('foo') + get '/' + expect(response).to be_ok Timecop.travel(29.minutes.from_now) end - get '/test' + get '/' expect(response).to redirect_to('/users/sign_in') expect(flash[:notice] = 'You have exceeded the maximum session length. Please sign in again to continue.') end end end - -class TestController < ApplicationController - before_action :authenticate_user! - def index - render plain: 'foo' - end -end diff --git a/dpc-portal/spec/requests/client_tokens_spec.rb b/dpc-portal/spec/requests/client_tokens_spec.rb index b96d2eba95..0109ad44ab 100644 --- a/dpc-portal/spec/requests/client_tokens_spec.rb +++ b/dpc-portal/spec/requests/client_tokens_spec.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' require 'support/credential_resource_shared_examples' RSpec.describe 'ClientTokens', type: :request do include DpcClientSupport + include LoginSupport let(:terms_of_service_accepted_by) { create(:user) } diff --git a/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb b/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb index ef44f52e79..948116a42e 100644 --- a/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb +++ b/dpc-portal/spec/requests/credential_delegate_invitations_spec.rb @@ -4,6 +4,7 @@ RSpec.describe 'CredentialDelegateInvitations', type: :request do include DpcClientSupport + include LoginSupport describe 'GET /new' do context 'not logged in' do diff --git a/dpc-portal/spec/requests/invitations_spec.rb b/dpc-portal/spec/requests/invitations_spec.rb index 2ccc0c7b19..628c23028a 100644 --- a/dpc-portal/spec/requests/invitations_spec.rb +++ b/dpc-portal/spec/requests/invitations_spec.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'Invitations', type: :request do + include LoginSupport + RSpec.shared_examples 'an invitation endpoint' do |method, path_suffix, type| let(:org) { invitation.provider_organization } let(:bad_org) { create(:provider_organization) } @@ -630,14 +633,14 @@ post "/organizations/#{org.id}/invitations/#{invitation.id}/register" end it 'should not create user if exists' do - create(:user, provider: :openid_connect, uid: user_info_template['sub']) + create(:user, pac_id: user_info_template['social_security_number'], email: 'bob@testy.com') expect do post "/organizations/#{org.id}/invitations/#{invitation.id}/register" end.to change { User.count }.by 0 end it 'should update name of user if changed' do - user = create(:user, provider: :openid_connect, uid: user_info_template['sub'], given_name: :foo, - family_name: :bar) + user = create(:user, pac_id: user_info_template['social_security_number'], + email: 'bob@testy.com', given_name: :foo, family_name: :bar) expect do post "/organizations/#{org.id}/invitations/#{invitation.id}/register" end.to change { User.count }.by 0 @@ -646,9 +649,10 @@ expect(user.family_name).to eq user_info_template['family_name'] end it 'should not override pac_id on existing user' do - create(:user, provider: :openid_connect, uid: user_info_template['sub'], pac_id: :foo) + create(:user, email: user_info_template['email'], pac_id: :foo) post "/organizations/#{org.id}/invitations/#{invitation.id}/register" - user = User.find_by(uid: user_info_template['sub']) + expect(response).to be_ok + user = User.find_by(email: user_info_template['email']) # We have the fake CPI API Gateway return the ssn as pac_id expect(user.pac_id).to eq 'foo' end @@ -705,9 +709,9 @@ get "/organizations/#{org.id}/invitations/#{invitation.id}/confirm_cd" end it 'should not save verification_status on user and org' do - create(:user, provider: :openid_connect, uid: user_info_template['sub'], pac_id: :foo) + create(:user, email: user_info_template['email']) post "/organizations/#{org.id}/invitations/#{invitation.id}/register" - user = User.find_by(uid: user_info_template['sub']) + user = User.find_by(email: user_info_template['email']) expect(user.verification_status).to be_nil expect(org.reload.verification_status).to be_nil end @@ -730,26 +734,69 @@ post "/organizations/#{org.id}/invitations/#{invitation.id}/confirm" end it 'should set pac_id on new user' do - post "/organizations/#{org.id}/invitations/#{invitation.id}/register" - user = User.find_by(uid: user_info_template['sub']) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { User.count }.by(1) + user = User.find_by(pac_id: user_info_template['social_security_number']) # We have the fake CPI API Gateway return the ssn as pac_id expect(user.pac_id).to eq user_info_template['social_security_number'] expect(request.session[:user_pac_id]).to be_nil end it 'should set pac_id on existing user' do - create(:user, provider: :openid_connect, uid: user_info_template['sub']) - post "/organizations/#{org.id}/invitations/#{invitation.id}/register" - user = User.find_by(uid: user_info_template['sub']) + create(:user, email: user_info_template['email']) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { User.count }.by 0 + user = User.find_by(email: user_info_template['email']) # We have the fake CPI API Gateway return the ssn as pac_id expect(user.pac_id).to eq user_info_template['social_security_number'] expect(request.session[:user_pac_id]).to be_nil end + it 'should add credential if user with pac id exists' do + user = create(:user, pac_id: user_info_template['social_security_number']) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { IdpUid.where(user:).count }.by 1 + end + it 'should add credential if user with pac id exists and non-matching credential exists' do + user = create(:user, pac_id: user_info_template['social_security_number']) + create(:idp_uid, user:, uid: user_info_template['sub'], provider: :other_idp) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { IdpUid.where(user:).count }.by 1 + end + it 'should not add credential if credential exists match on pac_id' do + user = create(:user, pac_id: user_info_template['social_security_number']) + create(:idp_uid, user:, uid: user_info_template['sub'], provider: :login_dot_gov) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { IdpUid.count }.by 0 + end + it 'should add credential if user with email exists' do + user = create(:user, email: user_info_template['email']) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { IdpUid.where(user:).count }.by 1 + end + it 'should not add credential if credential exists match on email' do + user = create(:user, email: user_info_template['email']) + create(:idp_uid, user:, uid: user_info_template['sub'], provider: :login_dot_gov) + expect do + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + end.to change { IdpUid.count }.by 0 + end it 'should save verification_status on user and org' do post "/organizations/#{org.id}/invitations/#{invitation.id}/register" - user = User.find_by(uid: user_info_template['sub']) + user = User.find_by(pac_id: user_info_template['social_security_number']) expect(user.verification_status).to eq('approved') expect(org.reload.verification_status).to eq('approved') end + it 'should fail if too many matches' do + create(:user, email: user_info_template['email']) + create(:user, pac_id: user_info_template['social_security_number']) + post "/organizations/#{org.id}/invitations/#{invitation.id}/register" + expect(response.body).to include(I18n.t('verification.multi_user_match_text')) + end end end end @@ -823,7 +870,7 @@ def log_in OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:openid_connect, + OmniAuth.config.add_mock(:login_dot_gov, { uid: '12345', credentials: { expires_in: 899, token: 'bearer-token' }, @@ -831,7 +878,7 @@ def log_in extra: { raw_info: { given_name: 'Bob', family_name: 'Hoskins', ial: 'http://idmanagement.gov/ns/assurance/ial/2' } } }) - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! end diff --git a/dpc-portal/spec/requests/ip_addresses_spec.rb b/dpc-portal/spec/requests/ip_addresses_spec.rb index 443e28d029..4693c39fc3 100644 --- a/dpc-portal/spec/requests/ip_addresses_spec.rb +++ b/dpc-portal/spec/requests/ip_addresses_spec.rb @@ -5,6 +5,7 @@ RSpec.describe 'IpAddresses', type: :request do include DpcClientSupport + include LoginSupport let(:terms_of_service_accepted_by) { create(:user) } diff --git a/dpc-portal/spec/requests/login_dot_gov_spec.rb b/dpc-portal/spec/requests/login_dot_gov_spec.rb index 8147572066..c4a87bd541 100644 --- a/dpc-portal/spec/requests/login_dot_gov_spec.rb +++ b/dpc-portal/spec/requests/login_dot_gov_spec.rb @@ -3,12 +3,15 @@ require 'rails_helper' RSpec.describe 'LoginDotGov', type: :request do - describe 'POST /users/auth/openid_connect' do + describe 'POST /auth/login_dot_gov' do RSpec.shared_examples 'an openid client' do context 'user exists' do - before { create(:user, uid: '12345', provider: 'openid_connect', email: 'bob@example.com') } + before do + user = create(:user, email: 'bob@example.com') + create(:idp_uid, user:, uid: '12345', provider: 'login_dot_gov') + end it 'should sign in a user' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(response.location).to eq organizations_url expect(response).to be_redirect @@ -20,22 +23,22 @@ expect(Rails.logger).to receive(:info).with(['User logged in', { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::UserLoggedIn }]) - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! end - it 'should not add another user' do - expect(User.where(uid: '12345', provider: 'openid_connect').count).to eq 1 + it 'should not add another user credential' do + expect(IdpUid.where(uid: '12345', provider: 'login_dot_gov').count).to eq 1 expect do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! - end.to change { User.count }.by(0) + end.to change { IdpUid.count }.by(0) end end context 'user does not exist' do it 'should not persist user' do expect do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! end.to change { User.count }.by(0) end @@ -46,7 +49,7 @@ context 'IAL/2' do before do OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:openid_connect, + OmniAuth.config.add_mock(:login_dot_gov, { uid: '12345', credentials: { expires_in: 899, token: }, @@ -61,20 +64,23 @@ it_behaves_like 'an openid client' context :user_exists do - before { create(:user, uid: '12345', provider: 'openid_connect', email: 'bob@example.com') } + before do + user = create(:user, email: 'bob@example.com') + create(:idp_uid, user:, uid: '12345', provider: 'login_dot_gov') + end it 'updates user names' do expect do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! end.to change { - User.where(uid: '12345', provider: 'openid_connect', email: 'bob@example.com', given_name: 'Bob', + User.where(email: 'bob@example.com', given_name: 'Bob', family_name: 'Hoskins').count }.by 1 expect(response.location).to eq organizations_url end it 'sets authentication token' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(request.session[:login_dot_gov_token]).to eq token expect(request.session[:login_dot_gov_token_exp]).to_not be_nil @@ -84,7 +90,7 @@ context :user_does_not_exist do it 'does not sign in user' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(response.location).to eq organizations_url expect(response).to be_redirect @@ -93,7 +99,7 @@ end it 'sets authentication token' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(request.session[:login_dot_gov_token]).to eq token expect(request.session[:login_dot_gov_token_exp]).to_not be_nil @@ -105,7 +111,7 @@ context 'IAL/1' do before do OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:openid_connect, + OmniAuth.config.add_mock(:login_dot_gov, { uid: '12345', info: { email: 'bob@example.com' }, extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], @@ -116,21 +122,22 @@ context :user_exists do before do - create(:user, uid: '12345', provider: 'openid_connect', email: 'bob@example.com', given_name: 'Bob', - family_name: 'Hoskins') + user = create(:user, email: 'bob@example.com', given_name: 'Bob', + family_name: 'Hoskins') + create(:idp_uid, user:, uid: '12345', provider: 'login_dot_gov') end it 'does not update user names' do - expect(User.where(uid: '12345', provider: 'openid_connect', email: 'bob@example.com', given_name: 'Bob', + expect(User.where(email: 'bob@example.com', given_name: 'Bob', family_name: 'Hoskins').count).to eq 1 - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(response.location).to eq organizations_url - expect(User.where(uid: '12345', provider: 'openid_connect', email: 'bob@example.com', given_name: 'Bob', + expect(User.where(email: 'bob@example.com', given_name: 'Bob', family_name: 'Hoskins').count).to eq 1 end it 'does not set authentication token' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(request.session[:login_dot_gov_token]).to be_nil expect(request.session[:login_dot_gov_token_exp]).to be_nil @@ -139,7 +146,7 @@ context 'user does not exist' do it 'does not sign in user' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(response.location).to eq no_account_url expect(response).to be_redirect @@ -152,12 +159,12 @@ { actionContext: LoggingConstants::ActionContext::Authentication, actionType: LoggingConstants::ActionType::UserLoginWithoutAccount }] ) - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! end it 'does not set authentication token' do - post '/users/auth/openid_connect' + post '/auth/login_dot_gov' follow_redirect! expect(request.session[:login_dot_gov_token]).to be_nil expect(request.session[:login_dot_gov_token_exp]).to be_nil @@ -166,7 +173,7 @@ end end - describe 'Get /users/auth/failure' do + describe 'Get /auth/failure' do it 'should succeed' do get '/users/auth/failure' expect(response).to be_ok @@ -195,23 +202,9 @@ end end - describe 'Get /users/auth/logged_out' do - it 'should redirect to user_return_to' do - get '/organizations' - expect(request.session[:user_return_to]).to eq organizations_path - get '/users/auth/logged_out' - expect(response).to redirect_to(organizations_path) - end - - it 'should redirect to new session if no user_return_to set' do - get '/users/auth/logged_out' - expect(response).to redirect_to(new_user_session_path) - end - end - - describe 'Get /users/auth/no_account' do + describe 'Get /auth/no_account' do it 'should show logout button' do - get '/users/auth/no_account' + get '/auth/no_account' expect(response.body).to include 'Sign out of Login.gov' end end diff --git a/dpc-portal/spec/requests/organizations_spec.rb b/dpc-portal/spec/requests/organizations_spec.rb index 235562a407..f62e399dc4 100644 --- a/dpc-portal/spec/requests/organizations_spec.rb +++ b/dpc-portal/spec/requests/organizations_spec.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'Organizations', type: :request do include DpcClientSupport include ComponentSupport + include LoginSupport describe 'GET /index' do context 'not logged in' do diff --git a/dpc-portal/spec/requests/public_keys_spec.rb b/dpc-portal/spec/requests/public_keys_spec.rb index 940e5ce6e7..4d2d91f0b1 100644 --- a/dpc-portal/spec/requests/public_keys_spec.rb +++ b/dpc-portal/spec/requests/public_keys_spec.rb @@ -2,9 +2,11 @@ require 'rails_helper' require 'support/credential_resource_shared_examples' +require 'support/login_support' RSpec.describe 'PublicKeys', type: :request do include DpcClientSupport + include LoginSupport let(:terms_of_service_accepted_by) { create(:user) } diff --git a/dpc-portal/spec/requests/users/sessions_spec.rb b/dpc-portal/spec/requests/users/sessions_spec.rb index fed47f3b12..09c65ec833 100644 --- a/dpc-portal/spec/requests/users/sessions_spec.rb +++ b/dpc-portal/spec/requests/users/sessions_spec.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' + +RSpec.describe 'Sessions', type: :request do + include LoginSupport -RSpec.describe 'Users::Sessions', type: :request do describe 'logout' do context 'logged in' do let!(:user) { create(:user) } @@ -31,5 +34,19 @@ expect(response.location).to include(ENV.fetch('IDP_HOST')) end end + + describe 'Get /auth/logged_out' do + it 'should redirect to user_return_to' do + get '/organizations' + expect(request.session[:user_return_to]).to eq organizations_path + get '/auth/logged_out' + expect(response).to redirect_to(organizations_path) + end + + it 'should redirect to new session if no user_return_to set' do + get '/auth/logged_out' + expect(response).to redirect_to(sign_in_path) + end + end end end diff --git a/dpc-portal/spec/services/auto_session_logout_service_spec.rb b/dpc-portal/spec/services/auto_session_logout_service_spec.rb index a2430acc42..5408bb0e32 100644 --- a/dpc-portal/spec/services/auto_session_logout_service_spec.rb +++ b/dpc-portal/spec/services/auto_session_logout_service_spec.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require 'rails_helper' +require 'support/login_support' RSpec.describe 'AutoSessionLogoutService', type: :request do + include LoginSupport + let(:user) { create(:user) } before { sign_in user } @@ -13,6 +16,6 @@ it 'is timed out' do get '/timeout' - expect(response).to redirect_to(new_user_session_path) + expect(response).to redirect_to(sign_in_path) end end diff --git a/dpc-portal/spec/support/login_support.rb b/dpc-portal/spec/support/login_support.rb new file mode 100644 index 0000000000..cdf4537eca --- /dev/null +++ b/dpc-portal/spec/support/login_support.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module LoginSupport + def sign_in(user) + idp_uid = create(:idp_uid, user:, provider: :login_dot_gov) + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(idp_uid.provider, + { uid: idp_uid.uid, + info: { email: user.email }, + extra: { raw_info: { all_emails: [user.email], + ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) + post '/auth/login_dot_gov' + follow_redirect! + end +end diff --git a/dpc-portal/spec/system/accessibility_spec.rb b/dpc-portal/spec/system/accessibility_spec.rb index 79b215f1cc..9692603a39 100644 --- a/dpc-portal/spec/system/accessibility_spec.rb +++ b/dpc-portal/spec/system/accessibility_spec.rb @@ -3,21 +3,26 @@ require 'rails_helper' RSpec.describe 'Accessibility', type: :system do - include Devise::Test::IntegrationHelpers include DpcClientSupport before do driven_by(:selenium_headless) end - - after do |test_case| - next unless test_case.exception - - warn "[Failure URL]: #{page.current_url}" if page.current_url.present? - end - let(:dpc_api_organization_id) { 'some-gnarly-guid' } let(:axe_standard) { %w[best-practice wcag21aa] } + let(:uid) { '12345' } + + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:login_dot_gov, + { uid:, + info: { email: 'bob@example.com' }, + extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], + ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) + end + def sign_in + visit '/auth/login_dot_gov/callback' + end context 'login' do it 'shows login page ok' do visit '/users/sign_in' @@ -32,31 +37,34 @@ end context 'bad user tries to log in' do - before do - OmniAuth.config.test_mode = true - OmniAuth.config.add_mock(:openid_connect, - { uid: '12345', - info: { email: 'bob@example.com' }, - extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], - ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) - end it 'shows no such user page' do - visit '/users/auth/openid_connect/callback' + visit '/auth/login_dot_gov/callback' expect(page).to have_text('The email you used is not associated with a DPC account.') expect(page).to be_axe_clean.according_to axe_standard end it 'shows sanctioned ao page' do - create(:user, provider: :openid_connect, uid: '12345', - verification_status: 'rejected', verification_reason: 'ao_med_sanctions') - visit '/users/auth/openid_connect/callback' + user = create(:user, verification_status: 'rejected', verification_reason: 'ao_med_sanctions') + create(:idp_uid, user_id: user.id, provider: :login_dot_gov, uid: '12345') + visit '/auth/login_dot_gov/callback' expect(page).to have_text(I18n.t('verification.ao_med_sanctions_status')) expect(page).to be_axe_clean.according_to axe_standard end end + + context 'valid user tries to log in' do + it 'shows success page' do + user = create(:user, verification_status: 'approved') + create(:idp_uid, user_id: user.id, provider: :login_dot_gov, uid: '12345') + visit '/auth/login_dot_gov/callback' + expect(page).to have_text("You don't have any organizations to show.") + expect(page).to be_axe_clean.according_to axe_standard + end + end end context 'organizations' do - let!(:user) { create(:user) } + let!(:user) { create(:user, verification_status: :approved) } + let!(:idp_uid) { create(:idp_uid, user_id: user.id, uid:, provider: :login_dot_gov) } let!(:org) { create(:provider_organization, dpc_api_organization_id:, name: 'Health Hut') } let(:mock_client_token_manager) { instance_double(ClientTokenManager) } let(:mock_public_key_manager) { instance_double(PublicKeyManager) } @@ -72,7 +80,7 @@ allow(mock_client_token_manager).to receive(:client_tokens).and_return(tokens) allow(mock_public_key_manager).to receive(:public_keys).and_return(keys) allow(mock_ip_address_manager).to receive(:ip_addresses).and_return(ip_addresses) - sign_in user + sign_in end context 'list' do it 'empty' do @@ -322,7 +330,6 @@ page.fill_in 'invited_email', with: invitation.invited_email page.fill_in 'invited_email_confirmation', with: invitation.invited_email page.find_button(value: 'Send invite').click - page.find_button(value: 'Yes, I acknowledge').click expect(page).to_not have_text("can't be blank") expect(page).to have_text(I18n.t('errors.attributes.base.duplicate_cd.status')) expect(page).to be_axe_clean.according_to axe_standard diff --git a/dpc-portal/spec/system/new_invitation_spec.rb b/dpc-portal/spec/system/new_invitation_spec.rb index a594cf7ba9..1812c45b64 100644 --- a/dpc-portal/spec/system/new_invitation_spec.rb +++ b/dpc-portal/spec/system/new_invitation_spec.rb @@ -3,21 +3,33 @@ require 'rails_helper' RSpec.describe Page::CredentialDelegate::NewInvitationComponent, type: :system, js: true do - include Devise::Test::IntegrationHelpers include DpcClientSupport before do driven_by(:selenium_headless) end + let(:uid) { '12345' } + before do + OmniAuth.config.test_mode = true + OmniAuth.config.add_mock(:login_dot_gov, + { uid:, + info: { email: 'bob@example.com' }, + extra: { raw_info: { all_emails: %w[bob@example.com bob2@example.com], + ial: 'http://idmanagement.gov/ns/assurance/ial/1' } } }) + end + def sign_in + visit '/auth/login_dot_gov/callback' + end context 'CD invite' do let(:dpc_api_organization_id) { 'some-gnarly-guid' } let!(:user) { create(:user) } + let!(:idp_uid) { create(:idp_uid, user_id: user.id, provider: :login_dot_gov, uid: '12345') } let!(:org) { create(:provider_organization, dpc_api_organization_id:, name: 'Health Hut') } let!(:ao_org_link) { create(:ao_org_link, user:, provider_organization: org) } before do - sign_in user + sign_in org.update!(terms_of_service_accepted_by: user) end