Skip to content

Commit c0173ed

Browse files
authored
Allow login for old Twitter users with same email (#1067)
Allow login for old Twitter users with same email
1 parent 761bbba commit c0173ed

File tree

4 files changed

+180
-1
lines changed

4 files changed

+180
-1
lines changed

app/models/authorization.rb

+27-1
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,35 @@ def self.handle_authorization(existing_user, auth)
1414
if authorization.present?
1515
authorization.user.update_from_auth! auth
1616
else
17-
user = existing_user || User.create_from_hash!(auth)
17+
user = existing_user || fallback_user_for_authorization(auth) || User.create_from_hash!(auth)
1818
authorization = user.authorizations.create! provider:, uid:
1919
end
2020
authorization
2121
end
22+
23+
def self.fallback_user_for_authorization(auth)
24+
return unless user_fallback_in_place?
25+
26+
email = auth.dig('info', 'email')
27+
return if email.blank?
28+
29+
users_with_email = User.where(email:)
30+
return unless users_with_email.count == 1
31+
32+
user = users_with_email.first
33+
return unless user.authorizations.count == 1 && user.authorizations.first.provider == 'twitter'
34+
35+
user.update_from_auth!(auth)
36+
end
37+
38+
DEFAULT_TWITTER_USER_FALLBACK_DEADLINE = Date.new(2025, 1, 1)
39+
40+
def self.user_fallback_in_place?
41+
deadline = begin
42+
Date.parse(ENV['TWITTER_USER_FALLBACK_DEADLINE'])
43+
rescue Date::Error, TypeError
44+
DEFAULT_TWITTER_USER_FALLBACK_DEADLINE
45+
end
46+
deadline.future?
47+
end
2248
end

readme.md

+30
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,36 @@ On the admin site we need to:
169169
- deploy to Heroku
170170
- add admin privileges to someone for the new RUG
171171

172+
## Users Login
173+
174+
In order to register for events, users need to log in first.
175+
176+
The app uses OAuth for that. At this point, it accepts Twitter [^1], GitHub and Google as valid OAuth providers.
177+
178+
When a user is not currently logged in, selecting one of the login providers creates an account for him/her, attaching this provider as a valid auth mechanism.
179+
180+
When a user is already logged in, selecting another provider will add that auth mechanism to the existing user.
181+
182+
[^1]: As of 2024 Twitter/X has deprecated API v1.1 and Twitter login is not working anymore
183+
184+
### Recovering login for existing users who only had Twitter auth
185+
186+
As a consequence of X deprecating API v1.1, Twitter authentication is not working anymore and registered users that only had defined Twitter as their auth method are left out in the cold.
187+
188+
In order to provide an easy way for these users to access their existing account using another auth provider, the app includes additional logic:
189+
190+
1. If a new session successfully authenticates using a provider that includes email as part of their info.
191+
2. If a user (and only one user) already exists with that same email
192+
3. If that user was using Twitter (and only Twitter) as OAuth provider
193+
194+
If all of these conditions are met, then the new provider is added to the existing user and the login proceeds with that.
195+
196+
If the existing user had other providers already registered, then this logic doesn't kick in, as they can still use one of the others to log in.
197+
198+
As this behaviour could lead to potential account takeovers (if another user had access to an OAuth account with the same email of the original user) there's a deadline after which it will stop working.
199+
200+
The default deadline is the end of year 2024. If needed it can be ajusted by setting the environment variable `TWITTER_USER_FALLBACK_DEADLINE` to a valid date address string (like '2025-06-30').
201+
172202
## Website
173203

174204
![OnRuby Website](https://cl.ly/image/3U0S3b0T0F0x/Screen%20Shot%202014-01-07%20at%2014.16.44.png)

spec/models/authorization_spec.rb

+122
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,126 @@
88
end.to change(User, :count).by(1)
99
end.to change(Authorization, :count).by(1)
1010
end
11+
12+
describe 'special treatment for stale Twitter users' do
13+
let(:existing_twitter_auth) { Authorization.handle_authorization(nil, TWITTER_AUTH_HASH) }
14+
let(:existing_twitter_user) { existing_twitter_auth.user }
15+
let(:new_auth_hash) { GITHUB_AUTH_HASH }
16+
let(:date_of_test) { Authorization::DEFAULT_TWITTER_USER_FALLBACK_DEADLINE - 1 }
17+
18+
around do |example|
19+
travel_to(date_of_test) do
20+
example.run
21+
end
22+
end
23+
24+
shared_examples 'failing with duplicate nickname' do
25+
it 'raises duplication error' do
26+
expect do
27+
Authorization.handle_authorization(nil, new_auth_hash)
28+
end.to raise_error(User::DuplicateNickname)
29+
end
30+
end
31+
32+
shared_examples 'updating the existing user' do
33+
it 'adds the auth to the existing Twitter account', :aggregate_failures do
34+
gh_auth = Authorization.handle_authorization(nil, new_auth_hash)
35+
expect(gh_auth.user).to eq(existing_twitter_user)
36+
expect(existing_twitter_user.reload.github).to eq(new_auth_hash.dig('info', 'nickname'))
37+
end
38+
end
39+
40+
before do
41+
existing_twitter_user
42+
create(:authorization, provider: 'twitter', user: create(:user, email: nil))
43+
end
44+
45+
context 'when emails do not match' do
46+
context 'when nicknames are the same' do
47+
it_behaves_like 'failing with duplicate nickname'
48+
end
49+
50+
context 'when nicknames are different' do
51+
before { existing_twitter_user.update!(nickname: existing_twitter_user.nickname * 2) }
52+
53+
it 'creates a new user', :aggregate_failures do
54+
gh_user = Authorization.handle_authorization(nil, new_auth_hash).user
55+
expect(gh_user).to be_persisted
56+
expect(gh_user).not_to eq(existing_twitter_user)
57+
end
58+
end
59+
end
60+
61+
context 'when emails match' do
62+
before { existing_twitter_user.update!(email: new_auth_hash.dig('info', 'email')) }
63+
64+
context 'when email is unique and user has only Twiter auth' do
65+
around do |example|
66+
value_before = ENV['TWITTER_USER_FALLBACK_DEADLINE']
67+
ENV['TWITTER_USER_FALLBACK_DEADLINE'] = deadline_value
68+
example.run
69+
ENV['TWITTER_USER_FALLBACK_DEADLINE'] = value_before
70+
end
71+
72+
shared_examples 'updating the user before the deadline, failing afterwards' do
73+
context 'before the deadline' do
74+
let(:date_of_test) { effective_deadline - 1 }
75+
76+
it_behaves_like 'updating the existing user'
77+
end
78+
79+
context 'with a valid value and after that deadline' do
80+
let(:date_of_test) { effective_deadline + 1 }
81+
82+
it_behaves_like 'failing with duplicate nickname'
83+
end
84+
end
85+
86+
context 'with no explicit deadline' do
87+
let(:deadline_value) { nil }
88+
let(:effective_deadline) { Authorization::DEFAULT_TWITTER_USER_FALLBACK_DEADLINE }
89+
90+
it_behaves_like 'updating the user before the deadline, failing afterwards'
91+
end
92+
93+
context 'with a valid explicit deadline' do
94+
let(:deadline_value) { '2026-01-01' }
95+
let(:effective_deadline) { Date.parse('2026-01-01') }
96+
97+
it_behaves_like 'updating the user before the deadline, failing afterwards'
98+
end
99+
100+
context 'with an invalid explicit deadline' do
101+
let(:deadline_value) { 'bad date!' }
102+
let(:effective_deadline) { Authorization::DEFAULT_TWITTER_USER_FALLBACK_DEADLINE }
103+
104+
it_behaves_like 'updating the user before the deadline, failing afterwards'
105+
end
106+
end
107+
108+
context 'when several users have the same email' do
109+
before { create(:user, email: existing_twitter_user.email) }
110+
111+
it_behaves_like 'failing with duplicate nickname'
112+
end
113+
114+
context 'when the user has other authorizations' do
115+
before { create(:authorization, user: existing_twitter_user) }
116+
117+
it_behaves_like 'failing with duplicate nickname'
118+
end
119+
120+
context 'when the user has a single auth but it is not Twitter' do
121+
before { existing_twitter_user.authorizations = [create(:authorization)] }
122+
123+
it_behaves_like 'failing with duplicate nickname'
124+
end
125+
end
126+
127+
context 'when new auth has no email' do
128+
let(:new_auth_hash) { GITHUB_AUTH_HASH.deep_merge('info' => { 'email' => nil }) }
129+
130+
it_behaves_like 'failing with duplicate nickname'
131+
end
132+
end
11133
end

spec/spec_helper.rb

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
config.include KaminariHelper
1313
config.include GeocoderHelper
1414
config.include FactoryBot::Syntax::Methods
15+
config.include ActiveSupport::Testing::TimeHelpers
1516

1617
# config.raise_errors_for_deprecations!
1718
config.infer_spec_type_from_file_location!

0 commit comments

Comments
 (0)