diff --git a/Gemfile b/Gemfile index df0e515..7c8e759 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,7 @@ gem 'jbuilder', '~> 2.7' # Use Redis adapter to run Action Cable in production # gem 'redis', '~> 4.0' # Use Active Model has_secure_password -# gem 'bcrypt', '~> 3.1.7' +gem 'bcrypt', '~> 3.1.7' # Configuration gem 'dotenv-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 3e551f1..444b10e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -259,6 +259,7 @@ GEM method_source (1.0.0) mini_magick (4.12.0) mini_mime (1.1.5) + mini_portile2 (2.8.5) minitest (5.20.0) multipart-post (2.3.0) net-imap (0.3.7) @@ -272,6 +273,9 @@ GEM net-smtp (0.4.0) net-protocol nio4r (2.5.9) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.15.4-arm64-darwin) racc (~> 1.4) nokogiri (1.15.4-x86_64-linux) @@ -422,6 +426,8 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + sqlite3 (1.6.7) + mini_portile2 (~> 2.8.0) sqlite3 (1.6.7-arm64-darwin) sqlite3 (1.6.7-x86_64-linux) stimulus-rails (1.3.0) @@ -466,6 +472,7 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 + bcrypt (~> 3.1.7) byebug (~> 11.1) capybara cssbundling-rails diff --git a/app/controllers/services/email_controller.rb b/app/controllers/services/email_controller.rb new file mode 100644 index 0000000..516505b --- /dev/null +++ b/app/controllers/services/email_controller.rb @@ -0,0 +1,34 @@ +class Services::EmailController < Services::BaseController + before_action :authenticate_user! + before_action :require_service_available + before_action :require_feature_enabled + + def show + ldap_entry = current_user.ldap_entry + + @service_enabled = ldap_entry[:email_password].present? + @maildrop = ldap_entry[:email_maildrop] + @email_forwarding_active = @maildrop.present? && + @maildrop.split("@").first != current_user.cn + end + + def new_password + if session[:new_email_password].present? + @new_password = session.delete(:new_email_password) + else + redirect_to setting_path(:email) + end + end + + private + + def require_service_available + http_status :not_found unless Setting.email_enabled? + end + + def require_feature_enabled + unless Flipper.enabled?(:email, current_user) + http_status :forbidden + end + end +end diff --git a/app/controllers/services/remotestorage_controller.rb b/app/controllers/services/remotestorage_controller.rb index 67c7e76..632e5c7 100644 --- a/app/controllers/services/remotestorage_controller.rb +++ b/app/controllers/services/remotestorage_controller.rb @@ -1,7 +1,7 @@ class Services::RemotestorageController < Services::BaseController before_action :authenticate_user! - before_action :require_feature_enabled before_action :require_service_available + before_action :require_feature_enabled # Dashboard def show @@ -14,13 +14,13 @@ class Services::RemotestorageController < Services::BaseController private + def require_service_available + http_status :not_found unless Setting.remotestorage_enabled? + end + def require_feature_enabled unless Flipper.enabled?(:remotestorage, current_user) http_status :forbidden end end - - def require_service_available - http_status :not_found unless Setting.remotestorage_enabled? - end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 8f30419..f1fa357 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,10 +1,11 @@ -require 'securerandom' +require "securerandom" +require "bcrypt" class SettingsController < ApplicationController before_action :authenticate_user! before_action :set_main_nav_section - before_action :set_settings_section, only: [:show, :update, :update_email] - before_action :set_user, only: [:show, :update, :update_email] + before_action :set_settings_section, only: [:show, :update, :update_email, :reset_email_password] + before_action :set_user, only: [:show, :update, :update_email, :reset_email_password] def index redirect_to setting_path(:profile) @@ -40,7 +41,7 @@ class SettingsController < ApplicationController end def update_email - if @user.valid_ldap_authentication?(email_params[:current_password]) + if @user.valid_ldap_authentication?(security_params[:current_password]) if @user.update email: email_params[:email] redirect_to setting_path(:account), flash: { notice: 'Please confirm your new address using the confirmation link we just sent you.' @@ -56,6 +57,28 @@ class SettingsController < ApplicationController end end + def reset_email_password + @user.current_password = security_params[:current_password] + + if @user.valid_ldap_authentication?(@user.current_password) + @user.current_password = nil + session[:new_email_password] = generate_email_password + hashed_password = hash_email_password(session[:new_email_password]) + LdapManager::UpdateEmailPassword.call(@user.dn, hashed_password) + + if @user.ldap_entry[:email_maildrop] != @user.address + LdapManager::UpdateEmailMaildrop.call(@user.dn, @user.address) + end + + redirect_to new_password_services_email_path + else + @validation_errors = { + current_password: [ "Wrong password. Try again!" ] + } + render :show, status: :forbidden + end + end + def reset_password current_user.send_reset_password_instructions sign_out current_user @@ -111,7 +134,8 @@ class SettingsController < ApplicationController def set_settings_section @settings_section = params[:section] allowed_sections = [ - :profile, :account, :lightning, :remotestorage, :xmpp, :experiments + :profile, :account, :xmpp, :email, :lightning, :remotestorage, + :experiments ] unless allowed_sections.include?(@settings_section.to_sym) @@ -132,7 +156,11 @@ class SettingsController < ApplicationController end def email_params - params.require(:user).permit(:email, :current_password) + params.require(:user).permit(:email) + end + + def security_params + params.require(:user).permit(:current_password) end def nostr_event_params @@ -140,4 +168,14 @@ class SettingsController < ApplicationController :id, :pubkey, :created_at, :kind, :tags, :content, :sig ]) 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 + end + + def hash_email_password(password) + salt = BCrypt::Engine.generate_salt + BCrypt::Engine.hash_secret(password, salt) + end end diff --git a/app/javascript/controllers/settings/email/password_controller.js b/app/javascript/controllers/settings/email/password_controller.js new file mode 100644 index 0000000..6c8784e --- /dev/null +++ b/app/javascript/controllers/settings/email/password_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "resetPasswordButton", "currentPasswordField" ] + static values = { validationFailed: Boolean } + + connect () { + if (this.validationFailedValue) return; + + this.element.querySelectorAll(".initial-hidden").forEach(el => { + el.classList.add("hidden"); + }) + this.element.querySelectorAll(".initial-visible").forEach(el => { + el.classList.remove("hidden"); + }) + } + + showPasswordReset () { + this.element.querySelectorAll(".initial-visible").forEach(el => { + el.classList.add("hidden"); + }) + this.element.querySelectorAll(".initial-hidden").forEach(el => { + el.classList.remove("hidden"); + }) + this.currentPasswordFieldTarget.select(); + } +} diff --git a/app/models/setting.rb b/app/models/setting.rb index 9cafa2e..27acdcc 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -168,4 +168,30 @@ class Setting < RailsSettings::Base field :rs_redis_url, type: :string, default: ENV["RS_REDIS_URL"] || "redis://localhost:6379/1" + + + # + # E-Mail Service + # + + field :email_enabled, type: :boolean, + default: ENV["EMAIL_SMTP_HOST"].present? + + # field :email_smtp_host, type: :string, + # default: ENV["EMAIL_SMTP_HOST"].presence + # + # field :email_smtp_port, type: :string, + # default: ENV["EMAIL_SMTP_PORT"].presence || 587 + # + # field :email_smtp_enable_starttls, type: :string, + # default: ENV["EMAIL_SMTP_PORT"].presence || true + # + # field :email_auth_method, type: :string, + # default: ENV["EMAIL_AUTH_METHOD"].presence || "plain" + # + # field :email_imap_host, type: :string, + # default: ENV["EMAIL_IMAP_HOST"].presence + # + # field :email_imap_port, type: :string, + # default: ENV["EMAIL_IMAP_PORT"].presence || 993 end diff --git a/app/models/user.rb b/app/models/user.rb index acda860..b31016a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord attr_accessor :display_name attr_accessor :avatar_new + attr_accessor :current_password serialize :preferences, UserPreferences @@ -90,11 +91,12 @@ class User < ApplicationRecord # E-Mail update confirmed LdapManager::UpdateEmail.call(self.dn, self.email) else - # TODO Make configurable # E-Mail from signup confirmed (i.e. account activation) - enable_service %w[ discourse gitea mediawiki xmpp ] - #TODO enable in development when we have easy setup of ejabberd etc. + # TODO Make configurable, only activate globally enabled services + enable_service %w[ discourse email gitea mediawiki xmpp ] + + # TODO enable in development when we have easy setup of ejabberd etc. return if Rails.env.development? || !Setting.ejabberd_enabled? XmppExchangeContactsJob.perform_later(inviter, self) if inviter.present? diff --git a/app/services/ldap_manager/update_email_maildrop.rb b/app/services/ldap_manager/update_email_maildrop.rb new file mode 100644 index 0000000..d26cf16 --- /dev/null +++ b/app/services/ldap_manager/update_email_maildrop.rb @@ -0,0 +1,12 @@ +module LdapManager + class UpdateEmailMaildrop < LdapManagerService + def initialize(dn, address) + @dn = dn + @address = address + end + + def call + replace_attribute @dn, :mailRoutingAddress, @address + end + end +end diff --git a/app/services/ldap_manager/update_email_password.rb b/app/services/ldap_manager/update_email_password.rb new file mode 100644 index 0000000..e9cde6a --- /dev/null +++ b/app/services/ldap_manager/update_email_password.rb @@ -0,0 +1,12 @@ +module LdapManager + class UpdateEmailPassword < LdapManagerService + def initialize(dn, password_hash) + @dn = dn + @password_hash = password_hash + end + + def call + replace_attribute @dn, :mailpassword, @password_hash + end + end +end diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb index eac64c2..1c56df6 100644 --- a/app/services/ldap_service.rb +++ b/app/services/ldap_service.rb @@ -50,8 +50,11 @@ class LdapService < ApplicationService treebase = ldap_config["base"] end - attributes = %w{dn cn uid mail displayName admin service} - filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") + attributes = %w[ + dn cn uid mail displayName admin service + mailRoutingAddress mailpassword + ] + filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*") entries = ldap_client.search(base: treebase, filter: filter, attributes: attributes) entries.sort_by! { |e| e.cn[0] } @@ -61,7 +64,9 @@ class LdapService < ApplicationService mail: e.try(:mail) ? e.mail.first : nil, display_name: e.try(:displayName) ? e.displayName.first : nil, admin: e.try(:admin) ? 'admin' : nil, - service: e.try(:service) + service: e.try(:service), + email_maildrop: e.try(:mailRoutingAddress), + email_password: e.try(:mailpassword) } end end diff --git a/app/views/admin/settings/services/_email.html.erb b/app/views/admin/settings/services/_email.html.erb new file mode 100644 index 0000000..4bf021a --- /dev/null +++ b/app/views/admin/settings/services/_email.html.erb @@ -0,0 +1,16 @@ +

