Skip to content

Commit

Permalink
DPC-3999 IAL/1 for main login page; IAL/2 for invitations page (#2149)
Browse files Browse the repository at this point in the history
## 🎫 Ticket

https://jira.cms.gov/browse/DPC-3999

## 🛠 Changes

- Basic login page switched from IAL/2 to IAL/1
- Accept/confirm invitation endpoints moved from CDInvitation Controller
to a new Invitation Controller
- New login ViewComponent created for Invitations
- Login endpoint added to invitation controller to make IAL/2 request to
login.gov

## ℹ️ Context for reviewers

- The new login component is simply a clone of the old one. They are
going to be quite different from each other in the future.
- The AuthorizedOfficialInvitationsController was just a placeholder.
- The invitations are remaining within the organizations resource just
to reduce url hacking.
- The update to the LoginDotGov controller was to account for
given/family name only being available via the invitations login flow.

## ✅ Acceptance Validation

Manual testing:
- Login page just IAL/1
- Invitation page shows login if not logged in
- Invitation page login is at IAL/2

## 🔒 Security Implications

- [ ] This PR adds a new software dependency or dependencies.
- [ ] This PR modifies or invalidates one or more of our security
controls.
- [ ] This PR stores or transmits data that was not stored or
transmitted before.
- [ ] This PR requires additional review of its security implications
for other reasons.

If any security implications apply, add Jason Ashbaugh (GitHub username:
StewGoin) as a reviewer and do not merge this PR without his approval.
  • Loading branch information
jdettmannnava authored Apr 24, 2024
1 parent 04eafc2 commit 3d0e118
Show file tree
Hide file tree
Showing 19 changed files with 498 additions and 244 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<h2>Provide invite code</h2>
<p>You are invited to <%= @organization.name %> by the Authorized Official (AO),
<%= @cd_invite.invited_by&.given_name %> <%= @cd_invite.invited_by&.family_name %>.</p>
<%= form_tag confirm_organization_credential_delegate_invitation_path(@organization, @cd_invite), method: :post, class: ['usa-form'], id: "cd-accept-form" do %>
<%= form_tag confirm_organization_invitation_path(@organization, @cd_invite), method: :post, class: ['usa-form'], id: "cd-accept-form" do %>
<%= render(Core::Form::TextInputComponent.new(label: 'Enter the invite code:',
attribute: :verification_code,
error_msg: @cd_invite.errors[:verification_code]&.first,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<section class="usa-section">
<div class="grid-row margin-x-neg-205 flex-justify-center">
<div class="grid-col-12 mobile-lg:grid-col-10 tablet:grid-col-8 desktop:grid-col-6 padding-x-205 margin-bottom-4">
<h1 class="desktop:display-none font-sans-lg margin-bottom-4 tablet:margin-top-neg-3">
Welcome to the production portal for the Data at the Point of Care (DPC) API
</h1>
<div class="bg-white padding-y-3 padding-x-5 border border-base-lighter">
<h1 class="margin-bottom-0">Sign in to your account</h1>
<p>You can access or create your account by signing in below.</p>
<%= button_to login_organization_invitation_url(@invitation.provider_organization, @invitation), class: 'usa-button width-full margin-bottom-3', data: { turbo: false } do %>
Sign in with <span class="login-button__logo">Login.gov</span>
<% end %>
</div>
</div>
<div class="grid-col-12 mobile-lg:grid-col-10 tablet:grid-col-8 desktop:grid-col-6 padding-x-205">
<div class="border-top border-base-lighter padding-top-4 desktop:border-0 desktop:padding-top-0">
<h2 class="display-none desktop:display-block margin-top-0">
Welcome to the production portal for the Data at the Point of Care (DPC) API
</h2>
<div class="usa-prose">
<p>Use this portal to get production credentials, manage X, and do X. If you're an
Authorized Official, you'll need to take the following steps:</p>
<section class="usa-graphic-list">
<div class="usa-graphic-list__row">
<div class="usa-media-block margin-y-2">
<%= image_tag 'circle-gray-20.svg', class: ['usa-media-block__img', 'height-7', 'width-7'] %>
<div class="usa-media-block__body">
<p><strong>Add an organization.</strong> Vivavmus nec velit sed leo scelerisque laoreet vestibulum.</p>
</div>
</div>
<div class="usa-media-block margin-y-2">
<%= image_tag 'circle-gray-20.svg', class: ['usa-media-block__img', 'height-7', 'width-7'] %>
<div class="usa-media-block__body">
<p><strong>Invite a credential delegate.</strong> Vivavmus nec velit sed leo scelerisque laoreet vestibulum.</p>
</div>
</div>
<div class="usa-media-block margin-y-2">
<%= image_tag 'circle-gray-20.svg', class: ['usa-media-block__img', 'height-7', 'width-7'] %>
<div class="usa-media-block__body">
<p><strong>Lorem ipsum.</strong> Vivavmus nec velit sed leo scelerisque laoreet</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</section>

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Page
module Session
# Component for Invitatation login (IAL/2 flow)
class InvitationLoginComponent < ViewComponent::Base
def initialize(invitation)
super
@invitation = invitation
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Page
module Session
# Preview of Invitatation login (IAL/2 flow)
class InvitationLoginComponentPreview < ViewComponent::Preview
def default
provider_organization = ProviderOrganization.new(id: 4, name: 'Health Hut')
invitation = Invitation.new(id: 2, provider_organization:)
render(Page::Session::InvitationLoginComponent.new(invitation))
end
end
end
end

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ class CredentialDelegateInvitationsController < ApplicationController
before_action :authenticate_user!
before_action :load_organization
before_action :require_ao, only: %i[new create success]
before_action :load_invitation, only: %i[accept confirm]
before_action :invitation_matches_cd, only: %i[confirm]

def new
render(Page::CredentialDelegate::NewInvitationComponent.new(@organization, Invitation.new))
Expand All @@ -18,8 +16,8 @@ def create
if @cd_invitation.save
InvitationMailer.with(invitation: @cd_invitation).invite_cd.deliver_later
if Rails.env.local?
logger.info("Invitation URL: #{accept_organization_credential_delegate_invitation_url(@organization,
@cd_invitation)}")
logger.info("Invitation URL: #{accept_organization_invitation_url(@organization,
@cd_invitation)}")
end
redirect_to success_organization_credential_delegate_invitation_path(@organization.path_id, 'new-invitation')
else
Expand All @@ -31,22 +29,6 @@ def success
render(Page::CredentialDelegate::InvitationSuccessComponent.new(@organization))
end

def accept
if current_user.email != @cd_invitation.invited_email
return render(Page::CredentialDelegate::BadInvitationComponent.new('pii_mismatch'),
status: :forbidden)
end

render(Page::CredentialDelegate::AcceptInvitationComponent.new(@organization, @cd_invitation))
end

def confirm
CdOrgLink.create!(user: current_user, provider_organization: @organization, invitation: @cd_invitation)
@cd_invitation.update!(invited_given_name: nil, invited_family_name: nil, invited_phone: nil, invited_email: nil)
flash[:notice] = "Invitation accepted. You can now manage this organization's credentials. Learn more."
redirect_to organizations_path
end

private

def build_invitation
Expand All @@ -58,26 +40,4 @@ def build_invitation
invited_by: current_user,
verification_code: (Array('A'..'Z') + Array(0..9)).sample(6).join)
end

