Compare commits
8 Commits
v0.7.0
...
5c2df3df07
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c2df3df07
|
|||
|
716d4b944a
|
|||
|
42af148168
|
|||
|
|
dabd892a25
|
||
|
|
eeabbdb7df
|
||
|
ee42d68471
|
|||
|
7acc3b2106
|
|||
|
20c014607c
|
@@ -42,6 +42,10 @@ steps:
|
||||
branch:
|
||||
- master
|
||||
|
||||
services:
|
||||
- name: redis
|
||||
image: redis
|
||||
|
||||
volumes:
|
||||
- name: cache
|
||||
host:
|
||||
|
||||
@@ -232,6 +232,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)
|
||||
@@ -371,6 +373,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)
|
||||
@@ -408,6 +411,7 @@ GEM
|
||||
zeitwerk (2.6.8)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
||||
130
app/controllers/rs/oauth_controller.rb
Normal file
130
app/controllers/rs/oauth_controller.rb
Normal 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
|
||||
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/expire_remote_storage_authorization_job.rb
Normal file
10
app/jobs/expire_remote_storage_authorization_job.rb
Normal 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
|
||||
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.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
|
||||
@@ -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/,
|
||||
|
||||
@@ -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>
|
||||
<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 %>
|
||||
@@ -65,7 +65,13 @@ 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' },
|
||||
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'
|
||||
|
||||
@@ -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_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
|
||||
|
||||
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
|
||||
36
spec/jobs/expire_remote_storage_authorization_job_spec.rb
Normal file
36
spec/jobs/expire_remote_storage_authorization_job_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user