E-Mail

+ diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 5d22d42..4d0f9ac 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -32,6 +32,17 @@ <% end %> <% end %> + <% if Setting.email_enabled? && + Flipper.enabled?(:email, current_user) %> +
+ <%= link_to services_email_path, class: "block h-full px-6 py-6 rounded-md" do %> +

E-Mail

+

+ A no-bullshit email account +

+ <% end %> +
+ <% end %> <% if Setting.discourse_enabled? %>
+ <%= link_to services_storage_path, + class: "block h-full px-6 py-6 rounded-md" do %> +

Storage

+

+ Sync your data between apps and devices +

+ <% end %> +
+ <% end %> <% if Setting.gitea_enabled? %>
- <%= link_to services_storage_path, - class: "block h-full px-6 py-6 rounded-md" do %> -

Storage

-

- Sync your data between apps and devices -

- <% end %> -
- <% end %> <% if Setting.mediawiki_enabled? %>
\ No newline at end of file + diff --git a/app/views/services/email/new_password.html.erb b/app/views/services/email/new_password.html.erb new file mode 100644 index 0000000..a9df60a --- /dev/null +++ b/app/views/services/email/new_password.html.erb @@ -0,0 +1,35 @@ +<%= render HeaderCompactComponent.new(title: "New E-Mail Password") %> + +<%= render MainCompactComponent.new do %> +
+

