1 Commits

Author SHA1 Message Date
0bd77bc37a WIP Add service accounts and ACIs
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-28 10:57:12 +04:00
38 changed files with 169 additions and 707 deletions

View File

@@ -1,5 +1,4 @@
PRIMARY_DOMAIN=kosmos.org
AKKOUNTS_DOMAIN=accounts.kosmos.org
REDIS_URL='redis://localhost:6379/0'
@@ -12,8 +11,6 @@ DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api'
MASTODON_PUBLIC_URL='http://example.social'
LNDHUB_API_URL='http://localhost:3026'
LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org'
LNDHUB_PUBLIC_KEY='024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946'

View File

@@ -62,7 +62,7 @@ gem "sentry-rails"
gem 'discourse_api'
gem "lnurl"
gem 'manifique'
gem 'nostr', '~> 0.6.0'
gem 'nostr'
group :development, :test do
# Use sqlite3 as the database for Active Record

View File

@@ -155,7 +155,7 @@ GEM
ruby2_keywords
e2mmap (0.1.0)
ecdsa (1.2.0)
ecdsa_ext (0.5.1)
ecdsa_ext (0.5.0)
ecdsa (~> 1.2.0)
erubi (1.12.0)
et-orbi (1.2.7)
@@ -278,9 +278,9 @@ GEM
racc (~> 1.4)
nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4)
nostr (0.6.0)
nostr (0.5.0)
bech32 (~> 1.4)
bip-schnorr (~> 0.7)
bip-schnorr (~> 0.6)
ecdsa (~> 1.2)
event_emitter (~> 0.2)
faye-websocket (~> 0.11)
@@ -517,7 +517,7 @@ DEPENDENCIES
lockbox
manifique
net-ldap
nostr (~> 0.6.0)
nostr
pagy (~> 6.0, >= 6.0.2)
pg (~> 1.5)
puma (~> 4.1)

View File

@@ -42,11 +42,6 @@
focus:ring-red-500 focus:ring-opacity-75;
}
.btn-outline-purple {
@apply border-2 border-purple-500 hover:bg-purple-100
focus:ring-purple-400 focus:ring-opacity-75;
}
.btn:disabled {
@apply bg-gray-100 hover:bg-gray-200 text-gray-400
focus:ring-gray-300 focus:ring-opacity-75;

View File

@@ -63,9 +63,4 @@ class ApplicationController < ActionController::Base
@fetch_balance_retried = true
lndhub_fetch_balance
end
def nostr_event_from_params
params.permit!
params[:signed_event].to_h.symbolize_keys
end
end

View File

@@ -3,7 +3,7 @@ class Services::ChatController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.service_enabled?(:xmpp)
@service_enabled = current_user.services_enabled.include?(:xmpp)
end
private

View File

@@ -3,7 +3,7 @@ class Services::MastodonController < Services::BaseController
before_action :require_service_available
def show
@service_enabled = current_user.service_enabled?(:mastodon)
@service_enabled = current_user.services_enabled.include?(:mastodon)
end
private

View File

@@ -5,7 +5,7 @@ class Services::RemotestorageController < Services::BaseController
# Dashboard
def show
# unless current_user.service_enabled?(:remotestorage)
# unless current_user.services_enabled.include?(:remotestorage)
# redirect_to service_remotestorage_info_path
# end
@rs_auths = current_user.remote_storage_authorizations

View File

@@ -87,27 +87,25 @@ class SettingsController < ApplicationController
end
def set_nostr_pubkey
signed_event = Nostr::Event.new(**nostr_event_from_params)
signed_event = nostr_event_params[:signed_event].to_h.symbolize_keys
is_valid_sig = signed_event.verify_signature
is_valid_auth = NostrManager::VerifyAuth.call(
event: signed_event,
challenge: session[:shared_secret]
)
is_valid_id = NostrManager::ValidateId.call(event: signed_event)
is_valid_sig = NostrManager::VerifySignature.call(event: signed_event)
is_correct_content = signed_event[:content] == "Connect my public key to #{current_user.address} (confirmation #{session[:shared_secret]})"
unless is_valid_sig && is_valid_auth
unless is_valid_id && is_valid_sig && is_correct_content
flash[:alert] = "Public key could not be verified"
http_status :unprocessable_entity and return
end
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
user_with_pubkey = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event[:pubkey])
if user_with_pubkey.present? && (user_with_pubkey != current_user)
flash[:alert] = "Public key already in use for a different account"
http_status :unprocessable_entity and return
end
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event.pubkey)
LdapManager::UpdateNostrKey.call(dn: current_user.dn, pubkey: signed_event[:pubkey])
session[:shared_secret] = nil
flash[:success] = "Public key verification successful"
@@ -162,6 +160,12 @@ class SettingsController < ApplicationController
params.require(:user).permit(:current_password)
end
def nostr_event_params
params.permit(signed_event: [
:id, :pubkey, :created_at, :kind, :content, :sig, tags: []
])
end
def generate_email_password
characters = [('a'..'z'), ('A'..'Z'), (0..9)].map(&:to_a).flatten
SecureRandom.random_bytes(16).each_byte.map { |b| characters[b % characters.length] }.join

