Merge pull request 'remoteStorage OAuth' (#109) from feature/rs-oauth into master
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #109
This commit is contained in:
commit
852e2fea1e
@ -20,6 +20,8 @@ steps:
|
|||||||
image: guildeducation/rails:2.7.2-14.20.0
|
image: guildeducation/rails:2.7.2-14.20.0
|
||||||
environment:
|
environment:
|
||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
RS_REDIS_URL: redis://redis:6379/1
|
||||||
commands:
|
commands:
|
||||||
- bundle config unset deployment
|
- bundle config unset deployment
|
||||||
- bundle config set cache_all 'true'
|
- bundle config set cache_all 'true'
|
||||||
@ -42,6 +44,10 @@ steps:
|
|||||||
branch:
|
branch:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: redis
|
||||||
|
image: redis
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: cache
|
- name: cache
|
||||||
host:
|
host:
|
||||||
|
@ -26,6 +26,7 @@ GITEA_PUBLIC_URL='https://gitea.kosmos.org'
|
|||||||
MASTODON_PUBLIC_URL='https://kosmos.social'
|
MASTODON_PUBLIC_URL='https://kosmos.social'
|
||||||
MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'
|
MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'
|
||||||
RS_STORAGE_URL='https://storage.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_ADMIN_URL='https://xmpp.kosmos.org/admin'
|
||||||
EJABBERD_API_URL='https://xmpp.kosmos.org/api'
|
EJABBERD_API_URL='https://xmpp.kosmos.org/api'
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
PRIMARY_DOMAIN=kosmos.org
|
PRIMARY_DOMAIN=kosmos.org
|
||||||
|
|
||||||
|
REDIS_URL='redis://localhost:6379/0'
|
||||||
|
|
||||||
DISCOURSE_PUBLIC_URL='http://discourse.example.com'
|
DISCOURSE_PUBLIC_URL='http://discourse.example.com'
|
||||||
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
|
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
|
||||||
|
|
||||||
@ -12,5 +14,6 @@ LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
|
|||||||
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'
|
||||||
|
|
||||||
RS_STORAGE_URL='https://storage.kosmos.org'
|
RS_STORAGE_URL='https://storage.kosmos.org'
|
||||||
|
RS_REDIS_URL='redis://localhost:6379/1'
|
||||||
|
|
||||||
WEBHOOKS_ALLOWED_IPS='10.1.1.23'
|
WEBHOOKS_ALLOWED_IPS='10.1.1.23'
|
||||||
|
1
Gemfile
1
Gemfile
@ -64,6 +64,7 @@ group :development, :test do
|
|||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
gem 'sqlite3', '~> 1.4'
|
gem 'sqlite3', '~> 1.4'
|
||||||
gem 'rspec-rails'
|
gem 'rspec-rails'
|
||||||
|
gem 'rails-controller-testing'
|
||||||
gem "byebug", "~> 11.1"
|
gem "byebug", "~> 11.1"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -234,6 +234,8 @@ GEM
|
|||||||
net-smtp (0.3.3)
|
net-smtp (0.3.3)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.5.9)
|
nio4r (2.5.9)
|
||||||
|
nokogiri (1.15.2-arm64-darwin)
|
||||||
|
racc (~> 1.4)
|
||||||
nokogiri (1.15.2-x86_64-linux)
|
nokogiri (1.15.2-x86_64-linux)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
@ -267,6 +269,10 @@ GEM
|
|||||||
activesupport (= 7.0.5)
|
activesupport (= 7.0.5)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.0.5)
|
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)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
@ -373,6 +379,7 @@ GEM
|
|||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
|
sqlite3 (1.6.3-arm64-darwin)
|
||||||
sqlite3 (1.6.3-x86_64-linux)
|
sqlite3 (1.6.3-x86_64-linux)
|
||||||
stimulus-rails (1.2.1)
|
stimulus-rails (1.2.1)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
@ -410,6 +417,7 @@ GEM
|
|||||||
zeitwerk (2.6.8)
|
zeitwerk (2.6.8)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
|
arm64-darwin-22
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
@ -440,6 +448,7 @@ DEPENDENCIES
|
|||||||
pg (~> 1.2.3)
|
pg (~> 1.2.3)
|
||||||
puma (~> 4.1)
|
puma (~> 4.1)
|
||||||
rails (~> 7.0.2)
|
rails (~> 7.0.2)
|
||||||
|
rails-controller-testing
|
||||||
rails-settings-cached (~> 2.8.3)
|
rails-settings-cached (~> 2.8.3)
|
||||||
rqrcode (~> 2.0)
|
rqrcode (~> 2.0)
|
||||||
rspec-rails
|
rspec-rails
|
||||||
|
145
app/controllers/rs/oauth_controller.rb
Normal file
145
app/controllers/rs/oauth_controller.rb
Normal file
@ -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
|
11
app/helpers/oauth_helper.rb
Normal file
11
app/helpers/oauth_helper.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module OauthHelper
|
||||||
|
|
||||||
|
def scope_name(scope)
|
||||||
|
scope.gsub(/(\:.+)/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def scope_permissions(scope)
|
||||||
|
scope.match(/\:r$/) ? "r" : "rw"
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
10
app/jobs/remote_storage_expire_authorization_job.rb
Normal file
10
app/jobs/remote_storage_expire_authorization_job.rb
Normal file
@ -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
|
63
app/models/remote_storage_authorization.rb
Normal file
63
app/models/remote_storage_authorization.rb
Normal file
@ -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
|
@ -12,7 +12,7 @@ class Setting < RailsSettings::Base
|
|||||||
# Internal services
|
# Internal services
|
||||||
#
|
#
|
||||||
|
|
||||||
field :redis_url, type: :string, readonly: true,
|
field :redis_url, type: :string,
|
||||||
default: ENV["REDIS_URL"] || "redis://localhost:6379/0"
|
default: ENV["REDIS_URL"] || "redis://localhost:6379/0"
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -131,4 +131,7 @@ class Setting < RailsSettings::Base
|
|||||||
|
|
||||||
field :rs_storage_url, type: :string,
|
field :rs_storage_url, type: :string,
|
||||||
default: ENV["RS_STORAGE_URL"].presence
|
default: ENV["RS_STORAGE_URL"].presence
|
||||||
|
|
||||||
|
field :rs_redis_url, type: :string,
|
||||||
|
default: ENV["RS_REDIS_URL"] || "redis://localhost:6379/1"
|
||||||
end
|
end
|
||||||
|
@ -18,6 +18,8 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
has_many :accounts, through: :lndhub_user
|
has_many :accounts, through: :lndhub_user
|
||||||
|
|
||||||
|
has_many :remote_storage_authorizations
|
||||||
|
|
||||||
validates_uniqueness_of :cn, scope: :ou
|
validates_uniqueness_of :cn, scope: :ou
|
||||||
validates_length_of :cn, minimum: 3
|
validates_length_of :cn, minimum: 3
|
||||||
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,
|
validates_format_of :cn, with: /\A([a-z0-9\-])*\z/,
|
||||||
|
@ -11,7 +11,11 @@
|
|||||||
<% if Setting.remotestorage_enabled? %>
|
<% if Setting.remotestorage_enabled? %>
|
||||||
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
key: :rs_storage_url,
|
key: :rs_storage_url,
|
||||||
title: "Storage URL"
|
title: "Storage Base URL"
|
||||||
|
) %>
|
||||||
|
<%= render FormElements::FieldsetResettableSettingComponent.new(
|
||||||
|
key: :rs_redis_url,
|
||||||
|
title: "Redis URL"
|
||||||
) %>
|
) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -12,7 +12,8 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>
|
<%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>
|
||||||
<p class="flex gap-2 items-center">
|
<p class="flex gap-2 items-center">
|
||||||
<%= 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" %>
|
required: true, class: "relative grow", tabindex: "1" %>
|
||||||
<span class="relative shrink-0 text-gray-500">@ <%= Setting.primary_domain %></span>
|
<span class="relative shrink-0 text-gray-500">@ <%= Setting.primary_domain %></span>
|
||||||
</p>
|
</p>
|
||||||
@ -20,7 +21,8 @@
|
|||||||
<p class="mb-8">
|
<p class="mb-8">
|
||||||
<%= f.label :password, class: 'block mb-2 font-bold' %>
|
<%= f.label :password, class: 'block mb-2 font-bold' %>
|
||||||
<%= f.password_field :password, autocomplete: "current-password",
|
<%= 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" %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= tag.div class: "flex items-center mb-8 gap-x-3", data: {
|
<%= tag.div class: "flex items-center mb-8 gap-x-3", data: {
|
||||||
|
@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle <%= custom_class %>"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
||||||
|
Before Width: | Height: | Size: 424 B After Width: | Height: | Size: 445 B |
1
app/views/icons/_asterisk.html.erb
Normal file
1
app/views/icons/_asterisk.html.erb
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 512 512" fill="currentColor" stroke="currentColor" stroke-width="2" class="<%= custom_class %>"><path d="M475.31 364.144L288 256l187.31-108.144c5.74-3.314 7.706-10.653 4.392-16.392l-4-6.928c-3.314-5.74-10.653-7.706-16.392-4.392L272 228.287V12c0-6.627-5.373-12-12-12h-8c-6.627 0-12 5.373-12 12v216.287L52.69 120.144c-5.74-3.314-13.079-1.347-16.392 4.392l-4 6.928c-3.314 5.74-1.347 13.079 4.392 16.392L224 256 36.69 364.144c-5.74 3.314-7.706 10.653-4.392 16.392l4 6.928c3.314 5.74 10.653 7.706 16.392 4.392L240 283.713V500c0 6.627 5.373 12 12 12h8c6.627 0 12-5.373 12-12V283.713l187.31 108.143c5.74 3.314 13.079 1.347 16.392-4.392l4-6.928c3.314-5.74 1.347-13.079-4.392-16.392z"/></svg>
|
After Width: | Height: | Size: 760 B |
@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder <%= custom_class %>"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 331 B |
58
app/views/rs/oauth/new.html.erb
Normal file
58
app/views/rs/oauth/new.html.erb
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<%= render HeaderCompactComponent.new(title: "Storage") %>
|
||||||
|
|
||||||
|
<%= render MainCompactComponent.new do %>
|
||||||
|
<section class="permissions">
|
||||||
|
<p class="mb-8">
|
||||||
|
The app on
|
||||||
|
<%= link_to @client_id, "https://#{@client_id}", class: "ks-text-link" %>
|
||||||
|
is asking for access to these folders:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% if @root_access_requested %>
|
||||||
|
<p class="scope text-lg">
|
||||||
|
<span class="text-red-700">
|
||||||
|
<%= render partial: "icons/alert-triangle",
|
||||||
|
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||||
|
All files and directories
|
||||||
|
</span>
|
||||||
|
<% if (@scopes & [":r"]).any? %>
|
||||||
|
<span class="text-sm text-gray-500">(read only)</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<% @scopes.each do |scope| %>
|
||||||
|
<p class="scope text-gray-600">
|
||||||
|
<span class="text-lg">
|
||||||
|
<%= render partial: "icons/folder",
|
||||||
|
locals: { custom_class: "inline-block align-bottom mr-1.5" } %>
|
||||||
|
<%= scope_name(scope) %>
|
||||||
|
</span>
|
||||||
|
<% if scope_permissions(scope) == "r" %>
|
||||||
|
<span>(read only)</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% 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 %>
|
||||||
|
<p class="mt-8 mb-6">
|
||||||
|
<%= f.label :expire_at, "Permission expires:", class: "mr-1.5" %>
|
||||||
|
<%= f.select :expire_at, options_for_select(@expire_at_dates) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
You can revoke access for this app at any time on your storage dashboard.
|
||||||
|
</p>
|
||||||
|
<p class="mt-8 flex flex-col sm:flex-row gap-3 sm:gap-2 sm:justify-items-stretch">
|
||||||
|
<%= 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" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
6
app/views/shared/status_bad_request.html.erb
Normal file
6
app/views/shared/status_bad_request.html.erb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<%= render HeaderCompactComponent.new(title: "404") %>
|
||||||
|
|
||||||
|
<%= render MainCompactComponent.new do %>
|
||||||
|
<h2>Bad request</h2>
|
||||||
|
<p>Please go back and try again.</p>
|
||||||
|
<% end %>
|
@ -66,7 +66,14 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
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
|
namespace :discourse do
|
||||||
get "connect", to: 'sso#connect'
|
get "connect", to: 'sso#connect'
|
||||||
|
@ -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
|
@ -87,10 +87,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_23_120753) do
|
|||||||
t.text "ln_login_ciphertext"
|
t.text "ln_login_ciphertext"
|
||||||
t.text "ln_password_ciphertext"
|
t.text "ln_password_ciphertext"
|
||||||
t.string "ln_account"
|
t.string "ln_account"
|
||||||
t.string "nostr_pubkey"
|
|
||||||
t.datetime "remember_created_at"
|
t.datetime "remember_created_at"
|
||||||
t.string "remember_token"
|
t.string "remember_token"
|
||||||
t.text "preferences"
|
t.text "preferences"
|
||||||
|
t.string "nostr_pubkey"
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
end
|
end
|
||||||
|
@ -38,6 +38,7 @@ services:
|
|||||||
RAILS_ENV: development
|
RAILS_ENV: development
|
||||||
PRIMARY_DOMAIN: kosmos.org
|
PRIMARY_DOMAIN: kosmos.org
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
RS_REDIS_URL: redis://redis:6379/1
|
||||||
LDAP_HOST: ldap
|
LDAP_HOST: ldap
|
||||||
LDAP_PORT: 3389
|
LDAP_PORT: 3389
|
||||||
LDAP_ADMIN_PASSWORD: passthebutter
|
LDAP_ADMIN_PASSWORD: passthebutter
|
||||||
@ -57,6 +58,7 @@ services:
|
|||||||
RAILS_ENV: development
|
RAILS_ENV: development
|
||||||
PRIMARY_DOMAIN: kosmos.org
|
PRIMARY_DOMAIN: kosmos.org
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
RS_REDIS_URL: redis://redis:6379/1
|
||||||
LDAP_HOST: ldap
|
LDAP_HOST: ldap
|
||||||
LDAP_PORT: 3389
|
LDAP_PORT: 3389
|
||||||
LDAP_ADMIN_PASSWORD: passthebutter
|
LDAP_ADMIN_PASSWORD: passthebutter
|
||||||
|
465
spec/controllers/rs/oauth_controller_spec.rb
Normal file
465
spec/controllers/rs/oauth_controller_spec.rb
Normal file
@ -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
|
9
spec/factories/remote_storage_authorizations.rb
Normal file
9
spec/factories/remote_storage_authorizations.rb
Normal file
@ -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
|
66
spec/features/rs/oauth_spec.rb
Normal file
66
spec/features/rs/oauth_spec.rb
Normal file
@ -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
|
36
spec/jobs/remote_storage_expire_authorization_job_spec.rb
Normal file
36
spec/jobs/remote_storage_expire_authorization_job_spec.rb
Normal file
@ -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
|
212
spec/models/remote_storage_authorization_spec.rb
Normal file
212
spec/models/remote_storage_authorization_spec.rb
Normal file
@ -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
|
@ -10,6 +10,7 @@ require 'capybara'
|
|||||||
require 'devise'
|
require 'devise'
|
||||||
require 'support/controller_macros'
|
require 'support/controller_macros'
|
||||||
require 'support/database_cleaner'
|
require 'support/database_cleaner'
|
||||||
|
require 'support/helpers/redis_helper'
|
||||||
require "view_component/test_helpers"
|
require "view_component/test_helpers"
|
||||||
require "capybara/rspec"
|
require "capybara/rspec"
|
||||||
|
|
||||||
|
8
spec/support/helpers/redis_helper.rb
Normal file
8
spec/support/helpers/redis_helper.rb
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user