Commit 4a973ba1 authored by James Edwards-Jones's avatar James Edwards-Jones

Signed out user flow for Group SAML

This will allow Group SAML to be used for signing in to GitLab.com
Initially this will be behind the feature flag:
"group_saml_allows_sign_in_to_gitlab" so we can roll it out per group.

Implements Auth::GroupSaml::User to process callback when signed out.
Updates Groups::OmniauthCallbacksController to handle more complicated
redirect flows.
parent 3fbad121
...@@ -116,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -116,8 +116,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
session[:service_tickets][provider] = ticket session[:service_tickets][provider] = ticket
end end
def build_auth_user(auth_user_class)
auth_user_class.new(oauth)
end
def sign_in_user_flow(auth_user_class) def sign_in_user_flow(auth_user_class)
auth_user = auth_user_class.new(oauth) auth_user = build_auth_user(auth_user_class)
user = auth_user.find_and_update! user = auth_user.find_and_update!
if auth_user.valid_sign_in? if auth_user.valid_sign_in?
......
...@@ -7,9 +7,9 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -7,9 +7,9 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController
def group_saml def group_saml
@unauthenticated_group = Group.find_by_full_path(params[:group_id]) @unauthenticated_group = Group.find_by_full_path(params[:group_id])
saml_provider = @unauthenticated_group.saml_provider @saml_provider = @unauthenticated_group.saml_provider
identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(current_user, oauth, saml_provider) identity_linker = Gitlab::Auth::GroupSaml::IdentityLinker.new(current_user, oauth, @saml_provider)
omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker) omniauth_flow(Gitlab::Auth::GroupSaml, identity_linker: identity_linker)
end end
...@@ -25,7 +25,7 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -25,7 +25,7 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController
override :redirect_identity_exists override :redirect_identity_exists
def redirect_identity_exists def redirect_identity_exists
flash[:notice] = "Signed in with SAML for #{@unauthenticated_group.name}" flash[:notice] = "Already signed in with SAML for #{@unauthenticated_group.name}"
redirect_to after_sign_in_path_for(current_user) redirect_to after_sign_in_path_for(current_user)
end end
...@@ -37,20 +37,58 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController ...@@ -37,20 +37,58 @@ class Groups::OmniauthCallbacksController < OmniauthCallbacksController
redirect_to after_sign_in_path_for(current_user) redirect_to after_sign_in_path_for(current_user)
end end
override :sign_in_and_redirect
def sign_in_and_redirect(user, *args)
flash[:notice] = "Signed in with SAML for #{@unauthenticated_group.name}"
super
end
override :after_sign_in_path_for override :after_sign_in_path_for
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)
saml_redirect_path || super saml_redirect_path || super
end end
override :build_auth_user
def build_auth_user(auth_user_class)
Gitlab::Auth::GroupSaml::User.new(oauth, @saml_provider)
end
override :sign_in_user_flow override :sign_in_user_flow
def sign_in_user_flow(auth_user_class) def sign_in_user_flow(auth_user_class)
# User has successfully authenticated with the SAML provider for the group # User has successfully authenticated with the SAML provider for the group
# but is not signed in to the GitLab instance. # but is not signed in to the GitLab instance.
if sign_in_to_gitlab_enabled?
super
else
flash[:notice] = "You must be signed in to use SAML with this group" flash[:notice] = "You must be signed in to use SAML with this group"
redirect_to new_user_session_path redirect_to new_user_session_path
end end
end
def sign_in_to_gitlab_enabled?
::Feature.enabled?(:group_saml_allows_sign_in_to_gitlab, @unauthenticated_group)
end
override :fail_login
def fail_login(user)
if user
super
else
redirect_to_login_or_register
end
end
def redirect_to_login_or_register
notice = "Login to a GitLab account to link with your SAML identity"
after_gitlab_sign_in = sso_group_saml_providers_path(@unauthenticated_group)
store_location_for(:redirect, after_gitlab_sign_in)
redirect_to new_user_session_path, notice: notice
end
def saml_redirect_path def saml_redirect_path
params['RelayState'].presence if current_user params['RelayState'].presence if current_user
......
# frozen_string_literal: true
module Auth
class GroupSamlIdentityFinder
attr_reader :saml_provider, :auth_hash
def initialize(saml_provider, auth_hash)
@saml_provider = saml_provider
@auth_hash = auth_hash
end
def first
Identity.find_by_group_saml_uid(saml_provider, uid)
end
private
def uid
auth_hash.uid
end
end
end
...@@ -24,6 +24,12 @@ module EE ...@@ -24,6 +24,12 @@ module EE
with_extern_uid(provider, extern_uid).take with_extern_uid(provider, extern_uid).take
end end
def find_by_group_saml_uid(saml_provider, extern_uid)
where(provider: :group_saml,
saml_provider: saml_provider,
extern_uid: extern_uid).take
end
def preload_saml_group def preload_saml_group
preload(saml_provider: { group: :route }) preload(saml_provider: { group: :route })
end end
......
...@@ -3,14 +3,14 @@ ...@@ -3,14 +3,14 @@
= render 'devise/shared/tab_single', tab_title: _('SAML SSO') = render 'devise/shared/tab_single', tab_title: _('SAML SSO')
.login-box .login-box
.login-body .login-body
- if @group_saml_identity - if @group_saml_identity || !user_signed_in?
%h4= _('Sign in to "%{group_name}"') % { group_name: @group_name } %h4= _('Sign in to "%{group_name}"') % { group_name: @group_name }
- else - else
%h4= _('Allow "%{group_name}" to sign you in') % { group_name: @group_name } %h4= _('Allow "%{group_name}" to sign you in') % { group_name: @group_name }
%p= _('The "%{group_path}" group allows you to sign in with your Single Sign-On Account') % { group_path: @group_path } %p= _('The "%{group_path}" group allows you to sign in with your Single Sign-On Account') % { group_path: @group_path }
- if @group_saml_identity - if @group_saml_identity || !user_signed_in?
%p= _("This will redirect you to an external sign in page.") %p= _("This will redirect you to an external sign in page.")
= saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button' = saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
......
...@@ -4,8 +4,41 @@ module Gitlab ...@@ -4,8 +4,41 @@ module Gitlab
module Auth module Auth
module GroupSaml module GroupSaml
class User class User
def initialize(auth_hash) attr_reader :auth_hash, :saml_provider
raise NotImplementedError
def initialize(auth_hash, saml_provider)
@auth_hash = auth_hash
@saml_provider = saml_provider
end
def find_and_update!
update_group_membership
user_from_identity
end
def valid_sign_in?
user_from_identity.present?
end
def bypass_two_factor?
false
end
private
def identity
@identity ||= ::Auth::GroupSamlIdentityFinder.new(saml_provider, auth_hash).first
end
def user_from_identity
@user_from_identity ||= identity&.user
end
def update_group_membership
return unless user_from_identity
MembershipUpdater.new(user_from_identity, saml_provider).execute
end end
end end
end end
......
...@@ -18,6 +18,10 @@ describe Groups::OmniauthCallbacksController do ...@@ -18,6 +18,10 @@ describe Groups::OmniauthCallbacksController do
Identity.where(user: user, extern_uid: uid, provider: provider) Identity.where(user: user, extern_uid: uid, provider: provider)
end end
def create_linked_user
create(:omniauth_user, extern_uid: uid, provider: provider, saml_provider: saml_provider)
end
context "when request hasn't been validated by omniauth middleware" do context "when request hasn't been validated by omniauth middleware" do
it "prevents authentication" do it "prevents authentication" do
sign_in(user) sign_in(user)
...@@ -34,13 +38,8 @@ describe Groups::OmniauthCallbacksController do ...@@ -34,13 +38,8 @@ describe Groups::OmniauthCallbacksController do
stub_omniauth_provider(provider, context: request) stub_omniauth_provider(provider, context: request)
end end
context "when signed in" do shared_examples "and identity already linked" do
before do let!(:user) { create_linked_user }
sign_in(user)
end
context "and identity already linked" do
let(:user) { create(:omniauth_user, extern_uid: uid, provider: provider, saml_provider: saml_provider) }
it "redirects to RelayState" do it "redirects to RelayState" do
post provider, params: { group_id: group, RelayState: '/explore' } post provider, params: { group_id: group, RelayState: '/explore' }
...@@ -51,7 +50,7 @@ describe Groups::OmniauthCallbacksController do ...@@ -51,7 +50,7 @@ describe Groups::OmniauthCallbacksController do
it "displays a flash message verifying group sign in" do it "displays a flash message verifying group sign in" do
post provider, params: { group_id: group } post provider, params: { group_id: group }
expect(flash[:notice]).to start_with "Signed in with SAML" expect(flash[:notice]).to match(/Signed in with SAML/i)
end end
it 'uses existing linked identity' do it 'uses existing linked identity' do
...@@ -68,9 +67,16 @@ describe Groups::OmniauthCallbacksController do ...@@ -68,9 +67,16 @@ describe Groups::OmniauthCallbacksController do
end end
end end
context "when signed in" do
before do
sign_in(user)
end
it_behaves_like "and identity already linked"
context 'oauth already linked to another account' do context 'oauth already linked to another account' do
before do before do
create(:omniauth_user, extern_uid: uid, provider: provider, saml_provider: saml_provider) create_linked_user
end end
it 'displays warning to user' do it 'displays warning to user' do
...@@ -102,6 +108,7 @@ describe Groups::OmniauthCallbacksController do ...@@ -102,6 +108,7 @@ describe Groups::OmniauthCallbacksController do
end end
context "when not signed in" do context "when not signed in" do
context "and identity hasn't been linked" do
it "redirects to sign in page" do it "redirects to sign in page" do
post provider, params: { group_id: group } post provider, params: { group_id: group }
...@@ -111,8 +118,25 @@ describe Groups::OmniauthCallbacksController do ...@@ -111,8 +118,25 @@ describe Groups::OmniauthCallbacksController do
it "informs users that they need to sign in to the GitLab instance first" do it "informs users that they need to sign in to the GitLab instance first" do
post provider, params: { group_id: group } post provider, params: { group_id: group }
expect(flash[:notice]).to start_with("You must be signed in") expect(flash[:notice]).to start_with("Login to a GitLab account to link with your SAML identity")
end
end
context 'identity linked but sign in flow disabled' do
before do
create_linked_user
stub_feature_flags(group_saml_allows_sign_in_to_gitlab: false)
end end
it 'prevents sign in' do
post provider, params: { group_id: group }
expect(flash[:notice]).to start_with('You must be signed in')
expect(response).to redirect_to('/users/sign_in')
end
end
it_behaves_like "and identity already linked"
end end
end end
......
...@@ -168,10 +168,12 @@ describe 'SAML provider settings' do ...@@ -168,10 +168,12 @@ describe 'SAML provider settings' do
end end
context 'when not signed in' do context 'when not signed in' do
it "doesn't show sso page" do it "shows the sso page so user can sign in" do
visit sso_group_saml_providers_path(group) visit sso_group_saml_providers_path(group)
expect(current_path).to eq(new_user_session_path) expect(page).to have_content('SAML SSO')
expect(page).to have_content("Sign in to \"#{group.full_name}\"")
expect(page).to have_content('Sign in with Single Sign-On')
end end
end end
...@@ -220,6 +222,12 @@ describe 'SAML provider settings' do ...@@ -220,6 +222,12 @@ describe 'SAML provider settings' do
expect(current_path).to eq(new_user_session_path) expect(current_path).to eq(new_user_session_path)
end end
it "shows the sso page if the token is given" do
visit sso_group_saml_providers_path(group, token: group.saml_discovery_token)
expect(current_path).to eq sso_group_saml_providers_path(group)
end
end end
context 'when signed in' do context 'when signed in' do
......
# frozen_string_literal: true
require 'spec_helper'
describe Auth::GroupSamlIdentityFinder do
let(:uid) { 1234 }
let!(:identity) { create(:group_saml_identity, extern_uid: uid) }
let(:saml_provider) { identity.saml_provider }
let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid) }
subject { described_class.new(saml_provider, auth_hash) }
describe '#first' do
it 'looks up identity by saml_provider and uid' do
expect(subject.first).to eq identity
end
end
end
# frozen_string_literal: true
require 'spec_helper'
require 'spec_helper'
describe Gitlab::Auth::GroupSaml::User do
let(:uid) { 1234 }
let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid) }
let(:saml_provider) { create(:saml_provider) }
let(:group) { saml_provider.group }
subject { described_class.new(auth_hash, saml_provider) }
def create_existing_identity
create(:group_saml_identity, extern_uid: uid, saml_provider: saml_provider)
end
describe '#valid_sign_in?' do
context 'with matching user for that group and uid' do
let!(:identity) { create_existing_identity }
it 'returns true' do
is_expected.to be_valid_sign_in
end
end
context 'with no matching user identity' do
it 'returns false' do
is_expected.not_to be_valid_sign_in
end
end
end
describe '#find_and_update!' do
context 'with matching user for that group and uid' do
let!(:identity) { create_existing_identity }
it 'updates group membership' do
expect do
subject.find_and_update!
end.to change { group.members.count }.by(1)
end
it 'returns the user' do
expect(subject.find_and_update!).to eq identity.user
end
end
context 'with no matching user identity' do
it 'does nothing' do
expect(subject.find_and_update!).to eq nil
expect(group.members.count).to eq 0
end
end
end
describe '#bypass_two_factor?' do
it 'is false' do
expect(subject.bypass_two_factor?).to eq false
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment