Skip to content

Commit 36a9566

Browse files
authored
Add identity model and management flow
1 parent cc30dc1 commit 36a9566

10 files changed

Lines changed: 232 additions & 17 deletions

File tree

app/controllers/callbacks_controller.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@ class CallbacksController < Devise::OmniauthCallbacksController
1111
private
1212

1313
def handle_callback(provider, config)
14-
@user = User.from_omniauth(request.env["omniauth.auth"])
14+
auth = request.env['omniauth.auth']
1515
if request.env['omniauth.params'] && request.env['omniauth.params']['space_id']
1616
space = Space.find_by_id(request.env['omniauth.params']['space_id'])
1717
end
1818

19+
if current_user && request.env.dig('omniauth.params', 'link_identity').to_s == '1'
20+
link_identity(auth, space)
21+
return
22+
end
23+
24+
@user = User.from_omniauth(auth)
25+
1926
if @user.new_record?
2027
# new user
2128
begin
@@ -42,4 +49,23 @@ def handle_callback(provider, config)
4249
redirect_to_space(after_sign_in_path_for(@user), space)
4350
end
4451
end
52+
53+
def link_identity(auth, space)
54+
identity = Identity.from_omniauth(auth)
55+
56+
if identity.user == current_user
57+
flash[:notice] = 'Identity is already linked to your account.'
58+
elsif identity.user.present?
59+
flash[:notice] = 'Identity is already linked to another account.'
60+
else
61+
identity.user = current_user
62+
identity.save!
63+
flash[:notice] = 'Identity linked successfully.'
64+
end
65+
66+
redirect_to_space(user_identities_path(current_user), space)
67+
rescue StandardError => e
68+
flash[:notice] = "Could not link identity: #{e.message}"
69+
redirect_to_space(user_identities_path(current_user), space)
70+
end
4571
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
class IdentitiesController < ApplicationController
2+
prepend_before_action :set_user
3+
before_action :set_breadcrumbs
4+
before_action :authorize_user
5+
6+
def index
7+
@identities = @user.identities.order(:provider, :uid)
8+
end
9+
10+
def destroy
11+
identity = @user.identities.find(params[:id])
12+
13+
if @user.identities.one? && @user.encrypted_password.blank?
14+
flash[:notice] = 'You cannot remove your last identity because your account has no password.'
15+
else
16+
identity.destroy
17+
flash[:notice] = 'Identity removed.'
18+
end
19+
20+
redirect_to user_identities_path(@user)
21+
end
22+
23+
private
24+
25+
def set_user
26+
@user = User.friendly.find(params[:user_id])
27+
end
28+
29+
def authorize_user
30+
handle_error(:forbidden) && return unless current_user == @user
31+
end
32+
33+
def set_breadcrumbs
34+
add_base_breadcrumbs('users')
35+
@breadcrumbs += [{ name: @user.name, url: user_path(@user) }, { name: 'Manage identities' }]
36+
end
37+
end

app/models/identity.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class Identity < ApplicationRecord
2+
belongs_to :user
3+
4+
validates :provider, presence: true
5+
validates :uid, uniqueness: { scope: :provider }, allow_nil: true
6+
7+
def self.from_omniauth(auth)
8+
Identity.where(provider: auth.provider, uid: auth.uid).first_or_initialize
9+
end
10+
end

app/models/user.rb

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class User < ApplicationRecord
4343
belongs_to :role, optional: true
4444
has_many :subscriptions, dependent: :destroy
4545
has_many :stars, dependent: :destroy
46+
has_many :identities, dependent: :destroy
4647
has_one :ban, dependent: :destroy, inverse_of: :user
4748
has_many :bans_as_banner, class_name: 'Ban', foreign_key: :banner_id, inverse_of: :banner, dependent: :nullify
4849
has_many :activities_as_owner,
@@ -201,21 +202,21 @@ def self.from_omniauth(auth)
201202
# TODO: The code below will update their account to note the Elixir auth. but leave their password intact;
202203
# TODO: is this what we should be doing?
203204

