discourse/lib/auth/default_current_user_provider.rb
Martin Brennan 6d7abc1c85
FIX: Make sure first admin users are added to auto groups (#18494)
When a user with an email matching those inside the
DISCOURSE_DEVELOPER_EMAILS env var log in, we make
them into admin users if they are not already. This
is used when setting up the first admin user for
self-hosters, since the discourse-setup script sets
the provided admin emails into DISCOURSE_DEVELOPER_EMAILS.

The issue being fixed here is that the new admins were
not being automatically added to the staff and admins
automatic groups, which was causing issues with the site
settings that are group_list based that don't have an explicit
staff override. All we need to do is refresh the automatic
staff, admin groups when admin is granted for the user.
2022-10-06 15:16:38 +10:00

487 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",
*[: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]
if cookie&.valid_encoding? && cookie.present? && 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)
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
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?
@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)
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?
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 = @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 = {})
@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])
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
# 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
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]
if !api_key.request_allowed?(@env)
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?
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,
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