mirror of
https://github.com/discourse/discourse.git
synced 2024-11-28 13:53:44 +08:00
e3590d4ead
Trigger an event for plugins to consume when a user session is refreshed. This allows external auth to be notified about account activity, and be able to take action such as use oauth refresh tokens to keep oauth tokens valid.
350 lines
10 KiB
Ruby
350 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
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"
|
|
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"
|
|
|
|
# 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.blank? ? nil : @env[HEADER_API_KEY] || request[API_KEY]
|
|
|
|
auth_token = request.cookies[TOKEN_COOKIE] unless user_api_key || api_key
|
|
|
|
current_user = nil
|
|
|
|
if auth_token && auth_token.length == 32
|
|
limiter = RateLimiter.new(nil, "cookie_auth_#{request.ip}", COOKIE_ATTEMPTS_PER_MIN , 60)
|
|
|
|
if limiter.can_perform?
|
|
@user_token = UserAuthToken.lookup(auth_token,
|
|
seen: true,
|
|
user_agent: @env['HTTP_USER_AGENT'],
|
|
path: @env['REQUEST_PATH'],
|
|
client_ip: @request.ip)
|
|
|
|
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)
|
|
raise Discourse::InvalidAccess.new(I18n.t('invalid_api_credentials'), nil, custom_message: "invalid_api_credentials") unless current_user
|
|
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
|
|
@env[API_KEY_ENV] = true
|
|
rate_limit_admin_api_requests(api_key)
|
|
end
|
|
|
|
# user api key handling
|
|
if user_api_key
|
|
|
|
limiter_min = RateLimiter.new(nil, "user_api_min_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_minute, 60)
|
|
limiter_day = RateLimiter.new(nil, "user_api_day_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_day, 86400)
|
|
|
|
unless limiter_day.can_perform?
|
|
limiter_day.performed!
|
|
end
|
|
|
|
unless limiter_min.can_perform?
|
|
limiter_min.performed!
|
|
end
|
|
|
|
current_user = lookup_user_api_user_and_update_key(user_api_key, @env[USER_API_CLIENT_ID])
|
|
raise Discourse::InvalidAccess unless current_user
|
|
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
|
|
|
|
limiter_min.performed!
|
|
limiter_day.performed!
|
|
|
|
@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
|
|
|
|
BookmarkReminderNotificationHandler.defer_at_desktop_reminder(
|
|
user: u, request_user_agent: @request.user_agent
|
|
)
|
|
end
|
|
|
|
@env[CURRENT_USER_KEY] = current_user
|
|
end
|
|
|
|
def refresh_session(user, session, cookies)
|
|
# 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'])
|
|
cookies[TOKEN_COOKIE] = cookie_hash(@user_token.unhashed_auth_token)
|
|
DiscourseEvent.trigger(:user_session_refreshed, user)
|
|
end
|
|
end
|
|
end
|
|
|
|
if !user && cookies.key?(TOKEN_COOKIE)
|
|
cookies.delete(TOKEN_COOKIE)
|
|
end
|
|
end
|
|
|
|
def log_on_user(user, session, cookies, 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])
|
|
|
|
cookies[TOKEN_COOKIE] = cookie_hash(@user_token.unhashed_auth_token)
|
|
user.unstage!
|
|
make_developer_admin(user)
|
|
enable_bootstrap_mode(user)
|
|
|
|
UserAuthToken.enforce_session_count_limit!(user.id)
|
|
|
|
@env[CURRENT_USER_KEY] = user
|
|
end
|
|
|
|
def cookie_hash(unhashed_auth_token)
|
|
hash = {
|
|
value: unhashed_auth_token,
|
|
httponly: true,
|
|
expires: SiteSetting.maximum_session_age.hours.from_now,
|
|
secure: SiteSetting.force_https
|
|
}
|
|
|
|
if SiteSetting.same_site_cookies != "Disabled"
|
|
hash[:same_site] = SiteSetting.same_site_cookies
|
|
end
|
|
|
|
hash
|
|
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, cookies)
|
|
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
|
|
cookies.delete("__profilin")
|
|
end
|
|
|
|
user.logged_out
|
|
elsif user && @user_token
|
|
@user_token.destroy
|
|
end
|
|
|
|
cookies.delete('authentication_data')
|
|
cookies.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?
|
|
cookie = @request.cookies[TOKEN_COOKIE]
|
|
!cookie.nil? && cookie.length == 32
|
|
end
|
|
|
|
def should_update_last_seen?
|
|
return false if Discourse.pg_readonly_mode?
|
|
|
|
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_user_api_user_and_update_key(user_api_key, client_id)
|
|
if api_key = UserApiKey.active.with_key(user_api_key).includes(:user).first
|
|
unless api_key.allow?(@env)
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
|
|
api_key.update_columns(last_used_at: Time.zone.now)
|
|
|
|
if client_id.present? && client_id != api_key.client_id
|
|
|
|
# invalidate old dupe api key for client if needed
|
|
UserApiKey
|
|
.where(client_id: client_id, user_id: api_key.user_id)
|
|
.where('id <> ?', api_key.id)
|
|
.destroy_all
|
|
|
|
api_key.update_columns(client_id: client_id)
|
|
end
|
|
|
|
api_key.user
|
|
end
|
|
end
|
|
|
|
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 !header_api_key?
|
|
raise Discourse::InvalidAccess unless is_whitelisted_query_param_auth_route?(request)
|
|
end
|
|
|
|
if api_key.allowed_ips.present? && !api_key.allowed_ips.any? { |ip| ip.include?(request.ip) }
|
|
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
|
|
api_key.update_columns(last_used_at: Time.zone.now)
|
|
end
|
|
|
|
user
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def is_whitelisted_query_param_auth_route?(request)
|
|
(is_user_feed?(request) || is_handle_mail?(request))
|
|
end
|
|
|
|
def is_user_feed?(request)
|
|
return true if request.path.match?(/\/(c|t){1}\/\S*.(rss|json)/) && request.get? # topic or category route
|
|
return true if request.path.match?(/\/(latest|top|categories).(rss|json)/) && request.get? # specific routes with rss
|
|
return true if request.path.match?(/\/u\/\S*\/bookmarks.(ics|json)/) && request.get? # specific routes with ics
|
|
end
|
|
|
|
def is_handle_mail?(request)
|
|
return true if request.path == "/admin/email/handle_mail" && request.post?
|
|
end
|
|
|
|
def header_api_key?
|
|
!!@env[HEADER_API_KEY]
|
|
end
|
|
|
|
def rate_limit_admin_api_requests(api_key)
|
|
return if Rails.env == "profile"
|
|
|
|
RateLimiter.new(
|
|
nil,
|
|
"admin_api_min_#{ApiKey.hash_key(api_key)}",
|
|
GlobalSetting.max_admin_api_reqs_per_key_per_minute,
|
|
60
|
|
).performed!
|
|
end
|
|
|
|
end
|