204-
# find by provider and { uid or email}
205-
users = User.where(provider: auth.provider, uid: auth.uid)
206-
if users.none? && auth.info.email.present? && auth.provider.present?
207-
users = User.where(provider: auth.provider, email: auth.info.email)
208-
end
205+
identity = Identity.where(provider: auth.provider, uid: auth.uid).first
206+
user = identity&.user
209207

210-
# get first user
211-
user = users.first
208+
if user.nil? && auth.info.email.present? && auth.provider.present?
209+
user = User.joins(:identities).where(email: auth.info.email, identities: { provider: auth.provider }).first
210+
end
212211

213212
if user
214-
# update provider and uid if present
215-
if user.provider.nil? and user.uid.nil?
216-
user.uid = auth.uid
217-
user.provider = auth.provider
218-
user.save
213+
existing_identity = user.identities.find_by(provider: auth.provider)
214+
unless existing_identity&.uid == auth.uid
215+
if existing_identity&.uid.blank?
216+
existing_identity.update(uid: auth.uid)
217+
else
218+
user.identities.create(provider: auth.provider, uid: auth.uid)
219+
end
219220
end
220221
else
221222
# set name components
@@ -226,12 +227,11 @@ def self.from_omniauth(auth)
226227

227228
# create user
228229
username = User.username_from_auth_info(auth.info)
229-
user = User.new(provider: auth.provider,
230-
uid: auth.uid,
231-
email: auth.info.email,
230+
user = User.new(email: auth.info.email,
232231
username: username,
233232
profile_attributes: { firstname: first_name, surname: last_name },
234233
)
234+
user.identities.build(provider: auth.provider, uid: auth.uid)
235235
user.skip_confirmation!
236236
end
237237

@@ -260,7 +260,7 @@ def self.not_banned
260260
end
261261

262262
def using_omniauth?
263-
provider.present? && uid.present?
263+
identities.any? || (provider.present? && uid.present?)
264264
end
265265

266266
def password_required?
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<div class="page-header">
2+
<%= page_title('Manage identities') %>
3+
</div>
4+
5+
<p>Linked identities can be used to sign in to your account.</p>
6+
7+
<div class="table-responsive">
8+
<table class="table table-striped">
9+
<thead>
10+
<tr>
11+
<th>Provider</th>
12+
<th>UID</th>
13+
<th></th>
14+
</tr>
15+
</thead>
16+
<tbody>
17+
<% @identities.each do |identity| %>
18+
<tr>
19+
<td><%= t("authentication.omniauth.providers.#{identity.provider}", default: identity.provider.to_s.titleize) %></td>
20+
<td><%= identity.uid.presence || content_tag(:span, 'Not set', class: 'empty') %></td>
21+
<td>
22+
<%= link_to 'Remove', user_identity_path(@user, identity),
23+
method: :delete,
24+
data: { confirm: 'Are you sure you want to remove this identity?' },
25+
class: 'btn btn-danger btn-sm' %>
26+
</td>
27+
</tr>
28+
<% end %>
29+
</tbody>
30+
</table>
31+
</div>
32+
33+
<h4>Add a new identity</h4>
34+
<ul>
35+
<% Devise.omniauth_configs.each do |provider, config| %>
36+
<% params = { link_identity: '1' } %>
37+
<% params[:space_id] = current_space.id unless current_space&.default? %>
38+
<li>
39+
<%= link_to t('authentication.omniauth.log_in_with',
40+
provider: config.options[:label] || t("authentication.omniauth.providers.#{provider}", default: provider.to_s.titleize)),
41+
omniauth_authorize_path('user', provider, **params),
42+
method: :post %>
43+
</li>
44+
<% end %>
45+
</ul>

app/views/users/show.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
<% if TeSS::Config.feature['registration'] %>
9494
<% if !current_user.nil? and current_user == @user %>
9595
<%= link_to "Manage account", edit_user_registration_path, :class => 'btn btn-default center-block' %>
96+
<%= link_to "Manage identities", user_identities_path(@user), :class => 'btn btn-default center-block mt-2' %>
9697
<% end %>
9798
<% end %>
9899
</div>
@@ -109,6 +110,7 @@
109110
<% if TeSS::Config.feature['registration'] %>
110111
<%= link_to "Manage account", edit_user_registration_path, class: 'btn btn-default' %>
111112
<% end %>
113+
<%= link_to "Manage identities", user_identities_path(@user), class: 'btn btn-default' %>
112114
<!-- Subscriptions Button -->
113115
<% if TeSS::Config.feature['subscription'] %>
114116
<%= link_to "Manage subscriptions", subscriptions_path, class: 'btn btn-default' %>

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
get 'sitemap.xml' => 'sitemaps#index', format: false
5454

5555
resources :users, only: [:show, :index, :edit, :create, :update, :destroy] do
56+
resources :identities, only: [:index, :destroy]
5657
resource :ban, only: [:create, :new, :destroy]
5758
end
5859

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class CreateIdentities < ActiveRecord::Migration[8.1]
2+
def change
3+
create_table :identities do |t|
4+
t.references :user, null: false, foreign_key: true
5+
t.string :provider, null: false
6+
t.string :uid
7+
8+
t.timestamps
9+
end
10+
11+
add_index :identities, [:provider, :uid], unique: true
12+
13+
reversible do |dir|
14+
dir.up do
15+
execute <<~SQL
16+
INSERT INTO identities (user_id, provider, uid, created_at, updated_at)
17+
SELECT id, provider, uid, NOW(), NOW()
18+
FROM users
19+
WHERE provider IS NOT NULL AND provider <> ''
20+
ON CONFLICT (provider, uid) DO NOTHING
21+
SQL
22+
end
23+
end
24+
end
25+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
require 'test_helper'
2+
3+
class IdentitiesControllerTest < ActionController::TestCase
4+
include Devise::Test::ControllerHelpers
5+
6+
test 'should show identities for current user' do
7+
sign_in users(:existing_aaf_user)
8+
9+
get :index, params: { user_id: users(:existing_aaf_user) }
10+
11+
assert_response :success
12+
assert_select 'h1', 'Manage identities'
13+
assert_select 'tbody tr', minimum: 1
14+
end
15+
16+
test "should not show another user's identities" do
17+
sign_in users(:regular_user)
18+
19+
get :index, params: { user_id: users(:existing_aaf_user) }
20+
21+
assert_response :forbidden
22+
end
23+
24+
test 'should remove linked identity' do
25+
user = users(:regular_user)
26+
sign_in user
27+
identity = user.identities.create!(provider: 'oidc', uid: 'abc-123')
28+
29+
assert_difference('Identity.count', -1) do
30+
delete :destroy, params: { user_id: user, id: identity }
31+
end
32+
33+
assert_redirected_to user_identities_path(user)
34+
assert_equal 'Identity removed.', flash[:notice]
35+
end
36+
37+
test 'should not remove last identity when account has no password' do
38+
user = users(:existing_aaf_user)
39+
sign_in user
40+
41+
assert_no_difference('Identity.count') do
42+
delete :destroy, params: { user_id: user, id: user.identities.first }
43+
end
44+
45+
assert_redirected_to user_identities_path(user)
46+
assert_equal 'You cannot remove your last identity because your account has no password.', flash[:notice]
47+
end
48+
end

test/fixtures/identities.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
existing_aaf_user_oidc:
2+
user: existing_aaf_user
3+
provider: oidc
4+
uid: '1234'
5+
6+
trainer_user_oidc:
7+
user: trainer_user
8+
provider: oidc
9+
10+
private_user_oidc:
11+
user: private_user
12+
provider: oidc
13+
14+
existing_tuakiri_user_oidc2:
15+
user: existing_tuakiri_user
16+
provider: oidc2
17+
18+
existing_aai_user_elixir_aai:
19+
user: existing_aai_user
20+
provider: elixir_aai
21+
uid: '1234'

0 commit comments

Comments
 (0)