def invitation_matches_cd
unless @cd_invitation.match_user?(current_user)
return render(Page::CredentialDelegate::BadInvitationComponent.new('pii_mismatch'),
status: :forbidden)
end
return if params[:verification_code] == @cd_invitation.verification_code

@cd_invitation.errors.add(:verification_code, :bad_code, message: 'tbd')
render(Page::CredentialDelegate::AcceptInvitationComponent.new(@organization, @cd_invitation),
status: :bad_request)
end

def load_invitation
@cd_invitation = Invitation.find(params[:id])
if @cd_invitation.expired? || @cd_invitation.accepted? || @cd_invitation.cancelled_at.present?
render(Page::CredentialDelegate::BadInvitationComponent.new('invalid'),
status: :forbidden)
end
rescue ActiveRecord::RecordNotFound
render(Page::CredentialDelegate::BadInvitationComponent.new('invalid'), status: :not_found)
end
end
89 changes: 89 additions & 0 deletions dpc-portal/app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true

# Handles acceptance of invitations
class InvitationsController < ApplicationController
before_action :load_organization
before_action :load_invitation
before_action :authenticate_user!, except: %i[login]
before_action :invitation_matches_cd, only: %i[confirm]

def accept
if current_user.email != @cd_invitation.invited_email
return render(Page::CredentialDelegate::BadInvitationComponent.new('pii_mismatch'),
status: :forbidden)
end

render(Page::CredentialDelegate::AcceptInvitationComponent.new(@organization, @cd_invitation))
end

