From 90a8a70c150d9c90df4b39f405b37a2f67abb9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 15:20:00 +0200 Subject: [PATCH 01/14] Add OpenPGP key to LDAP directory and User model --- Gemfile | 2 + Gemfile.lock | 5 ++ app/models/user.rb | 30 ++++++- app/services/ldap_service.rb | 7 +- .../20240922205634_add_pgp_fpr_to_users.rb | 5 ++ db/schema.rb | 3 +- lib/tasks/ldap.rake | 4 +- schemas/ldap/pgp_key.ldif | 8 ++ spec/fixtures/files/pgp_key_invalid.asc | 11 +++ spec/fixtures/files/pgp_key_valid_alice.asc | 16 ++++ spec/fixtures/files/pgp_key_valid_jimmy.asc | 13 +++ spec/models/user_spec.rb | 86 ++++++++++++++++--- 12 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20240922205634_add_pgp_fpr_to_users.rb create mode 100644 schemas/ldap/pgp_key.ldif create mode 100644 spec/fixtures/files/pgp_key_invalid.asc create mode 100644 spec/fixtures/files/pgp_key_valid_alice.asc create mode 100644 spec/fixtures/files/pgp_key_valid_jimmy.asc diff --git a/Gemfile b/Gemfile index fe749cb..3cf7942 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,8 @@ gem 'pagy', '~> 6.0', '>= 6.0.2' gem 'flipper' gem 'flipper-active_record' gem 'flipper-ui' +gem 'gpgme', '~> 2.0.24' +gem 'zbase32', '~> 0.1.1' # HTTP requests gem 'faraday' diff --git a/Gemfile.lock b/Gemfile.lock index 59cb7d8..ffcd15b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -197,6 +197,8 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + gpgme (2.0.24) + mini_portile2 (~> 2.7) hashdiff (1.1.0) i18n (1.14.1) concurrent-ruby (~> 1.0) @@ -483,6 +485,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.34) + zbase32 (0.1.1) zeitwerk (2.6.12) PLATFORMS @@ -507,6 +510,7 @@ DEPENDENCIES flipper flipper-active_record flipper-ui + gpgme (~> 2.0.24) image_processing (~> 1.12.2) importmap-rails jbuilder (~> 2.7) @@ -540,6 +544,7 @@ DEPENDENCIES warden web-console (~> 4.2) webmock + zbase32 (~> 0.1.1) BUNDLED WITH 2.5.5 diff --git a/app/models/user.rb b/app/models/user.rb index 248a865..b0539ec 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,9 +3,10 @@ require 'nostr' class User < ApplicationRecord include EmailValidatable - attr_accessor :display_name - attr_accessor :avatar_new attr_accessor :current_password + attr_accessor :avatar_new + attr_accessor :display_name + attr_accessor :pgp_pubkey serialize :preferences, coder: UserPreferences @@ -51,6 +52,8 @@ class User < ApplicationRecord validate :acceptable_avatar + validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey != "" } + # # Scopes # @@ -165,6 +168,23 @@ class User < ApplicationRecord Nostr::PublicKey.new(nostr_pubkey).to_bech32 end + def pgp_pubkey + @pgp_pubkey ||= ldap_entry[:pgp_key] + end + + def gnupg_key + return nil unless pgp_pubkey.present? + @gnupg_key ||= GPGME::Key.get(pgp_fpr) + end + + def pgp_pubkey_contains_user_address? + gnupg_key.uids.map(&:email).include?(address) + end + + def wkd_hash + ZBase32.encode(Digest::SHA1.digest(cn)) + end + def avatar @avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn) end @@ -214,4 +234,10 @@ class User < ApplicationRecord errors.add(:avatar, "must be a JPEG or PNG file") end end + + def acceptable_pgp_key_format + unless GPGME::Key.valid?(pgp_pubkey) + errors.add(:pgp_pubkey, 'is not a valid armored PGP public key block') + end + end end diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb index 8364eb5..316a58a 100644 --- a/app/services/ldap_service.rb +++ b/app/services/ldap_service.rb @@ -58,7 +58,7 @@ class LdapService < ApplicationService attributes = %w[ dn cn uid mail displayName admin serviceEnabled - mailRoutingAddress mailpassword nostrKey + mailRoutingAddress mailpassword nostrKey pgpKey ] filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") @@ -73,7 +73,8 @@ class LdapService < ApplicationService services_enabled: e.try(:serviceEnabled), email_maildrop: e.try(:mailRoutingAddress), email_password: e.try(:mailpassword), - nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil + nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil, + pgp_key: e.try(:pgpKey) ? e.pgpKey.first : nil } end end @@ -101,7 +102,7 @@ class LdapService < ApplicationService dn = "ou=#{ou},cn=users,#{ldap_suffix}" aci = <<-EOS -(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";) +(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || pgpKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";) EOS attrs = { diff --git a/db/migrate/20240922205634_add_pgp_fpr_to_users.rb b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb new file mode 100644 index 0000000..d3b1572 --- /dev/null +++ b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb @@ -0,0 +1,5 @@ +class AddPgpFprToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :pgp_fpr, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 90a60e6..31e617c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do +ActiveRecord::Schema[7.1].define(version: 2024_09_22_205634) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -132,6 +132,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do t.datetime "remember_created_at" t.string "remember_token" t.text "preferences" + t.string "pgp_fpr" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake index 3beb0e9..244a7d2 100644 --- a/lib/tasks/ldap.rake +++ b/lib/tasks/ldap.rake @@ -21,7 +21,7 @@ namespace :ldap do desc "Add custom attributes to schema" task add_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add") Rake::Task['ldap:modify_ldap_schema'].reenable end @@ -29,7 +29,7 @@ namespace :ldap do desc "Delete custom attributes from schema" task delete_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete") Rake::Task['ldap:modify_ldap_schema'].reenable end diff --git a/schemas/ldap/pgp_key.ldif b/schemas/ldap/pgp_key.ldif new file mode 100644 index 0000000..e8ca2a6 --- /dev/null +++ b/schemas/ldap/pgp_key.ldif @@ -0,0 +1,8 @@ +dn: cn=schema +changetype: modify +add: attributeTypes +attributeTypes: ( 1.3.6.1.4.1.3401.8.2.11 + NAME 'pgpKey' + DESC 'OpenPGP public key block' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) diff --git a/spec/fixtures/files/pgp_key_invalid.asc b/spec/fixtures/files/pgp_key_invalid.asc new file mode 100644 index 0000000..08bd918 --- /dev/null +++ b/spec/fixtures/files/pgp_key_invalid.asc @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_alice.asc b/spec/fixtures/files/pgp_key_valid_alice.asc new file mode 100644 index 0000000..d509cb1 --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_alice.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: Alice's OpenPGP certificate +Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html + +mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO + +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.asc b/spec/fixtures/files/pgp_key_valid_jimmy.asc new file mode 100644 index 0000000..385caaf --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_jimmy.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZvFjRhYJKwYBBAHaRw8BAQdACUxVX9bGlbuNR0MNYUyHHxTcOgm4qjwq8Bjg +7P41OFK0GEppbW15IDxqaW1teUBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEEMWv1FiNt +r3cjaxX2BX2Tly+4YsMFAmbxY0YCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQBX2Tly+4YsMjHgEAoOOLrv9pWbi8hhrSMkqJ7FJvsBTQF//U +aJUQRa8CTgoBAI3kyGKZ8gOC8UOOKsUC0LiNCVXPyX45h8T4QFRdEVYKuDgEZvFj +RhIKKwYBBAGXVQEFAQEHQIomqcQ59UjtQex54pz8qGqyxCj2DPJYUat9pXinDgN8 +AwEIB4h+BBgWCgAmFiEEMWv1FiNtr3cjaxX2BX2Tly+4YsMFAmbxY0YCGwwFCQWj +moAACgkQBX2Tly+4YsPoVgEA/9Q5Gs1klP4u/nw343V57e9s4RKmEiRSkErnC9wW +Iu0A/jp6Elz2pDQPB2XLwcb+n7JlgA05HI0zWj1+EoM7TC4J +=KQbn +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5f6c7c1..60ce8ed 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,20 +1,16 @@ require 'rails_helper' RSpec.describe User, type: :model do - let(:user) { create :user, cn: "philipp" } + let(:user) { create :user, cn: "philipp", ou: "kosmos.org", email: "philipp@example.com" } let(:dn) { "cn=philipp,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } describe "#address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - it "returns the user address" do - expect(user.address).to eq("jimmy@kosmos.org") + expect(user.address).to eq("philipp@kosmos.org") end end describe "#mastodon_address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - context "Mastodon service not configured" do before do Setting.mastodon_enabled = false @@ -32,7 +28,7 @@ RSpec.describe User, type: :model do describe "domain is the same as primary domain" do it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.org") + expect(user.mastodon_address).to eq("philipp@kosmos.org") end end @@ -42,7 +38,7 @@ RSpec.describe User, type: :model do end it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.social") + expect(user.mastodon_address).to eq("philipp@kosmos.social") end end @@ -239,7 +235,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -250,7 +246,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey_bech32" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -258,4 +254,74 @@ RSpec.describe User, type: :model do expect(user.nostr_pubkey_bech32).to eq("npub1qlsc3g0lsl8pw8230w8d9wm6xxcax3f6pkemz5measrmwfxjxteslf2hac") end end + + describe "OpenPGP key" do + let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" } + let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } + let(:gnupg_key_alice) { } + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + + before do + GPGME::Key.import(valid_key_alice) + GPGME::Key.import(valid_key_jimmy) + alice.update pgp_fpr: fingerprint_alice + jimmy.update pgp_fpr: fingerprint_jimmy + allow(alice).to receive(:ldap_entry).and_return({ pgp_key: valid_key_alice }) + allow(jimmy).to receive(:ldap_entry).and_return({ pgp_key: valid_key_jimmy }) + end + + after do + GPGME::Key.get(fingerprint_alice).delete! + GPGME::Key.get(fingerprint_jimmy).delete! + end + + describe "#acceptable_pgp_key_format" do + it "validates the record when the key is valid" do + alice.pgp_pubkey = valid_key_alice + expect(alice).to be_valid + end + + it "adds a validation error when the key is not valid" do + user.pgp_pubkey = invalid_key + expect(user).to_not be_valid + expect(user.errors[:pgp_pubkey]).to be_present + end + end + + describe "#pgp_pubkey" do + it "returns the raw pubkey from LDAP" do + expect(alice.pgp_pubkey).to eq(valid_key_alice) + end + end + + describe "#gnupg_key" do + subject { alice.gnupg_key } + + it "returns a GPGME::Key object from the system's GPG keyring" do + expect(subject).to be_a(GPGME::Key) + expect(subject.fingerprint).to eq(fingerprint_alice) + expect(subject.email).to eq("alice@openpgp.example") + end + end + + describe "#pgp_pubkey_contains_user_address?" do + it "returns false when the user address is one of the UIDs of the key" do + expect(alice.pgp_pubkey_contains_user_address?).to eq(false) + end + + it "returns true when the user address is missing from the UIDs of the key" do + expect(jimmy.pgp_pubkey_contains_user_address?).to eq(true) + end + end + + describe "wkd_hash" do + it "returns a z-base32 encoded SHA-1 digest of the username" do + expect(alice.wkd_hash).to eq("kei1q4tipxxu1yj79k9kfukdhfy631xe") + end + end + end end From ba683a7b95699000f7549e7b696d22bd1f7e84ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 16:03:02 +0200 Subject: [PATCH 02/14] Move some Rails app services to UserManager namespace --- app/controllers/admin/users_controller.rb | 2 +- app/controllers/signup_controller.rb | 2 +- app/services/create_account.rb | 54 ------------------ app/services/create_invitations.rb | 17 ------ app/services/user_manager/create_account.rb | 56 +++++++++++++++++++ .../user_manager/create_invitations.rb | 19 +++++++ app/services/user_manager_service.rb | 2 + spec/features/signup_spec.rb | 4 +- .../{ => user_manager}/create_account_spec.rb | 12 ++-- .../create_invitations_spec.rb | 6 +- 10 files changed, 90 insertions(+), 84 deletions(-) delete mode 100644 app/services/create_account.rb delete mode 100644 app/services/create_invitations.rb create mode 100644 app/services/user_manager/create_account.rb create mode 100644 app/services/user_manager/create_invitations.rb create mode 100644 app/services/user_manager_service.rb rename spec/services/{ => user_manager}/create_account_spec.rb (89%) rename spec/services/{ => user_manager}/create_invitations_spec.rb (84%) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 6b6f510..82b91da 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -30,7 +30,7 @@ class Admin::UsersController < Admin::BaseController amount = params[:amount].to_i notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user]) - CreateInvitations.call(user: @user, amount: amount, notify: notify_user) + UserManager::CreateInvitations.call(user: @user, amount: amount, notify: notify_user) redirect_to admin_user_path(@user.cn), flash: { success: "Added #{amount} invitations to #{@user.cn}'s account" diff --git a/app/controllers/signup_controller.rb b/app/controllers/signup_controller.rb index 236db54..631551d 100644 --- a/app/controllers/signup_controller.rb +++ b/app/controllers/signup_controller.rb @@ -96,7 +96,7 @@ class SignupController < ApplicationController session[:new_user] = nil session[:validation_error] = nil - CreateAccount.call(account: { + UserManager::CreateAccount.call(account: { username: @user.cn, domain: Setting.primary_domain, email: @user.email, diff --git a/app/services/create_account.rb b/app/services/create_account.rb deleted file mode 100644 index 12bf07b..0000000 --- a/app/services/create_account.rb +++ /dev/null @@ -1,54 +0,0 @@ -class CreateAccount < ApplicationService - def initialize(account:) - @username = account[:username] - @domain = account[:ou] || Setting.primary_domain - @email = account[:email] - @password = account[:password] - @invitation = account[:invitation] - @confirmed = account[:confirmed] - end - - def call - user = create_user_in_database - add_ldap_document - create_lndhub_account(user) if Setting.lndhub_enabled - - if @invitation.present? - update_invitation(user.id) - end - end - - private - - def create_user_in_database - User.create!( - cn: @username, - ou: @domain, - email: @email, - password: @password, - password_confirmation: @password, - confirmed_at: @confirmed ? DateTime.now : nil - ) - end - - def update_invitation(user_id) - @invitation.update! invited_user_id: user_id, used_at: DateTime.now - end - - 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 - ) - end - - def create_lndhub_account(user) - #TODO enable in development when we have a local lndhub (mock?) API - return if Rails.env.development? - CreateLndhubAccountJob.perform_later(user) - end -end diff --git a/app/services/create_invitations.rb b/app/services/create_invitations.rb deleted file mode 100644 index 3003b1a..0000000 --- a/app/services/create_invitations.rb +++ /dev/null @@ -1,17 +0,0 @@ -class CreateInvitations < ApplicationService - def initialize(user:, amount:, notify: true) - @user = user - @amount = amount - @notify = notify - end - - def call - @amount.times do - Invitation.create(user: @user) - end - - if @notify - NotificationMailer.with(user: @user).new_invitations_available.deliver_later - end - end -end diff --git a/app/services/user_manager/create_account.rb b/app/services/user_manager/create_account.rb new file mode 100644 index 0000000..ca65451 --- /dev/null +++ b/app/services/user_manager/create_account.rb @@ -0,0 +1,56 @@ +module UserManager + class CreateAccount < UserManagerService + def initialize(account:) + @username = account[:username] + @domain = account[:ou] || Setting.primary_domain + @email = account[:email] + @password = account[:password] + @invitation = account[:invitation] + @confirmed = account[:confirmed] + end + + def call + user = create_user_in_database + add_ldap_document + create_lndhub_account(user) if Setting.lndhub_enabled + + if @invitation.present? + update_invitation(user.id) + end + end + + private + + def create_user_in_database + User.create!( + cn: @username, + ou: @domain, + email: @email, + password: @password, + password_confirmation: @password, + confirmed_at: @confirmed ? DateTime.now : nil + ) + end + + def update_invitation(user_id) + @invitation.update! invited_user_id: user_id, used_at: DateTime.now + end + + 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 + ) + end + + def create_lndhub_account(user) + #TODO enable in development when we have a local lndhub (mock?) API + return if Rails.env.development? + CreateLndhubAccountJob.perform_later(user) + end + end +end diff --git a/app/services/user_manager/create_invitations.rb b/app/services/user_manager/create_invitations.rb new file mode 100644 index 0000000..67d2fe8 --- /dev/null +++ b/app/services/user_manager/create_invitations.rb @@ -0,0 +1,19 @@ +module UserManager + class CreateInvitations < UserManagerService + def initialize(user:, amount:, notify: true) + @user = user + @amount = amount + @notify = notify + end + + def call + @amount.times do + Invitation.create(user: @user) + end + + if @notify + NotificationMailer.with(user: @user).new_invitations_available.deliver_later + end + end + end +end diff --git a/app/services/user_manager_service.rb b/app/services/user_manager_service.rb new file mode 100644 index 0000000..74f9d37 --- /dev/null +++ b/app/services/user_manager_service.rb @@ -0,0 +1,2 @@ +class UserManagerService < ApplicationService +end diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb index e668e9d..0691688 100644 --- a/spec/features/signup_spec.rb +++ b/spec/features/signup_spec.rb @@ -52,7 +52,7 @@ RSpec.describe "Signup", type: :feature do click_button "Continue" expect(page).to have_content("Choose a password") - expect(CreateAccount).to receive(:call) + expect(UserManager::CreateAccount).to receive(:call) .with(account: { username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", @@ -96,7 +96,7 @@ RSpec.describe "Signup", type: :feature do click_button "Create account" expect(page).to have_content("Password is too short") - expect(CreateAccount).to receive(:call) + expect(UserManager::CreateAccount).to receive(:call) .with(account: { username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", diff --git a/spec/services/create_account_spec.rb b/spec/services/user_manager/create_account_spec.rb similarity index 89% rename from spec/services/create_account_spec.rb rename to spec/services/user_manager/create_account_spec.rb index 4aaf139..3958b5a 100644 --- a/spec/services/create_account_spec.rb +++ b/spec/services/user_manager/create_account_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' -RSpec.describe CreateAccount, type: :model do +RSpec.describe UserManager::CreateAccount, type: :model do describe "#create_user_in_database" do - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'isaacnewton', email: 'isaacnewton@example.com', password: 'bright-ideas-in-autumn' @@ -19,7 +19,7 @@ RSpec.describe CreateAccount, type: :model do describe "#update_invitation" do let(:invitation) { create :invitation } - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'isaacnewton', email: 'isaacnewton@example.com', password: 'bright-ideas-in-autumn', @@ -42,7 +42,7 @@ RSpec.describe CreateAccount, type: :model do describe "#add_ldap_document" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'remember-remember-the-5th-of-november' @@ -68,7 +68,7 @@ RSpec.describe CreateAccount, type: :model do describe "#add_ldap_document for pre-confirmed account" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'remember-remember-the-5th-of-november', @@ -89,7 +89,7 @@ RSpec.describe CreateAccount, type: :model do describe "#create_lndhub_account" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'bright-ideas-in-winter' })} diff --git a/spec/services/create_invitations_spec.rb b/spec/services/user_manager/create_invitations_spec.rb similarity index 84% rename from spec/services/create_invitations_spec.rb rename to spec/services/user_manager/create_invitations_spec.rb index 4e5de23..d994c6e 100644 --- a/spec/services/create_invitations_spec.rb +++ b/spec/services/user_manager/create_invitations_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' -RSpec.describe CreateInvitations, type: :model do +RSpec.describe UserManager::CreateInvitations, type: :model do include ActiveJob::TestHelper let(:user) { create :user } describe "#call" do before do - CreateInvitations.call(user: user, amount: 5) + described_class.call(user: user, amount: 5) end after(:each) { clear_enqueued_jobs } @@ -28,7 +28,7 @@ RSpec.describe CreateInvitations, type: :model do describe "#call with notification disabled" do before do - CreateInvitations.call(user: user, amount: 3, notify: false) + described_class.call(user: user, amount: 3, notify: false) end after(:each) { clear_enqueued_jobs } From 118fddb497907815ca9372bb3c732fa3b105ef0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 16:07:02 +0200 Subject: [PATCH 03/14] Document URLs for settings controller actions No need to read the route sources all the time --- app/controllers/settings_controller.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index a21c542..2200bcb 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -21,6 +21,7 @@ class SettingsController < ApplicationController end end + # PUT /settings/:section def update @user.preferences.merge!(user_params[:preferences] || {}) @user.display_name = user_params[:display_name] @@ -44,6 +45,7 @@ class SettingsController < ApplicationController end end + # POST /settings/update_email def update_email if @user.valid_ldap_authentication?(security_params[:current_password]) if @user.update email: email_params[:email] @@ -61,6 +63,7 @@ class SettingsController < ApplicationController end end + # POST /settings/reset_email_password def reset_email_password @user.current_password = security_params[:current_password] @@ -83,6 +86,7 @@ class SettingsController < ApplicationController end end + # POST /settings/reset_password def reset_password current_user.send_reset_password_instructions sign_out current_user @@ -90,6 +94,7 @@ class SettingsController < ApplicationController redirect_to check_your_email_path, notice: msg end + # POST /settings/set_nostr_pubkey def set_nostr_pubkey signed_event = Nostr::Event.new(**nostr_event_from_params) From 3042a02a179374b9684a72c3b2dafec2a81b85dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 18:13:39 +0200 Subject: [PATCH 04/14] Allow users to update their OpenPGP pubkey --- app/controllers/settings_controller.rb | 10 ++- app/models/user.rb | 2 +- app/services/ldap_manager/update_pgp_key.rb | 16 ++++ app/services/user_manager/update_pgp_key.rb | 24 ++++++ app/views/settings/_account.html.erb | 29 +++++++- spec/features/settings/account_spec.rb | 41 ++++++++++ spec/features/settings/profile_spec.rb | 2 +- spec/models/user_spec.rb | 5 +- .../user_manager/update_pgp_key_spec.rb | 74 +++++++++++++++++++ 9 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 app/services/ldap_manager/update_pgp_key.rb create mode 100644 app/services/user_manager/update_pgp_key.rb create mode 100644 spec/services/user_manager/update_pgp_key_spec.rb diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 2200bcb..e469d66 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -25,7 +25,8 @@ class SettingsController < ApplicationController def update @user.preferences.merge!(user_params[:preferences] || {}) @user.display_name = user_params[:display_name] - @user.avatar_new = user_params[:avatar] + @user.avatar_new = user_params[:avatar] + @user.pgp_pubkey = user_params[:pgp_pubkey] if @user.save if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name]) @@ -36,6 +37,10 @@ class SettingsController < ApplicationController LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new) end + if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key]) + UserManager::UpdatePgpKey.call(user: @user) + end + redirect_to setting_path(@settings_section), flash: { success: 'Settings saved.' } @@ -157,7 +162,8 @@ class SettingsController < ApplicationController def user_params params.require(:user).permit( - :display_name, :avatar, preferences: UserPreferences.pref_keys + :display_name, :avatar, :pgp_pubkey, + preferences: UserPreferences.pref_keys ) end diff --git a/app/models/user.rb b/app/models/user.rb index b0539ec..67c6fcd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -52,7 +52,7 @@ class User < ApplicationRecord validate :acceptable_avatar - validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey != "" } + validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? } # # Scopes diff --git a/app/services/ldap_manager/update_pgp_key.rb b/app/services/ldap_manager/update_pgp_key.rb new file mode 100644 index 0000000..fa5b982 --- /dev/null +++ b/app/services/ldap_manager/update_pgp_key.rb @@ -0,0 +1,16 @@ +module LdapManager + class UpdatePgpKey < LdapManagerService + def initialize(dn:, pubkey:) + @dn = dn + @pubkey = pubkey + end + + def call + if @pubkey.present? + replace_attribute @dn, :pgpKey, @pubkey + else + delete_attribute @dn, :pgpKey + end + end + end +end diff --git a/app/services/user_manager/update_pgp_key.rb b/app/services/user_manager/update_pgp_key.rb new file mode 100644 index 0000000..16b78c7 --- /dev/null +++ b/app/services/user_manager/update_pgp_key.rb @@ -0,0 +1,24 @@ +module UserManager + class UpdatePgpKey < UserManagerService + def initialize(user:) + @user = user + end + + def call + if @user.pgp_pubkey.blank? + @user.update! pgp_fpr: nil + else + result = GPGME::Key.import(@user.pgp_pubkey) + + if result.imports.present? + @user.update! pgp_fpr: result.imports.first.fpr + else + # TODO notify Sentry, user + raise "Failed to import OpenPGP pubkey" + end + end + + LdapManager::UpdatePgpKey.call(dn: @user.dn, pubkey: @user.pgp_pubkey) + end + end +end diff --git a/app/views/settings/_account.html.erb b/app/views/settings/_account.html.erb index b1b3430..90144b2 100644 --- a/app/views/settings/_account.html.erb +++ b/app/views/settings/_account.html.erb @@ -1,6 +1,6 @@ <%= tag.section data: { controller: "settings--account--email", - "settings--account--email-validation-failed-value": @validation_errors.present? + "settings--account--email-validation-failed-value": @validation_errors&.[](:email)&.present? } do %>

