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 %>