users: track logins, signups, and other user events.

Add tracking of certain important user actions. These events include:

* Logins
* Logouts
* Failed login attempts
* Account creations
* Account deletions
* Password reset requests
* Password changes
* Email address changes

This is similar to the mod actions log, except for account activity
related to a single user.

The information tracked includes the user, the event type (login,
logout, etc), the timestamp, the user's IP address, IP geolocation
information, the user's browser user agent, and the user's session ID
from their session cookie. This information is visible to mods only.

This is done with three models. The UserEvent model tracks the event
type (login, logout, password change, etc) and the user. The UserEvent
is tied to a UserSession, which contains the user's IP address and
browser metadata. Finally, the IpGeolocation model contains the
geolocation information for IPs, including the city, country, ISP, and
whether the IP is a proxy.

This tracking will be used for a few purposes:

* Letting users view their account history, to detect things like logins
  from unrecognized IPs, failed logins attempts, password changes, etc.
* Rate limiting failed login attempts.
* Detecting sockpuppet accounts using their login history.
* Detecting unauthorized account sharing.
This commit is contained in:
evazion
2021-01-07 20:06:59 -06:00
parent 94e125709c
commit 65adcd09c2
39 changed files with 856 additions and 28 deletions

View File

@@ -26,6 +26,7 @@ class EmailsController < ApplicationController
@user = authorize User.find(params[:user_id]), policy_class: EmailAddressPolicy
if @user.authenticate_password(params[:user][:password])
UserEvent.build_from_request(@user, :email_change, request)
@user.update(email_address_attributes: { address: params[:user][:email] })
else
@user.errors.add(:base, "Password was incorrect")

View File

@@ -17,7 +17,7 @@ class IpAddressesController < ApplicationController
def show
@ip_address = authorize IpAddress.new(ip_addr: params[:id])
@ip_info = @ip_address.lookup.info
@ip_info = @ip_address.lookup.response
respond_with(@ip_info)
end
end

View File

@@ -0,0 +1,9 @@
class IpGeolocationsController < ApplicationController
respond_to :html, :json, :xml
def index
@ip_geolocations = authorize IpGeolocation.visible(CurrentUser.user).paginated_search(params, count_pages: true)
respond_with(@ip_geolocations)
end
end

View File

@@ -7,7 +7,7 @@ module Maintenance
end
def destroy
deletion = UserDeletion.new(CurrentUser.user, params.dig(:user, :password))
deletion = UserDeletion.new(CurrentUser.user, params.dig(:user, :password), request)
deletion.delete!
if deletion.errors.none?

View File

@@ -9,6 +9,7 @@ class PasswordResetsController < ApplicationController
redirect_to password_reset_path
elsif @user.can_receive_email?(require_verification: false)
UserMailer.password_reset(@user).deliver_later
UserEvent.create_from_request!(@user, :password_reset, request)
flash[:notice] = "Password reset email sent. Check your email"
respond_with(@user, location: new_session_path)
else

View File

@@ -10,6 +10,7 @@ class PasswordsController < ApplicationController
@user = authorize User.find(params[:user_id]), policy_class: PasswordPolicy
if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id]) || CurrentUser.user.is_owner?
UserEvent.build_from_request(@user, :password_change, request)
@user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation])
else
@user.errors.add(:base, "Incorrect password")

View File

@@ -20,7 +20,7 @@ class SessionsController < ApplicationController
end
def destroy
session.delete(:user_id)
SessionLoader.new(request).logout
redirect_to(posts_path, :notice => "You are now logged out")
end

View File

@@ -0,0 +1,10 @@
class UserEventsController < ApplicationController
respond_to :html, :json, :xml
def index
@user_events = authorize UserEvent.visible(CurrentUser.user).paginated_search(params, count_pages: true)
@user_events = @user_events.includes(:user, user_session: [:ip_geolocation]) if request.format.html?
respond_with(@user_events)
end
end

