mirror of
https://github.com/discourse/discourse.git
synced 2024-12-02 05:29:17 -06:00
b86127ad12
Currently, Discourse rate limits all incoming requests by the IP address they originate from regardless of the user making the request. This can be frustrating if there are multiple users using Discourse simultaneously while sharing the same IP address (e.g. employees in an office). This commit implements a new feature to make Discourse apply rate limits by user id rather than IP address for users at or higher than the configured trust level (1 is the default). For example, let's say a Discourse instance is configured to allow 200 requests per minute per IP address, and we have 10 users at trust level 4 using Discourse simultaneously from the same IP address. Before this feature, the 10 users could only make a total of 200 requests per minute before they got rate limited. But with the new feature, each user is allowed to make 200 requests per minute because the rate limits are applied on user id rather than the IP address. The minimum trust level for applying user-id-based rate limits can be configured by the `skip_per_ip_rate_limit_trust_level` global setting. The default is 1, but it can be changed by either adding the `DISCOURSE_SKIP_PER_IP_RATE_LIMIT_TRUST_LEVEL` environment variable with the desired value to your `app.yml`, or changing the setting's value in the `discourse.conf` file. Requests made with API keys are still rate limited by IP address and the relevant global settings that control API keys rate limits. Before this commit, Discourse's auth cookie (`_t`) was simply a 32 characters string that Discourse used to lookup the current user from the database and the cookie contained no additional information about the user. However, we had to change the cookie content in this commit so we could identify the user from the cookie without making a database query before the rate limits logic and avoid introducing a bottleneck on busy sites. Besides the 32 characters auth token, the cookie now includes the user id, trust level and the cookie's generation date, and we encrypt/sign the cookie to prevent tampering. Internal ticket number: t54739.
471 lines
14 KiB
Ruby
471 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
require_relative '../route_matcher'
|
|
|
|
# You may have seen references to v0 and v1 of our auth cookie in the codebase
|
|
# and you're not sure how they differ, so here is an explanation:
|
|
#
|
|
# From the very early days of Discourse, the auth cookie (_t) consisted only of
|
|
# a 32 characters random string that Discourse used to identify/lookup the
|
|
# current user. We didn't include any metadata with the cookie or encrypt/sign
|
|
# it.
|
|
#
|
|
# That was v0 of the auth cookie until Nov 2021 when we merged a change that
|
|
# required us to store additional metadata with the cookie so we could get more
|
|
# information about current user early in the request lifecycle before we
|
|
# performed database lookup. We also started encrypting and signing the cookie
|
|
# to prevent tampering and obfuscate user information that we include in the
|
|
# cookie. This is v1 of our auth cookie and we still use it to this date.
|
|
#
|
|
# We still accept v0 of the auth cookie to keep users logged in, but upon
|
|
# cookie rotation (which happen every 10 minutes) they'll be switched over to
|
|
# the v1 format.
|
|
#
|
|
# We'll drop support for v0 after Discourse 2.9 is released.
|
|
|
|
class Auth::DefaultCurrentUserProvider
|
|
|
|
CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER"
|
|
API_KEY ||= "api_key"
|
|
API_USERNAME ||= "api_username"
|
|
HEADER_API_KEY ||= "HTTP_API_KEY"
|
|
HEADER_API_USERNAME ||= "HTTP_API_USERNAME"
|
|
HEADER_API_USER_EXTERNAL_ID ||= "HTTP_API_USER_EXTERNAL_ID"
|
|
HEADER_API_USER_ID ||= "HTTP_API_USER_ID"
|
|
PARAMETER_USER_API_KEY ||= "user_api_key"
|
|
USER_API_KEY ||= "HTTP_USER_API_KEY"
|
|
USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID"
|
|
API_KEY_ENV ||= "_DISCOURSE_API"
|
|
USER_API_KEY_ENV ||= "_DISCOURSE_USER_API"
|
|
TOKEN_COOKIE ||= ENV['DISCOURSE_TOKEN_COOKIE'] || "_t"
|
|
PATH_INFO ||= "PATH_INFO"
|
|
COOKIE_ATTEMPTS_PER_MIN ||= 10
|
|
BAD_TOKEN ||= "_DISCOURSE_BAD_TOKEN"
|
|
DECRYPTED_AUTH_COOKIE = "_DISCOURSE_DECRYPTED_AUTH_COOKIE"
|
|
|
|
TOKEN_SIZE = 32
|
|
|
|
PARAMETER_API_PATTERNS ||= [
|
|
RouteMatcher.new(
|
|
methods: :get,
|
|
actions: [
|
|
"posts#latest",
|
|
"posts#user_posts_feed",
|
|
"groups#posts_feed",
|
|
"groups#mentions_feed",
|
|
"list#user_topics_feed",
|
|
"list#category_feed",
|
|
"topics#feed",
|
|
"badges#show",
|
|
"tags#tag_feed",
|
|
"tags#show",
|
|
*[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "list##{f}_feed" },
|
|
*[:all, :yearly, :quarterly, :monthly, :weekly, :daily].map { |p| "list#top_#{p}_feed" },
|
|
*[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "tags#show_#{f}" }
|
|
],
|
|
formats: :rss
|
|
),
|
|
RouteMatcher.new(
|
|
methods: :get,
|
|
actions: "users#bookmarks",
|
|
formats: :ics
|
|
),
|
|
RouteMatcher.new(
|
|
methods: :post,
|
|
actions: "admin/email#handle_mail",
|
|
formats: nil
|
|
),
|
|
]
|
|
|
|
def self.find_v0_auth_cookie(request)
|
|
cookie = request.cookies[TOKEN_COOKIE].presence
|
|
if cookie && cookie.size == TOKEN_SIZE
|
|
cookie
|
|
end
|
|
end
|
|
|
|
def self.find_v1_auth_cookie(env)
|
|
return env[DECRYPTED_AUTH_COOKIE] if env.key?(DECRYPTED_AUTH_COOKIE)
|
|
|
|
env[DECRYPTED_AUTH_COOKIE] = begin
|
|
request = ActionDispatch::Request.new(env)
|
|
# don't even initialize a cookie jar if we don't have a cookie at all
|
|
if request.cookies[TOKEN_COOKIE].present?
|
|
request.cookie_jar.encrypted[TOKEN_COOKIE]
|
|
end
|
|
end
|
|
end
|
|
|
|
# do all current user initialization here
|
|
def initialize(env)
|
|
@env = env
|
|
@request = Rack::Request.new(env)
|
|
end
|
|
|
|
# our current user, return nil if none is found
|
|
def current_user
|
|
return @env[CURRENT_USER_KEY] if @env.key?(CURRENT_USER_KEY)
|
|
|
|
# bypass if we have the shared session header
|
|
if shared_key = @env['HTTP_X_SHARED_SESSION_KEY']
|
|
uid = Discourse.redis.get("shared_session_key_#{shared_key}")
|
|
user = nil
|
|
if uid
|
|
user = User.find_by(id: uid.to_i)
|
|
end
|
|
@env[CURRENT_USER_KEY] = user
|
|
return user
|
|
end
|
|
|
|
request = @request
|
|
|
|
user_api_key = @env[USER_API_KEY]
|
|
api_key = @env[HEADER_API_KEY]
|
|
|
|
if !@env.blank? && request[PARAMETER_USER_API_KEY] && api_parameter_allowed?
|
|
user_api_key ||= request[PARAMETER_USER_API_KEY]
|
|
end
|
|
|
|
if !@env.blank? && request[API_KEY] && api_parameter_allowed?
|
|
api_key ||= request[API_KEY]
|
|
end
|
|
|
|
auth_token = find_auth_token
|
|
current_user = nil
|
|
|
|
if auth_token
|
|
limiter = RateLimiter.new(nil, "cookie_auth_#{request.ip}", COOKIE_ATTEMPTS_PER_MIN , 60)
|
|
|
|
if limiter.can_perform?
|
|
@user_token = begin
|
|
UserAuthToken.lookup(
|
|
auth_token,
|
|
seen: true,
|
|
user_agent: @env['HTTP_USER_AGENT'],
|
|
path: @env['REQUEST_PATH'],
|
|
client_ip: @request.ip
|
|
)
|
|
rescue ActiveRecord::ReadOnlyError
|
|
nil
|
|
end
|
|
|
|
current_user = @user_token.try(:user)
|
|
end
|
|
|
|
if !current_user
|
|
@env[BAD_TOKEN] = true
|
|
begin
|
|
limiter.performed!
|
|
rescue RateLimiter::LimitExceeded
|
|
raise Discourse::InvalidAccess.new(
|
|
'Invalid Access',
|
|
nil,
|
|
delete_cookie: TOKEN_COOKIE
|
|
)
|
|
end
|
|
end
|
|
elsif @env['HTTP_DISCOURSE_LOGGED_IN']
|
|
@env[BAD_TOKEN] = true
|
|
end
|
|
|
|
# possible we have an api call, impersonate
|
|
if api_key
|
|
current_user = lookup_api_user(api_key, request)
|
|
if !current_user
|
|
raise Discourse::InvalidAccess.new(
|
|
I18n.t('invalid_api_credentials'),
|
|
nil,
|
|
custom_message: "invalid_api_credentials"
|
|
)
|
|
end
|
|
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
|
|
admin_api_key_limiter.performed! if !Rails.env.profile?
|
|
@env[API_KEY_ENV] = true
|
|
end
|
|
|
|
# user api key handling
|
|
if user_api_key
|
|
@hashed_user_api_key = ApiKey.hash_key(user_api_key)
|
|
|
|
user_api_key_obj = UserApiKey
|
|
.active
|
|
.joins(:user)
|
|
.where(key_hash: @hashed_user_api_key)
|
|
.includes(:user, :scopes)
|
|
.first
|
|
|
|
raise Discourse::InvalidAccess unless user_api_key_obj
|
|
|
|
user_api_key_limiter_60_secs.performed!
|
|
user_api_key_limiter_1_day.performed!
|
|
|
|
user_api_key_obj.ensure_allowed!(@env)
|
|
|
|
current_user = user_api_key_obj.user
|
|
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
|
|
|
|
if can_write?
|
|
user_api_key_obj.update_last_used(@env[USER_API_CLIENT_ID])
|
|
end
|
|
|
|
@env[USER_API_KEY_ENV] = true
|
|
end
|
|
|
|
# keep this rule here as a safeguard
|
|
# under no conditions to suspended or inactive accounts get current_user
|
|
if current_user && (current_user.suspended? || !current_user.active)
|
|
current_user = nil
|
|
end
|
|
|
|
if current_user && should_update_last_seen?
|
|
u = current_user
|
|
ip = request.ip
|
|
|
|
Scheduler::Defer.later "Updating Last Seen" do
|
|
u.update_last_seen!
|
|
u.update_ip_address!(ip)
|
|
end
|
|
end
|
|
|
|
@env[CURRENT_USER_KEY] = current_user
|
|
end
|
|
|
|
def refresh_session(user, session, cookie_jar)
|
|
# if user was not loaded, no point refreshing session
|
|
# it could be an anonymous path, this would add cost
|
|
return if is_api? || !@env.key?(CURRENT_USER_KEY)
|
|
|
|
if !is_user_api? && @user_token && @user_token.user == user
|
|
rotated_at = @user_token.rotated_at
|
|
|
|
needs_rotation = @user_token.auth_token_seen ? rotated_at < UserAuthToken::ROTATE_TIME.ago : rotated_at < UserAuthToken::URGENT_ROTATE_TIME.ago
|
|
|
|
if needs_rotation
|
|
if @user_token.rotate!(user_agent: @env['HTTP_USER_AGENT'],
|
|
client_ip: @request.ip,
|
|
path: @env['REQUEST_PATH'])
|
|
set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar)
|
|
DiscourseEvent.trigger(:user_session_refreshed, user)
|
|
end
|
|
end
|
|
end
|
|
|
|
if !user && cookie_jar.key?(TOKEN_COOKIE)
|
|
cookie_jar.delete(TOKEN_COOKIE)
|
|
end
|
|
end
|
|
|
|
def log_on_user(user, session, cookie_jar, opts = {})
|
|
@user_token = UserAuthToken.generate!(
|
|
user_id: user.id,
|
|
user_agent: @env['HTTP_USER_AGENT'],
|
|
path: @env['REQUEST_PATH'],
|
|
client_ip: @request.ip,
|
|
staff: user.staff?,
|
|
impersonate: opts[:impersonate])
|
|
|
|
set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar)
|
|
user.unstage!
|
|
make_developer_admin(user)
|
|
enable_bootstrap_mode(user)
|
|
|
|
UserAuthToken.enforce_session_count_limit!(user.id)
|
|
|
|
@env[CURRENT_USER_KEY] = user
|
|
end
|
|
|
|
def set_auth_cookie!(unhashed_auth_token, user, cookie_jar)
|
|
data = {
|
|
token: unhashed_auth_token,
|
|
user_id: user.id,
|
|
trust_level: user.trust_level,
|
|
issued_at: Time.zone.now.to_i
|
|
}
|
|
|
|
if SiteSetting.persistent_sessions
|
|
expires = SiteSetting.maximum_session_age.hours.from_now
|
|
end
|
|
|
|
if SiteSetting.same_site_cookies != "Disabled"
|
|
same_site = SiteSetting.same_site_cookies
|
|
end
|
|
|
|
cookie_jar.encrypted[TOKEN_COOKIE] = {
|
|
value: data,
|
|
httponly: true,
|
|
secure: SiteSetting.force_https,
|
|
expires: expires,
|
|
same_site: same_site
|
|
}
|
|
end
|
|
|
|
def make_developer_admin(user)
|
|
if user.active? &&
|
|
!user.admin &&
|
|
Rails.configuration.respond_to?(:developer_emails) &&
|
|
Rails.configuration.developer_emails.include?(user.email)
|
|
user.admin = true
|
|
user.save
|
|
end
|
|
end
|
|
|
|
def enable_bootstrap_mode(user)
|
|
return if SiteSetting.bootstrap_mode_enabled
|
|
|
|
if user.admin && user.last_seen_at.nil? && user.is_singular_admin?
|
|
Jobs.enqueue(:enable_bootstrap_mode, user_id: user.id)
|
|
end
|
|
end
|
|
|
|
def log_off_user(session, cookie_jar)
|
|
user = current_user
|
|
|
|
if SiteSetting.log_out_strict && user
|
|
user.user_auth_tokens.destroy_all
|
|
|
|
if user.admin && defined?(Rack::MiniProfiler)
|
|
# clear the profiling cookie to keep stuff tidy
|
|
cookie_jar.delete("__profilin")
|
|
end
|
|
|
|
user.logged_out
|
|
elsif user && @user_token
|
|
@user_token.destroy
|
|
end
|
|
|
|
cookie_jar.delete('authentication_data')
|
|
cookie_jar.delete(TOKEN_COOKIE)
|
|
end
|
|
|
|
# api has special rights return true if api was detected
|
|
def is_api?
|
|
current_user
|
|
!!(@env[API_KEY_ENV])
|
|
end
|
|
|
|
def is_user_api?
|
|
current_user
|
|
!!(@env[USER_API_KEY_ENV])
|
|
end
|
|
|
|
def has_auth_cookie?
|
|
find_auth_token.present?
|
|
end
|
|
|
|
def should_update_last_seen?
|
|
return false unless can_write?
|
|
|
|
api = !!@env[API_KEY_ENV] || !!@env[USER_API_KEY_ENV]
|
|
|
|
if @request.xhr? || api
|
|
@env["HTTP_DISCOURSE_PRESENT"] == "true"
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
protected
|
|
|
|
def lookup_api_user(api_key_value, request)
|
|
if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first
|
|
api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME]
|
|
|
|
unless api_key.request_allowed?(@env)
|
|
Rails.logger.warn("[Unauthorized API Access] username: #{api_username}, IP address: #{request.ip}")
|
|
return nil
|
|
end
|
|
|
|
user =
|
|
if api_key.user
|
|
api_key.user if !api_username || (api_key.user.username_lower == api_username.downcase)
|
|
elsif api_username
|
|
User.find_by(username_lower: api_username.downcase)
|
|
elsif user_id = header_api_key? ? @env[HEADER_API_USER_ID] : request["api_user_id"]
|
|
User.find_by(id: user_id.to_i)
|
|
elsif external_id = header_api_key? ? @env[HEADER_API_USER_EXTERNAL_ID] : request["api_user_external_id"]
|
|
SingleSignOnRecord.find_by(external_id: external_id.to_s).try(:user)
|
|
end
|
|
|
|
if user && can_write?
|
|
api_key.update_columns(last_used_at: Time.zone.now)
|
|
end
|
|
|
|
user
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def parameter_api_patterns
|
|
PARAMETER_API_PATTERNS + DiscoursePluginRegistry.api_parameter_routes
|
|
end
|
|
|
|
# By default we only allow headers for sending API credentials
|
|
# However, in some scenarios it is essential to send them via url parameters
|
|
# so we need to add some exceptions
|
|
def api_parameter_allowed?
|
|
parameter_api_patterns.any? { |p| p.match?(env: @env) }
|
|
end
|
|
|
|
def header_api_key?
|
|
!!@env[HEADER_API_KEY]
|
|
end
|
|
|
|
def can_write?
|
|
@can_write ||= !Discourse.pg_readonly_mode?
|
|
end
|
|
|
|
def admin_api_key_limiter
|
|
return @admin_api_key_limiter if @admin_api_key_limiter
|
|
|
|
limit = GlobalSetting.max_admin_api_reqs_per_minute.to_i
|
|
if GlobalSetting.respond_to?(:max_admin_api_reqs_per_key_per_minute)
|
|
Discourse.deprecate("DISCOURSE_MAX_ADMIN_API_REQS_PER_KEY_PER_MINUTE is deprecated. Please use DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE", drop_from: '2.9.0')
|
|
limit = [
|
|
GlobalSetting.max_admin_api_reqs_per_key_per_minute.to_i,
|
|
limit
|
|
].max
|
|
end
|
|
@admin_api_key_limiter = RateLimiter.new(
|
|
nil,
|
|
"admin_api_min",
|
|
limit,
|
|
60,
|
|
error_code: "admin_api_key_rate_limit"
|
|
)
|
|
end
|
|
|
|
def user_api_key_limiter_60_secs
|
|
@user_api_key_limiter_60_secs ||= RateLimiter.new(
|
|
nil,
|
|
"user_api_min_#{@hashed_user_api_key}",
|
|
GlobalSetting.max_user_api_reqs_per_minute,
|
|
60,
|
|
error_code: "user_api_key_limiter_60_secs"
|
|
)
|
|
end
|
|
|
|
def user_api_key_limiter_1_day
|
|
@user_api_key_limiter_1_day ||= RateLimiter.new(
|
|
nil,
|
|
"user_api_day_#{@hashed_user_api_key}",
|
|
GlobalSetting.max_user_api_reqs_per_day,
|
|
86400,
|
|
error_code: "user_api_key_limiter_1_day"
|
|
)
|
|
end
|
|
|
|
def find_auth_token
|
|
return @auth_token if defined?(@auth_token)
|
|
|
|
@auth_token = begin
|
|
if v0 = self.class.find_v0_auth_cookie(@request)
|
|
v0
|
|
elsif v1 = self.class.find_v1_auth_cookie(@env)
|
|
if v1[:issued_at] >= SiteSetting.maximum_session_age.hours.ago.to_i
|
|
v1[:token]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|