def confirm
CdOrgLink.create!(user: current_user, provider_organization: @organization, invitation: @cd_invitation)
@cd_invitation.update!(invited_given_name: nil, invited_family_name: nil, invited_phone: nil, invited_email: nil)
flash[:notice] = "Invitation accepted. You can now manage this organization's credentials. Learn more."
redirect_to organizations_path
end

def login
login_session
client_id = "urn:gov:cms:openidconnect.profiles:sp:sso:cms:dpc:#{ENV.fetch('ENV')}"
url = URI::HTTPS.build(host: 'idp.int.identitysandbox.gov',
path: '/openid_connect/authorize',
query: { acr_values: 'http://idmanagement.gov/ns/assurance/ial/2',
client_id:,
redirect_uri: "#{redirect_host}/portal/users/auth/openid_connect/callback",
response_type: 'code',
scope: 'openid email all_emails profile phone social_security_number',
nonce: @nonce,
state: @state }.to_query)
redirect_to url, allow_other_host: true
end

private

def authenticate_user!
return if current_user

render(Page::Session::InvitationLoginComponent.new(@cd_invitation))
end

def invitation_matches_cd
unless @cd_invitation.match_user?(current_user)
return render(Page::CredentialDelegate::BadInvitationComponent.new('pii_mismatch'),
status: :forbidden)
end
return if params[:verification_code] == @cd_invitation.verification_code

@cd_invitation.errors.add(:verification_code, :bad_code, message: 'tbd')
render(Page::CredentialDelegate::AcceptInvitationComponent.new(@organization, @cd_invitation),
status: :bad_request)
end

def load_invitation
@cd_invitation = Invitation.find(params[:id])
if @organization != @cd_invitation.provider_organization
render(Page::CredentialDelegate::BadInvitationComponent.new('invalid'), status: :not_found)
elsif @cd_invitation.expired? || @cd_invitation.accepted? || @cd_invitation.cancelled_at.present?
render(Page::CredentialDelegate::BadInvitationComponent.new('invalid'),
status: :forbidden)
end
rescue ActiveRecord::RecordNotFound
render(Page::CredentialDelegate::BadInvitationComponent.new('invalid'), status: :not_found)
end

def login_session
session[:user_return_to] = accept_organization_invitation_url(@organization, params[:id])
session['omniauth.nonce'] = @nonce = SecureRandom.hex(16)
session['omniauth.state'] = @state = SecureRandom.hex(16)
end

def redirect_host
case ENV.fetch('ENV', nil)
when 'local'
'http://localhost:3100'
when 'prod'
'https://dpc.cms.gov'
else
"https://#{ENV.fetch('ENV', nil)}.dpc.cms.gov"
end
end
end
11 changes: 9 additions & 2 deletions dpc-portal/app/controllers/login_dot_gov_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def openid_connect
user = User.find_or_create_by(provider: auth.provider, uid: auth.uid) do |user_to_create|
assign_user_properties(user_to_create, auth)
end
maybe_update_user(user, auth)
sign_in(:user, user)
redirect_to session[:user_return_to] || organizations_path
end
Expand All @@ -25,10 +26,16 @@ def failure

private

def maybe_update_user(user, auth)
data = auth.extra.raw_info
return unless data.ial == 'http://idmanagement.gov/ns/assurance/ial/2'

user.update(given_name: data.given_name,
family_name: data.family_name)
end

def assign_user_properties(user, auth)
user.email = auth.info.email
user.given_name = auth.extra.raw_info.given_name
user.family_name = auth.extra.raw_info.family_name
# Assign random, acceptable password to keep Devise happy.
# User should log in only through IdP
user.password = user.password_confirmation = Devise.friendly_token[0, 20]
Expand Down
2 changes: 1 addition & 1 deletion dpc-portal/app/views/invitation_mailer/invite_ao.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<h1>Dear <%= @given_name %> <%= @family_name %>,</h1>
<p>You have been invited to exercise Authorized Official management of
<%= @invitation.provider_organization.name %> within Data at the Point of Care.</p>
<p><%= link_to 'Accept this invitation', accept_organization_authorized_official_invitation_url(@invitation.provider_organization, @invitation) %></p>
<p><%= link_to 'Accept this invitation', accept_organization_invitation_url(@invitation.provider_organization, @invitation) %></p>
<p>Sincerely,</p>

