User export: profile as json, export auth token logs (#10819)

* FEATURE: Export the entire user profile as json, not just bio/website

* FEATURE: Add session log information to user export

Even though the columns are named 'auth_token' etc, the content is not actually usable to log into the forum with. Despite all that, it is still truncated for export, to avoid any 'token hash cracking' situations.
This commit is contained in:
Kane York 2020-10-06 15:51:53 -07:00 committed by GitHub
parent 7b34433fc2
commit 68e87bb58e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 122 additions and 11 deletions

View File

@ -12,7 +12,9 @@ module Jobs
COMPONENTS ||= %w( COMPONENTS ||= %w(
user_archive user_archive
user_archive_profile preferences
auth_tokens
auth_token_logs
badges badges
bookmarks bookmarks
category_preferences category_preferences
@ -22,6 +24,8 @@ module Jobs
HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new(
user_archive: ['topic_title', 'categories', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], user_archive: ['topic_title', 'categories', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'],
user_archive_profile: ['location', 'website', 'bio', 'views'], user_archive_profile: ['location', 'website', 'bio', 'views'],
auth_tokens: ['id', 'auth_token_hash', 'prev_auth_token_hash', 'auth_token_seen', 'client_ip', 'user_agent', 'seen_at', 'rotated_at', 'created_at', 'updated_at'],
auth_token_logs: ['id', 'action', 'user_auth_token_id', 'client_ip', 'auth_token_hash', 'created_at', 'path', 'user_agent'],
badges: ['badge_id', 'badge_name', 'granted_at', 'post_id', 'seq', 'granted_manually', 'notification_id', 'featured_rank'], badges: ['badge_id', 'badge_name', 'granted_at', 'post_id', 'seq', 'granted_manually', 'notification_id', 'featured_rank'],
bookmarks: ['post_id', 'topic_id', 'post_number', 'link', 'name', 'created_at', 'updated_at', 'reminder_type', 'reminder_at', 'reminder_last_sent_at', 'reminder_set_at', 'auto_delete_preference'], bookmarks: ['post_id', 'topic_id', 'post_number', 'link', 'name', 'created_at', 'updated_at', 'reminder_type', 'reminder_at', 'reminder_last_sent_at', 'reminder_set_at', 'auto_delete_preference'],
category_preferences: ['category_id', 'category_names', 'notification_level', 'dismiss_new_timestamp'], category_preferences: ['category_id', 'category_names', 'notification_level', 'dismiss_new_timestamp'],
@ -38,12 +42,15 @@ module Jobs
COMPONENTS.each do |name| COMPONENTS.each do |name|
h = { name: name, method: :"#{name}_export" } h = { name: name, method: :"#{name}_export" }
h[:filetype] = :csv h[:filetype] = :csv
filename_method = :"#{name}_filename" filetype_method = :"#{name}_filetype"
if respond_to? filename_method if respond_to? filetype_method
h[:filename] = public_send(filename_method) h[:filetype] = public_send(filetype_method)
else
h[:filename] = name
end end
condition_method = :"include_#{name}?"
if respond_to? condition_method
h[:skip] = !public_send(condition_method)
end
h[:filename] = name
components.push(h) components.push(h)
end end
@ -61,12 +68,17 @@ module Jobs
zip_filename = nil zip_filename = nil
begin begin
components.each do |component| components.each do |component|
next if component[:skip]
case component[:filetype] case component[:filetype]
when :csv when :csv
CSV.open("#{dirname}/#{component[:filename]}.csv", "w") do |csv| CSV.open("#{dirname}/#{component[:filename]}.csv", "w") do |csv|
csv << get_header(component[:name]) csv << get_header(component[:name])
public_send(component[:method]) { |d| csv << d } public_send(component[:method]) { |d| csv << d }
end end
when :json
File.open("#{dirname}/#{component[:filename]}.json", "w") do |file|
file.write MultiJson.dump(public_send(component[:method]), indent: 4)
end
else else
raise 'unknown export filetype' raise 'unknown export filetype'
end end
@ -132,6 +144,59 @@ module Jobs
end end
end end
def preferences_export
UserSerializer.new(@current_user, scope: guardian)
end
def preferences_filetype
:json
end
def auth_tokens_export
return enum_for(:auth_tokens) unless block_given?
UserAuthToken
.where(user_id: @current_user.id)
.each do |token|
yield [
token.id,
token.auth_token.to_s[0..4] + "...", # hashed and truncated
token.prev_auth_token[0..4] + "...",
token.auth_token_seen,
token.client_ip,
token.user_agent,
token.seen_at,
token.rotated_at,
token.created_at,
token.updated_at,
]
end
end
def include_auth_token_logs?
# SiteSetting.verbose_auth_token_logging
UserAuthTokenLog.where(user_id: @current_user.id).exists?
end
def auth_token_logs_export
return enum_for(:auth_token_logs) unless block_given?
UserAuthTokenLog
.where(user_id: @current_user.id)
.each do |log|
yield [
log.id,
log.action,
log.user_auth_token_id,
log.client_ip,
log.auth_token.to_s[0..4] + "...", # hashed and truncated
log.created_at,
log.path,
log.user_agent,
]
end
end
def badges_export def badges_export
return enum_for(:badges_export) unless block_given? return enum_for(:badges_export) unless block_given?

View File

@ -26,6 +26,10 @@ describe Jobs::ExportUserArchive do
[data_rows, csv_out] [data_rows, csv_out]
end end
def make_component_json
JSON.parse(MultiJson.dump(job.public_send(:"#{component}_export")))
end
context '#execute' do context '#execute' do
let(:post) { Fabricate(:post, user: user) } let(:post) { Fabricate(:post, user: user) }
@ -33,6 +37,11 @@ describe Jobs::ExportUserArchive do
_ = post _ = post
user.user_profile.website = 'https://doe.example.com/john' user.user_profile.website = 'https://doe.example.com/john'
user.user_profile.save user.user_profile.save
# force a UserAuthTokenLog entry
Discourse.current_user_provider.new({
'HTTP_USER_AGENT' => 'MyWebBrowser',
'REQUEST_PATH' => '/some_path/456852',
}).log_on_user(user, {}, {})
end end
after do after do
@ -143,20 +152,57 @@ describe Jobs::ExportUserArchive do
end end
end end
context 'user_archive_profile' do context 'preferences' do
let(:component) { 'user_archive_profile' } let(:component) { 'preferences' }
before do before do
user.user_profile.website = 'https://doe.example.com/john' user.user_profile.website = 'https://doe.example.com/john'
user.user_profile.bio_raw = "I am John Doe\n\nHere I am" user.user_profile.bio_raw = "I am John Doe\n\nHere I am"
user.user_profile.save user.user_profile.save
user.user_option.text_size = :smaller
user.user_option.automatically_unpin_topics = false
user.user_option.save
end end
it 'properly includes the profile fields' do it 'properly includes the profile fields' do
_, csv_out = make_component_csv serializer = job.preferences_export
# puts MultiJson.dump(serializer, indent: 4)
output = make_component_json
payload = output['user']
expect(csv_out).to match('doe.example.com') expect(payload['website']).to match('doe.example.com')
expect(csv_out).to match("Doe\n\nHere") expect(payload['bio_raw']).to match("Doe\n\nHere")
expect(payload['user_option']['automatically_unpin_topics']).to eq(false)
expect(payload['user_option']['text_size']).to eq('smaller')
end
end
context 'auth tokens' do
let(:component) { 'auth_tokens' }
before do
Discourse.current_user_provider.new({
'HTTP_USER_AGENT' => 'MyWebBrowser',
'REQUEST_PATH' => '/some_path/456852',
}).log_on_user(user, {}, {})
end
it 'properly includes session records' do
data, csv_out = make_component_csv
expect(data.length).to eq(1)
expect(data[0]['user_agent']).to eq('MyWebBrowser')
end
context 'auth token logs' do
let(:component) { 'auth_token_logs' }
it 'includes details such as the path' do
data, csv_out = make_component_csv
expect(data.length).to eq(1)
expect(data[0]['action']).to eq('generate')
expect(data[0]['path']).to eq('/some_path/456852')
end
end end
end end