+ Your email password has been updated. +

+

+ Please store the new one in a password manager or write it down somewhere: +

+

+ <%= label_tag :new_password, 'New password', class: 'hidden' %> + <%= text_field_tag :new_password, @new_password, disabled: true, class: 'text-xl grow', + data: { "clipboard-target": "source"} %> + + +

+

+ <%= link_to "Done", services_email_path, class: "btn-md btn-blue w-full" %> +

+ <%= render QrCodeModalComponent.new(qr_content: @new_password) %> +
+<% end %> diff --git a/app/views/services/email/show.html.erb b/app/views/services/email/show.html.erb new file mode 100644 index 0000000..2ad55f7 --- /dev/null +++ b/app/views/services/email/show.html.erb @@ -0,0 +1,128 @@ +<%= render HeaderComponent.new(title: "E-Mail") %> + +<%= render MainSimpleComponent.new do %> +
+

+ Send and receive electronic mail. +

+
+
+

Your E-Mail Address

+

+ disabled="disabled" + data-clipboard-target="source" /> + + +

+ <%= render QrCodeModalComponent.new(qr_content: "xmpp:"+current_user.address) %> +
+
+

E-Mail Password

+

+ Your email password is different from your main account password. You can + reset your email password in the + <%= link_to "email settings", setting_path(:email), class: "ks-text-link" %>. +

