# frozen_string_literal: true require "digest/sha1" class UserAuthToken < ActiveRecord::Base belongs_to :user ROTATE_TIME_MINS = 10 ROTATE_TIME = ROTATE_TIME_MINS.minutes # used when token did not arrive at client URGENT_ROTATE_TIME = 1.minute MAX_SESSION_COUNT = 60 USER_ACTIONS = ["generate"] attr_accessor :unhashed_auth_token before_destroy do UserAuthToken.log_verbose( action: "destroy", user_auth_token_id: self.id, user_id: self.user_id, user_agent: self.user_agent, client_ip: self.client_ip, auth_token: self.auth_token, ) end def self.log(info) UserAuthTokenLog.create!(info) end def self.log_verbose(info) log(info) if SiteSetting.verbose_auth_token_logging end RAD_PER_DEG = Math::PI / 180 EARTH_RADIUS_KM = 6371 # kilometers def self.login_location(ip) ipinfo = DiscourseIpInfo.get(ip) ipinfo[:latitude] && ipinfo[:longitude] ? [ipinfo[:latitude], ipinfo[:longitude]] : nil end def self.distance(loc1, loc2) lat1_rad, lon1_rad = loc1[0] * RAD_PER_DEG, loc1[1] * RAD_PER_DEG lat2_rad, lon2_rad = loc2[0] * RAD_PER_DEG, loc2[1] * RAD_PER_DEG a = Math.sin((lat2_rad - lat1_rad) / 2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin((lon2_rad - lon1_rad) / 2)**2 c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) c * EARTH_RADIUS_KM end def self.is_suspicious(user_id, user_ip) return false unless User.find_by(id: user_id)&.staff? ips = UserAuthTokenLog.where(user_id: user_id).pluck(:client_ip) ips.delete_at(ips.index(user_ip) || ips.length) # delete one occurrence (current) ips.uniq! return false if ips.empty? # first login is never suspicious if user_location = login_location(user_ip) ips.none? do |ip| if location = login_location(ip) distance(user_location, location) < SiteSetting.max_suspicious_distance_km end end end end def self.generate!( user_id:, user_agent: nil, client_ip: nil, path: nil, staff: nil, impersonate: false, authenticated_with_oauth: false ) token = SecureRandom.hex(16) hashed_token = hash_token(token) user_auth_token = UserAuthToken.create!( user_id: user_id, user_agent: user_agent, client_ip: client_ip, auth_token: hashed_token, prev_auth_token: hashed_token, rotated_at: Time.zone.now, authenticated_with_oauth: !!authenticated_with_oauth, ) user_auth_token.unhashed_auth_token = token log( action: "generate", user_auth_token_id: user_auth_token.id, user_id: user_id, user_agent: user_agent, client_ip: client_ip, path: path, auth_token: hashed_token, ) if staff && !impersonate Jobs.enqueue( :suspicious_login, user_id: user_id, client_ip: client_ip, user_agent: user_agent, ) end user_auth_token end def self.lookup(unhashed_token, opts = nil) mark_seen = opts && opts[:seen] token = hash_token(unhashed_token) expire_before = SiteSetting.maximum_session_age.hours.ago user_token = where( "(auth_token = :token OR prev_auth_token = :token) AND rotated_at > :expire_before", token: token, expire_before: expire_before, ) if SiteSetting.verbose_auth_token_logging && path = opts.dig(:path) user_token = user_token.annotate("path:#{path}") end user_token = user_token.first if !user_token log_verbose( action: "miss token", user_id: nil, auth_token: token, user_agent: opts && opts[:user_agent], path: opts && opts[:path], client_ip: opts && opts[:client_ip], ) return nil end if user_token.auth_token != token && user_token.prev_auth_token == token && user_token.auth_token_seen changed_rows = UserAuthToken .where("rotated_at < ?", 1.minute.ago) .where(id: user_token.id, prev_auth_token: token) .update_all(auth_token_seen: false) # not updating AR model cause we want to give it one more req # with wrong cookie UserAuthToken.log_verbose( action: changed_rows == 0 ? "prev seen token unchanged" : "prev seen token", user_auth_token_id: user_token.id, user_id: user_token.user_id, auth_token: user_token.auth_token, user_agent: opts && opts[:user_agent], path: opts && opts[:path], client_ip: opts && opts[:client_ip], ) end if mark_seen && user_token && !user_token.auth_token_seen && user_token.auth_token == token # we must protect against concurrency issues here changed_rows = UserAuthToken.where(id: user_token.id, auth_token: token).update_all( auth_token_seen: true, seen_at: Time.zone.now, ) if changed_rows == 1 # not doing a reload so we don't risk loading a rotated token user_token.auth_token_seen = true user_token.seen_at = Time.zone.now end log_verbose( action: changed_rows == 0 ? "seen wrong token" : "seen token", user_auth_token_id: user_token.id, user_id: user_token.user_id, auth_token: user_token.auth_token, user_agent: opts && opts[:user_agent], path: opts && opts[:path], client_ip: opts && opts[:client_ip], ) end user_token end def self.hash_token(token) Digest::SHA1.base64digest("#{token}#{GlobalSetting.safe_secret_key_base}") end def self.cleanup! if SiteSetting.verbose_auth_token_logging UserAuthTokenLog.where( "created_at < :time", time: SiteSetting.maximum_session_age.hours.ago - ROTATE_TIME, ).delete_all end where( "rotated_at < :time", time: SiteSetting.maximum_session_age.hours.ago - ROTATE_TIME, ).delete_all end def rotate!(info = nil) user_agent = (info && info[:user_agent] || self.user_agent) client_ip = (info && info[:client_ip] || self.client_ip) token = SecureRandom.hex(16) result = DB.exec( " UPDATE user_auth_tokens SET auth_token_seen = false, seen_at = null, user_agent = :user_agent, client_ip = :client_ip, prev_auth_token = case when auth_token_seen then auth_token else prev_auth_token end, auth_token = :new_token, rotated_at = :now WHERE id = :id AND (auth_token_seen or rotated_at < :safeguard_time) ", id: self.id, user_agent: user_agent, client_ip: client_ip&.to_s, now: Time.zone.now, new_token: UserAuthToken.hash_token(token), safeguard_time: 30.seconds.ago, ) if result > 0 reload self.unhashed_auth_token = token UserAuthToken.log( action: "rotate", user_auth_token_id: id, user_id: user_id, auth_token: auth_token, user_agent: user_agent, client_ip: client_ip, path: info && info[:path], ) true else false end end def self.enforce_session_count_limit!(user_id) tokens_to_destroy = where(user_id: user_id) .where("rotated_at > ?", SiteSetting.maximum_session_age.hours.ago) .order("rotated_at DESC") .offset(MAX_SESSION_COUNT) tokens_to_destroy.delete_all # Returns the number of deleted rows end end # == Schema Information # # Table name: user_auth_tokens # # id :integer not null, primary key # user_id :integer not null # auth_token :string not null # prev_auth_token :string not null # user_agent :string # auth_token_seen :boolean default(FALSE), not null # client_ip :inet # rotated_at :datetime not null # created_at :datetime not null # updated_at :datetime not null # seen_at :datetime # authenticated_with_oauth :boolean default(FALSE) # # Indexes # # index_user_auth_tokens_on_auth_token (auth_token) UNIQUE # index_user_auth_tokens_on_prev_auth_token (prev_auth_token) UNIQUE # index_user_auth_tokens_on_user_id (user_id) #