View File

@@ -0,0 +1,9 @@
class UserSessionsController < ApplicationController
respond_to :html, :json, :xml
def index
@user_sessions = authorize UserSession.visible(CurrentUser.user).paginated_search(params, count_pages: true)
respond_with(@user_sessions)
end
end

View File

@@ -70,6 +70,8 @@ class UsersController < ApplicationController
password_confirmation: params[:user][:password_confirmation]
)
UserEvent.build_from_request(@user, :user_creation, request)
if params[:user][:email].present?
@user.email_address = EmailAddress.new(address: params[:user][:email])
end

View File

@@ -193,7 +193,7 @@ module Searchable
search_text_attribute(name, params)
when :boolean
search_boolean_attribute(name, params)
when :integer, :datetime
when :integer, :float, :datetime
search_numeric_attribute(name, params)
when :inet
search_inet_attribute(name, params)

View File

@@ -9,23 +9,51 @@ class IpLookup
Danbooru.config.ip_registry_api_key.present?
end
def initialize(ip_addr, api_key: Danbooru.config.ip_registry_api_key, cache_duration: 1.day)
@ip_addr = ip_addr
def initialize(ip_addr, api_key: Danbooru.config.ip_registry_api_key, cache_duration: 3.days)
@ip_addr = IPAddress.parse(ip_addr.to_s)
@api_key = api_key
@cache_duration = cache_duration
end
def info
def ip_info
return {} if response.blank?
{
ip_addr: ip_addr.to_s,
network: response.dig(:connection, :route),
asn: response.dig(:connection, :asn),
is_proxy: is_proxy?,
latitude: response.dig(:location, :latitude),
longitude: response.dig(:location, :longitude),
organization: response.dig(:connection, :organization),
time_zone: response.dig(:time_zone, :id),
continent: response.dig(:location, :continent, :code),
country: response.dig(:location, :country, :code),
region: response.dig(:location, :region, :code),
city: response.dig(:location, :city),
carrier: response.dig(:carrier, :name),
}.with_indifferent_access
end
def response
return {} if api_key.blank?
response = Danbooru::Http.cache(cache_duration).get("https://api.ipregistry.co/#{ip_addr}?key=#{api_key}")
response = Danbooru::Http.cache(cache_duration).get("https://api.ipregistry.co/#{ip_addr.to_s}?key=#{api_key}")
return {} if response.status != 200
json = response.parse.deep_symbolize_keys.with_indifferent_access
json
end
def is_proxy?
info[:security].present? && info[:security].values.any?
def is_local?
if ip_addr.ipv4?
ip_addr.loopback? || ip_addr.link_local? || ip_addr.private?
elsif ip_addr.ipv6?
ip_addr.loopback? || ip_addr.link_local? || ip_addr.unique_local?
end
end
memoize :info, :is_proxy?
def is_proxy?
response[:security].present? && response[:security].values.any?
end
memoize :response, :is_proxy?
end

View File

@@ -10,12 +10,28 @@ class SessionLoader
end
def login(name, password)
user = User.find_by_name(name)&.authenticate_password(password)
return nil unless user
user = User.find_by_name(name)
session[:user_id] = user.id
user.update_column(:last_ip_addr, request.remote_ip)
user
if user.present? && user.authenticate_password(password)
session[:user_id] = user.id
UserEvent.build_from_request(user, :login, request)
user.last_logged_in_at = Time.now
user.last_ip_addr = request.remote_ip
user.save!
user
elsif user.nil?
nil # username incorrect
else
UserEvent.create_from_request!(user, :failed_login, request)
nil # password incorrect
end
end
def logout
session.delete(:user_id)
UserEvent.create_from_request!(CurrentUser.user, :logout, request)
end
def load
@@ -76,6 +92,7 @@ class SessionLoader
CurrentUser.user = user
end
# XXX use rails 6.1 signed ids (https://github.com/rails/rails/blob/6-1-stable/activerecord/CHANGELOG.md)
def load_param_user(signed_user_id)
session[:user_id] = Danbooru::MessageVerifier.new(:login).verify(signed_user_id)
load_session_user

