mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 19:01:36 +08:00
ac365a86a2
What did this fix? =============== Previously, we only triggered this event in the `user.logged_out` method. This resulted in the event being triggered only when the user was logged out by the administrator or the site had strict logout mode enabled. This bug affected customers who managed user status via webhooks. meta topic: https://meta.discourse.org/t/user-log-out-event-not-triggered-in-webhooks/249464
476 lines
14 KiB
Ruby
476 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"
|
|
USER_TOKEN_KEY ||= "_DISCOURSE_USER_TOKEN"
|
|
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",
|
|
*%i[latest unread new read posted bookmarks].map { |f| "list##{f}_feed" },
|
|
*%i[all yearly quarterly monthly weekly daily].map { |p| "list#top_#{p}_feed" },
|
|
*%i[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]
|
|
|
|
cookie if cookie&.valid_encoding? && cookie.present? && cookie.size == TOKEN_SIZE
|
|
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)
|
|
cookie = request.cookies[TOKEN_COOKIE]
|
|
|
|
# don't even initialize a cookie jar if we don't have a cookie at all
|
|
if cookie&.valid_encoding? && cookie.present?
|
|
request.cookie_jar.encrypted[TOKEN_COOKIE]&.with_indifferent_access
|
|
end
|
|
end
|
|
end
|
|
|
|
# do all current user initialization here
|
|
def initialize(env)
|
|
@env = env
|
|
@request = Rack::Request.new(env)
|
|
@user_token = env[USER_TOKEN_KEY]
|
|
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
|
|
user = User.find_by(id: uid.to_i) if uid
|
|
@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
|
|
|
|
api_key ||= request[API_KEY] if !@env.blank? && request[API_KEY] && api_parameter_allowed?
|
|
|
|
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?
|
|
@env[USER_TOKEN_KEY] = @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)
|
|
current_user.authenticated_with_oauth = @user_token.authenticated_with_oauth if current_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
|
|
|
|
if !Rails.env.profile?
|
|
admin_api_key_limiter.performed!
|
|
|
|
# Don't enforce the default per ip limits for authenticated admin api
|
|
# requests
|
|
(@env["DISCOURSE_RATE_LIMITERS"] || []).each(&:rollback!)
|
|
end
|
|
|
|
@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
|
|
|
|
user_api_key_obj.update_last_used(@env[USER_API_CLIENT_ID]) if can_write?
|
|
|
|
@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
|
|
current_user = nil if current_user && (current_user.suspended? || !current_user.active)
|
|
|
|
if current_user && should_update_last_seen?
|
|
ip = request.ip
|
|
user_id = current_user.id
|
|
old_ip = current_user.ip_address
|
|
|
|
Scheduler::Defer.later "Updating Last Seen" do
|
|
if User.should_update_last_seen?(user_id)
|
|
if u = User.find_by(id: user_id)
|
|
u.update_last_seen!(Time.zone.now, force: true)
|
|
end
|
|
end
|
|
User.update_ip_address!(user_id, new_ip: ip, old_ip: old_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 =
|
|
(
|
|
if @user_token.auth_token_seen
|
|
rotated_at < UserAuthToken::ROTATE_TIME.ago
|
|
else
|
|
rotated_at < UserAuthToken::URGENT_ROTATE_TIME.ago
|
|
end
|
|
)
|
|
|
|
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
|
|
|
|
cookie_jar.delete(TOKEN_COOKIE) if !user && cookie_jar.key?(TOKEN_COOKIE)
|
|
end
|
|
|
|
def log_on_user(user, session, cookie_jar, opts = {})
|
|
@env[USER_TOKEN_KEY] = @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],
|
|
authenticated_with_oauth: opts[:authenticated_with_oauth],
|
|
)
|
|
|
|
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,
|
|
username: user.username,
|
|
trust_level: user.trust_level,
|
|
issued_at: Time.zone.now.to_i,
|
|
}
|
|
|
|
expires = SiteSetting.maximum_session_age.hours.from_now if SiteSetting.persistent_sessions
|
|
|
|
same_site = SiteSetting.same_site_cookies if SiteSetting.same_site_cookies != "Disabled"
|
|
|
|
cookie_jar.encrypted[TOKEN_COOKIE] = {
|
|
value: data,
|
|
httponly: true,
|
|
secure: SiteSetting.force_https,
|
|
expires: expires,
|
|
same_site: same_site,
|
|
}
|
|
end
|
|
|
|
# This is also used to set the first admin of the site via
|
|
# the finish installation & register -> user account activation
|
|
# for signup flow, since all admin emails are stored in
|
|
# DISCOURSE_DEVELOPER_EMAILS for self-hosters.
|
|
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
|
|
Group.refresh_automatic_groups!(:staff, :admins)
|
|
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
|
|
DiscourseEvent.trigger(:user_logged_out, user)
|
|
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]
|
|
|
|
return nil if !api_key.request_allowed?(@env)
|
|
|
|
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?
|
|
Scheduler::Defer.later "Updating api_key last_used" do
|
|
api_key.update_last_used!
|
|
end
|
|
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,
|
|
86_400,
|
|
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)
|
|
v1[:token] if v1[:issued_at] >= SiteSetting.maximum_session_age.hours.ago.to_i
|
|
end
|
|
end
|
|
end
|
|
end
|