<p>The Data at the Point of Care Team</p>
Expand Down
2 changes: 1 addition & 1 deletion dpc-portal/app/views/invitation_mailer/invite_cd.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<h1>Dear <%= @invitation.invited_given_name %> <%= @invitation.invited_family_name %>,</h1>
<p><%= @invitation.invited_by.given_name %> <%= @invitation.invited_by.family_name %> has invited you to manage
the credentials for <%= @invitation.provider_organization.name %> within Data at the Point of Care.</p>
<p><%= link_to 'Accept this invitation', accept_organization_credential_delegate_invitation_url(@invitation.provider_organization, @invitation) %></p>
<p><%= link_to 'Accept this invitation', accept_organization_invitation_url(@invitation.provider_organization, @invitation) %></p>
<p>Sincerely,</p>

<p>The Data at the Point of Care Team</p>
Expand Down
4 changes: 2 additions & 2 deletions dpc-portal/config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
name: :openid_connect,
issuer: 'https://idp.int.identitysandbox.gov/',
discovery: true,
scope: %i[openid email profile phone social_security_number],
scope: %i[openid email all_emails],
response_type: :code,
acr_values: 'http://idmanagement.gov/ns/assurance/ial/2',
acr_values: 'http://idmanagement.gov/ns/assurance/ial/1',
client_auth_method: :jwt_bearer,
client_options: {
port: 443,
Expand Down
6 changes: 3 additions & 3 deletions dpc-portal/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
resources :public_keys, only: [:new, :create, :destroy]
resources :ip_addresses, only: [:new, :create, :destroy]
resources :credential_delegate_invitations, only: [:new, :create, :destroy] do
get 'accept', on: :member
post 'confirm', on: :member
get 'success', on: :member
end
resources :authorized_official_invitations, only: [] do
resources :invitations, only: [] do
get 'accept', on: :member
post 'confirm', on: :member
post 'login', on: :member
end
get 'tos_form', on: :member
post 'sign_tos', on: :member
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
end

it 'should match form tag' do
form_url = "/portal/organizations/#{org.path_id}/credential_delegate_invitations/#{cd_invite.id}/confirm"
form_url = "/portal/organizations/#{org.path_id}/invitations/#{cd_invite.id}/confirm"
form_tag = ['<form class="usa-form" id="cd-accept-form"',
%(action="#{form_url}"),
'accept-charset="UTF-8" method="post">'].join(' ')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Page::Session::InvitationLoginComponent, type: :component do
include ComponentSupport
describe 'login component' do
let(:provider_organization) { build(:provider_organization, dpc_api_organization_id: 'foo') }
let(:invitation) { create(:invitation, :cd, provider_organization:) }
let(:component) { described_class.new(invitation) }
before { render_inline(component) }
it 'should be a usa section' do
expect(page).to have_selector('section.usa-section')
end

it 'should have a login button' do
expect(page).to have_selector('button.usa-button')
expect(page.find('button.usa-button')).to have_content('Sign in with')
expect(page).to have_selector('button.usa-button span.login-button__logo')
expect(page.find('button.usa-button span.login-button__logo')).to have_content('Login.gov')
end

it 'should post to appropriate url' do
path = "organizations/#{provider_organization.id}/invitations/#{invitation.id}/login"
url = "http://test.host/portal/#{path}"
expect(page.find('form')[:action]).to eq url
expect(page.find('form')[:method]).to eq 'post'
end

it 'should have two columns' do
expect(page.find_all('.grid-col-12').size).to eq 2
end
end
end
4 changes: 2 additions & 2 deletions dpc-portal/spec/mailers/invitation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
provider_organization = build(:provider_organization, id: 2)
invitation = build(:invitation, id: 4, invited_by:, provider_organization:)
mailer = InvitationMailer.with(invitation:).invite_cd
expected_url = 'http://localhost:3100/portal/organizations/2/credential_delegate_invitations/4/accept'
expected_url = 'http://localhost:3100/portal/organizations/2/invitations/4/accept'
expect(mailer.body).to match(expected_url)
end
end
Expand All @@ -19,7 +19,7 @@
invitation = build(:invitation, id: 4, provider_organization:)
given_name = family_name = ''
mailer = InvitationMailer.with(invitation:, given_name:, family_name:).invite_ao
expected_url = 'http://localhost:3100/portal/organizations/2/authorized_official_invitations/4/accept'
expected_url = 'http://localhost:3100/portal/organizations/2/invitations/4/accept'
expect(mailer.body).to match(expected_url)
end
end
Expand Down
Loading

0 comments on commit 3d0e118

Please sign in to comment.