diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e438da4..290582dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Upcoming Version +- Added a mechanism of password recovery in case users forget about their +password. See PR [#325](https://github.com/SUSE/Portus/pull/325). - Set admin user from a rake task and disable first-user is admin. See PR [#314] (https://github.com/SUSE/Portus/pull/314) - Review requirements and provides in the RPM diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index e1d304c16..78d553c96 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -6,6 +6,7 @@ class Auth::SessionsController < Devise::SessionsController # the signup page. def new if User.not_portus.any? || Portus::LDAP.enabled? + @errors_occurred = flash[:alert] && !flash[:alert].empty? super else # For some reason if we get here from the root path, we'll get a flashy diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..9101b83ca --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,43 @@ +# PasswordsController is a Devise controller that takes care of the "password +# forgotten" mechanism. +class PasswordsController < Devise::PasswordsController + layout "authentication" + + # Re-implemented from Devise to respond with a proper message on error. + def create + self.resource = resource_class.send_reset_password_instructions(resource_params) + yield resource if block_given? + + if successfully_sent?(resource) + respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) + else + redirect_to new_user_password_path, alert: resource.errors.full_messages + end + end + + # Re-implemented from Devise to respond with a proper message on error. + def update + self.resource = resource_class.reset_password_by_token(resource_params) + yield resource if block_given? + + if resource.errors.empty? + update_success + else + token = params[:user][:reset_password_token] + redirect_to "/users/password/edit?reset_password_token=#{token}", + alert: resource.errors.full_messages + end + end + + protected + + def update_success + resource.unlock_access! if unlockable?(resource) + + flash_message = resource.active_for_authentication? ? :updated : :updated_not_active + set_flash_message(:notice, flash_message) if is_flashing_format? + sign_in(resource_name, resource) + + respond_with resource, location: after_resetting_password_path_for(resource) + end +end diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb new file mode 100644 index 000000000..a332697e6 --- /dev/null +++ b/app/mailers/devise_mailer.rb @@ -0,0 +1,4 @@ +class DeviseMailer < Devise::Mailer + default from: "#{APP_CONFIG["email"]["name"]} <#{APP_CONFIG["email"]["from"]}>" + default reply_to: APP_CONFIG["email"]["reply_to"] +end diff --git a/app/views/devise/passwords/edit.html.slim b/app/views/devise/passwords/edit.html.slim new file mode 100644 index 000000000..42cde6225 --- /dev/null +++ b/app/views/devise/passwords/edit.html.slim @@ -0,0 +1,15 @@ +section.row-0 + .center-panel + .col-md-4.col-sm-2.col-xs-1 + .col-md-4.col-sm-8.col-xs-10.text-center + = render 'shared/notifications' + = image_tag 'layout/portus-logo-login-page.png', class: 'login-picture' + = form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| + = f.hidden_field :reset_password_token + = f.password_field :password, class: 'input form-control input-lg', placeholder: 'Password', autofocus: true, autocomplete: "off", required: true + = f.password_field :password_confirmation, class: 'input form-control input-lg', placeholder: 'Password confirmation', autocomplete: "off", required: true + + = f.button class: 'classbutton btn btn-primary btn-block btn-lg' do + i.fa.fa-check Change my password + + .text-center = link_to 'Go back to the login page', new_user_session_url diff --git a/app/views/devise/passwords/new.html.slim b/app/views/devise/passwords/new.html.slim new file mode 100644 index 000000000..3cf199cca --- /dev/null +++ b/app/views/devise/passwords/new.html.slim @@ -0,0 +1,12 @@ +section.row-0 + .center-panel + .col-md-4.col-sm-2.col-xs-1 + .col-md-4.col-sm-8.col-xs-10.text-center + = render 'shared/notifications' + = image_tag 'layout/portus-logo-login-page.png', class: 'login-picture' + = form_for(resource, as: resource_name, url: password_path(resource_name)) do |f| + = f.email_field :email, class: 'input form-control input-lg', placeholder: 'Email', autofocus: true, required: true + = f.button class: 'classbutton btn btn-primary btn-block btn-lg' do + i.fa.fa-check Reset password + + .text-center = link_to 'Go back to the login page', new_user_session_url diff --git a/app/views/devise/sessions/new.html.slim b/app/views/devise/sessions/new.html.slim index de2dbbeb5..03db89c92 100644 --- a/app/views/devise/sessions/new.html.slim +++ b/app/views/devise/sessions/new.html.slim @@ -19,3 +19,6 @@ section.row-0 | NOTE: The first user to be created will have admin permissions ! - else .text-center = link_to 'or, Create a new account', new_user_registration_url + + - if @errors_occurred + .text-center = link_to "Did you forget your password?", new_user_password_path diff --git a/config/config.yml b/config/config.yml index 695144a8c..66689bc5d 100644 --- a/config/config.yml +++ b/config/config.yml @@ -2,6 +2,17 @@ # application. In order to change them, write your own config-local.yml file # (it will be ignored by git). +# Settings for the Portus mailer. +email: + from: "portus@example.com" + name: "Portus" + reply_to: "no-reply@example.com" + + # If set to true, then SMTP will be used with the configuration values as + # given in config/initializers/smtp.rb. Otherwise 'sendmail' will be used + # (defaults to: /usr/sbin/sendmail -i -t). + smtp: false + # If enabled, then the profile picture will be picked from the Gravatar # associated with each user. See: https://en.gravatar.com/ gravatar: diff --git a/config/environments/production.rb b/config/environments/production.rb index f28808f55..40a3a5ebd 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -71,6 +71,14 @@ # Send deprecation notices to registered listeners. config.active_support.deprecation = :notify + # If SMTP is enabled, then pick it up, and the config/initializers/smtp.rb + # will be loaded. Otherwise, we fallback to sendmail. + if APP_CONFIG["email"]["smtp"].enabled? + config.action_mailer.delivery_method = :smtp + else + config.action_mailer.delivery_method = :sendmail + end + # Use default logging formatter so that PID and timestamp are not suppressed. config.log_formatter = ::Logger::Formatter.new diff --git a/config/environments/test.rb b/config/environments/test.rb index ab4b82202..27260ec21 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -30,6 +30,7 @@ # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } # Randomize the order test cases are executed. config.active_support.test_order = :random diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2b3e028d5..edae57bef 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -10,10 +10,10 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" + # config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' + config.mailer = "DeviseMailer" # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and @@ -185,7 +185,7 @@ # ==> Configuration for :recoverable # # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [:email] + config.reset_password_keys = [:email] # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to diff --git a/config/initializers/smtp.rb b/config/initializers/smtp.rb new file mode 100644 index 000000000..5ae437028 --- /dev/null +++ b/config/initializers/smtp.rb @@ -0,0 +1,21 @@ +# Fetch the value of the given "PORTUS_SMTP_*" environment variable. If it's +# not set, then it will raise an exception containing a descriptive message. +def safe_env(name) + name = "PORTUS_SMTP_#{name}".upcase + unless ENV[name] + raise StandardError, "SMTP is enabled but the environment variable '#{name}' has not been set!" + end + ENV[name] +end + +if Rails.env.production? && APP_CONFIG["email"]["smtp"] + ActionMailer::Base.smtp_settings = { + address: safe_env("address"), + port: safe_env("port"), + user_name: safe_env("username"), + password: safe_env("password"), + domain: safe_env("domain"), + authentication: :login, + enable_starttls_auto: true + } +end diff --git a/config/routes.rb b/config/routes.rb index fcb31f90f..45a8d0bfb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,7 +10,9 @@ post :toggle_star, on: :member end - devise_for :users, controllers: { registrations: "auth/registrations", sessions: "auth/sessions" } + devise_for :users, controllers: { registrations: "auth/registrations", + sessions: "auth/sessions", + passwords: "passwords" } resource :dashboard, only: [:index] resources :search, only: [:index] @@ -42,5 +44,4 @@ end end match "(errors)/:status", to: "errors#show", constraints: { status: /\d{3}/ }, via: :all - end diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb new file mode 100644 index 000000000..ed1ede3f5 --- /dev/null +++ b/spec/controllers/passwords_controller_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +describe PasswordsController do + before :each do + request.env["devise.mapping"] = Devise.mappings[:user] + @user = create(:admin) + @raw = @user.send_reset_password_instructions + end + + it "updates the user's password on success" do + put :update, "user" => { + "reset_password_token" => @raw, + "password" => "12341234", + "password_confirmation" => "12341234" + } + + expect(response.status).to eq 302 + @user.reload + expect(@user.valid_password?("12341234")).to be true + end + + it "does nothing if the user's password does not match confirm" do + put :update, "user" => { + "reset_password_token" => @raw, + "password" => "12341234", + "password_confirmation" => "12341234asdasda" + } + + expect(response.status).to eq 302 + @user.reload + expect(@user.valid_password?("12341234")).to be false + end +end diff --git a/spec/features/forgotten_password_spec.rb b/spec/features/forgotten_password_spec.rb new file mode 100644 index 000000000..bf9e9f670 --- /dev/null +++ b/spec/features/forgotten_password_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +feature "Forgotten password support" do + let!(:user) { create(:admin) } + + before :each do + APP_CONFIG["email"] = { + "from" => "test@example.com", + "name" => "Portus", + "reply_to" => "no-reply@example.com" + } + end + + scenario "gives the user a link to reset their password", js: true do + visit new_user_session_path + expect(page).to_not have_content("Did you forget your password?") + + fill_in "Username", with: "random" + fill_in "Password", with: "12341234" + click_button "Login" + + expect(current_path).to eq new_user_session_path + expect(page).to have_content("Did you forget your password?") + click_link("Did you forget your password?") + expect(current_path).to eq new_user_password_path + end + + scenario "sends the reset email when appropiate", js: true do + visit new_user_password_path + + fill_in "Email", with: "random@example.com" + click_button "Reset password" + expect(current_path).to eq new_user_password_path + expect(page).to have_content("Email not found") + + fill_in "Email", with: user.email + click_button "Reset password" + expect(current_path).to eq new_user_session_path + expect(page).to have_content("You will receive an email with instructions on " \ + "how to reset your password in a few minutes.") + + # The email has been sent. + mail = ActionMailer::Base.deliveries.first + ActionMailer::Base.deliveries.clear + expect(mail.to).to match_array [user.email] + end +end