diff --git a/.drone.yml b/.drone.yml index eb43e96..daf1849 100644 --- a/.drone.yml +++ b/.drone.yml @@ -20,6 +20,8 @@ steps: image: guildeducation/rails:2.7.2-14.20.0 environment: RAILS_ENV: test + REDIS_URL: redis://redis:6379/0 + RS_REDIS_URL: redis://redis:6379/1 commands: - bundle config unset deployment - bundle config set cache_all 'true' @@ -42,6 +44,10 @@ steps: branch: - master +services: + - name: redis + image: redis + volumes: - name: cache host: diff --git a/.env.example b/.env.example index fef8c93..081d0ae 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,7 @@ GITEA_PUBLIC_URL='https://gitea.kosmos.org' MASTODON_PUBLIC_URL='https://kosmos.social' MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org' RS_STORAGE_URL='https://storage.kosmos.org' +RS_REDIS_URL='redis://localhost:6379/2' EJABBERD_ADMIN_URL='https://xmpp.kosmos.org/admin' EJABBERD_API_URL='https://xmpp.kosmos.org/api' diff --git a/.env.test b/.env.test index 92892e3..33761c3 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,7 @@ PRIMARY_DOMAIN=kosmos.org +REDIS_URL='redis://localhost:6379/0' + DISCOURSE_PUBLIC_URL='http://discourse.example.com' DISCOURSE_CONNECT_SECRET='discourse_connect_ftw' @@ -12,5 +14,6 @@ LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946' RS_STORAGE_URL='https://storage.kosmos.org' +RS_REDIS_URL='redis://localhost:6379/1' WEBHOOKS_ALLOWED_IPS='10.1.1.23' diff --git a/Gemfile b/Gemfile index 1f0a7b3..ff3396f 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,7 @@ group :development, :test do # Use sqlite3 as the database for Active Record gem 'sqlite3', '~> 1.4' gem 'rspec-rails' + gem 'rails-controller-testing' gem "byebug", "~> 11.1" end diff --git a/Gemfile.lock b/Gemfile.lock index 3fdc74c..04c1e55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -234,6 +234,8 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.9) + nokogiri (1.15.2-arm64-darwin) + racc (~> 1.4) nokogiri (1.15.2-x86_64-linux) racc (~> 1.4) orm_adapter (0.5.0) @@ -267,6 +269,10 @@ GEM activesupport (= 7.0.5) bundler (>= 1.15.0) railties (= 7.0.5) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -373,6 +379,7 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sqlite3 (1.6.3-arm64-darwin) sqlite3 (1.6.3-x86_64-linux) stimulus-rails (1.2.1) railties (>= 6.0.0) @@ -410,6 +417,7 @@ GEM zeitwerk (2.6.8) PLATFORMS + arm64-darwin-22 x86_64-linux DEPENDENCIES @@ -440,6 +448,7 @@ DEPENDENCIES pg (~> 1.2.3) puma (~> 4.1) rails (~> 7.0.2) + rails-controller-testing rails-settings-cached (~> 2.8.3) rqrcode (~> 2.0) rspec-rails diff --git a/app/controllers/rs/oauth_controller.rb b/app/controllers/rs/oauth_controller.rb new file mode 100644 index 0000000..061bf84 --- /dev/null +++ b/app/controllers/rs/oauth_controller.rb @@ -0,0 +1,145 @@ +class Rs::OauthController < ApplicationController + before_action :require_signed_in_with_username, only: :new + before_action :authenticate_user!, only: :create + + def new + username, org = params[:useraddress].split("@") + @user = User.where(cn: username.downcase, ou: org).first + @scopes = parse_scopes params[:scope] + @redirect_uri = params[:redirect_uri] + @client_id = params[:client_id] + @state = params[:state] + @root_access_requested = (@scopes & [":r",":rw"]).any? + + @denial_url = url_with_state("#{@redirect_uri}#error=access_denied", @state) + + @expire_at_dates = [["Never", nil], + ["In 1 month", 1.month.from_now], + ["In 1 day", 1.day.from_now]] + + http_status :bad_request and return unless @redirect_uri.present? + + unless current_user == @user + sign_out :user + + redirect_to new_rs_oauth_url(@user.address, + scope: params[:scope], + redirect_uri: params[:redirect_uri], + client_id: params[:client_id], + state: params[:state]) + return + end + + unless @client_id.present? + redirect_to(url_with_state("#{@redirect_uri}#error=invalid_request", @state), + allow_other_host: true) and return + end + + if @scopes.empty? + redirect_to(url_with_state("#{@redirect_uri}#error=invalid_scope", @state), + allow_other_host: true) and return + end + + unless hostname_of(@client_id) == hostname_of(@redirect_uri) + redirect_to(url_with_state("#{@redirect_uri}#error=invalid_client", @state), + allow_other_host: true) and return + end + + @client_id.gsub!(/http(s)?:\/\//, "") + + if auth = current_user.remote_storage_authorizations.valid.where(permissions: @scopes, client_id: @client_id).first + redirect_to(url_with_state("#{@redirect_uri}#access_token=#{auth.token}", @state), + allow_other_host: true) and return + end + end + + def create + unless current_user.id.to_s == params[:user_id] + Rails.logger.info("NO MATCH: #{params[:user_id]}, #{current_user.id}") + http_status :forbidden and return + end + + permissions = parse_scopes params[:scope] + redirect_uri = params[:redirect_uri].presence + client_id = params[:client_id].presence + state = params[:state].presence + expire_at = params[:expire_at].presence + + http_status :bad_request and return unless redirect_uri.present? + + if permissions.empty? + redirect_to(url_with_state("#{redirect_uri}#error=invalid_scope", state), + allow_other_host: true) and return + end + + unless client_id.present? + redirect_to(url_with_state("#{redirect_uri}#error=invalid_request", state), + allow_other_host: true) and return + end + + unless hostname_of(client_id) == hostname_of(redirect_uri) + redirect_to(url_with_state("#{redirect_uri}#error=invalid_client", state), + allow_other_host: true) and return + end + + client_id.gsub!(/http(s)?:\/\//, "") + + auth = current_user.remote_storage_authorizations.create!( + permissions: permissions, + client_id: client_id, + redirect_uri: redirect_uri, + app_name: client_id, #TODO use user-defined name + expire_at: expire_at + ) + + redirect_to url_with_state("#{redirect_uri}#access_token=#{auth.token}", state), + allow_other_host: true + end + + # GET /rs/oauth/token/:id/launch_app + def launch_app + auth = current_user.remote_storage_authorizations.find(params[:id]) + + redirect_to app_auth_url(auth), allow_other_host: true + end + + private + + def require_signed_in_with_username + unless user_signed_in? + username, org = params[:useraddress].split("@") + redirect_to new_user_session_path(cn: username, ou: org) + end + end + + def app_auth_url(auth) + url = "#{auth.url}#remotestorage=#{current_user.address}" + url += "&access_token=#{auth.token}" + url + end + + def hostname_of(uri) + uri.gsub(/http(s)?:\/\//, "").split(":")[0].split("/")[0] + end + + def parse_scopes(scope_string) + return [] if scope_string.blank? + + scopes = scope_string. + gsub(/\[|\]/, ""). + gsub(/\,/, " "). + gsub(/\/:/, ":"). + split(/\s/).map(&:strip). + reject(&:empty?) + + scopes = [":r"] if scopes.include?("*:r") + scopes = [":rw"] if scopes.include?("*:rw") + + scopes + end + + def url_with_state(url, state) + state ? "#{url}&state=#{CGI.escape(state)}" : url + end + +end diff --git a/app/helpers/oauth_helper.rb b/app/helpers/oauth_helper.rb new file mode 100644 index 0000000..12f76a5 --- /dev/null +++ b/app/helpers/oauth_helper.rb @@ -0,0 +1,11 @@ +module OauthHelper + + def scope_name(scope) + scope.gsub(/(\:.+)/, '') + end + + def scope_permissions(scope) + scope.match(/\:r$/) ? "r" : "rw" + end + +end diff --git a/app/jobs/remote_storage_expire_authorization_job.rb b/app/jobs/remote_storage_expire_authorization_job.rb new file mode 100644 index 0000000..62c240c --- /dev/null +++ b/app/jobs/remote_storage_expire_authorization_job.rb @@ -0,0 +1,10 @@ +class RemoteStorageExpireAuthorizationJob < ApplicationJob + queue_as :remotestorage + + def perform(rs_auth_id) + rs_auth = RemoteStorageAuthorization.find rs_auth_id + return unless rs_auth.expire_at.nil? || rs_auth.expire_at <= DateTime.now + + rs_auth.destroy! + end +end diff --git a/app/models/remote_storage_authorization.rb b/app/models/remote_storage_authorization.rb new file mode 100644 index 0000000..82ac744 --- /dev/null +++ b/app/models/remote_storage_authorization.rb @@ -0,0 +1,63 @@ +class RemoteStorageAuthorization < ApplicationRecord + belongs_to :user + + serialize :permissions + + 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 + after_create :schedule_token_expiry + before_destroy :delete_token_from_redis + after_destroy :remove_token_expiry_job + + def url + if self.redirect_uri + uri = URI.parse self.redirect_uri + "#{uri.scheme}://#{client_id}" + else + "http://#{client_id}" + end + end + + def delete_token_from_redis + key = "rs:authorizations:#{user.address}:#{token}" + redis.srem? key, redis.smembers(key) + 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? + end + + def store_token_in_redis + redis.sadd "rs:authorizations:#{user.address}:#{token}", 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 + queue = Sidekiq::Queue.new(RemoteStorageExpireAuthorizationJob.queue_name) + queue.each do |job| + next unless job.display_class == "RemoteStorageExpireAuthorizationJob" + job.delete if job.display_args == [id] + end + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index f32c86f..fc3b068 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -12,7 +12,7 @@ class Setting < RailsSettings::Base # Internal services # - field :redis_url, type: :string, readonly: true, + field :redis_url, type: :string, default: ENV["REDIS_URL"] || "redis://localhost:6379/0" # @@ -131,4 +131,7 @@ class Setting < RailsSettings::Base field :rs_storage_url, type: :string, default: ENV["RS_STORAGE_URL"].presence + + field :rs_redis_url, type: :string, + default: ENV["RS_REDIS_URL"] || "redis://localhost:6379/1" end diff --git a/app/models/user.rb b/app/models/user.rb index 871c43f..fb7537a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,8 @@ class User < ApplicationRecord has_many :accounts, through: :lndhub_user + has_many :remote_storage_authorizations + validates_uniqueness_of :cn, scope: :ou validates_length_of :cn, minimum: 3 validates_format_of :cn, with: /\A([a-z0-9\-])*\z/, diff --git a/app/views/admin/settings/services/_remotestorage.html.erb b/app/views/admin/settings/services/_remotestorage.html.erb index 42aea32..5b8f47b 100644 --- a/app/views/admin/settings/services/_remotestorage.html.erb +++ b/app/views/admin/settings/services/_remotestorage.html.erb @@ -11,7 +11,11 @@ <% if Setting.remotestorage_enabled? %> <%= render FormElements::FieldsetResettableSettingComponent.new( key: :rs_storage_url, - title: "Storage URL" + title: "Storage Base URL" + ) %> + <%= render FormElements::FieldsetResettableSettingComponent.new( + key: :rs_redis_url, + title: "Redis URL" ) %> <% end %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index d3d02e0..ddb0a44 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -12,7 +12,8 @@
<%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>