View File

@@ -1,23 +1,26 @@
class UserDeletion
include ActiveModel::Validations
attr_reader :user, :password
attr_reader :user, :password, :request
validate :validate_deletion
def initialize(user, password)
def initialize(user, password, request)
@user = user
@password = password
@request = request
end
def delete!
return false if invalid?
clear_user_settings
remove_favorites
clear_saved_searches
rename
reset_password
create_mod_action
create_user_event
user
end
@@ -27,6 +30,10 @@ class UserDeletion
ModAction.log("user ##{user.id} deleted", :user_delete)
end
def create_user_event
UserEvent.create_from_request!(user, :user_deletion, request)
end
def clear_saved_searches
SavedSearch.where(user_id: user.id).destroy_all
end

View File

@@ -33,11 +33,7 @@ class UserVerifier
end
def is_local_ip?
if ip_address.ipv4?
ip_address.loopback? || ip_address.link_local? || ip_address.private?
elsif ip_address.ipv6?
ip_address.loopback? || ip_address.link_local? || ip_address.unique_local?
end
IpLookup.new(ip_address).is_local?
end
def is_logged_in?

View File

@@ -0,0 +1,29 @@
# An IpGeolocation contains metadata associated with an IP address, primarily geolocation data.
class IpGeolocation < ApplicationRecord
has_many :user_sessions, foreign_key: :ip_addr, primary_key: :ip_addr
def self.visible(user)
if user.is_moderator?
all
else
none
end
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :ip_addr, :network, :asn, :is_proxy, :latitude, :longitude, :organization, :time_zone, :continent, :country, :region, :city, :carrier)
q = q.apply_default_order(params)
q
end
def self.create_or_update!(ip_addr)
ip_lookup = IpLookup.new(ip_addr)
return nil if ip_lookup.is_local?
return nil if ip_lookup.ip_info.blank?
ip_geolocation = IpGeolocation.create_with(**ip_lookup.ip_info).create_or_find_by!(ip_addr: ip_addr)
ip_geolocation.update!(**ip_lookup.ip_info)
ip_geolocation
end
end

View File

@@ -102,6 +102,7 @@ class User < ApplicationRecord
has_many :bans, -> {order("bans.id desc")}
has_many :received_upgrades, class_name: "UserUpgrade", foreign_key: :recipient_id, dependent: :destroy
has_many :purchased_upgrades, class_name: "UserUpgrade", foreign_key: :purchaser_id, dependent: :destroy
has_many :user_events, dependent: :destroy
has_one :recent_ban, -> {order("bans.id desc")}, :class_name => "Ban"
has_one :api_key

54
app/models/user_event.rb Normal file
View File

@@ -0,0 +1,54 @@
# A UserEvent is used to track important events related to a user's account,
# such as signups, logins, password changes, etc. A UserEvent is associated
# with a UserSession, which contains the IP and browser information associated
# with the event.
class UserEvent < ApplicationRecord
belongs_to :user
belongs_to :user_session
enum category: {
login: 0,
failed_login: 50,
logout: 100,
user_creation: 200,
user_deletion: 300,
password_reset: 400,
password_change: 500,
email_change: 600,
}
delegate :session_id, :ip_addr, :ip_geolocation, to: :user_session
delegate :country, :city, :is_proxy?, to: :ip_geolocation, allow_nil: true
def self.visible(user)
if user.is_moderator?
all
else
where(user: user)
end
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :category, :user, :user_session)
q = q.apply_default_order(params)
q
end
concerning :ConstructorMethods do
class_methods do
# Build an event but don't save it yet. The caller is expected to update the user, which will save the event.
def build_from_request(user, category, request)
ip_addr = request.remote_ip
IpGeolocation.create_or_update!(ip_addr)
user_session = UserSession.new(session_id: request.session[:session_id], ip_addr: ip_addr, user_agent: request.user_agent)
user.user_events.build(user: user, category: category, user_session: user_session)
end
def create_from_request!(...)
build_from_request(...).save!
end
end
end
end

