mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 04:30:35 +08:00
6d7abc1c85
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.
487 lines
14 KiB
Ruby
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
|