- <%= f.text_field :cn, autofocus: true, autocomplete: "username", + <%= f.text_field :cn, value: h(params[:cn]), + autofocus: params[:cn].blank?, autocomplete: "username", required: true, class: "relative grow", tabindex: "1" %> @ <%= Setting.primary_domain %>

@@ -20,7 +21,8 @@

<%= f.label :password, class: 'block mb-2 font-bold' %> <%= f.password_field :password, autocomplete: "current-password", - required: true, class: "w-full", tabindex: "2" %> + autofocus: params[:cn].present?, required: true, + class: "w-full", tabindex: "2" %>

<%= tag.div class: "flex items-center mb-8 gap-x-3", data: { diff --git a/app/views/icons/_alert-triangle.html.erb b/app/views/icons/_alert-triangle.html.erb index 6dcb096..36b0133 100644 --- a/app/views/icons/_alert-triangle.html.erb +++ b/app/views/icons/_alert-triangle.html.erb @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/views/icons/_asterisk.html.erb b/app/views/icons/_asterisk.html.erb new file mode 100644 index 0000000..a97802b --- /dev/null +++ b/app/views/icons/_asterisk.html.erb @@ -0,0 +1 @@ + diff --git a/app/views/icons/_folder.html.erb b/app/views/icons/_folder.html.erb index 134458b..69e55bb 100644 --- a/app/views/icons/_folder.html.erb +++ b/app/views/icons/_folder.html.erb @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/views/rs/oauth/new.html.erb b/app/views/rs/oauth/new.html.erb new file mode 100644 index 0000000..3d63f81 --- /dev/null +++ b/app/views/rs/oauth/new.html.erb @@ -0,0 +1,58 @@ +<%= render HeaderCompactComponent.new(title: "Storage") %> + +<%= render MainCompactComponent.new do %> +
+

+ The app on + <%= link_to @client_id, "https://#{@client_id}", class: "ks-text-link" %> + is asking for access to these folders: +

+ + <% if @root_access_requested %> +

+ + <%= render partial: "icons/alert-triangle", + locals: { custom_class: "inline-block align-bottom mr-1.5" } %> + All files and directories + + <% if (@scopes & [":r"]).any? %> + (read only) + <% end %> +

+ <% else %> + <% @scopes.each do |scope| %> +

+ + <%= render partial: "icons/folder", + locals: { custom_class: "inline-block align-bottom mr-1.5" } %> + <%= scope_name(scope) %> + + <% if scope_permissions(scope) == "r" %> + (read only) + <% end %> +

+ <% end %> + <% end %> + + <%= form_with(url: rs_oauth_path, method: :post, data: { turbo: false }) do |f| %> + <%= f.hidden_field :redirect_uri, value: @redirect_uri %> + <%= f.hidden_field :scope, value: @scopes.join(" ") %> + <%= f.hidden_field :user_id, value: @user.id %> + <%= f.hidden_field :client_id, value: @client_id %> + <%= f.hidden_field :state, value: @state %> +

+ <%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %> + <%= f.select :expire_at, options_for_select(@expire_at_dates) %> +

+

+ You can revoke access for this app at any time on your storage dashboard. +

+

+ <%= f.submit "Allow", + class: "btn-md btn-blue w-full sm:order-last sm:grow", + data: { disable_with: "Saving..." } %> + <%= link_to "Deny", @denial_url, class: "btn-md btn-gray text-red-700 w-full sm:grow" %> +

+ <% end %> + +<% end %> diff --git a/app/views/shared/status_bad_request.html.erb b/app/views/shared/status_bad_request.html.erb new file mode 100644 index 0000000..d8851df --- /dev/null +++ b/app/views/shared/status_bad_request.html.erb @@ -0,0 +1,6 @@ +<%= render HeaderCompactComponent.new(title: "404") %> + +<%= render MainCompactComponent.new do %> +

Bad request

+

Please go back and try again.

+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 04143a5..7d51379 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,7 +66,14 @@ Rails.application.routes.draw do end end - get ".well-known/webfinger", to: 'webfinger#show' + namespace :rs do + resource :oauth, only: [:new, :create], path_names: { + new: ':useraddress', create: ':useraddress' + }, controller: 'oauth', constraints: { useraddress: /[^\/]+/} + get 'oauth/token/:id/launch_app' => 'oauth#launch_app', as: :launch_app + end + + get '.well-known/webfinger', to: 'webfinger#show' namespace :discourse do get "connect", to: 'sso#connect' diff --git a/db/migrate/20230312212030_create_remote_storage_authorizations.rb b/db/migrate/20230312212030_create_remote_storage_authorizations.rb new file mode 100644 index 0000000..ebb8733 --- /dev/null +++ b/db/migrate/20230312212030_create_remote_storage_authorizations.rb @@ -0,0 +1,17 @@ +class CreateRemoteStorageAuthorizations < ActiveRecord::Migration[7.0] + def change + create_table :remote_storage_authorizations do |t| + t.references :user, null: false, foreign_key: true + t.string :token + t.text :permissions, array: true, default: [].to_yaml + t.string :client_id + t.string :redirect_uri + t.string :app_name + t.datetime :expire_at + + t.timestamps + end + + add_index :remote_storage_authorizations, :permissions, using: 'gin' + end +end diff --git a/db/schema.rb b/db/schema.rb index 82260de..758a0d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -87,10 +87,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do t.text "ln_login_ciphertext" t.text "ln_password_ciphertext" t.string "ln_account" - t.string "nostr_pubkey" t.datetime "remember_created_at" t.string "remember_token" t.text "preferences" + t.string "nostr_pubkey" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/docker-compose.yml b/docker-compose.yml index 9242fb6..3690616 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: RAILS_ENV: development PRIMARY_DOMAIN: kosmos.org REDIS_URL: redis://redis:6379/0 + RS_REDIS_URL: redis://redis:6379/1 LDAP_HOST: ldap LDAP_PORT: 3389 LDAP_ADMIN_PASSWORD: passthebutter @@ -57,6 +58,7 @@ services: RAILS_ENV: development PRIMARY_DOMAIN: kosmos.org REDIS_URL: redis://redis:6379/0 + RS_REDIS_URL: redis://redis:6379/1 LDAP_HOST: ldap LDAP_PORT: 3389 LDAP_ADMIN_PASSWORD: passthebutter diff --git a/spec/controllers/rs/oauth_controller_spec.rb b/spec/controllers/rs/oauth_controller_spec.rb new file mode 100644 index 0000000..48ca14b --- /dev/null +++ b/spec/controllers/rs/oauth_controller_spec.rb @@ -0,0 +1,465 @@ +require 'rails_helper' + +RSpec.describe Rs::OauthController, type: :controller do + let(:user) { create :user } + + describe "GET /rs/oauth/:useraddress" do + context "when user is signed in" do + before do + sign_in user + end + + context "when username is different than current user" do + let(:other_user) { create :user, id: 23, cn: "jomokenyatta", email: "jomo@hotmail.com" } + + before do + get :new, params: { + useraddress: other_user.address, + redirect_uri: "https://example.com", + client_id: "example.com", + scope: "examples" + } + end + + it "logs out the users and repeats the request" do + url = new_rs_oauth_url other_user.address, + redirect_uri: "https://example.com", + client_id: "example.com", + scope: "examples" + + expect(response).to redirect_to(url) + end + end + + context "when no valid token exists" do + before do + get :new, params: { + useraddress: user.address, + redirect_uri: "https://example.com", + client_id: "example.com", + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + state: "foobar123" + } + end + + it "returns a 200" do + expect(response.response_code).to eq(200) + end + + it "sets the instance variables" do + expected_scopes = %w(documents photos contacts:rw videos:r tasks/work:r) + + expect(assigns["user"]).to eq(user) + expect(assigns["redirect_uri"]).to eq("https://example.com") + expect(assigns["scopes"]).to eq(expected_scopes) + expect(assigns["client_id"]).to eq("example.com") + expect(assigns["root_access_requested"]).to eq(false) + expect(assigns["state"]).to eq("foobar123") + expect(assigns["denial_url"]).to eq("https://example.com#error=access_denied&state=foobar123") + end + + context "no redirect_uri" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + client_id: "https://example.com" + } + end + + it "returns a 400" do + expect(response.response_code).to eq(400) + end + end + + context "no client_id" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com" + } + end + + it "redirects with invalid_request error" do + expect(response).to redirect_to("https://example.com#error=invalid_request") + end + end + + context "different host for client_id and redirect_uri" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com/foobar", + client_id: "https://google.com" + } + end + + it "redirects with invalid_client error" do + expect(response).to redirect_to("https://example.com/foobar#error=invalid_client") + end + end + end + + context "when valid token already exists" do + before do + @auth = 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", + expire_at: 1.day.from_now + ) + end + + after { @auth.destroy } + + context "with same host for client_id and redirect_uri" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com", + client_id: "https://example.com" + } + end + + it "redirects to the redirect_uri with the existing token" do + expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}") + end + end + + context "with different host for client_id and redirect_uri" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://app.example.com", + client_id: "https://example.com" + } + end + + it "redirects with invalid_client error" do + expect(response).to redirect_to("https://app.example.com#error=invalid_client") + end + end + + context "with different redirect_uri" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com/a_new_route", + client_id: "https://example.com" + } + end + + it "redirects to the new redirect_uri" do + expect(response).to redirect_to("https://example.com/a_new_route#access_token=#{@auth.token}") + end + end + + context "with state param given" do + before do + get :new, params: { + useraddress: user.address, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com", + client_id: "https://example.com", + state: "foobar123" + } + end + + it "redirects to the redirect_uri with token and state" do + expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123") + end + end + end + + context "no scope" do + before do + get :new, params: { + useraddress: user.address, + redirect_uri: "https://example.com", + client_id: "https://example.com", + state: "foobar123" + } + end + + it "redirects to the redirect_uri with an error code" do + expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123") + end + end + + context "empty scope" do + before do + get :new, params: { + useraddress: user.address, + scope: "", + redirect_uri: "https://example.com", + client_id: "https://example.com", + state: "foobar123" + } + end + + it "redirects to the redirect_uri with an error code" do + expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123") + end + end + end + + context "when user is not signed in" do + it "redirects to the signin page with username pre-filled" do + get :new, params: { + useraddress: user.address, + scope: "documents,photos", + redirect_uri: "https://example.com" + } + + expect(response).to redirect_to(new_user_session_path(cn: user.cn, ou: user.ou)) + end + end + + describe "root access" do + before do + sign_in user + end + + describe "full" do + before do + get :new, params: { + useraddress: user.address, + scope: "*:rw", + redirect_uri: "https://example.com", + client_id: "example.com" + } + end + + it "sets the instance variables" do + expect(assigns["scopes"]).to eq([":rw"]) + expect(assigns["root_access_requested"]).to be(true) + end + end + + describe "read-only" do + before do + get :new, params: { + useraddress: user.address, + scope: "*:r", + redirect_uri: "https://example.com", + client_id: "example.com" + } + end + + it "sets the instance variables" do + expect(assigns["scopes"]).to eq([":r"]) + expect(assigns["root_access_requested"]).to be(true) + end + end + end + end + + describe "POST /rs/oauth/:useraddress" do + context "when user is signed in" do + before do + sign_in user + end + + after do + user.remote_storage_authorizations.destroy_all + end + + context "when no valid token exists" do + before do + post :create, params: { + user_id: user.id, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com", + client_id: "example.com", + state: "foobar123", + expire_at: 1.day.from_now + } + @auth = user.reload.remote_storage_authorizations.first + end + + it "creates a new token" do + expect(@auth.permissions).to eq(%w(documents photos contacts:rw videos:r tasks/work:r)) + end + + it "redirects to the redirect_uri" do + expect(response).to redirect_to("https://example.com#access_token=#{@auth.token}&state=foobar123") + end + end + + context "when token is expired" do + before do + @auth = user.remote_storage_authorizations.create!( + permissions: %w(documents), + client_id: "example.com", + redirect_uri: "https://example.com", + expire_at: 1.day.ago, + token: nil + ) + post :create, params: { + user_id: user.id, + scope: "documents", + redirect_uri: "https://example.com", + client_id: "example.com", + state: "foobar123", + expire_at: 1.month.from_now + } + end + + it "updates the token" do + expect(@auth.reload.token).not_to be_nil + end + end + + context "root access with several scopes" do + before do + post :create, params: { + user_id: user.id, + scope: "*:rw contacts:r", + redirect_uri: "https://example.com", + client_id: "example.com", + expire_at: 1.month.from_now + } + @auth = user.reload.remote_storage_authorizations.first + end + + it "removes all scopes except for the root permission" do + expect(@auth.permissions).to eq(%w(:rw)) + end + end + + context "no redirect_uri" do + before do + post :create, params: { + user_id: user.id, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + client_id: "example.com", + expire_at: 1.month.from_now + } + end + + it "returns a 400" do + expect(response.response_code).to eq(400) + end + end + + context "no client_id" do + before do + post :create, params: { + user_id: user.id, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + redirect_uri: "https://example.com", + expire_at: 1.month.from_now + } + end + + it "redirects with invalid_request error" do + expect(response).to redirect_to("https://example.com#error=invalid_request") + end + end + + context "hostnames of client_id and redirect_uri do not match" do + before do + post :create, params: { + user_id: user.id, + scope: "documents,[photos], contacts:rw videos:r tasks/work/:r", + client_id: "fishing.com", + redirect_uri: "https://example.com", + expire_at: 1.month.from_now + } + end + + it "redirects with invalid_client error" do + expect(response).to redirect_to("https://example.com#error=invalid_client") + end + end + + context "empty scope" do + before do + post :create, params: { + user_id: user.id, + scope: "", + redirect_uri: "https://example.com", + client_id: "example.com", + state: "foobar123", + expire_at: 1.month.from_now + } + end + + it "redirects to the redirect_uri with an error code" do + expect(response).to redirect_to("https://example.com#error=invalid_scope&state=foobar123") + end + end + + context "when the user_id is different from the signed in user" do + before do + post :create, params: { + user_id: user.id, + scope: "documents,photos", + redirect_uri: "https://example.com", + client_id: "example.com", + expire_at: 1.month.from_now + } + end + + it "returns a 403" do + post :create, params: { + user_id: "69", + scope: "documents,photos", + redirect_uri: "https://example.com", + client_id: "example.com", + expire_at: 1.month.from_now + } + + expect(response.response_code).to eq(403) + end + end + end + + context "when user is not signed in" do + it "redirects to the signin page" do + post :create, params: { + user_id: user.id, + scope: "documents,photos", + redirect_uri: "https://example.com", + client_id: "example.com", + expire_at: 1.month.from_now + } + + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe "GET /rs/oauth/token/:id/launch_app" do + context "when user is signed in" do + before do + sign_in user + end + + context "token exists" do + before do + @auth = user.remote_storage_authorizations.create!( + permissions: %w(documents), client_id: "app.example.com", + redirect_uri: "https://app.example.com", + expire_at: 2.days.from_now + ) + + get :launch_app, params: { id: @auth.id } + end + + after do + @auth.destroy + end + + it "redirects to the given URL with the correct RS URL fragment params" do + launch_url = "https://app.example.com#remotestorage=#{user.address}&access_token=#{@auth.token}" + expect(response).to redirect_to(launch_url) + end + end + end + end +end diff --git a/spec/factories/remote_storage_authorizations.rb b/spec/factories/remote_storage_authorizations.rb new file mode 100644 index 0000000..be7c810 --- /dev/null +++ b/spec/factories/remote_storage_authorizations.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :remote_storage_authorization do + permissions { ["documents:rw"] } + client_id { "some-fancy-app" } + redirect_uri { "https://example.com/some-fancy-app" } + app_name { "Fancy App" } + expire_at { nil } + end +end diff --git a/spec/features/rs/oauth_spec.rb b/spec/features/rs/oauth_spec.rb new file mode 100644 index 0000000..48a5a13 --- /dev/null +++ b/spec/features/rs/oauth_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +RSpec.describe 'remoteStorage OAuth Dialog', type: :feature do + context "when signed in" do + let(:user) { create :user } + + before do + login_as user, :scope => :user + end + + context "with normal permissions" do + before do + visit new_rs_oauth_path(useraddress: user.address, + redirect_uri: "http://example.com", + client_id: "http://example.com", + scope: "documents,[photos], contacts:r") + end + + it "shows the permissions in a list" do + within ".permissions" do + expect(page).to have_content("documents") + expect(page).to have_content("photos") + expect(page).to have_content("contacts") + end + + within ".scope:first-of-type" do + expect(page).not_to have_content("read only") + end + + within ".scope:last-of-type" do + expect(page).to have_content("read only") + end + end + end + + context "root access" do + context "full" do + before do + visit new_rs_oauth_path(useraddress: user.address, + redirect_uri: "http://example.com", + client_id: "http://example.com", + scope: ":rw") + end + + it "shows a special permission for all files and dirs" do + within ".scope" do + expect(page).to have_content("All files and directories") + end + end + end + end + end + + context "when signed out" do + let(:user) { create :user } + + it "prefills the username field in the signin form" do + visit new_rs_oauth_path(useraddress: user.address, + redirect_uri: "http://example.com", + client_id: "http://example.com", + scope: "documents,[photos], contacts:r") + + expect(find("#user_cn").value).to eq(user.cn) + end + end +end diff --git a/spec/jobs/remote_storage_expire_authorization_job_spec.rb b/spec/jobs/remote_storage_expire_authorization_job_spec.rb new file mode 100644 index 0000000..e65662e --- /dev/null +++ b/spec/jobs/remote_storage_expire_authorization_job_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe RemoteStorageExpireAuthorizationJob, type: :job do + before do + @user = create :user, cn: "ronald", ou: "kosmos.org" + @rs_authorization = create :remote_storage_authorization, user: @user, expire_at: 1.day.ago + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end + + subject(:job) { + described_class.perform_later(@rs_authorization.id) + } + + let(:redis) { + @redis ||= Redis.new(url: Setting.rs_redis_url) + } + + it "removes the RS authorization from redis" do + redis_key = "rs:authorizations:#{@user.address}:#{@rs_authorization.token}" + expect(redis.keys(redis_key)).to_not be_empty + + perform_enqueued_jobs { job } + + expect(redis.keys(redis_key)).to be_empty + end + + it "deletes the RS authorization object" do + expect { + perform_enqueued_jobs { job } + }.to change(RemoteStorageAuthorization, :count).by(-1) + end +end diff --git a/spec/models/remote_storage_authorization_spec.rb b/spec/models/remote_storage_authorization_spec.rb new file mode 100644 index 0000000..3d046cf --- /dev/null +++ b/spec/models/remote_storage_authorization_spec.rb @@ -0,0 +1,212 @@ +require 'rails_helper' + +RSpec.describe RemoteStorageAuthorization, type: :model do + include ActiveJob::TestHelper + + let(:user) { create :user } + + describe "#create" do + after(:each) { clear_enqueued_jobs } + after(:all) { redis_rs_delete_keys("rs:authorizations:*") } + + 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 a token in redis" do + user_auth_keys = redis_rs.keys("rs:authorizations:#{user.address}:*") + expect(user_auth_keys.length).to eq(1) + + authorizations = redis_rs.smembers(user_auth_keys.first) + 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 } + after(:all) { redis_rs_delete_keys("rs:authorizations:*") } + + 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.keys("rs:authorizations:#{user.address}:*")).to be_empty + 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 + # before do + # auth = 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", + # expire_at: 1.month.from_now + # ) + # end + # + # it "generates a web_app" do + # expect(auth.web_app).to be_a(AppCatalog::WebApp) + # end + # + # it "uses the Web App's name as app name" do + # expect(auth.app_name).to eq("Example Domain") + # end + # end + # + # context "when creating two authorizations for the same app" do + # before do + # user_2 = create :user + # ResqueSpec.reset! + # auth_1 = 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", + # expire_at: 1.month.from_now + # ) + # auth_2 = 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", + # expire_at: 1.month.from_now + # ) + # end + # + # after do + # auth_1.destroy + # auth_2.destroy + # user_2.destroy + # end + # + # it "uses the same web app instance for both authorizations" do + # expect(auth_1.web_app).to be_a(AppCatalog::WebApp) + # 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 + # before do + # auth = 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 + # before do + # auth = 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 # before do + # auth = 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 "auth notifications" do + # context "with auth notifications enabled" do + # before do + # ResqueSpec.reset! + # user.push(mailing_lists: "rs-auth-notifications-#{Rails.env}") + # auth = 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 "notifies the user via email" do + # expect(enqueued_jobs.size).to eq(1) + # job = enqueued_jobs.first + # expect(job).to eq( + # job: ActionMailer::DeliveryJob, + # args: ['StorageAuthorizationMailer', 'authorized_rs_app', 'deliver_now', + # auth.id.to_s], + # queue: 'mailers' + # ) + # end + # end + # + # context "with auth notifications disabled" do + # before do + # ResqueSpec.reset! + # user.pull(mailing_lists: "rs-auth-notifications-#{Rails.env}") + # auth = 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 "does not notify the user via email about new RS app" do + # expect(enqueued_jobs.size).to eq(0) + # end + # end + # end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index eab4dee..ae6309d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -10,6 +10,7 @@ require 'capybara' require 'devise' require 'support/controller_macros' require 'support/database_cleaner' +require 'support/helpers/redis_helper' require "view_component/test_helpers" require "capybara/rspec" diff --git a/spec/support/helpers/redis_helper.rb b/spec/support/helpers/redis_helper.rb new file mode 100644 index 0000000..2571fce --- /dev/null +++ b/spec/support/helpers/redis_helper.rb @@ -0,0 +1,8 @@ +def redis_rs + @redis_rs ||= Redis.new(url: Setting.rs_redis_url) +end + +def redis_rs_delete_keys(pattern) + keys = redis_rs.keys(pattern) + redis_rs.del(*keys) +end