From 65adcd09c2d16fb56c5494ed20b050feec225865 Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 7 Jan 2021 20:06:59 -0600 Subject: [PATCH] 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. --- app/controllers/emails_controller.rb | 1 + app/controllers/ip_addresses_controller.rb | 2 +- app/controllers/ip_geolocations_controller.rb | 9 + .../maintenance/user/deletions_controller.rb | 2 +- app/controllers/password_resets_controller.rb | 1 + app/controllers/passwords_controller.rb | 1 + app/controllers/sessions_controller.rb | 2 +- app/controllers/user_events_controller.rb | 10 + app/controllers/user_sessions_controller.rb | 9 + app/controllers/users_controller.rb | 2 + app/logical/concerns/searchable.rb | 2 +- app/logical/ip_lookup.rb | 42 ++- app/logical/session_loader.rb | 27 +- app/logical/user_deletion.rb | 11 +- app/logical/user_verifier.rb | 6 +- app/models/ip_geolocation.rb | 29 ++ app/models/user.rb | 1 + app/models/user_event.rb | 54 +++ app/models/user_session.rb | 21 ++ app/policies/ip_geolocation_policy.rb | 5 + app/policies/user_event_policy.rb | 5 + app/policies/user_session_policy.rb | 5 + app/views/ip_geolocations/index.html.erb | 43 +++ app/views/user_events/index.html.erb | 66 ++++ app/views/user_sessions/index.html.erb | 31 ++ config/routes.rb | 3 + .../20210108030722_create_ip_geolocations.rb | 21 ++ .../20210108030723_create_user_sessions.rb | 10 + .../20210108030724_create_user_events.rb | 10 + db/structure.sql | 325 +++++++++++++++++- test/factories/user_event.rb | 5 + test/functional/emails_controller_test.rb | 3 + .../user/deletions_controller_test.rb | 4 + .../password_resets_controller_test.rb | 1 + test/functional/passwords_controller_test.rb | 3 + test/functional/sessions_controller_test.rb | 18 +- test/functional/users_controller_test.rb | 9 + test/unit/ip_geolocation_test.rb | 72 ++++ test/unit/user_deletion_test.rb | 13 +- 39 files changed, 856 insertions(+), 28 deletions(-) create mode 100644 app/controllers/ip_geolocations_controller.rb create mode 100644 app/controllers/user_events_controller.rb create mode 100644 app/controllers/user_sessions_controller.rb create mode 100644 app/models/ip_geolocation.rb create mode 100644 app/models/user_event.rb create mode 100644 app/models/user_session.rb create mode 100644 app/policies/ip_geolocation_policy.rb create mode 100644 app/policies/user_event_policy.rb create mode 100644 app/policies/user_session_policy.rb create mode 100644 app/views/ip_geolocations/index.html.erb create mode 100644 app/views/user_events/index.html.erb create mode 100644 app/views/user_sessions/index.html.erb create mode 100644 db/migrate/20210108030722_create_ip_geolocations.rb create mode 100644 db/migrate/20210108030723_create_user_sessions.rb create mode 100644 db/migrate/20210108030724_create_user_events.rb create mode 100644 test/factories/user_event.rb create mode 100644 test/unit/ip_geolocation_test.rb diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb index 6d8eb3f19..2f14ad9c3 100644 --- a/app/controllers/emails_controller.rb +++ b/app/controllers/emails_controller.rb @@ -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") diff --git a/app/controllers/ip_addresses_controller.rb b/app/controllers/ip_addresses_controller.rb index 11a592b01..e0ab7ffd4 100644 --- a/app/controllers/ip_addresses_controller.rb +++ b/app/controllers/ip_addresses_controller.rb @@ -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 diff --git a/app/controllers/ip_geolocations_controller.rb b/app/controllers/ip_geolocations_controller.rb new file mode 100644 index 000000000..a51f9fcc4 --- /dev/null +++ b/app/controllers/ip_geolocations_controller.rb @@ -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 diff --git a/app/controllers/maintenance/user/deletions_controller.rb b/app/controllers/maintenance/user/deletions_controller.rb index 627a65a73..8b85bb163 100644 --- a/app/controllers/maintenance/user/deletions_controller.rb +++ b/app/controllers/maintenance/user/deletions_controller.rb @@ -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? diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb index 06274fb0d..746f43869 100644 --- a/app/controllers/password_resets_controller.rb +++ b/app/controllers/password_resets_controller.rb @@ -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 diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f5fcc40e0..ffec5bc2a 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -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") diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 610e00150..e95e76e2b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/user_events_controller.rb b/app/controllers/user_events_controller.rb new file mode 100644 index 000000000..5348fcfc5 --- /dev/null +++ b/app/controllers/user_events_controller.rb @@ -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 diff --git a/app/controllers/user_sessions_controller.rb b/app/controllers/user_sessions_controller.rb new file mode 100644 index 000000000..49446fe8b --- /dev/null +++ b/app/controllers/user_sessions_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a53d97ade..448f46c6f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index aa6e4971f..db7f7788b 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -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) diff --git a/app/logical/ip_lookup.rb b/app/logical/ip_lookup.rb index 7a6e3bf1a..f8dae9747 100644 --- a/app/logical/ip_lookup.rb +++ b/app/logical/ip_lookup.rb @@ -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 diff --git a/app/logical/session_loader.rb b/app/logical/session_loader.rb index 12f12e189..c3c4bcac1 100644 --- a/app/logical/session_loader.rb +++ b/app/logical/session_loader.rb @@ -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 diff --git a/app/logical/user_deletion.rb b/app/logical/user_deletion.rb index e39192736..d74d6ba56 100644 --- a/app/logical/user_deletion.rb +++ b/app/logical/user_deletion.rb @@ -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 diff --git a/app/logical/user_verifier.rb b/app/logical/user_verifier.rb index 3b493a51c..0e17e8446 100644 --- a/app/logical/user_verifier.rb +++ b/app/logical/user_verifier.rb @@ -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? diff --git a/app/models/ip_geolocation.rb b/app/models/ip_geolocation.rb new file mode 100644 index 000000000..266ce25b0 --- /dev/null +++ b/app/models/ip_geolocation.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index c48b5c620..5eb1fc0f0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_event.rb b/app/models/user_event.rb new file mode 100644 index 000000000..752a02398 --- /dev/null +++ b/app/models/user_event.rb @@ -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 diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 000000000..9ecaaaab7 --- /dev/null +++ b/app/models/user_session.rb @@ -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 diff --git a/app/policies/ip_geolocation_policy.rb b/app/policies/ip_geolocation_policy.rb new file mode 100644 index 000000000..bd269f3ee --- /dev/null +++ b/app/policies/ip_geolocation_policy.rb @@ -0,0 +1,5 @@ +class IpGeolocationPolicy < ApplicationPolicy + def index? + user.is_moderator? + end +end diff --git a/app/policies/user_event_policy.rb b/app/policies/user_event_policy.rb new file mode 100644 index 000000000..ddc8b240e --- /dev/null +++ b/app/policies/user_event_policy.rb @@ -0,0 +1,5 @@ +class UserEventPolicy < ApplicationPolicy + def index? + user.is_moderator? + end +end diff --git a/app/policies/user_session_policy.rb b/app/policies/user_session_policy.rb new file mode 100644 index 000000000..cb84fe503 --- /dev/null +++ b/app/policies/user_session_policy.rb @@ -0,0 +1,5 @@ +class UserSessionPolicy < ApplicationPolicy + def index? + user.is_moderator? + end +end diff --git a/app/views/ip_geolocations/index.html.erb b/app/views/ip_geolocations/index.html.erb new file mode 100644 index 000000000..2c99283a7 --- /dev/null +++ b/app/views/ip_geolocations/index.html.erb @@ -0,0 +1,43 @@ +
+
+ <%= 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) %> +
+
diff --git a/app/views/user_events/index.html.erb b/app/views/user_events/index.html.erb new file mode 100644 index 000000000..c0ac5b22c --- /dev/null +++ b/app/views/user_events/index.html.erb @@ -0,0 +1,66 @@ +
+
+ <%= 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) %> +
+
diff --git a/app/views/user_sessions/index.html.erb b/app/views/user_sessions/index.html.erb new file mode 100644 index 000000000..f8b3c5af9 --- /dev/null +++ b/app/views/user_sessions/index.html.erb @@ -0,0 +1,31 @@ +
+
+ <%= 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) %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 7b32daaf4..5457e8719 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -140,6 +140,7 @@ Rails.application.routes.draw do resources :forum_topic_visits, only: [:index] resources :ip_bans, only: [:index, :new, :create, :update] resources :ip_addresses, only: [:show, :index], id: /.+?(?=\.json|\.xml|\.html)|.+/ + resources :ip_geolocations, only: [:index] resource :iqdb_queries, :only => [:show, :create] do collection do get :preview @@ -260,7 +261,9 @@ Rails.application.routes.draw do get :payment, on: :member put :refund, on: :member end + resources :user_events, only: [:index] resources :user_feedbacks, except: [:destroy] + resources :user_sessions, only: [:index] resources :user_name_change_requests, only: [:new, :create, :show, :index] resources :webhooks do post :receive, on: :collection diff --git a/db/migrate/20210108030722_create_ip_geolocations.rb b/db/migrate/20210108030722_create_ip_geolocations.rb new file mode 100644 index 000000000..7a9da0176 --- /dev/null +++ b/db/migrate/20210108030722_create_ip_geolocations.rb @@ -0,0 +1,21 @@ +class CreateIpGeolocations < ActiveRecord::Migration[6.1] + def change + create_table :ip_geolocations do |t| + t.timestamps null: false, index: true + + t.inet :ip_addr, null: false, index: { unique: true } + t.inet :network, index: true + t.integer :asn, index: true + t.boolean :is_proxy, null: false, index: true + t.float :latitude, index: true + t.float :longitude, index: true + t.string :organization, index: true + t.string :time_zone, index: true + t.string :continent, index: true + t.string :country, index: true + t.string :region, index: true + t.string :city, index: true + t.string :carrier, index: true + end + end +end diff --git a/db/migrate/20210108030723_create_user_sessions.rb b/db/migrate/20210108030723_create_user_sessions.rb new file mode 100644 index 000000000..56210cc6e --- /dev/null +++ b/db/migrate/20210108030723_create_user_sessions.rb @@ -0,0 +1,10 @@ +class CreateUserSessions < ActiveRecord::Migration[6.1] + def change + create_table :user_sessions do |t| + t.timestamps null: false, index: true + t.inet :ip_addr, null: false, index: true + t.string :session_id, null: false, index: true + t.string :user_agent + end + end +end diff --git a/db/migrate/20210108030724_create_user_events.rb b/db/migrate/20210108030724_create_user_events.rb new file mode 100644 index 000000000..dec3a7bdf --- /dev/null +++ b/db/migrate/20210108030724_create_user_events.rb @@ -0,0 +1,10 @@ +class CreateUserEvents < ActiveRecord::Migration[6.1] + def change + create_table :user_events do |t| + t.timestamps null: false, index: true + t.references :user, null: false, index: true + t.references :user_session, null: false, index: true + t.integer :category, null: false, index: true + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 2d978ad70..9508f40a1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2364,6 +2364,49 @@ CREATE SEQUENCE public.ip_bans_id_seq ALTER SEQUENCE public.ip_bans_id_seq OWNED BY public.ip_bans.id; +-- +-- Name: ip_geolocations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ip_geolocations ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + ip_addr inet NOT NULL, + network inet, + asn integer, + is_proxy boolean NOT NULL, + latitude double precision, + longitude double precision, + organization character varying, + time_zone character varying, + continent character varying, + country character varying, + region character varying, + city character varying, + carrier character varying +); + + +-- +-- Name: ip_geolocations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.ip_geolocations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: ip_geolocations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.ip_geolocations_id_seq OWNED BY public.ip_geolocations.id; + + -- -- Name: mod_actions; Type: TABLE; Schema: public; Owner: - -- @@ -3038,6 +3081,39 @@ CREATE SEQUENCE public.uploads_id_seq ALTER SEQUENCE public.uploads_id_seq OWNED BY public.uploads.id; +-- +-- Name: user_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_events ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + user_id bigint NOT NULL, + user_session_id bigint NOT NULL, + category integer NOT NULL +); + + +-- +-- Name: user_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_events_id_seq OWNED BY public.user_events.id; + + -- -- Name: user_feedback; Type: TABLE; Schema: public; Owner: - -- @@ -3106,6 +3182,39 @@ CREATE SEQUENCE public.user_name_change_requests_id_seq ALTER SEQUENCE public.user_name_change_requests_id_seq OWNED BY public.user_name_change_requests.id; +-- +-- Name: user_sessions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_sessions ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + ip_addr inet NOT NULL, + session_id character varying NOT NULL, + user_agent character varying +); + + +-- +-- Name: user_sessions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_sessions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_sessions_id_seq OWNED BY public.user_sessions.id; + + -- -- Name: user_upgrades; Type: TABLE; Schema: public; Owner: - -- @@ -4062,6 +4171,13 @@ ALTER TABLE ONLY public.forum_topics ALTER COLUMN id SET DEFAULT nextval('public ALTER TABLE ONLY public.ip_bans ALTER COLUMN id SET DEFAULT nextval('public.ip_bans_id_seq'::regclass); +-- +-- Name: ip_geolocations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ip_geolocations ALTER COLUMN id SET DEFAULT nextval('public.ip_geolocations_id_seq'::regclass); + + -- -- Name: mod_actions id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4195,6 +4311,13 @@ ALTER TABLE ONLY public.tags ALTER COLUMN id SET DEFAULT nextval('public.tags_id ALTER TABLE ONLY public.uploads ALTER COLUMN id SET DEFAULT nextval('public.uploads_id_seq'::regclass); +-- +-- Name: user_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_events ALTER COLUMN id SET DEFAULT nextval('public.user_events_id_seq'::regclass); + + -- -- Name: user_feedback id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4209,6 +4332,13 @@ ALTER TABLE ONLY public.user_feedback ALTER COLUMN id SET DEFAULT nextval('publi ALTER TABLE ONLY public.user_name_change_requests ALTER COLUMN id SET DEFAULT nextval('public.user_name_change_requests_id_seq'::regclass); +-- +-- Name: user_sessions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_sessions ALTER COLUMN id SET DEFAULT nextval('public.user_sessions_id_seq'::regclass); + + -- -- Name: user_upgrades id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4414,6 +4544,13 @@ ALTER TABLE ONLY public.ip_bans -- +-- Name: ip_geolocations ip_geolocations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ip_geolocations + ADD CONSTRAINT ip_geolocations_pkey PRIMARY KEY (id); + + -- Name: mod_actions mod_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4565,6 +4702,14 @@ ALTER TABLE ONLY public.uploads ADD CONSTRAINT uploads_pkey PRIMARY KEY (id); +-- +-- Name: user_events user_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_events + ADD CONSTRAINT user_events_pkey PRIMARY KEY (id); + + -- -- Name: user_feedback user_feedback_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4581,6 +4726,14 @@ ALTER TABLE ONLY public.user_name_change_requests ADD CONSTRAINT user_name_change_requests_pkey PRIMARY KEY (id); +-- +-- Name: user_sessions user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_sessions + ADD CONSTRAINT user_sessions_pkey PRIMARY KEY (id); + + -- -- Name: user_upgrades user_upgrades_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -6531,6 +6684,110 @@ CREATE INDEX index_ip_bans_on_is_deleted ON public.ip_bans USING btree (is_delet -- +-- Name: index_ip_geolocations_on_asn; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_asn ON public.ip_geolocations USING btree (asn); + + +-- +-- Name: index_ip_geolocations_on_carrier; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_carrier ON public.ip_geolocations USING btree (carrier); + + +-- +-- Name: index_ip_geolocations_on_city; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_city ON public.ip_geolocations USING btree (city); + + +-- +-- Name: index_ip_geolocations_on_continent; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_continent ON public.ip_geolocations USING btree (continent); + + +-- +-- Name: index_ip_geolocations_on_country; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_country ON public.ip_geolocations USING btree (country); + + +-- +-- Name: index_ip_geolocations_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_created_at ON public.ip_geolocations USING btree (created_at); + + +-- +-- Name: index_ip_geolocations_on_ip_addr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_ip_geolocations_on_ip_addr ON public.ip_geolocations USING btree (ip_addr); + + +-- +-- Name: index_ip_geolocations_on_is_proxy; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_is_proxy ON public.ip_geolocations USING btree (is_proxy); + + +-- +-- Name: index_ip_geolocations_on_latitude; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_latitude ON public.ip_geolocations USING btree (latitude); + + +-- +-- Name: index_ip_geolocations_on_longitude; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_longitude ON public.ip_geolocations USING btree (longitude); + + +-- +-- Name: index_ip_geolocations_on_network; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_network ON public.ip_geolocations USING btree (network); + + +-- +-- Name: index_ip_geolocations_on_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_organization ON public.ip_geolocations USING btree (organization); + + +-- +-- Name: index_ip_geolocations_on_region; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_region ON public.ip_geolocations USING btree (region); + + +-- +-- Name: index_ip_geolocations_on_time_zone; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_time_zone ON public.ip_geolocations USING btree (time_zone); + + +-- +-- Name: index_ip_geolocations_on_updated_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ip_geolocations_on_updated_at ON public.ip_geolocations USING btree (updated_at); + + -- Name: index_mod_actions_on_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -7062,6 +7319,41 @@ CREATE INDEX index_uploads_on_uploader_id ON public.uploads USING btree (uploade CREATE INDEX index_uploads_on_uploader_ip_addr ON public.uploads USING btree (uploader_ip_addr); +-- +-- Name: index_user_events_on_category; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_events_on_category ON public.user_events USING btree (category); + + +-- +-- Name: index_user_events_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_events_on_created_at ON public.user_events USING btree (created_at); + + +-- +-- Name: index_user_events_on_updated_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_events_on_updated_at ON public.user_events USING btree (updated_at); + + +-- +-- Name: index_user_events_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_events_on_user_id ON public.user_events USING btree (user_id); + + +-- +-- Name: index_user_events_on_user_session_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_events_on_user_session_id ON public.user_events USING btree (user_session_id); + + -- -- Name: index_user_feedback_on_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -7097,6 +7389,34 @@ CREATE INDEX index_user_name_change_requests_on_original_name ON public.user_nam CREATE INDEX index_user_name_change_requests_on_user_id ON public.user_name_change_requests USING btree (user_id); +-- +-- Name: index_user_sessions_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_sessions_on_created_at ON public.user_sessions USING btree (created_at); + + +-- +-- Name: index_user_sessions_on_ip_addr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_sessions_on_ip_addr ON public.user_sessions USING btree (ip_addr); + + +-- +-- Name: index_user_sessions_on_session_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_sessions_on_session_id ON public.user_sessions USING btree (session_id); + + +-- +-- Name: index_user_sessions_on_updated_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_sessions_on_updated_at ON public.user_sessions USING btree (updated_at); + + -- -- Name: index_user_upgrades_on_purchaser_id; Type: INDEX; Schema: public; Owner: - -- @@ -7526,6 +7846,9 @@ INSERT INTO "schema_migrations" (version) VALUES ('20201213052805'), ('20201219201007'), ('20201224101208'), -('20210106212805'); +('20210106212805'), +('20210108030722'), +('20210108030723'), +('20210108030724'); diff --git a/test/factories/user_event.rb b/test/factories/user_event.rb new file mode 100644 index 000000000..37957ea71 --- /dev/null +++ b/test/factories/user_event.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory(:user_event) do + user + end +end diff --git a/test/functional/emails_controller_test.rb b/test/functional/emails_controller_test.rb index ce6c5ae5c..ec6ad52d0 100644 --- a/test/functional/emails_controller_test.rb +++ b/test/functional/emails_controller_test.rb @@ -89,6 +89,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest assert_equal("abc@ogres.net", @user.reload.email_address.address) assert_equal(false, @user.email_address.is_verified) assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default" + assert_equal(true, @user.user_events.email_change.exists?) end should "create a new address" do @@ -102,6 +103,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest assert_equal("abc@ogres.net", @user.reload.email_address.address) assert_equal(false, @user.reload.email_address.is_verified) assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default" + assert_equal(true, @user.user_events.email_change.exists?) end end @@ -112,6 +114,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal("bob@ogres.net", @user.reload.email_address.address) assert_no_emails + assert_equal(false, @user.user_events.email_change.exists?) end end end diff --git a/test/functional/maintenance/user/deletions_controller_test.rb b/test/functional/maintenance/user/deletions_controller_test.rb index bf4ce204e..07fab37c3 100644 --- a/test/functional/maintenance/user/deletions_controller_test.rb +++ b/test/functional/maintenance/user/deletions_controller_test.rb @@ -18,18 +18,22 @@ module Maintenance context "#destroy" do should "delete the user when given the correct password" do delete_auth maintenance_user_deletion_path, @user, params: { user: { password: "password" }} + assert_redirected_to posts_path assert_equal(true, @user.reload.is_deleted?) assert_equal("Your account has been deactivated", flash[:notice]) assert_nil(session[:user_id]) + assert_equal(true, @user.user_events.user_deletion.exists?) end should "not delete the user when given an incorrect password" do delete_auth maintenance_user_deletion_path, @user, params: { user: { password: "hunter2" }} + assert_redirected_to maintenance_user_deletion_path assert_equal(false, @user.reload.is_deleted?) assert_equal("Password is incorrect", flash[:notice]) assert_equal(@user.id, session[:user_id]) + assert_equal(false, @user.user_events.user_deletion.exists?) end end end diff --git a/test/functional/password_resets_controller_test.rb b/test/functional/password_resets_controller_test.rb index 16c6c8588..c4ad7b123 100644 --- a/test/functional/password_resets_controller_test.rb +++ b/test/functional/password_resets_controller_test.rb @@ -16,6 +16,7 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_session_path assert_enqueued_email_with UserMailer, :password_reset, args: [@user], queue: "default" + assert_equal(true, @user.user_events.password_reset.exists?) end should "should fail if the user doesn't have a verified email address" do diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb index f865957f2..e67822180 100644 --- a/test/functional/passwords_controller_test.rb +++ b/test/functional/passwords_controller_test.rb @@ -20,6 +20,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @user assert_equal(false, @user.reload.authenticate_password("12345")) assert_equal(@user, @user.authenticate_password("abcde")) + assert_equal(true, @user.user_events.password_change.exists?) end should "update the password when given a valid login key" do @@ -29,6 +30,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @user assert_equal(false, @user.reload.authenticate_password("12345")) assert_equal(@user, @user.authenticate_password("abcde")) + assert_equal(true, @user.user_events.password_change.exists?) end should "allow the site owner to change the password of other users" do @@ -55,6 +57,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_equal(@user, @user.reload.authenticate_password("12345")) assert_equal(false, @user.authenticate_password("abcde")) + assert_equal(false, @user.user_events.password_change.exists?) end should "not update the password when password confirmation fails for the new password" do diff --git a/test/functional/sessions_controller_test.rb b/test/functional/sessions_controller_test.rb index 1eff49170..7e233b924 100644 --- a/test/functional/sessions_controller_test.rb +++ b/test/functional/sessions_controller_test.rb @@ -20,11 +20,20 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to posts_path assert_equal(@user.id, session[:user_id]) assert_not_nil(@user.reload.last_ip_addr) + assert_equal(true, @user.user_events.login.exists?) end should "not log the user in when given an incorrect password" do post session_path, params: { name: @user.name, password: "wrong"} + assert_response 401 + assert_nil(nil, session[:user_id]) + assert_equal(true, @user.user_events.failed_login.exists?) + end + + should "not log the user in when given an incorrect username" do + post session_path, params: { name: "dne", password: "password" } + assert_response 401 assert_nil(nil, session[:user_id]) end @@ -66,11 +75,18 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end context "destroy action" do - should "clear the session" do + setup do delete_auth session_path, @user + end + + should "clear the session" do assert_redirected_to posts_path assert_nil(session[:user_id]) end + + should "generate a logout event" do + assert_equal(true, @user.user_events.logout.exists?) + end end context "sign_out action" do diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 0b807e4c3..e71d27c12 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -260,6 +260,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(User.last, User.last.authenticate_password("xxxxx1")) assert_nil(User.last.email_address) assert_no_enqueued_emails + assert_equal(true, User.last.user_events.user_creation.exists?) end should "create a user with a valid email" do @@ -270,6 +271,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(User.last, User.last.authenticate_password("xxxxx1")) assert_equal("webmaster@danbooru.donmai.us", User.last.email_address.address) assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last], queue: "default" + assert_equal(true, User.last.user_events.user_creation.exists?) end should "not create a user with an invalid email" do @@ -307,6 +309,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(true, User.last.is_member?) assert_equal(false, User.last.is_restricted?) assert_equal(false, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "mark accounts created by already logged in users as restricted" do @@ -318,6 +321,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(false, User.last.is_member?) assert_equal(true, User.last.is_restricted?) assert_equal(true, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "mark users signing up from proxies as restricted" do @@ -330,6 +334,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(false, User.last.is_member?) assert_equal(true, User.last.is_restricted?) assert_equal(true, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "mark users signing up from a partial banned IP as restricted" do @@ -344,6 +349,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(true, User.last.requires_verification) assert_equal(1, @ip_ban.reload.hit_count) assert(@ip_ban.last_hit_at > 1.minute.ago) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "not mark users signing up from non-proxies as restricted" do @@ -356,6 +362,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(true, User.last.is_member?) assert_equal(false, User.last.is_restricted?) assert_equal(false, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "mark accounts registered from an IPv4 address recently used for another account as restricted" do @@ -368,6 +375,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(false, User.last.is_member?) assert_equal(true, User.last.is_restricted?) assert_equal(true, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end should "not mark users signing up from localhost as restricted" do @@ -379,6 +387,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal(true, User.last.is_member?) assert_equal(false, User.last.is_restricted?) assert_equal(false, User.last.requires_verification) + assert_equal(true, User.last.user_events.user_creation.exists?) end end end diff --git a/test/unit/ip_geolocation_test.rb b/test/unit/ip_geolocation_test.rb new file mode 100644 index 000000000..b89a44e3a --- /dev/null +++ b/test/unit/ip_geolocation_test.rb @@ -0,0 +1,72 @@ +require 'test_helper' + +class IpGeolocationTest < ActiveSupport::TestCase + context "IpGeolocation: " do + context "the create_or_update! method" do + should "create a new record if the IP record doesn't already exist" do + assert_difference("IpGeolocation.count", 1) do + IpGeolocation.create_or_update!("1.1.1.1") + end + end + + should "update an existing record if the IP record already exists" do + @ip1 = IpGeolocation.create_or_update!("1.1.1.1") + @ip1.update(asn: -1) + @ip2 = IpGeolocation.create_or_update!("1.1.1.1") + + assert_equal(1, IpGeolocation.count) + assert_equal(@ip1.id, @ip2.id) + assert_equal(13335, @ip1.reload.asn) + end + + should "return nothing for an invalid IP" do + assert_nil(IpGeolocation.create_or_update!("0.0.0.0")) + end + + should "return nothing for a local IP" do + assert_nil(IpGeolocation.create_or_update!("127.0.0.1")) + assert_nil(IpGeolocation.create_or_update!("10.0.0.1")) + assert_nil(IpGeolocation.create_or_update!("fe80::1")) + assert_nil(IpGeolocation.create_or_update!("::1")) + end + + should "work for a residential IP" do + @ip = IpGeolocation.create_or_update!("2a01:0e35:2f22:e3d0::1") + + assert_equal(28, @ip.network.prefix) + assert_equal(false, @ip.is_proxy?) + assert_equal(48.75919, @ip.latitude) + assert_equal(2.16969, @ip.longitude) + assert_equal("Free SAS", @ip.organization) + assert_equal("Europe/Paris", @ip.time_zone) + assert_equal("EU", @ip.continent) + assert_equal("FR", @ip.country) + assert_equal("FR-IDF", @ip.region) + assert_equal("Jouy-en-Josas", @ip.city) + assert_nil(@ip.carrier) + end + + should "work for a mobile IP" do + @ip = IpGeolocation.create_or_update!("37.173.153.166") + assert_equal("Free Mobile", @ip.carrier) + end + + should "work for a proxy IP" do + @ip = IpGeolocation.create_or_update!("31.214.184.59") + assert_equal("Soluciones Corporativas IP SL", @ip.organization) + assert_equal(true, @ip.is_proxy?) + end + + should "work for a cloud hosting IP" do + @ip = IpGeolocation.create_or_update!("157.230.244.215") + assert_equal("DigitalOcean LLC", @ip.organization) + assert_equal(true, @ip.is_proxy?) + end + + should "work for a bogon IP" do + @ip = IpGeolocation.create_or_update!("103.10.192.0") + assert_equal(true, @ip.is_proxy?) + end + end + end +end diff --git a/test/unit/user_deletion_test.rb b/test/unit/user_deletion_test.rb index a1d8b15df..b89c45306 100644 --- a/test/unit/user_deletion_test.rb +++ b/test/unit/user_deletion_test.rb @@ -1,11 +1,18 @@ require 'test_helper' class UserDeletionTest < ActiveSupport::TestCase + setup do + @request = mock + @request.stubs(:remote_ip).returns("1.1.1.1") + @request.stubs(:user_agent).returns("Firefox") + @request.stubs(:session).returns(session_id: "1234") + end + context "an invalid user deletion" do context "for an invalid password" do should "fail" do @user = create(:user) - @deletion = UserDeletion.new(@user, "wrongpassword") + @deletion = UserDeletion.new(@user, "wrongpassword", @request) @deletion.delete! assert_includes(@deletion.errors[:base], "Password is incorrect") end @@ -14,7 +21,7 @@ class UserDeletionTest < ActiveSupport::TestCase context "for an admin" do should "fail" do @user = create(:admin_user) - @deletion = UserDeletion.new(@user, "password") + @deletion = UserDeletion.new(@user, "password", @request) @deletion.delete! assert_includes(@deletion.errors[:base], "Admins cannot delete their account") end @@ -24,7 +31,7 @@ class UserDeletionTest < ActiveSupport::TestCase context "a valid user deletion" do setup do @user = create(:user, name: "foo", email_address: build(:email_address)) - @deletion = UserDeletion.new(@user, "password") + @deletion = UserDeletion.new(@user, "password", @request) end should "blank out the email" do