E-Mail

<%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %> @@ -23,7 +23,7 @@

- <% if @validation_errors.present? && @validation_errors[:email].present? %> + <% if @validation_errors&.[](:email)&.present? %>

<%= @validation_errors[:email].first %>

<% end %>
@@ -41,10 +41,33 @@ <% end %>

Password

-

Use the following button to request an email with a password reset link:

+

Use the following button to request an email with a password reset link:

<%= form_with(url: reset_password_settings_path, method: :post) do %>

<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>

<% end %>
+<%= form_for(@user, url: setting_path(:account), html: { :method => :put }) do |f| %> +
+

OpenPGP

+
    + <%= render FormElements::FieldsetComponent.new( + title: "Public key", + description: "Your OpenPGP public key in ASCII Armor format ([example])" + ) do %> + <%= f.text_area :pgp_pubkey, + value: @user.pgp_pubkey, + class: "h-24 w-full" %> + <% if @validation_errors&.[](:pgp_pubkey)&.present? %> +

    This <%= @validation_errors[:pgp_pubkey].first %>

    + <% end %> + <% end %> +
+
+
+

+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +

+
+<% end %> diff --git a/spec/features/settings/account_spec.rb b/spec/features/settings/account_spec.rb index 51f1c83..47bb9ad 100644 --- a/spec/features/settings/account_spec.rb +++ b/spec/features/settings/account_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'Account settings', type: :feature do .with("invalid password").and_return(false) allow_any_instance_of(User).to receive(:valid_ldap_authentication?) .with("valid password").and_return(true) + allow_any_instance_of(User).to receive(:pgp_pubkey).and_return(nil) end scenario 'fails with invalid password' do @@ -55,4 +56,44 @@ RSpec.describe 'Account settings', type: :feature do end end end + + feature "Update OpenPGP key" do + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + + before do + login_as user, :scope => :user + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil + }) + end + + scenario 'rejects an invalid key' do + expect(UserManager::UpdatePgpKey).not_to receive(:call) + + visit setting_path(:account) + fill_in 'Public key', with: invalid_key + click_button "Save" + + expect(current_url).to eq(setting_url(:account)) + within ".error-msg" do + expect(page).to have_content("This is not a valid armored PGP public key block") + end + end + + scenario 'stores a valid key' do + expect(UserManager::UpdatePgpKey).to receive(:call) + .with(user: user).and_return(true) + + visit setting_path(:account) + fill_in 'Public key', with: valid_key_alice + click_button "Save" + + expect(current_url).to eq(setting_url(:account)) + within ".flash-msg" do + expect(page).to have_content("Settings saved") + end + end + end end diff --git a/spec/features/settings/profile_spec.rb b/spec/features/settings/profile_spec.rb index 1d5fa8c..55b861e 100644 --- a/spec/features/settings/profile_spec.rb +++ b/spec/features/settings/profile_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Profile settings', type: :feature do allow(user).to receive(:display_name).and_return("Mark") allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org") allow_any_instance_of(User).to receive(:ldap_entry).and_return({ - uid: user.cn, ou: user.ou, display_name: "Mark" + uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil }) allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 60ce8ed..a4e0f57 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -262,7 +262,6 @@ RSpec.describe User, type: :model do let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } - let(:gnupg_key_alice) { } let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } before do @@ -275,8 +274,8 @@ RSpec.describe User, type: :model do end after do - GPGME::Key.get(fingerprint_alice).delete! - GPGME::Key.get(fingerprint_jimmy).delete! + alice.gnupg_key.delete! + jimmy.gnupg_key.delete! end describe "#acceptable_pgp_key_format" do diff --git a/spec/services/user_manager/update_pgp_key_spec.rb b/spec/services/user_manager/update_pgp_key_spec.rb new file mode 100644 index 0000000..844eaf4 --- /dev/null +++ b/spec/services/user_manager/update_pgp_key_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe UserManager::UpdatePgpKey, type: :model do + include ActiveJob::TestHelper + + let(:alice) { create :user, cn: "alice" } + let(:dn) { "cn=alice,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } + let(:pubkey_asc) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + + before do + allow(alice).to receive(:dn).and_return(dn) + allow(alice).to receive(:ldap_entry).and_return({ + uid: alice.cn, ou: alice.ou, pgp_key: nil + }) + end + + describe "#call" do + context "with valid key" do + before do + alice.pgp_pubkey = pubkey_asc + + allow(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: pubkey_asc) + end + + after do + alice.gnupg_key.delete! + end + + it "imports the key into the GnuPG keychain" do + described_class.call(user: alice) + expect(alice.gnupg_key).to be_present + end + + it "stores the key's fingerprint on the user record" do + described_class.call(user: alice) + expect(alice.pgp_fpr).to eq(fingerprint) + end + + it "updates the user's LDAP entry with the new key" do + expect(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: pubkey_asc) + described_class.call(user: alice) + end + end + + context "with empty key" do + before do + alice.update pgp_fpr: fingerprint + alice.pgp_pubkey = "" + + allow(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: "") + end + + it "does not attempt to import the key" do + expect(GPGME::Key).not_to receive(:import) + described_class.call(user: alice) + end + + it "removes the key's fingerprint from the user record" do + described_class.call(user: alice) + expect(alice.pgp_fpr).to be_nil + end + + it "removes the key from the user's LDAP entry" do + expect(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: "") + described_class.call(user: alice) + end + end + end +end From 4a677178e81930734dc3bf2b0a127b520e315be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 19:20:10 +0200 Subject: [PATCH 05/14] Add Web Key Directory endpoint Serve public keys in binary and armored text, if they contain a user's account address. --- .../web_key_directory_controller.rb | 34 +++++++ config/routes.rb | 7 +- db/seeds/admin.asc | 13 +++ spec/fixtures/files/pgp_key_valid_jimmy.pem | Bin 0 -> 420 bytes spec/requests/web_key_directory_spec.rb | 84 ++++++++++++++++++ 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 app/controllers/web_key_directory_controller.rb create mode 100644 db/seeds/admin.asc create mode 100644 spec/fixtures/files/pgp_key_valid_jimmy.pem create mode 100644 spec/requests/web_key_directory_spec.rb diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb new file mode 100644 index 0000000..ffd2844 --- /dev/null +++ b/app/controllers/web_key_directory_controller.rb @@ -0,0 +1,34 @@ +class WebKeyDirectoryController < WellKnownController + before_action :allow_cross_origin_requests, only: [ :show ] + + # /.well-known/openpgpkey/hu/:hashed_username(.txt) + def show + @user = User.find_by(cn: params[:l]) + + if @user.nil? || + @user.pgp_pubkey.empty? || + !@user.pgp_pubkey_contains_user_address? + http_status :not_found and return + end + + if params[:hashed_username] != @user.wkd_hash + http_status :unprocessable_entity and return + end + + respond_to do |format| + format.text do + response.headers['Content-Type'] = 'text/plain' + render plain: @user.pgp_pubkey + end + + format.any do + key = @user.gnupg_key.export + send_data key, filename: "#{@user.wkd_hash}.pem", + type: "application/octet-stream" + end + end + end + + private + +end diff --git a/config/routes.rb b/config/routes.rb index 07e1ffe..9751f3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -70,10 +70,11 @@ Rails.application.routes.draw do get '.well-known/webfinger', to: 'webfinger#show' get '.well-known/nostr', to: 'well_known#nostr' - get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: 'lightning_address' - get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: 'lightning_address_keysend' + get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address + get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend + get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key - get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: 'lnurlpay_invoice' + get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice post 'webhooks/lndhub', to: 'webhooks#lndhub' diff --git a/db/seeds/admin.asc b/db/seeds/admin.asc new file mode 100644 index 0000000..d3b65a1 --- /dev/null +++ b/db/seeds/admin.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZvGiUxYJKwYBBAHaRw8BAQdARPZXLqyB3nylJuzuARlOJxqc9mchMKHI4Cy+ +hPWlzja0GEFkbWluIDxhZG1pbkBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEE0pie1+fG +ImdZwzGnwgEYSg8AulYFAmbxolMCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQwgEYSg8AulaldAEA7yzh7XRCdIJDHgLUvKHsy2NnyLaDD1Tl +hyZWbl5og0IBAJAQ2Dm82YXMdUK3X1OGlK8KH5O4E5lSFY4+8/xx0UEJuDgEZvGi +UxIKKwYBBAGXVQEFAQEHQJc8pzzeIF7Hm5z1eseRAqGvFa+V1BIDf+1XQzuJhhxi +AwEIB4h+BBgWCgAmFiEE0pie1+fGImdZwzGnwgEYSg8AulYFAmbxolMCGwwFCQWj +moAACgkQwgEYSg8AulbLtgEApZvuDqSP77lrl1jmtCAJEEZk/ofsRFkf1g3U3Zhm +9PcA/1+AbcyqjLTcqIPjHmZyGEPiaAvEsBzbPKEPiL3JYhkG +=45sx +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.pem b/spec/fixtures/files/pgp_key_valid_jimmy.pem new file mode 100644 index 0000000000000000000000000000000000000000..44c792b627c60f5f4018d07d882c09446c00c498 GIT binary patch literal 420 zcmbPX%#!vo*-ea7n~jl$@s>M3BO|*5r%!17wPRCv_qsdtCi=9?i`=o|+_B0=>x0CD zH~&m6g0@I_W#;BqD%fO!D2MF);@tdVz5JqdyN;PG!eU$uj$(=|hS^`mlyldYD`$&- zW38P$U4KW?VOA!PWlYk{temWiXEm^Lb8@gVF)1;ziE?pra5J%pF)=eT$+0tuH!yH< z3cz$J%P}%6c-+12e`e&4J#A8#jJ!JE1m$lKxgh@kO2$+H*Y!+(T#O98PfjGw{KVY! z(Ya6SDAR=pZ#-T*J)B!QN5FA_4T28Uau7Ks<%)@)u!@4F9iKN}Wxa z@=xzyjrrr!%D3-x9ttfJQVE*i^_=^Tn9^H@e^ymOG2fP$@Uy3$K6vck{7tD1yp}S( O#!X+sK literal 0 HcmV?d00001 diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb new file mode 100644 index 0000000..627c89a --- /dev/null +++ b/spec/requests/web_key_directory_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +RSpec.describe "OpenPGP Web Key Directory", type: :request do + describe "non-existent user" do + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle" + expect(response).to have_http_status(:not_found) + end + end + + describe "user without pubkey" do + let(:user) { create :user, cn: 'bernd', ou: 'kosmos.org' } + + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/kp95h369c89sx8ia1hn447i868nqyz4t?l=bernd" + expect(response).to have_http_status(:not_found) + end + end + + describe "user with pubkey" do + let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" } + let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + + before do + GPGME::Key.import(valid_key_alice) + GPGME::Key.import(valid_key_jimmy) + alice.update pgp_fpr: fingerprint_alice + jimmy.update pgp_fpr: fingerprint_jimmy + end + + after do + alice.gnupg_key.delete! + jimmy.gnupg_key.delete! + end + + describe "pubkey does not contain user address" do + before do + allow_any_instance_of(User).to receive(:ldap_entry) + .and_return({ pgp_key: valid_key_alice }) + end + + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/kei1q4tipxxu1yj79k9kfukdhfy631xe?l=alice" + expect(response).to have_http_status(:not_found) + end + end + + describe "pubkey contains user address" do + before do + allow_any_instance_of(User).to receive(:ldap_entry) + .and_return({ pgp_key: valid_key_jimmy }) + end + + it "returns the pubkey in binary format" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=jimmy" + expect(response).to have_http_status(:ok) + expect(response.headers['Content-Type']).to eq("application/octet-stream") + expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem") + expect(response.body).to eq(expected_binary_data) + end + + context "with .txt extension" do + it "returns the pubkey as ASCII Armor plain text" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy" + expect(response).to have_http_status(:ok) + expect(response.body).to eq(valid_key_jimmy) + expect(response.headers['Content-Type']).to eq("text/plain") + end + end + + context "invalid URL" do + it "returns a 422 status" do + get "/.well-known/openpgpkey/hu/123456abcdef?l=alice" + expect(response).to have_http_status(:not_found) + end + end + end + end +end From c3f1f97e1a7402ed006551a9def8f190d3bcc4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 19:21:59 +0200 Subject: [PATCH 06/14] Add display name and PGP key to admin user page Link the key to the ASCII Armor WKD endpoint, if it contains the user's account address --- app/views/admin/users/show.html.erb | 48 ++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 4184e31..11417d0 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -89,13 +89,47 @@
- <% if @avatar.present? %> -

