Store only SHA256(token) in a new token_digest column and use the digest for the Redis authorization keys instead of the plaintext token. The plaintext is now held in memory only for issuance. This is the expand step: the plaintext token column stays in place so the old code can keep running during the deploy. A follow-up migration will drop it once rs:rekey_tokens has re-keyed Redis and confirmed no plaintext keys remain. Refs #259
168 lines
5.0 KiB
Ruby
168 lines
5.0 KiB
Ruby
class RemoteStorageAuthorization < ApplicationRecord
|
|
belongs_to :user
|
|
belongs_to :web_app, class_name: "AppCatalog::WebApp", optional: true
|
|
|
|
# The plaintext token is only ever held in memory, long enough to hand it to
|
|
# the client at issuance. At rest we store only its SHA256 digest.
|
|
attr_accessor :token
|
|
|
|
# Tokens issued before user agents were recorded may have been used from any
|
|
# number of devices, so they cannot be attributed to a single one.
|
|
UNKNOWN_DEVICE = "Unknown device(s)"
|
|
|
|
serialize :permissions, coder: YAML unless Rails.env.production?
|
|
|
|
validates_presence_of :permissions
|
|
validates_presence_of :client_id
|
|
|
|
scope :valid, -> { where(expire_at: nil).or(where(expire_at: (DateTime.now)..)) }
|
|
scope :expired, -> { where(expire_at: ..(DateTime.now)) }
|
|
|
|
after_initialize do |a|
|
|
a.permissions = [] if a.permissions == nil
|
|
end
|
|
|
|
before_create :generate_token
|
|
before_create :store_token_in_redis
|
|
before_create :find_or_create_web_app
|
|
after_commit :schedule_token_expiry, on: :create
|
|
after_commit :notify_user, on: :create
|
|
before_destroy :delete_token_from_redis
|
|
after_destroy :remove_token_expiry_job
|
|
|
|
def url
|
|
uri = URI.parse self.redirect_uri
|
|
"#{uri.scheme}://#{client_id}"
|
|
end
|
|
|
|
def launch_url
|
|
return url unless web_app && web_app.metadata[:start_url].present?
|
|
|
|
start_url = web_app.metadata[:start_url]
|
|
|
|
if start_url.match("^https?:\/\/")
|
|
return start_url.start_with?(url) ? start_url : url
|
|
else
|
|
path = start_url.gsub(/^\.\.\//, "").gsub(/^\.\//, "").gsub(/^\//, "")
|
|
"#{url}/#{path}"
|
|
end
|
|
end
|
|
|
|
def expired?
|
|
expire_at.present? && expire_at.past?
|
|
end
|
|
|
|
def device_name
|
|
return nil if user_agent.blank?
|
|
|
|
client = DeviceDetector.new(user_agent)
|
|
name = client.name.presence
|
|
os = client.os_name.presence
|
|
os = "Linux" if os == "GNU/Linux"
|
|
os = "macOS" if os == "Mac"
|
|
|
|
# Safari never runs on Linux. A WebKit "Safari" user agent on Linux is a
|
|
# WebKitGTK browser (e.g. GNOME Web), which sends a Safari-compatible UA.
|
|
name = "WebKit" if name == "Safari" && os == "Linux"
|
|
|
|
[name, os].compact.join(" on ").presence
|
|
end
|
|
|
|
def device_label
|
|
device_name || UNKNOWN_DEVICE
|
|
end
|
|
|
|
def display_name
|
|
web_app&.name.presence || app_name.presence || client_id
|
|
end
|
|
|
|
def root_access?
|
|
(permissions & [":r", ":rw"]).any?
|
|
end
|
|
|
|
def delete_token_from_redis
|
|
value = token_digest.presence
|
|
value ||= read_attribute(:token) if self.class.column_names.include?("token")
|
|
return if value.blank?
|
|
|
|
key = "authorizations:#{user.cn}:#{value}"
|
|
redis.del key
|
|
rescue => e
|
|
Rails.logger.error e
|
|
Sentry.capture_exception(e) if Setting.sentry_enabled?
|
|
end
|
|
|
|
private
|
|
|
|
def redis
|
|
@redis ||= Redis.new(url: Setting.rs_redis_url)
|
|
end
|
|
|
|
def generate_token(length=16)
|
|
self.token = SecureRandom.hex(length) if self.token.blank?
|
|
self.token_digest = Digest::SHA256.hexdigest(token)
|
|
end
|
|
|
|
def store_token_in_redis
|
|
redis.sadd "authorizations:#{user.cn}:#{token_digest}", permissions
|
|
end
|
|
|
|
def schedule_token_expiry
|
|
return unless expire_at.present?
|
|
RemoteStorageExpireAuthorizationJob.set(wait_until: expire_at)
|
|
.perform_later(id)
|
|
end
|
|
|
|
def remove_token_expiry_job
|
|
job_class = RemoteStorageExpireAuthorizationJob
|
|
job_args = [id]
|
|
|
|
query = SolidQueue::Job.where(class_name: job_class.to_s)
|
|
|
|
case ActiveRecord::Base.connection.adapter_name.downcase
|
|
when /sqlite/
|
|
query.where("json_extract(arguments, '$.arguments') = ?", job_args.to_json)
|
|
when /postgres/
|
|
query.where("CAST(arguments AS jsonb)->>'arguments' = ?", job_args.to_json)
|
|
else
|
|
raise "Unsupported database adapter"
|
|
end.destroy_all
|
|
end
|
|
|
|
def find_or_create_web_app
|
|
if looks_like_hosted_origin?
|
|
web_app = AppCatalog::WebApp.find_or_create_by!(url: self.url)
|
|
web_app.update_metadata unless web_app.name.present?
|
|
self.web_app = web_app
|
|
self.app_name = web_app.name.presence || client_id
|
|
else
|
|
self.app_name = client_id
|
|
end
|
|
end
|
|
|
|
def looks_like_hosted_origin?
|
|
uri = URI.parse self.redirect_uri
|
|
!!(uri.host =~ /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63}$)/)
|
|
rescue URI::InvalidURIError
|
|
false
|
|
end
|
|
|
|
def notify_user
|
|
notify = user.preferences[:remotestorage_notify_auth_created]
|
|
|
|
case notify
|
|
when "xmpp"
|
|
router = Router.new
|
|
payload = {
|
|
type: "normal", to: user.address,
|
|
from: Setting.xmpp_notifications_from_address,
|
|
body: "You have just granted '#{self.client_id}' access to your Kosmos Storage. Visit your Storage dashboard to check on your connected apps and revoke permissions anytime: #{router.services_storage_url}"
|
|
}
|
|
XmppSendMessageJob.perform_later(payload)
|
|
when "email"
|
|
NotificationMailer.with(user: user, auth: self)
|
|
.remotestorage_auth_created.deliver_later
|
|
end
|
|
end
|
|
end
|