+
+
+

Recommended Apps

+
+ + + + + + + +
+
+<% end %> diff --git a/app/views/settings/_email.html.erb b/app/views/settings/_email.html.erb new file mode 100644 index 0000000..8d48661 --- /dev/null +++ b/app/views/settings/_email.html.erb @@ -0,0 +1,34 @@ +<%= tag.section data: { + controller: "settings--email--password", + "settings--email--password-validation-failed-value": @validation_errors.present? + } do %> +

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: +

+ +
+

+ <%= f.label :current_password, 'Current account password', class: 'font-bold' %> +

+

+ <%= f.password_field :current_password, class: "w-full", required: true, + data: { 'settings--email--password-target': "currentPasswordField" } %> +

+ <% if @validation_errors.present? && @validation_errors[:current_password].present? %> +

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

+ <% end %> +

+ <%= f.submit "Create new email password", class: "btn-md btn-blue w-full md:w-auto" %> +

+
+ <% end %> +<% end %> diff --git a/app/views/shared/_admin_sidenav_settings_services.html.erb b/app/views/shared/_admin_sidenav_settings_services.html.erb index 8650277..a69f3cc 100644 --- a/app/views/shared/_admin_sidenav_settings_services.html.erb +++ b/app/views/shared/_admin_sidenav_settings_services.html.erb @@ -19,6 +19,13 @@ text_icon: Setting.droneci_enabled? ? "◉" : "○", active: current_page?(admin_settings_services_path(params: { s: "droneci" })), ) %> +<%= render SidenavLinkComponent.new( + level: 2, + name: "E-Mail", + path: admin_settings_services_path(params: { s: "email" }), + text_icon: Setting.email_enabled? ? "◉" : "○", + active: current_page?(admin_settings_services_path(params: { s: "email" })), +) %> <%= render SidenavLinkComponent.new( level: 2, name: "ejabberd", diff --git a/app/views/shared/_sidenav_settings.html.erb b/app/views/shared/_sidenav_settings.html.erb index 359f3e1..86947b1 100644 --- a/app/views/shared/_sidenav_settings.html.erb +++ b/app/views/shared/_sidenav_settings.html.erb @@ -12,13 +12,21 @@ active: @settings_section.to_s == "xmpp" ) %> <% end %> +<% if Setting.email_enabled? && + Flipper.enabled?(:email, current_user) %> +<%= render SidenavLinkComponent.new( + name: "E-Mail", path: setting_path(:email), icon: "mail", + active: @settings_section.to_s == "email" +) %> +<% end %> <% if Setting.lndhub_enabled %> <%= render SidenavLinkComponent.new( name: "Lightning", path: setting_path(:lightning), icon: "zap", active: @settings_section.to_s == "lightning" ) %> <% end %> -<% if Setting.remotestorage_enabled %> +<% if Setting.remotestorage_enabled? && + Flipper.enabled?(:remotestorage, current_user) %> <%= render SidenavLinkComponent.new( name: "Storage", path: setting_path(:remotestorage), icon: "remotestorage", active: @settings_section.to_s == "remotestorage" diff --git a/config/routes.rb b/config/routes.rb index c9f4281..eaa40c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,12 @@ Rails.application.routes.draw do resource :mastodon, only: [:show], controller: 'mastodon' + resource :email, only: [:show], controller: 'email' do + member do + get 'new_password' + end + end + resources :lightning, only: [:index] do collection do get 'transactions' @@ -44,6 +50,7 @@ Rails.application.routes.draw do collection do post 'update_email' post 'reset_password' + post 'reset_email_password' post 'set_nostr_pubkey' delete 'nostr_pubkey', to: 'settings#remove_nostr_pubkey' end diff --git a/public/img/logos/icon_k9-mail.png b/public/img/logos/icon_k9-mail.png new file mode 100644 index 0000000..0c64d4b Binary files /dev/null and b/public/img/logos/icon_k9-mail.png differ diff --git a/public/img/logos/icon_thunderbird.png b/public/img/logos/icon_thunderbird.png new file mode 100644 index 0000000..101f2c4 Binary files /dev/null and b/public/img/logos/icon_thunderbird.png differ diff --git a/spec/features/settings/email_spec.rb b/spec/features/settings/email_spec.rb new file mode 100644 index 0000000..348d0be --- /dev/null +++ b/spec/features/settings/email_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.describe 'E-Mail settings', type: :feature do + let(:user) { create :user } + + feature "Reset email password" do + before do + login_as user, :scope => :user + + allow_any_instance_of(User).to receive(:valid_ldap_authentication?) + .with("invalid password").and_return(false) + allow_any_instance_of(User).to receive(:valid_ldap_authentication?) + .with("valid password").and_return(true) + end + + scenario 'fails with invalid password' do + expect(LdapManager::UpdateEmailPassword).not_to receive(:call) + expect(LdapManager::UpdateEmailMaildrop).not_to receive(:call) + + visit setting_path(:email) + fill_in 'Current account password', with: "invalid password" + click_button "Create new email password" + + within ".error-msg" do + expect(page).to have_content("Wrong password") + end + end + + scenario 'works with valid password' do + 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(:ldap_entry) + .and_return({ uid: user.cn, ou: user.ou, email_maildrop: user.address }) + + expect(LdapManager::UpdateEmailPassword).to receive(:call).and_return(true) + expect(LdapManager::UpdateEmailMaildrop).not_to receive(:call) + + visit setting_path(:email) + fill_in 'Current account password', with: "valid password" + click_button "Create new email password" + + expect(current_url).to eq(new_password_services_email_url) + end + + scenario 'updates the maildrop attribute if necessary' do + 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(:ldap_entry) + .and_return({ uid: user.cn, ou: user.ou, email_maildrop: "mahafaly@example.com" }) + + expect(LdapManager::UpdateEmailPassword).to receive(:call).and_return(true) + expect(LdapManager::UpdateEmailMaildrop).to receive(:call) + .with(user.dn, user.address).and_return(true) + + visit setting_path(:email) + fill_in 'Current account password', with: "valid password" + click_button "Create new email password" + + expect(current_url).to eq(new_password_services_email_url) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a3e8051..bdf8563 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -66,9 +66,9 @@ RSpec.describe User, type: :model do it "returns the entries from the LDAP service attribute" do expect(user).to receive(:ldap_entry).and_return({ uid: user.cn, ou: user.ou, mail: user.email, admin: nil, - service: ["discourse", "gitea", "wiki", "xmpp"] + service: ["discourse", "email", "gitea", "wiki", "xmpp"] }) - expect(user.services_enabled).to eq(["discourse", "gitea", "wiki", "xmpp"]) + expect(user.services_enabled).to eq(["discourse", "email", "gitea", "wiki", "xmpp"]) end end @@ -147,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 mediawiki xmpp ]) + expect(user).to receive(:enable_service).with(%w[ discourse email gitea mediawiki xmpp ]) user.send :devise_after_confirmation end