View File

@@ -0,0 +1,21 @@
# A UserSession contains browser and IP metadata associated with a UserEvent. This
# includes the user's session ID from their session cookie, their IP address,
# and their browser user agent. This is used to track logins and other events.
class UserSession < ApplicationRecord
belongs_to :ip_geolocation, foreign_key: :ip_addr, primary_key: :ip_addr, optional: true
def self.visible(user)
if user.is_moderator?
all
else
none
end
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :session_id, :user_agent, :ip_addr, :ip_geolocation)
q = q.apply_default_order(params)
q
end
end

View File

@@ -0,0 +1,5 @@
class IpGeolocationPolicy < ApplicationPolicy
def index?
user.is_moderator?
end
end

View File

@@ -0,0 +1,5 @@
class UserEventPolicy < ApplicationPolicy
def index?
user.is_moderator?
end
end

View File

@@ -0,0 +1,5 @@
class UserSessionPolicy < ApplicationPolicy
def index?
user.is_moderator?
end
end

View File

@@ -0,0 +1,43 @@
<div id="c-ip-geolocations">
<div id="a-index">
<%= search_form_for(ip_geolocations_path) do |f| %>
<%= f.input :ip_addr, label: "IP Address", input_html: { value: params[:search][:ip_addr] } %>
<%= f.input :asn, input_html: { value: params[:search][:asn] } %>
<%= f.input :continent, input_html: { value: params[:search][:continent] } %>
<%= f.input :country, as: :string, input_html: { value: params[:search][:country] } %>
<%= f.input :region, input_html: { value: params[:search][:region] } %>
<%= f.input :city, input_html: { value: params[:search][:city] } %>
<%= f.input :is_proxy, label: "Proxy?", as: :select, include_blank: true, selected: params[:search][:is_proxy] %>
<%= f.submit "Search" %>
<% end %>
<%= table_for @ip_geolocations, class: "striped autofit" do |t| %>
<% t.column "IP Address" do |ip_geolocation| %>
<%= ip_geolocation.ip_addr %>
<% end %>
<% t.column :network %>
<% t.column :asn %>
<% t.column :organization %>
<% t.column :is_proxy %>
<% t.column :continent %>
<% t.column :country %>
<% t.column :region %>
<% t.column :city %>
<% t.column "Updated" do |ip_geolocation| %>
<%= time_ago_in_words_tagged(ip_geolocation.updated_at) %>
<% end %>
<% t.column "Created" do |ip_geolocation| %>
<%= time_ago_in_words_tagged(ip_geolocation.created_at) %>
<% end %>
<% t.column column: "control" do |ip_geolocation| %>
<%= external_link_to "https://ipinfo.io/#{ip_geolocation.ip_addr}", "IP Info" %>
<% end %>
<% end %>
<%= numbered_paginator(@ip_geolocations) %>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<div id="c-user-events">
<div id="a-index">
<%= search_form_for(user_events_path) do |f| %>
<%= f.input :user_name, label: "User", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %>
<%= f.simple_fields_for :user_session do |f1| %>
<%= f1.input :ip_addr, label: "IP Address", input_html: { value: params.dig(:search, :user_session, :ip_addr) } %>
<%= f1.input :session_id, label: "Session", input_html: { value: params.dig(:search, :user_session, :session_id) } %>
<%= f1.simple_fields_for :ip_geolocation do |f2| %>
<%= f2.input :country, as: :string, input_html: { value: params.dig(:search, :user_session, :ip_geolocation, :country) } %>
<%= f2.input :city, input_html: { value: params.dig(:search, :user_session, :ip_geolocation, :city) } %>
<%= f2.input :is_proxy, label: "Proxy?", as: :select, include_blank: true, selected: params.dig(:search, :user_session, :ip_geolocation, :is_proxy) %>
<% end %>
<% end %>
<%= f.input :category, collection: UserEvent.categories.transform_keys(&:humanize), include_blank: true, selected: params[:search][:category] %>
<%= f.submit "Search" %>
<% end %>
<%= table_for @user_events, class: "striped autofit" do |t| %>
<% t.column "User" do |user_event| %>
<%= link_to_user user_event.user %>
<%= link_to "»", user_events_path(search: { user_name: user_event.user.name }) %>
<% end %>
<% t.column :category do |user_event| %>
<%= link_to user_event.category.humanize, user_events_path(search: { category: UserEvent.categories[user_event.category] }) %>
<% end %>
<% t.column "Session" do |user_event| %>
<%= link_to user_event.session_id, user_events_path(search: { user_session: { session_id: user_event.session_id }}) %>
<% end %>
<% t.column "IP Address" do |user_event| %>
<%= link_to user_event.ip_addr, user_events_path(search: { user_session: { ip_addr: user_event.ip_addr }}) %>
<%= link_to "»", ip_geolocations_path(search: { ip_addr: user_event.ip_addr }) %>
<% end %>
<% t.column "Country" do |user_event| %>
<% if user_event.country.present? %>
<%= link_to user_event.country, user_events_path(search: { user_session: { ip_geolocation: { country: user_event.country }}}) %>
<% end %>
<% end %>
<% t.column "City" do |user_event| %>
<% if user_event.city.present? %>
<%= link_to user_event.city, user_events_path(search: { user_session: { ip_geolocation: { city: user_event.city }}}) %>
<% end %>
<% end %>
<% t.column "Proxy?" do |user_event| %>
<% if user_event.is_proxy?.present? %>
<%= link_to user_event.is_proxy? ? "Yes" : "No", user_events_path(search: { user_session: { ip_geolocation: { is_proxy: user_event.is_proxy? }}}) %>
<% end %>
<% end %>
<% t.column "Created" do |user_event| %>
<%= time_ago_in_words_tagged(user_event.created_at) %>
<% end %>
<% t.column column: "control" do |user_event| %>
<%= external_link_to "https://ipinfo.io/#{user_event.ip_addr}", "IP Info" %>
<% end %>
<% end %>
<%= numbered_paginator(@user_events) %>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<div id="c-user-sessions">
<div id="a-index">
<%= search_form_for(user_sessions_path) do |f| %>
<%= f.input :session_id, label: "Session", input_html: { value: params[:search][:session_id] } %>
<%= f.input :ip_addr, label: "IP Address", input_html: { value: params[:search][:ip_addr] } %>
<%= f.submit "Search" %>
<% end %>
<%= table_for @user_sessions, class: "striped autofit" do |t| %>
<% t.column "Session" do |user_session| %>
<%= link_to user_session.session_id, user_sessions_path(search: { session_id: user_session.session_id }) %>
<%= link_to "»", user_events_path(search: { user_session: { session_id: user_session.session_id }}) %>
<% end %>
<% t.column "IP Address" do |user_session| %>
<%= link_to user_session.ip_addr, user_sessions_path(search: { ip_addr: user_session.ip_addr }) %>
<%= link_to "»", user_events_path(search: { user_session: { ip_addr: user_session.ip_addr }}) %>
<% end %>
<% t.column "Browser" do |user_session| %>
<%= user_session.user_agent %>
<% end %>
<% t.column "Created" do |user_session| %>
<%= time_ago_in_words_tagged(user_session.created_at) %>
<% end %>
<% end %>
<%= numbered_paginator(@user_sessions) %>
</div>
</div>