LDAP

-

- -

- <% end %> - +

LDAP

+ + + + + + + + + + + + + + + +
Avatar + <% if @avatar.present? %> + + <% else %> + — + <% end %> +
Display name<%= @user.display_name || "—" %>
PGP key + <% if @user.pgp_pubkey.present? %> + + <% if @user.pgp_pubkey_contains_user_address? %> + <%= link_to wkd_key_url(hashed_username: @user.wkd_hash, l: @user.cn, format: :txt), + class: "ks-text-link", target: "_blank" do %> + <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %> + <% end %> + <% else %> + <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %> + <% end %> +
+ <% @user.gnupg_key.uids.each do |uid| %> + <%= uid.uid %>
+ <% end %> + <% else %> + — + <% end %> +
From 37b106e73c5d16b52c8f329cdfc7d929779807fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Sep 2024 19:22:52 +0200 Subject: [PATCH 07/14] Whitespace --- app/views/settings/_email.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/settings/_email.html.erb b/app/views/settings/_email.html.erb index 8d48661..a041ea0 100644 --- a/app/views/settings/_email.html.erb +++ b/app/views/settings/_email.html.erb @@ -5,7 +5,7 @@

E-Mail Password

<%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %> <%= hidden_field_tag :section, "email" %> -

+

Use the following button to generate a new email password:

    <%= render FormElements::FieldsetComponent.new( title: "Public key", - description: "Your OpenPGP public key in ASCII Armor format ([example])" + description: "Your OpenPGP public key in ASCII Armor format" ) do %> <%= f.text_area :pgp_pubkey, value: @user.pgp_pubkey, From 1b72c97f42396efd436656f9c262ad0c52eaf805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 25 Sep 2024 00:17:30 +0200 Subject: [PATCH 09/14] Remove obsolete code --- app/controllers/web_key_directory_controller.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb index ffd2844..730fe1d 100644 --- a/app/controllers/web_key_directory_controller.rb +++ b/app/controllers/web_key_directory_controller.rb @@ -1,5 +1,5 @@ class WebKeyDirectoryController < WellKnownController - before_action :allow_cross_origin_requests, only: [ :show ] + before_action :allow_cross_origin_requests # /.well-known/openpgpkey/hu/:hashed_username(.txt) def show @@ -28,7 +28,4 @@ class WebKeyDirectoryController < WellKnownController end end end - - private - end From 534e5a9d3c9349f0d5d1e11fd399581d86d96654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 25 Sep 2024 00:20:30 +0200 Subject: [PATCH 10/14] Gracefully handle wrong capitalization of username --- app/controllers/web_key_directory_controller.rb | 2 +- spec/requests/web_key_directory_spec.rb | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb index 730fe1d..d25c899 100644 --- a/app/controllers/web_key_directory_controller.rb +++ b/app/controllers/web_key_directory_controller.rb @@ -3,7 +3,7 @@ class WebKeyDirectoryController < WellKnownController # /.well-known/openpgpkey/hu/:hashed_username(.txt) def show - @user = User.find_by(cn: params[:l]) + @user = User.find_by(cn: params[:l].downcase) if @user.nil? || @user.pgp_pubkey.empty? || diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb index 627c89a..1d8f083 100644 --- a/spec/requests/web_key_directory_spec.rb +++ b/spec/requests/web_key_directory_spec.rb @@ -64,6 +64,15 @@ RSpec.describe "OpenPGP Web Key Directory", type: :request do expect(response.body).to eq(expected_binary_data) end + context "with wrong capitalization of username" do + it "returns the pubkey as ASCII Armor plain text" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=JimmY" + expect(response).to have_http_status(:ok) + expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem") + expect(response.body).to eq(expected_binary_data) + end + end + context "with .txt extension" do it "returns the pubkey as ASCII Armor plain text" do get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy" From 729e4fd5662fc65382e2b5772f224234add7a034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 26 Sep 2024 23:11:21 +0200 Subject: [PATCH 11/14] Add WKD policy endpoint --- app/controllers/web_key_directory_controller.rb | 4 ++++ config/routes.rb | 1 + spec/requests/web_key_directory_spec.rb | 8 ++++++++ 3 files changed, 13 insertions(+) diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb index d25c899..593a62f 100644 --- a/app/controllers/web_key_directory_controller.rb +++ b/app/controllers/web_key_directory_controller.rb @@ -28,4 +28,8 @@ class WebKeyDirectoryController < WellKnownController end end end + + def policy + head :ok + end end diff --git a/config/routes.rb b/config/routes.rb index 9751f3d..b9c771e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -73,6 +73,7 @@ Rails.application.routes.draw do get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key + get '.well-known/openpgpkey/policy', to: 'web_key_directory#policy' get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb index 1d8f083..c7ef1f3 100644 --- a/spec/requests/web_key_directory_spec.rb +++ b/spec/requests/web_key_directory_spec.rb @@ -1,6 +1,14 @@ require 'rails_helper' RSpec.describe "OpenPGP Web Key Directory", type: :request do + describe "policy" do + it "returns an empty 200 response" do + get "/.well-known/openpgpkey/policy" + expect(response).to have_http_status(:ok) + expect(response.body).to be_empty + end + end + describe "non-existent user" do it "returns a 404 status" do get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle" From 3ee76e26abdb3253353a9f4886178ae0c09a9b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Oct 2024 11:34:18 +0200 Subject: [PATCH 12/14] Re-import user's pubkey on access Sometimes, the pubkey might not be imported in the local keychain (anymore), but at this point in the code it had been successfully imported at least once before. So we just (re-)import every time for it to never fail. --- app/models/user.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index 67c6fcd..284f350 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -174,7 +174,8 @@ class User < ApplicationRecord def gnupg_key return nil unless pgp_pubkey.present? - @gnupg_key ||= GPGME::Key.get(pgp_fpr) + GPGME::Key.import(pgp_pubkey) + GPGME::Key.get(pgp_fpr) end def pgp_pubkey_contains_user_address? From c4c2d16342a7af00cb486aac8bb22915c176a24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 25 Sep 2024 23:43:11 +0200 Subject: [PATCH 13/14] Encrypt outgoing emails when possible --- app/mailers/application_mailer.rb | 86 +++++++++++++++++++++++ app/mailers/custom_mailer.rb | 2 +- app/mailers/notification_mailer.rb | 8 +-- app/services/user_manager/pgp_encrypt.rb | 19 ++++++ spec/mailers/notification_mailer_spec.rb | 87 ++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 app/services/user_manager/pgp_encrypt.rb create mode 100644 spec/mailers/notification_mailer_spec.rb diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index d8bd387..a289acf 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,3 +1,89 @@ class ApplicationMailer < ActionMailer::Base layout 'mailer' + + private + + def send_mail + @template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}" + headers['Message-ID'] = message_id + + if @user.pgp_pubkey.present? + mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format| + format.text { render plain: pgp_content } + end + else + mail(to: @user.email, subject: @subject) do |format| + format.text { render @template } + end + end + end + + def from_address + ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost') + end + + def from_domain + Mail::Address.new(from_address).domain + end + + def message_id + @message_id ||= "#{SecureRandom.uuid}@#{from_domain}" + end + + def boundary + @boundary ||= SecureRandom.hex(8) + end + + def pgp_content_type + "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\"" + end + + def pgp_nested_content + message_content = render_to_string(template: @template) + message_content_base64 = Base64.encode64(message_content) + nested_boundary = SecureRandom.hex(8) + + <<~NESTED_CONTENT + Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1" + Subject: #{@subject} + From: <#{from_address}> + To: #{@user.display_name || @user.cn} <#{@user.email}> + Message-ID: <#{message_id}> + + --------------#{nested_boundary} + Content-Type: text/plain; charset=UTF-8; format=flowed + Content-Transfer-Encoding: base64 + + #{message_content_base64} + + --------------#{nested_boundary}-- + NESTED_CONTENT + end + + def pgp_content + encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content) + encrypted_base64 = Base64.encode64(encrypted_content.to_s) + + <<~EMAIL_CONTENT + This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156) + --------------#{boundary} + Content-Type: application/pgp-encrypted + Content-Description: PGP/MIME version identification + + Version: 1 + + --------------#{boundary} + Content-Type: application/octet-stream; name="encrypted.asc" + Content-Description: OpenPGP encrypted message + Content-Disposition: inline; filename="encrypted.asc" + + -----BEGIN PGP MESSAGE----- + + #{encrypted_base64} + + -----END PGP MESSAGE----- + + --------------#{boundary}-- + EMAIL_CONTENT + end end diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb index 325a620..fd35185 100644 --- a/app/mailers/custom_mailer.rb +++ b/app/mailers/custom_mailer.rb @@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer @user = params[:user] @subject = params[:subject] @body = params[:body] - mail(to: @user.email, subject: @subject) + send_mail end end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index b4ec37d..d281380 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer @user = params[:user] @amount_sats = params[:amount_sats] @subject = "Sats received" - mail to: @user.email, subject: @subject + send_mail end def remotestorage_auth_created @@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer "#{access} #{directory}" end @subject = "New app connected to your storage" - mail to: @user.email, subject: @subject + send_mail end def new_invitations_available @user = params[:user] @subject = "New invitations added to your account" - mail to: @user.email, subject: @subject + send_mail end def bitcoin_donation_confirmed @user = params[:user] @donation = params[:donation] @subject = "Donation confirmed" - mail to: @user.email, subject: @subject + send_mail end end diff --git a/app/services/user_manager/pgp_encrypt.rb b/app/services/user_manager/pgp_encrypt.rb new file mode 100644 index 0000000..afb24b3 --- /dev/null +++ b/app/services/user_manager/pgp_encrypt.rb @@ -0,0 +1,19 @@ +require 'gpgme' + +module UserManager + class PgpEncrypt < UserManagerService + def initialize(user:, text:) + @user = user + @text = text + end + + def call + crypto = GPGME::Crypto.new + crypto.encrypt( + @text, + recipients: @user.gnupg_key, + always_trust: true + ) + end + end +end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb new file mode 100644 index 0000000..a2b3099 --- /dev/null +++ b/spec/mailers/notification_mailer_spec.rb @@ -0,0 +1,87 @@ +# spec/mailers/welcome_mailer_spec.rb +require 'rails_helper' + +RSpec.describe NotificationMailer, type: :mailer do + describe '#lightning_sats_received' do + + context "without PGP key" do + let(:user) { create(:user, cn: "phil", email: 'phil@example.com') } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil + }) + end + + describe "unencrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'renders the correct subject' do + expect(mail.subject).to eq('Sats received') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('text/plain') + end + + it 'renders the body with correct content' do + expect(mail.body.encoded).to match(/You just received 21,000 sats/) + expect(mail.body.encoded).to include(user.address) + end + + it 'includes a link to the lightning service page' do + expect(mail.body.encoded).to include("https://accounts.kosmos.org/services/lightning") + end + end + end + + context "with PGP key" do + let(:pgp_pubkey) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:pgp_fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:user) { create(:user, id: 2, cn: "alice", email: 'alice@example.com', pgp_fpr: pgp_fingerprint) } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: pgp_pubkey + }) + end + + describe "encrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'encrypts the subject line' do + expect(mail.subject).to eq('...') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('multipart/encrypted') + expect(mail.header['content-type'].to_s).to include('protocol="application/pgp-encrypted"') + end + + it 'renders the PGP version part' do + expect(mail.body.encoded).to include("Content-Type: application/pgp-encrypted") + expect(mail.body.encoded).to include("Content-Description: PGP/MIME version identification") + expect(mail.body.encoded).to include("Version: 1") + end + + it 'renders the encrypted PGP part' do + expect(mail.body.encoded).to include('Content-Type: application/octet-stream; name="encrypted.asc"') + expect(mail.body.encoded).to include('Content-Description: OpenPGP encrypted message') + expect(mail.body.encoded).to include('Content-Disposition: inline; filename="encrypted.asc"') + expect(mail.body.encoded).to include('-----BEGIN PGP MESSAGE-----') + expect(mail.body.encoded).to include('hF4DR') + end + end + end + end +end From 339462f3205fa41c9e4344735dd5589907698fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 8 Oct 2024 14:06:10 +0200 Subject: [PATCH 14/14] Refactor mailer options usage --- app/mailers/application_mailer.rb | 3 ++- config/environments/development.rb | 16 +++++++++++----- config/environments/production.rb | 2 +- config/environments/test.rb | 8 ++++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index a289acf..a9ccecf 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,5 @@ class ApplicationMailer < ActionMailer::Base + default Rails.application.config.action_mailer.default_options layout 'mailer' private @@ -19,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base end def from_address - ENV.fetch('SMTP_FROM_ADDRESS', 'accounts@localhost') + self.class.default[:from] end def from_domain diff --git a/config/environments/development.rb b/config/environments/development.rb index 9f73578..a5b46ac 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -57,16 +57,22 @@ Rails.application.configure do # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker - config.action_mailer.default_options = { - from: "accounts@localhost" - } - # Don't actually send emails, cache them for viewing via letter opener config.action_mailer.delivery_method = :letter_opener + # Don't care if the mailer can't send config.action_mailer.raise_delivery_errors = true + # Base URL to be used by email template link helpers - config.action_mailer.default_url_options = { host: "localhost:3000", protocol: "http" } + config.action_mailer.default_url_options = { + host: "localhost:3000", + protocol: "http" + } + + config.action_mailer.default_options = { + from: "accounts@localhost", + message_id: -> { "<#{Mail.random_tag}@localhost>" }, + } # Allow requests from any IP config.web_console.permissions = '0.0.0.0/0' diff --git a/config/environments/production.rb b/config/environments/production.rb index 16e7fa5..da0fa6f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -63,7 +63,7 @@ Rails.application.configure do outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain config.action_mailer.default_url_options = { - host: ENV['AKKOUNTS_DOMAIN'], + host: ENV.fetch('AKKOUNTS_DOMAIN'), protocol: "https", } diff --git a/config/environments/test.rb b/config/environments/test.rb index 65473dd..0c474bf 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,8 +46,12 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: "accounts.kosmos.org", - protocol: "https", - from: "accounts@kosmos.org" + protocol: "https" + } + + config.action_mailer.default_options = { + from: "accounts@kosmos.org", + message_id: -> { "<#{Mail.random_tag}@kosmos.org>" }, } config.active_job.queue_adapter = :test