8 Commits

Author SHA1 Message Date
5c2df3df07 Add Redis service to Drone config
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2023-06-27 15:07:28 +02:00
716d4b944a Merge branch 'master' into feature/rs-oauth
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
# Conflicts:
#	app/models/user.rb
#	config/routes.rb
#	db/schema.rb
2023-06-20 14:07:46 +02:00
42af148168 Persist RS auth tokens in Redis 2023-06-20 14:02:48 +02:00
Râu Cao
dabd892a25 Improve RS OAuth UI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-04-13 16:21:48 +02:00
Râu Cao
eeabbdb7df Merge branch 'master' into feature/rs-oauth
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-04-13 15:02:51 +02:00
ee42d68471 Add RemoteStorageAuthorization model
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-03-28 01:21:28 +02:00
7acc3b2106 RemoteStorage OAuth dialog 2023-03-28 01:21:28 +02:00
20c014607c Basic RemoteStorage settings 2023-03-27 22:52:01 +02:00
16 changed files with 355 additions and 4 deletions

View File

@@ -42,6 +42,10 @@ steps:
branch: branch:
- master - master
services:
- name: redis
image: redis
volumes: volumes:
- name: cache - name: cache
host: host:

View File

@@ -232,6 +232,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)
@@ -371,6 +373,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)
@@ -408,6 +411,7 @@ GEM
zeitwerk (2.6.8) zeitwerk (2.6.8)
PLATFORMS PLATFORMS
arm64-darwin-22
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES

View File

@@ -0,0 +1,130 @@
class Rs::OauthController < ApplicationController
before_action :require_user_signed_in
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) and return
end
if @scopes.empty?
redirect_to url_with_state("#{@redirect_uri}#error=invalid_scope", @state) and return
end
unless hostname_of(@client_id) == hostname_of(@redirect_uri)
redirect_to url_with_state("#{@redirect_uri}#error=invalid_client", @state) and return
end
@client_id.gsub!(/http(s)?:\/\//, "")
# TODO
# 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
# 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)
end
private
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

View 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

View File

@@ -0,0 +1,10 @@
class ExpireRemoteStorageAuthorizationJob < ApplicationJob
queue_as :remote_storage
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

View 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.permisisons = [] 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}"
# You can't delete multiple members of a set with Redis 2
redis.smembers(key).each { |auth| redis.srem(key, auth) }
end
private
def redis
@redis ||= Redis.new(url: Setting.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?
ExpireRemoteStorageAuthorizationJob.set(wait_unil: expire_at).perform_later(id)
end
def remove_token_expiry_job
queue = Sidekiq::Queue.new(ExpireRemoteStorageAuthorizationJob.queue_name)
queue.each do |job|
next unless job.display_class == "ExpireRemoteStorageAuthorizationJob"
job.delete if job.display_args == [id]
end
end
end

View File

@@ -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/,

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -0,0 +1,58 @@
<%= render HeaderCompactComponent.new(title: "Storage") %>
<%= render MainCompactComponent.new do %>
<section>
<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="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="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 %>

View File

@@ -65,7 +65,13 @@ 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' },
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'

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe ExpireRemoteStorageAuthorizationJob, 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.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