discourse/lib/auth/default_current_user_provider.rb
Martin Brennan 793f39139a
FEATURE: Send notifications for time-based and At Desktop bookmark reminders (#9071)
* This PR implements the scheduling and notification system for bookmark reminders. Every 5 minutes a schedule runs to check any reminders that need to be sent before now, limited to **300** reminders at a time. Any leftover reminders will be sent in the next run. This is to avoid having to deal with fickle sidekiq and reminders in the far-flung future, which would necessitate having a background job anyway to clean up any missing `enqueue_at` reminders.

* If a reminder is sent its `reminder_at` time is cleared and the `reminder_last_sent_at` time is filled in. Notifications are only user-level notifications for now.

* All JavaScript and frontend code related to displaying the bookmark reminder notification is contained here. The reminder functionality is now re-enabled in the bookmark modal as well.

* This PR also implements the "Remind me next time I am at my desktop" bookmark reminder functionality. When the user is on a mobile device they are able to select this option. When they choose this option we set a key in Redis saying they have a pending at desktop reminder. The next time they change devices we check if the new device is desktop, and if it is we send reminders using a DistributedMutex. There is also a job to ensure consistency of these reminders in Redis (in case Redis drops the ball) and the at desktop reminders expire after 20 days.

* Also in this PR is a fix to delete all Bookmarks for a user via `UserDestroyer`
2020-03-12 10:16:00 +10:00

349 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)
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)
unstage_user(user)
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 unstage_user(user)
if user.staged
user.unstage
user.save
end
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_VISIBLE".freeze] == "true".freeze
else
true
end
end
protected
def lookup_user_api_user_and_update_key(user_api_key, client_id)
if api_key = UserApiKey.where(key: user_api_key, revoked_at: nil).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]
# Check for deprecated api auth
if !header_api_key?
if request.path == "/admin/email/handle_mail"
# Notify admins that the mail receiver is still using query auth and to update
AdminDashboardData.add_problem_message('dashboard.update_mail_receiver', 1.day)
else
# Notify admins of deprecated auth method
AdminDashboardData.add_problem_message('dashboard.deprecated_api_usage', 1.day)
end
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 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