mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-04 18:48:01 +08:00
The current impersonation feature works by signing you in as the user you are impersonating. This has the side effect of invalidating your own session and forcing you to log out and in again. In this experimental implementation you keep your existing session, but DefaultCurrentUserProvider returns the user being impersonated, allowing you to see the site from their perspective.
490 lines
15 KiB
Ruby
490 lines
15 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, 1.minute)
|
|
|
|
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, :client)
|
|
.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 start_impersonating_user(user)
|
|
@user_token.update!(
|
|
impersonated_user_id: user.id,
|
|
impersonation_expires_at:
|
|
SiteSetting.experimental_impersonation_time_limit_minutes.minutes.from_now,
|
|
)
|
|
end
|
|
|
|
def stop_impersonating_user
|
|
@user_token.update!(impersonated_user_id: nil, impersonation_expires_at: nil)
|
|
# Clear memoization of `current_user` so we can get the acting user back in
|
|
# the context of the same request.
|
|
@env.delete(CURRENT_USER_KEY)
|
|
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, 1.minute, 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,
|
|
1.minute,
|
|
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,
|
|
1.day,
|
|
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
|