View File

@@ -1,62 +0,0 @@
# frozen_string_literal: true
class Users::SessionsController < Devise::SessionsController
# before_action :configure_sign_in_params, only: [:create]
# GET /resource/sign_in
def new
session[:shared_secret] = SecureRandom.base64(12)
super
end
# POST /resource/sign_in
# def create
# super
# end
# DELETE /resource/sign_out
# def destroy
# super
# end
# POST /users/nostr_login
def nostr_login
signed_event = Nostr::Event.new(**nostr_event_from_params)
is_valid_sig = signed_event.verify_signature
is_valid_auth = NostrManager::VerifyAuth.call(
event: signed_event,
challenge: session[:shared_secret]
)
session[:shared_secret] = nil
unless is_valid_sig && is_valid_auth
flash[:alert] = "Login verification failed"
http_status :unauthorized and return
end
user = LdapManager::FetchUserByNostrKey.call(pubkey: signed_event.pubkey)
if user.present?
set_flash_message!(:notice, :signed_in)
sign_in("user", user)
render json: { redirect_url: after_sign_in_path_for(user) }, status: :ok
else
flash[:alert] = "Failed to find your account. Nostr login may be disabled."
http_status :unauthorized
end
end
protected
def set_flash_message(key, kind, options = {})
# Hide flash message after redirecting from a signin route while logged in
super unless key == :alert && kind == "already_authenticated"
end
# If you have extra params to permit, append them to the sanitizer.
# def configure_sign_in_params
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
# end
end

View File

