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
417 lines
14 KiB
Ruby
417 lines
14 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe RemoteStorageAuthorization, type: :model do
|
|
include ActiveJob::TestHelper
|
|
|
|
let(:user) { create :user }
|
|
|
|
before do
|
|
allow_any_instance_of(AppCatalog::WebApp).to receive(:update_metadata).and_return(true)
|
|
allow_any_instance_of(RemoteStorageAuthorization).to receive(:remove_token_expiry_job).and_return(nil)
|
|
end
|
|
|
|
describe "#create" do
|
|
after(:each) { clear_enqueued_jobs }
|
|
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
|
|
client_id: "example.com",
|
|
redirect_uri: "https://example.com"
|
|
)
|
|
end
|
|
|
|
it "generates a token" do
|
|
expect(auth.token).to match(/[a-zA-Z0-9]+/)
|
|
end
|
|
|
|
it "stores only the token digest" do
|
|
expect(auth.token_digest).to eq(Digest::SHA256.hexdigest(auth.token))
|
|
end
|
|
|
|
it "does not persist the plaintext token" do
|
|
plaintext = auth.token
|
|
auth.reload
|
|
|
|
expect(auth.read_attribute(:token)).to be_nil
|
|
expect(auth.token_digest).to eq(Digest::SHA256.hexdigest(plaintext))
|
|
end
|
|
|
|
it "stores a token in redis" do
|
|
auth
|
|
|
|
authorizations = redis_rs.smembers("authorizations:#{user.cn}:#{auth.token_digest}")
|
|
expect(authorizations.sort).to eq(%w(documents photos contacts:rw videos:r tasks/work:r).sort)
|
|
end
|
|
|
|
context "with expiry set" do
|
|
it "enqueues an expiration job" do
|
|
auth_with_expiry = user.remote_storage_authorizations.create!(
|
|
permissions: %w(documents:rw), client_id: "example.com",
|
|
redirect_uri: "https://example.com",
|
|
expire_at: 1.month.from_now
|
|
)
|
|
job = enqueued_jobs.select{|j| j['job_class'] == "RemoteStorageExpireAuthorizationJob"}.first
|
|
expect(job['arguments'][0]).to eq(auth_with_expiry.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#destroy" do
|
|
after(:each) { clear_enqueued_jobs }
|
|
|
|
it "removes the token from redis" do
|
|
auth = user.remote_storage_authorizations.create!(
|
|
permissions: %w(shares:rw documents pictures:r),
|
|
client_id: "sharesome.5apps.com",
|
|
redirect_uri: "https://sharesome.5apps.com"
|
|
)
|
|
auth.destroy!
|
|
|
|
expect(redis_rs.exists?("authorizations:#{user.cn}:#{auth.token_digest}")).to be(false)
|
|
end
|
|
|
|
it "removes a legacy plaintext token from redis" do
|
|
auth = user.remote_storage_authorizations.create!(
|
|
permissions: %w(shares:rw),
|
|
client_id: "sharesome.5apps.com",
|
|
redirect_uri: "https://sharesome.5apps.com"
|
|
)
|
|
auth.update_columns(token: "legacytoken", token_digest: nil)
|
|
redis_rs.sadd("authorizations:#{user.cn}:legacytoken", %w(shares:rw))
|
|
|
|
auth.destroy!
|
|
|
|
expect(redis_rs.exists?("authorizations:#{user.cn}:legacytoken")).to be(false)
|
|
end
|
|
|
|
context "with expiry set" do
|
|
it "removes the expiration job" do
|
|
auth_with_expiry = user.remote_storage_authorizations.create!(
|
|
permissions: %w(documents:rw), client_id: "example.com",
|
|
redirect_uri: "https://example.com",
|
|
expire_at: 1.month.from_now
|
|
)
|
|
# Cannot test for removal from the actual Sidekiq::Queue, because it is
|
|
# not used in specs, but the method directly removes jobs from there
|
|
expect(auth_with_expiry).to receive(:remove_token_expiry_job)
|
|
auth_with_expiry.destroy!
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#find_or_create_web_app" do
|
|
context "with origin that looks hosted" do
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(documents:rw),
|
|
client_id: "example.com",
|
|
redirect_uri: "https://example.com"
|
|
)
|
|
end
|
|
|
|
it "generates a web_app" do
|
|
expect(auth.web_app).to be_a(AppCatalog::WebApp)
|
|
end
|
|
end
|
|
|
|
context "when creating two authorizations for the same app" do
|
|
let(:user_2) { create :user, id: 23, cn: "michiel", email: "michiel@example.com" }
|
|
|
|
let(:auth_1) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
|
|
client_id: "example.com",
|
|
redirect_uri: "https://example.com"
|
|
)
|
|
end
|
|
|
|
let(:auth_2) do
|
|
user_2.remote_storage_authorizations.create!(
|
|
permissions: %w(documents photos contacts:rw videos:r tasks/work:r),
|
|
client_id: "example.com",
|
|
redirect_uri: "https://example.com"
|
|
)
|
|
end
|
|
|
|
after do
|
|
auth_1.destroy
|
|
auth_2.destroy
|
|
user_2.destroy
|
|
end
|
|
|
|
it "uses the same web app for both authorizations" do
|
|
expect(auth_1.web_app).to eq(auth_2.web_app)
|
|
end
|
|
end
|
|
|
|
describe "non-production app origins" do
|
|
context "when host is not an FQDN" do
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(recipes),
|
|
client_id: "localhost:4200",
|
|
redirect_uri: "http://localhost:4200"
|
|
)
|
|
end
|
|
|
|
it "does not create a web app" do
|
|
expect(auth.web_app).to be_nil
|
|
expect(auth.app_name).to eq("localhost:4200")
|
|
end
|
|
end
|
|
|
|
context "when host is an IP address" do
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(recipes),
|
|
client_id: "192.168.0.23:3000",
|
|
redirect_uri: "http://192.168.0.23:3000"
|
|
)
|
|
end
|
|
|
|
it "does not create a web app" do
|
|
expect(auth.web_app).to be_nil
|
|
expect(auth.app_name).to eq("192.168.0.23:3000")
|
|
end
|
|
end
|
|
|
|
context "when host is an extension URL" do
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(bookmarks),
|
|
client_id: "123.addons.allizom.org",
|
|
redirect_uri: "123.addons.allizom.org/foo"
|
|
)
|
|
end
|
|
|
|
it "does not create a web app" do
|
|
expect(auth.web_app).to be_nil
|
|
expect(auth.app_name).to eq("123.addons.allizom.org")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "#launch_url" do
|
|
context "without start URL" do
|
|
before do
|
|
AppCatalog::WebApp.create!(
|
|
url: "https://webmarks.5apps.com", name: "Webmarks",
|
|
metadata: { name: "Webmarks", start_url: nil, scope: nil }
|
|
)
|
|
end
|
|
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(bookmarks:rw), client_id: "webmarks.5apps.com",
|
|
redirect_uri: "https://webmarks.5apps.com/connect"
|
|
)
|
|
end
|
|
|
|
it "uses the base URL (from client ID)" do
|
|
expect(auth.launch_url).to eq("https://webmarks.5apps.com")
|
|
end
|
|
end
|
|
|
|
context "with start URL" do
|
|
before do
|
|
AppCatalog::WebApp.create!(
|
|
url: "https://hyperdraft.rosano.ca", name: "Hyperdraft",
|
|
metadata: {
|
|
name: "Hyperdraft", scope: nil,
|
|
start_url: "https://hyperdraft.rosano.ca/start"
|
|
}
|
|
)
|
|
end
|
|
|
|
let(:auth) do
|
|
user.remote_storage_authorizations.create!(
|
|
permissions: %w(notes:rw), client_id: "hyperdraft.rosano.ca",
|
|
redirect_uri: "https://hyperdraft.rosano.ca/write/foo"
|
|
)
|
|
end
|
|
|
|
describe "full URL" do
|
|
it "respects the start URL" do
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start")
|
|
end
|
|
|
|
it "does not respect URLs outside of the client ID scope" do
|
|
auth.web_app.metadata[:start_url] = "https://uberdraft.rosano.ca/write"
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca")
|
|
end
|
|
end
|
|
|
|
describe "relative paths" do
|
|
it "includes the path relative from the base URL" do
|
|
auth.web_app.metadata[:start_url] = "start.html"
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
|
|
|
|
auth.web_app.metadata[:start_url] = "./start.html"
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
|
|
|
|
auth.web_app.metadata[:start_url] = "../start.html"
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/start.html")
|
|
end
|
|
end
|
|
|
|
describe "absolute path" do
|
|
it "includes the path relative from the base URL" do
|
|
auth.web_app.metadata[:start_url] = "/write"
|
|
expect(auth.launch_url).to eq("https://hyperdraft.rosano.ca/write")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "notifications" do
|
|
include ActiveJob::TestHelper
|
|
|
|
after(:each) { clear_enqueued_jobs }
|
|
|
|
before { allow(user).to receive(:display_name).and_return("Jimmy") }
|
|
|
|
context "with notifications disabled" do
|
|
before do
|
|
user.preferences.merge!({ remotestorage_notify_auth_created: "off" })
|
|
user.save!
|
|
user.remote_storage_authorizations.create!(
|
|
:permissions => %w(photos), :client_id => "app.example.com",
|
|
:redirect_uri => "https://app.example.com"
|
|
)
|
|
end
|
|
|
|
it "does not notify the user via email about new RS app" do
|
|
expect(enqueued_jobs.size).to eq(0)
|
|
end
|
|
end
|
|
|
|
context "with email notifications enabled" do
|
|
before do
|
|
user.preferences.merge!({ remotestorage_notify_auth_created: "email" })
|
|
user.save!
|
|
user.remote_storage_authorizations.create!(
|
|
:permissions => %w(photos), :client_id => "app.example.com",
|
|
:redirect_uri => "https://app.example.com"
|
|
)
|
|
end
|
|
|
|
it "notifies the user via email" do
|
|
expect(enqueued_jobs.size).to eq(1)
|
|
job = enqueued_jobs.select{|j| j['job_class'] == "ActionMailer::MailDeliveryJob"}.first
|
|
expect(job['arguments'][0]).to eq('NotificationMailer')
|
|
expect(job['arguments'][1]).to eq('remotestorage_auth_created')
|
|
expect(job['arguments'][3]['params']['user']['_aj_globalid']).to eq('gid://akkounts/User/1')
|
|
expect(job['arguments'][3]['params']['auth']['_aj_globalid']).to eq('gid://akkounts/RemoteStorageAuthorization/1')
|
|
end
|
|
end
|
|
|
|
context "with XMPP notifications enabled" do
|
|
before do
|
|
Setting.xmpp_notifications_from_address = "botka@kosmos.org"
|
|
user.preferences.merge!({ remotestorage_notify_auth_created: "xmpp" })
|
|
user.save!
|
|
user.remote_storage_authorizations.create!(
|
|
:permissions => %w(photos), :client_id => "app.example.com",
|
|
:redirect_uri => "https://app.example.com"
|
|
)
|
|
end
|
|
|
|
it "sends an XMPP message to the account owner's JID" do
|
|
expect(enqueued_jobs.size).to eq(1)
|
|
expect(enqueued_jobs.first["job_class"]).to eq("XmppSendMessageJob")
|
|
msg = enqueued_jobs.first["arguments"].first
|
|
expect(msg["type"]).to eq("normal")
|
|
expect(msg["from"]).to eq("botka@kosmos.org")
|
|
expect(msg["to"]).to eq(user.address)
|
|
expect(msg["body"]).to match(/granted 'app\.example\.com' access to your Kosmos Storage/)
|
|
end
|
|
end
|
|
end
|
|
describe "#expired?" do
|
|
def auth_with_expiry(expire_at)
|
|
RemoteStorageAuthorization.new(
|
|
user: user, permissions: %w(documents:rw), client_id: "example.com",
|
|
redirect_uri: "https://example.com", expire_at: expire_at
|
|
)
|
|
end
|
|
|
|
it "is false without an expiry" do
|
|
expect(auth_with_expiry(nil)).not_to be_expired
|
|
end
|
|
|
|
it "is false before the expiry" do
|
|
expect(auth_with_expiry(1.day.from_now)).not_to be_expired
|
|
end
|
|
|
|
it "is true after the expiry" do
|
|
expect(auth_with_expiry(1.day.ago)).to be_expired
|
|
end
|
|
end
|
|
|
|
describe "#device_name" do
|
|
def auth_with_user_agent(user_agent)
|
|
RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
|
|
end
|
|
|
|
it "returns nil without a user agent" do
|
|
expect(auth_with_user_agent(nil).device_name).to be_nil
|
|
end
|
|
|
|
it "recognizes Firefox on Linux" do
|
|
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("Firefox on Linux")
|
|
end
|
|
|
|
it "recognizes Chrome on macOS" do
|
|
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
|
|
"(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("Chrome on macOS")
|
|
end
|
|
|
|
it "recognizes Mobile Safari on iOS" do
|
|
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) " \
|
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("Mobile Safari on iOS")
|
|
end
|
|
|
|
it "recognizes GNOME Web on Linux (Epiphany's Safari-like user agent)" do
|
|
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
|
|
"Version/16.0 Safari/605.1.15 Epiphany/45.0"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("GNOME Web on Linux")
|
|
end
|
|
|
|
it "treats a WebKit Safari user agent on Linux as WebKit (WebKitGTK)" do
|
|
ua = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
|
|
"Version/60.5 Safari/605.1.15"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("WebKit on Linux")
|
|
end
|
|
|
|
it "keeps Safari on macOS as Safari" do
|
|
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " \
|
|
"(KHTML, like Gecko) Version/17.1 Safari/605.1.15"
|
|
expect(auth_with_user_agent(ua).device_name).to eq("Safari on macOS")
|
|
end
|
|
|
|
it "falls back to the browser when the OS is unknown" do
|
|
expect(auth_with_user_agent("Firefox/120.0").device_name).to eq("Firefox")
|
|
end
|
|
end
|
|
|
|
describe "#device_label" do
|
|
def auth_with_user_agent(user_agent)
|
|
RemoteStorageAuthorization.new(user: user, user_agent: user_agent)
|
|
end
|
|
|
|
it "falls back to the unrecorded placeholder without a user agent" do
|
|
expect(auth_with_user_agent(nil).device_label).to eq("Unknown device(s)")
|
|
end
|
|
|
|
it "returns the device name when one can be determined" do
|
|
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
|
|
expect(auth_with_user_agent(ua).device_label).to eq("Firefox on Linux")
|
|
end
|
|
end
|
|
end
|