@@ -7,15 +7,14 @@ class WebfingerController < ApplicationController
resource = params[:resource]
if resource && @useraddress = resource.match(/acct:(.+)/)&.[](1)
@username, @domain = @useraddress.split("@")
@username, @org = @useraddress.split("@")
unless Rails.env.development?
# Allow different domains (e.g. localhost:3000) in development only
head 404 and return unless @domain == Setting.primary_domain
head 404 and return unless @org == Setting.primary_domain
end
unless @user = User.where(ou: Setting.primary_domain)
.find_by(cn: @username.downcase)
unless User.where(cn: @username.downcase, ou: Setting.primary_domain).any?
head 404 and return
end
@@ -29,50 +28,12 @@ class WebfingerController < ApplicationController
private
def webfinger
jrd = {
subject: "acct:#{@user.address}",
aliases: [],
links: []
}
links = [];
if Setting.mastodon_enabled && @user.service_enabled?(:mastodon)
# https://docs.joinmastodon.org/spec/webfinger/
jrd[:aliases] += mastodon_aliases
jrd[:links] += mastodon_links
end
# TODO check if storage service is enabled for user, not just globally
links << remotestorage_link if Setting.remotestorage_enabled
if Setting.remotestorage_enabled && @user.service_enabled?(:remotestorage)
# https://datatracker.ietf.org/doc/draft-dejong-remotestorage/
jrd[:links] << remotestorage_link
end
jrd
end
def mastodon_aliases
[
"#{Setting.mastodon_public_url}/@#{@user.cn}",
"#{Setting.mastodon_public_url}/users/#{@user.cn}"
]
end
def mastodon_links
[
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: "#{Setting.mastodon_public_url}/@#{@user.cn}"
},
{
rel: "self",
type: "application/activity+json",
href: "#{Setting.mastodon_public_url}/users/#{@user.cn}"
},
{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: "#{Setting.mastodon_public_url}/authorize_interaction?uri={uri}"
}
]
{ "links" => links }
end
def remotestorage_link
@@ -80,9 +41,9 @@ class WebfingerController < ApplicationController
storage_url = "#{Setting.rs_storage_url}/#{@username}"
{
rel: "http://tools.ietf.org/id/draft-dejong-remotestorage",
href: storage_url,
properties: {
"rel" => "http://tools.ietf.org/id/draft-dejong-remotestorage",
"href" => storage_url,
"properties" => {
"http://remotestorage.io/spec/version" => "draft-dejong-remotestorage-13",
"http://tools.ietf.org/html/rfc6749#section-4.2" => auth_url,
"http://tools.ietf.org/html/rfc6750#section-2.3" => nil, # access token via a HTTP query parameter

View File

@@ -1,53 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="nostr-login"
export default class extends Controller {
static targets = [ "loginForm", "loginButton" ]
static values = { site: String, sharedSecret: String }
connect() {
if (window.nostr) {
this.loginButtonTarget.disabled = false
this.loginFormTarget.classList.remove("hidden")
}
}
async login () {
this.loginButtonTarget.disabled = true
try {
// Auth based on NIP-42
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 22242,
tags: [
["site", this.siteValue],
["challenge", this.sharedSecretValue]
],
content: ""
})
const res = await fetch("/users/nostr_login", {
method: "POST", credentials: "include", headers: {
"Accept": "application/json", 'Content-Type': 'application/json',
"X-CSRF-Token": this.csrfToken
}, body: JSON.stringify({ signed_event: signedEvent })
})
if (res.status === 200) {
res.json().then(r => { window.location.href = r.redirect_url })
} else {
window.location.reload()
}
} catch (error) {
console.warn('Unable to authenticate:', error.message)
} finally {
this.loginButtonTarget.disabled = false
}
}
get csrfToken () {
const element = document.head.querySelector('meta[name="csrf-token"]')
return element.getAttribute("content")
}
}

View File

@@ -3,12 +3,7 @@ import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="settings--nostr-pubkey"
export default class extends Controller {
static targets = [ "noExtension", "setPubkey", "pubkeyBech32Input" ]
static values = {
userAddress: String,
pubkeyHex: String,
site: String,
sharedSecret: String
}
static values = { userAddress: String, pubkeyHex: String, sharedSecret: String }
connect () {
if (window.nostr) {
@@ -24,15 +19,11 @@ export default class extends Controller {
this.setPubkeyTarget.disabled = true
try {
// Auth based on NIP-42
const signedEvent = await window.nostr.signEvent({
created_at: Math.floor(Date.now() / 1000),
kind: 22242,
tags: [
["site", this.siteValue],
["challenge", this.sharedSecretValue]
],
content: ""
kind: 1,
tags: [],
content: `Connect my public key to ${this.userAddressValue} (confirmation ${this.sharedSecretValue})`
})
const res = await fetch("/settings/set_nostr_pubkey", {

View File

@@ -1,7 +1,7 @@
class CreateLdapUserJob < ApplicationJob
queue_as :default
def perform(username:, domain:, email:, hashed_pw:, confirmed: false)
def perform(username, domain, email, hashed_pw)
dn = "cn=#{username},ou=#{domain},cn=users,dc=kosmos,dc=org"
attr = {
objectclass: ["top", "account", "person", "extensibleObject"],
@@ -12,10 +12,6 @@ class CreateLdapUserJob < ApplicationJob
userPassword: hashed_pw
}
if confirmed
attr[:serviceEnabled] = Setting.default_services
end
ldap_client.add(dn: dn, attributes: attr)
end

View File

@@ -2,8 +2,8 @@ class XmppExchangeContactsJob < ApplicationJob
queue_as :default
def perform(inviter, invitee)
return unless inviter.service_enabled?(:xmpp) &&
invitee.service_enabled?(:xmpp) &&
return unless inviter.services_enabled.include?("xmpp") &&
invitee.services_enabled.include?("xmpp") &&
inviter.preferences[:xmpp_exchange_contacts_with_invitees]
ejabberd = EjabberdApiClient.new

View File

@@ -206,9 +206,4 @@ class Setting < RailsSettings::Base
#
# field :email_imap_port, type: :string,
# default: ENV["EMAIL_IMAP_PORT"].presence || 993
def self.default_services
# TODO Make configurable from respective service settings page
%w[ discourse gitea mastodon mediawiki xmpp ]
end
end

View File

@@ -93,7 +93,9 @@ class User < ApplicationRecord
LdapManager::UpdateEmail.call(dn: self.dn, address: self.email)
else
# E-Mail from signup confirmed (i.e. account activation)
enable_default_services
# TODO Make configurable, only activate globally enabled services
enable_service %w[ discourse gitea mediawiki xmpp ]
# TODO enable in development when we have easy setup of ejabberd etc.
return if Rails.env.development? || !Setting.ejabberd_enabled?
@@ -131,7 +133,7 @@ class User < ApplicationRecord
def mastodon_address
return nil unless Setting.mastodon_enabled?
"#{self.cn.gsub("-", "_")}@#{Setting.mastodon_address_domain}"
"#{self.cn}@#{Setting.mastodon_address_domain}"
end
def valid_attribute?(attribute_name)
@@ -139,10 +141,6 @@ class User < ApplicationRecord
self.errors[attribute_name].blank?
end
def enable_default_services
enable_service Setting.default_services
end
def ln_create_invoice(payload)
lndhub = Lndhub.new
lndhub.authenticate self
@@ -180,10 +178,6 @@ class User < ApplicationRecord
ldap_entry[:services_enabled] || []
end
def service_enabled?(name)
services_enabled.map(&:to_sym).include?(name.to_sym)
end
def enable_service(service)
current_services = services_enabled
new_services = Array(service).map(&:to_s)

View File

@@ -35,15 +35,11 @@ class CreateAccount < ApplicationService
@invitation.update! invited_user_id: user_id, used_at: DateTime.now
end
# TODO move to confirmation
# (and/or add email_confirmed to entry and use in login filter)
def add_ldap_document
hashed_pw = Devise.ldap_auth_password_builder.call(@password)
CreateLdapUserJob.perform_later(
username: @username,
domain: @domain,
email: @email,
hashed_pw: hashed_pw,
confirmed: @confirmed
)
CreateLdapUserJob.perform_later(@username, @domain, @email, hashed_pw)
end
def create_lndhub_account(user)

View File

@@ -0,0 +1,11 @@
module NostrManager
class ValidateId < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
@event.id == Digest::SHA256.hexdigest(JSON.generate(@event.serialize))
end
end
end

View File

@@ -1,17 +0,0 @@
module NostrManager
class VerifyAuth < NostrManagerService
def initialize(event:, challenge:)
@event = event
@challenge_expected = challenge
@site_expected = Setting.accounts_domain
end
def call
site_given = @event.tags.find{|t| t[0] == "site"}[1]
challenge_given = @event.tags.find{|t| t[0] == "challenge"}[1]
site_given == @site_expected &&
challenge_given == @challenge_expected
end
end
end

View File

@@ -0,0 +1,17 @@
module NostrManager
class VerifySignature < NostrManagerService
def initialize(event:)
@event = Nostr::Event.new(**event)
end
def call
Schnorr.check_sig!(
[@event.id].pack('H*'),
[@event.pubkey].pack('H*'),
[@event.sig].pack('H*')
)
rescue Schnorr::InvalidSignatureError
false
end
end
end

View File

@@ -16,8 +16,8 @@
<p>
There's something to do for everyone, especially non-programmers! For
example, we need more help with graphics, UI/UX design, and
content/copywriting. Also, testing any of our software and reporting
issues you encounter along the way is very valuable.
content/copywriting. We also need moderators for social media. And beta
testers for our software. The list doesn't end there.
</p>
<p>
A good way to get started is to join one of our
@@ -43,7 +43,7 @@
</p>
<p>
We have run two 6-month trials so far, with the next trial period
starting sometime in Q2 2024. Watch your email for notifications about it!
starting sometime in Q1 2024. Watch your email for notifications about it!
</p>
</section>
<% end %>

View File

@@ -55,27 +55,4 @@
<%= f.submit "Log in", class: 'btn-md btn-blue w-full', tabindex: "4" %>
</p>
<% end %>
<div data-controller="nostr-login"
data-nostr-login-target="loginForm"
data-nostr-login-site-value="<%= Setting.accounts_domain %>"
data-nostr-login-shared-secret-value="<%= session[:shared_secret] %>"
class="hidden">
<div class="relative my-6">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-white px-2 text-sm text-gray-500 italic">or</span>
</div>
</div>
<p>
<button disabled tabindex="5"
class="w-full btn-md btn-gray text-purple-600"
data-nostr-login-target="loginButton"
data-action="nostr-login#login">
Log in with Nostr
</button>
</p>
</div>
<% end %>

View File

@@ -98,17 +98,7 @@
description: "The official Web app",
icon_path: "/img/logos/icon_mastodon-2.svg",
links: [
["Launch", "https://kosmos.social"],
["GitHub", "https://github.com/mastodon/mastodon"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
["Launch", "https://kosmos.social"]
]
) %>
<%= render AppInfoComponent.new(
@@ -160,15 +150,6 @@
["Google Play", "https://play.google.com/store/apps/details?id=org.joinmastodon.android.sk"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(
@@ -199,15 +180,6 @@
["App Store", "https://apps.apple.com/app/mammoth-for-mastodon/id1667573899"]
]
) %>
<%= render AppInfoComponent.new(
name: "Phanpy",
description: " A slick, feature-rich Web app for mobile and desktop",
icon_path: "/img/logos/icon_phanpy.svg",
links: [
["Launch", "https://phanpy.social"],
["GitHub", "https://github.com/cheeaun/phanpy"]
]
) %>
</div>
<div class="hidden grid grid-cols-1 gap-6" data-tabs-target="panel">
<%= render AppInfoComponent.new(

View File

@@ -3,7 +3,6 @@
<h4 class="mb-0">Public Key</h4>
<div data-controller="settings--nostr-pubkey"
data-settings--nostr-pubkey-user-address-value="<%= current_user.address %>"
data-settings--nostr-pubkey-site-value="<%= Setting.accounts_domain %>"
data-settings--nostr-pubkey-shared-secret-value="<%= session[:shared_secret] %>"
data-settings--nostr-pubkey-pubkey-hex-value="<%= current_user.nostr_pubkey %>">

View File

@@ -1,14 +1,7 @@
require 'sidekiq/web'
Rails.application.routes.draw do
devise_for :users, controllers: {
confirmations: 'users/confirmations',
sessions: 'users/sessions'
}
devise_scope :user do
post 'users/nostr_login', to: 'users/sessions#nostr_login'
end
devise_for :users, controllers: { confirmations: 'users/confirmations' }
get 'welcome', to: 'welcome#index'
get 'check_your_email', to: 'welcome#check_your_email'

View File

@@ -2,4 +2,3 @@
:queues:
- default
- mailers
- remotestorage

View File

@@ -19,6 +19,18 @@ namespace :ldap do
}, true
end
# TODO
desc "Add application account to directory"
task add_application_account: :environment do |t, args|
# Add uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org with userPassword
end
# TODO
desc "Add application ACI/permissions for OU, i.e. read/search users"
task add_application_account: :environment do |t, args|
# (target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
end
desc "Add custom attributes to schema"
task add_custom_attributes: :environment do |t, args|
%w[ admin service_enabled nostr_key ].each do |name|

View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="54"
height="54"
viewBox="0 0 54 54"
version="1.1"
xml:space="preserve"
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"
id="svg4"
sodipodi:docname="icon_phanpy.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"><defs
id="defs4" /><sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.359375"
inkscape:cx="26.192932"
inkscape:cy="24.542502"
inkscape:window-width="2160"
inkscape:window-height="1281"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<rect
id="Logo-simple"
serif:id="Logo simple"
x="0"
y="0"
width="63.993999"
height="63.993999"
style="fill:none" />
<g
id="Logo-simple1"
serif:id="Logo simple"
transform="translate(-5.123639,-4.9968626)">
<g
id="g4">
<path
d="m 37.774,11.471 c 14.639,3.752 19.034,16.557 15.889,31.304 -0.696,3.261 -2.563,6.661 -6.356,8.693 -3.204,1.717 -8.07,2.537 -15.338,0.55 0,0 -9.634,-2.404 -9.634,-2.404 C 11.651,46.992 8.378,38.733 10.027,31.823 13.654,16.622 25.57,8.343 37.774,11.471 Z"
style="fill:#a4bff7"
id="path1" />
<path
d="m 36.76,15.429 c 12.289,3.15 15.547,14.114 12.907,26.493 -0.947,4.44 -4.937,9.365 -16.664,6.143 L 23.319,45.648 C 15.465,43.725 12.789,37.848 14.001,32.771 17.017,20.132 26.612,12.828 36.76,15.429 Z"
style="fill:#d8e7fe"
id="path2" />
<path
d="m 27.471,24.991 c -1.457,-0.698 -7.229,3.213 -7.663,8.926 -0.182,2.39 4.55,3.237 5.071,-0.169 0.725,-4.743 3.715,-8.218 2.592,-8.757 z"
style="fill:#6081e6"
id="path3" />
<path
d="m 38.217,26.996 c -2.083,0.327 -0.382,5.901 -0.595,10.727 -0.123,2.8 4.388,3.464 4.703,2.011 1.098,-5.073 -2.066,-13.058 -4.108,-12.738 z"
style="fill:#6081e6"
id="path4" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

4
schemas/ldap/aci.ldif Normal file
View File

@@ -0,0 +1,4 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
add: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || serviceEnabled || displayName || jpegPhoto || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

View File

@@ -0,0 +1,4 @@
dn: ou=kosmos.org,cn=users,dc=kosmos,dc=org
changetype: modify
delete: aci
aci: (target="ldap:///cn=*,ou=kosmos.org,cn=users,dc=kosmos,dc=org")(targetattr="cn || sn || uid || mail || userPassword || nsRole || objectClass") (version 3.0; acl "service-kosmos-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=kosmos.org,cn=applications,dc=kosmos,dc=org";)

View File

@@ -1,9 +0,0 @@
{
"id": "7cc165c4fe4c9ec3f2b859cb422f01b38beaf6bbd228fea928ea1400ec254a89",
"pubkey": "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
"created_at": 1711963922,
"kind": 22242,
"tags": [["site","accounts.kosmos.org"],["challenge","YMeTyOxIEJcfe6vd"]],
"content": "",
"sig": "b484a28cd9c92facca0eba80e8ef5303d25ed044c3815e3a068b9887f91d3546ad209a0dd674c59b48cf8057aecd75df5416973d17ed58f68195030af09c28d1"
}

View File

@@ -3,24 +3,12 @@ require 'rails_helper'
RSpec.describe CreateLdapUserJob, type: :job do
let(:ldap_client_mock) { instance_double(Net::LDAP) }
before do
allow_any_instance_of(described_class).to receive(:ldap_client).and_return(ldap_client_mock)
end
subject(:job) {
described_class.perform_later(
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november'
)
}
allow_any_instance_of(described_class).to receive(:ldap_client).and_return(ldap_client_mock)
subject(:job_for_preconfirmed_account) {
described_class.perform_later(
username: 'halfinney', domain: 'kosmos.org',
email: 'halfinney@example.com',
hashed_pw: 'remember-remember-the-5th-of-november',
confirmed: true
'halfinney', 'kosmos.org', 'halfinney@example.com',
'remember-remember-the-5th-of-november'
)
}
@@ -42,26 +30,6 @@ RSpec.describe CreateLdapUserJob, type: :job do
)
end
it "adds default services for pre-confirmed accounts" do
allow(ldap_client_mock).to receive(:add) # spy on mock
allow(Setting).to receive(:default_services).and_return(["xmpp", "discourse"])
perform_enqueued_jobs { job_for_preconfirmed_account }
expect(ldap_client_mock).to have_received(:add).with(
dn: "cn=halfinney,ou=kosmos.org,cn=users,dc=kosmos,dc=org",
attributes: {
objectclass: ["top", "account", "person", "extensibleObject"],
cn: "halfinney",
sn: "halfinney",
uid: "halfinney",
mail: "halfinney@example.com",
serviceEnabled: ["xmpp", "discourse"],
userPassword: "remember-remember-the-5th-of-november"
}
)
end
after do
clear_enqueued_jobs
clear_performed_jobs

View File

@@ -16,10 +16,6 @@ RSpec.describe User, type: :model do
let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" }
context "Mastodon service not configured" do
before do
Setting.mastodon_enabled = false
end
it "returns nil" do
expect(user.mastodon_address).to be_nil
end
@@ -45,14 +41,6 @@ RSpec.describe User, type: :model do
expect(user.mastodon_address).to eq("jimmy@kosmos.social")
end
end
describe "username contains hyphen/dash" do
let(:jammy) { build :user, cn: "jammy-jellyfish", ou: "kosmos.org" }
it "returns the user address" do
expect(jammy.mastodon_address).to eq("jammy_jellyfish@kosmos.org")
end
end
end
end
@@ -84,25 +72,6 @@ RSpec.describe User, type: :model do
end
end
describe "#service_enabled?" do
before do
allow(user).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["gitea", "xmpp"]
})
end
it "returns true or false" do
expect(user.service_enabled?("gitea")).to be(true)
expect(user.service_enabled?("email")).to be(false)
end
it "returns false when service is not enabled" do
expect(user.service_enabled?(:gitea)).to be(true)
expect(user.service_enabled?(:email)).to be(false)
end
end
describe "#enable_service" do
before do
allow(user).to receive(:ldap_entry).and_return({
@@ -178,7 +147,7 @@ RSpec.describe User, type: :model do
after { clear_enqueued_jobs }
it "enables default services" do
expect(user).to receive(:enable_service).with(%w[ discourse gitea mastodon mediawiki xmpp ])
expect(user).to receive(:enable_service).with(%w[ discourse gitea mediawiki xmpp ])
user.send :devise_after_confirmation
end

View File

@@ -3,7 +3,6 @@ require 'rails_helper'
RSpec.describe "Settings", type: :request do
let(:user) { create :user, cn: 'mark', ou: 'kosmos.org' }
let(:other_user) { create :user, id: 2, cn: 'markymark', ou: 'kosmos.org', email: 'markymark@interscope.com' }
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
before do
login_as user, :scope => :user
@@ -26,7 +25,7 @@ RSpec.describe "Settings", type: :request do
describe "POST /settings/set_nostr_pubkey" do
before do
session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
session_stub = { shared_secret: "rMjWEmvcvtTlQkMd" }
allow_any_instance_of(SettingsController).to receive(:session).and_return(session_stub)
end
@@ -37,12 +36,19 @@ RSpec.describe "Settings", type: :request do
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(0)
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a success status" do
@@ -61,12 +67,19 @@ RSpec.describe "Settings", type: :request do
).and_return(other_user)
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do
@@ -78,21 +91,23 @@ RSpec.describe "Settings", type: :request do
end
end
context "With wrong site tag" do
context "With wrong username" do
before do
Setting.accounts_domain = "accounts.wikipedia.org"
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "2e1e20ee762d6a5b5b30835eda9ca03146e4baf82490e53fd75794c08de08ac0",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678255391,
kind: 1,
content: "Connect my public key to admin@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "2ace19c9db892ac6383848721a3e08b13d90d689fdeac60d9633a623d3f08eb7e0d468f1b3e928d1ea979477c2ec46ee6cdb2d053ef2e4ed3c0630a51d249029"
}
end
after do
Setting.accounts_domain = "accounts.kosmos.org"
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do
@@ -111,12 +126,19 @@ RSpec.describe "Settings", type: :request do
expect(LdapManager::UpdateNostrKey).not_to receive(:call)
post set_nostr_pubkey_settings_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
post set_nostr_pubkey_settings_path, params: {
signed_event: {
id: "84f266bbd784551aaa9e35cb0aceb4ee59182a1dab9ab279d9e40dd56ecbbdd3",
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3",
created_at: 1678254161,
kind: 1,
content: "Connect my public key to mark@kosmos.org (confirmation rMjWEmvcvtTlQkMd)",
sig: "96796d420547d6e2c7be5de82a2ce7a48be99aac6415464a6081859ac1a9017305accc0228c630466a57d45ec1c3b456376eb538b76dfdaa2397e3258be02fdd"
}
}.to_json, headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do

View File

@@ -1,94 +0,0 @@
require 'rails_helper'
RSpec.describe "Devise login sessions", type: :request do
let(:user) { create :user, cn: 'fiatjaf', ou: 'kosmos.org' }
let(:auth_event) { JSON.parse(File.read("#{Rails.root}/spec/fixtures/nostr/valid_auth_event.json")) }
before do
login_as user, :scope => :user
allow_any_instance_of(User).to receive(:dn)
.and_return("cn=#{user.cn},ou=kosmos.org,cn=users,dc=kosmos,dc=org")
allow_any_instance_of(User).to receive(:nostr_pubkey).and_return(nil)
allow(LdapManager::FetchUserByNostrKey).to receive(:call).with(
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(nil)
end
describe "POST /users/nostr_login" do
before do
session_stub = { shared_secret: "YMeTyOxIEJcfe6vd" }
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
end
context "With key configured for an account" do
before do
expect(LdapManager::FetchUserByNostrKey).to receive(:call).with(
pubkey: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3"
).and_return(user)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a success status" do
expect(response).to have_http_status(200)
end
end
context "With wrong site tag" do
before do
Setting.accounts_domain = "accounts.wikipedia.org"
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
after do
Setting.accounts_domain = "accounts.kosmos.org"
end
it "returns a 422 status" do
expect(response).to have_http_status(401)
end
it "informs the user about the failure" do
expect(flash[:alert]).to eq("Login verification failed")
end
end
context "With wrong shared secret" do
before do
session_stub = { shared_secret: "ho-chi-minh" }
allow_any_instance_of(Users::SessionsController).to receive(:session).and_return(session_stub)
expect(LdapManager::FetchUserByNostrKey).not_to receive(:call)
post users_nostr_login_path,
params: { signed_event: auth_event }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
"HTTP_ACCEPT" => "application/json"
}
end
it "returns a 422 status" do
expect(response).to have_http_status(401)
end
it "informs the user about the failure" do
expect(flash[:alert]).to eq("Login verification failed")
end
end
end
end

View File

@@ -1,87 +1,13 @@
require 'rails_helper'
RSpec.describe "WebFinger", type: :request do
describe "User does not exist" do
it "returns a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
context "User exists" do
let(:user) { create :user, cn: 'tony', ou: 'kosmos.org' }
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["mastodon", "remotestorage"]
})
end
describe "Mastodon entries" do
context "Mastodon available" do
it "includes the Mastodon aliases and links for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).to include("http://example.social/@tony")
expect(res["aliases"]).to include("http://example.social/users/tony")
profile_link = res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}
self_link = res["links"].find{|l| l["rel"] == "self"}
ostatus_link = res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}
expect(profile_link["type"]).to eql("text/html")
expect(profile_link["href"]).to eql("http://example.social/@tony")
expect(self_link["type"]).to eql("application/activity+json")
expect(self_link["href"]).to eql("http://example.social/users/tony")
expect(ostatus_link["template"]).to eql("http://example.social/authorize_interaction?uri={uri}")
end
describe "remoteStorage link relation" do
context "user exists" do
before do
create :user, cn: 'tony', ou: 'kosmos.org'
end
context "Mastodon not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
context "Mastodon not available" do
before do
Setting.mastodon_enabled = false
end
it "does not include Mastodon aliases or links" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
expect(res["aliases"]).not_to include("http://example.social/@tony")
expect(res["aliases"]).not_to include("http://example.social/users/tony")
expect(res["links"].find{|l| l["rel"] == "http://webfinger.net/rel/profile-page"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "self"}).to be(nil)
expect(res["links"].find{|l| l["rel"] == "http://ostatus.org/schema/1.0/subscribe"}).to be(nil)
end
end
end
describe "remoteStorage entries" do
context "remoteStorage available" do
context "remoteStorage enabled globally" do
it "includes the remoteStorage link for the user" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
@@ -96,25 +22,6 @@ RSpec.describe "WebFinger", type: :request do
end
end
context "remoteStorage not enabled for user" do
before do
allow_any_instance_of(User).to receive(:ldap_entry).and_return({
uid: user.cn, ou: user.ou, mail: user.email, admin: nil,
services_enabled: ["xmpp"]
})
end
it "does not include the remoteStorage link" do
get "/.well-known/webfinger?resource=acct%3Atony%40kosmos.org"
expect(response).to have_http_status(:ok)
res = JSON.parse(response.body)
rs_link = res["links"].find {|l| l["rel"] == "http://tools.ietf.org/id/draft-dejong-remotestorage"}
expect(rs_link).to be_nil
end
end
context "remoteStorage not available" do
before do
Setting.remotestorage_enabled = false
@@ -131,5 +38,12 @@ RSpec.describe "WebFinger", type: :request do
end
end
end
context "user does not exist" do
it "does return a 404 status" do
get "/.well-known/webfinger?resource=acct%3Ajane.doe%40kosmos.org"
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -53,32 +53,11 @@ RSpec.describe CreateAccount, type: :model do
expect(enqueued_jobs.size).to eq(1)
args = enqueued_jobs.first['arguments'][0]
expect(args["username"]).to eq('halfinney')
expect(args["domain"]).to eq('kosmos.org')
expect(args["email"]).to eq('halfinney@example.com')
expect(args["hashed_pw"]).to match(/^{SSHA512}.{171}=/)
end
after do
clear_enqueued_jobs
end
end
describe "#add_ldap_document for pre-confirmed account" do
include ActiveJob::TestHelper
let(:service) { CreateAccount.new(account: {
username: 'halfinney',
email: 'halfinney@example.com',
password: 'remember-remember-the-5th-of-november',
confirmed: true
})}
it "enqueues a job to create the LDAP user document" do
service.send(:add_ldap_document)
args = enqueued_jobs.first['arguments'][0]
expect(args["confirmed"]).to be(true)
args = enqueued_jobs.first['arguments']
expect(args[0]).to eq('halfinney')
expect(args[1]).to eq('kosmos.org')
expect(args[2]).to eq('halfinney@example.com')
expect(args[3]).to match(/^{SSHA512}.{171}=/)
end
after do