From 04fef7b8886bb78f3473e143894a521ca578f1db Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 2 Feb 2018 10:18:55 +0100 Subject: [PATCH] pam authentication (#5303) * add pam support, without extra column * bugfixes for pam login * document options * fix code style * fix codestyle * fix tests * don't call remember_me without password * fix codestyle * improve checks for pam usage (should fix tests) * fix remember_me part 1 * add remember_token column because :rememberable requires either a password or this column. * migrate db for remember_token * move pam_authentication to the right place, fix logic bug in edit.html.haml * fix tests * fix pam authentication, improve username lookup, add comment * valid? is sometimes not honored, return nil instead trying to authenticate with pam * update devise_pam_authenticatable2 and adjust code. Fixes sideeffects observed in tests * update devise_pam_authenticatable gem, fixes for codeconventions, fix finding user * codeconvention fixes * code convention fixes * fix idention * update dependency, explicit conflict check * fix disabled password updates if in pam mode * fix check password if password is present, fix templates * block registration if account is maintained by pam * Revert "block registration if account is maintained by pam" This reverts commit 8e7a083d650240b6fac414926744b4b90b435f20. * fix identation error introduced by rebase * block usernames maintained by pam * document pam settings better * fix code style --- Gemfile | 3 + Gemfile.lock | 5 ++ app/controllers/application_controller.rb | 5 ++ .../auth/registrations_controller.rb | 5 ++ app/controllers/auth/sessions_controller.rb | 6 +- app/models/user.rb | 69 +++++++++++++++++++ .../unreserved_username_validator.rb | 6 ++ app/views/auth/passwords/edit.html.haml | 18 +++-- app/views/auth/registrations/edit.html.haml | 15 ++-- app/views/auth/sessions/new.html.haml | 5 +- config/initializers/devise.rb | 34 ++++++++- config/locales/simple_form.de.yml | 1 + config/locales/simple_form.en.yml | 1 + ...80109143959_add_remember_token_to_users.rb | 5 ++ db/schema.rb | 3 +- 15 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 db/migrate/20180109143959_add_remember_token_to_users.rb diff --git a/Gemfile b/Gemfile index eaa1d29de6..f3844aca6f 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,9 @@ gem 'iso-639' gem 'cld3', '~> 3.2.0' gem 'devise', '~> 4.4' gem 'devise-two-factor', '~> 3.0' + +gem 'devise_pam_authenticatable2', '~> 8.0' + gem 'doorkeeper', '~> 4.2' gem 'fast_blank', '~> 1.0' gem 'goldfinger', '~> 2.1' diff --git a/Gemfile.lock b/Gemfile.lock index b3bd6fcb0d..7da9bfe394 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,6 +137,9 @@ GEM devise (~> 4.0) railties (< 5.2) rotp (~> 2.0) + devise_pam_authenticatable2 (8.0.1) + devise (>= 4.0.0) + rpam2 (~> 3.0) diff-lcs (1.3) docile (1.1.5) domain_name (0.5.20170404) @@ -420,6 +423,7 @@ GEM actionpack (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3) rotp (2.1.2) + rpam2 (3.1.0) rqrcode (0.10.1) chunky_png (~> 1.0) rspec-core (3.7.0) @@ -570,6 +574,7 @@ DEPENDENCIES climate_control (~> 0.2) devise (~> 4.4) devise-two-factor (~> 3.0) + devise_pam_authenticatable2 (~> 8.0) doorkeeper (~> 4.2) dotenv-rails (~> 2.2) fabrication (~> 2.18) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e1aae0b677..b38a684677 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,6 +14,7 @@ class ApplicationController < ActionController::Base helper_method :current_session helper_method :current_theme helper_method :single_user_mode? + helper_method :use_pam? rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found @@ -75,6 +76,10 @@ class ApplicationController < ActionController::Base @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? end + def use_pam? + Devise.pam_authentication + end + def current_account @current_account ||= current_user.try(:account) end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index b8ff4e54f2..417e2b63bd 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -14,6 +14,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController protected + def update_resource(resource, params) + params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank? + super + end + def build_resource(hash = nil) super(hash) diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index a5acb6c36f..4fc41b3784 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -28,7 +28,11 @@ class Auth::SessionsController < Devise::SessionsController if session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:email] - User.find_for_authentication(email: user_params[:email]) + if use_pam? && Devise.check_at_sign && user_params[:email].index('@').nil? + User.joins(:account).find_by(accounts: { username: user_params[:email] }) + else + User.find_for_authentication(email: user_params[:email]) + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 40c298b1a2..fa4ebfc717 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -34,6 +34,7 @@ # disabled :boolean default(FALSE), not null # moderator :boolean default(FALSE), not null # invite_id :integer +# remember_token :string # class User < ApplicationRecord @@ -50,6 +51,8 @@ class User < ApplicationRecord devise :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable + devise :pam_authenticatable + belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true accepts_nested_attributes_for :account @@ -84,6 +87,33 @@ class User < ApplicationRecord attr_accessor :invite_code + def pam_conflict(_) + # block pam login tries on traditional account + nil + end + + def pam_conflict? + return false unless Devise.pam_authentication + encrypted_password.present? && is_pam_account? + end + + def pam_get_name + return account.username if account.present? + super + end + + def pam_setup(_attributes) + acc = Account.new(username: pam_get_name) + acc.save!(validate: false) + + self.email = "#{acc.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix + self.confirmed_at = Time.now.utc + self.admin = false + self.account = acc + + acc.destroy! unless save + end + def confirmed? confirmed_at.present? end @@ -213,6 +243,45 @@ class User < ApplicationRecord @invite_code = code end + def password_required? + return false if Devise.pam_authentication + super + end + + def send_reset_password_instructions + return false if encrypted_password.blank? && Devise.pam_authentication + super + end + + def reset_password!(new_password, new_password_confirmation) + return false if encrypted_password.blank? && Devise.pam_authentication + super + end + + def self.pam_get_user(attributes = {}) + if attributes[:email] + resource = + if Devise.check_at_sign && !attributes[:email].index('@') + joins(:account).find_by(accounts: { username: attributes[:email] }) + else + find_by(email: attributes[:email]) + end + + if resource.blank? + resource = new(email: attributes[:email]) + if Devise.check_at_sign && !resource[:email].index('@') + resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" + end + end + resource + end + end + + def self.authenticate_with_pam(attributes = {}) + return nil unless Devise.pam_authentication + super + end + protected def send_devise_notification(notification, *args) diff --git a/app/validators/unreserved_username_validator.rb b/app/validators/unreserved_username_validator.rb index 44ea4359bb..c2311a89ab 100644 --- a/app/validators/unreserved_username_validator.rb +++ b/app/validators/unreserved_username_validator.rb @@ -8,7 +8,13 @@ class UnreservedUsernameValidator < ActiveModel::Validator private + def pam_controlled?(value) + return false unless Devise.pam_authentication && Devise.pam_controlled_service + Rpam2.account(Devise.pam_controlled_service, value).present? + end + def reserved_username?(value) + return true if pam_controlled?(value) return false unless Setting.reserved_usernames Setting.reserved_usernames.include?(value.downcase) end diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml index 5ef3de9762..d8fed9e776 100644 --- a/app/views/auth/passwords/edit.html.haml +++ b/app/views/auth/passwords/edit.html.haml @@ -1,14 +1,18 @@ - content_for :page_title do = t('auth.set_new_password') -= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| - = render 'shared/error_messages', object: resource - = f.input :reset_password_token, as: :hidden + = simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| + = render 'shared/error_messages', object: resource - = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + - if use_pam? || current_user.encrypted_password.present? + = f.input :reset_password_token, as: :hidden - .actions - = f.button :button, t('auth.set_new_password'), type: :submit + = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + + .actions + = f.button :button, t('auth.set_new_password'), type: :submit + - else + = t('simple_form.labels.defaults.pam_account') .form-footer= render 'auth/shared/links' diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 145f5cd9e1..102199f819 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -4,13 +4,16 @@ = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f| = render 'shared/error_messages', object: resource - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } - = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } - = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' } + - if !use_pam? || current_user.encrypted_password.present? + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' } - .actions - = f.button :button, t('generic.save_changes'), type: :submit + .actions + = f.button :button, t('generic.save_changes'), type: :submit + - else + = t('simple_form.labels.defaults.pam_account') %hr/ diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index a52b0053b3..3edb0d2d4f 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -5,7 +5,10 @@ = render partial: 'shared/og' = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| - = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + - if use_pam? + = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.username_or_email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') } + - else + = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } .actions diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 07912c28b8..f2f7f1ba33 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -30,6 +30,19 @@ Warden::Manager.before_logout do |_, warden| warden.cookies.delete('_session_id') end +module Devise + mattr_accessor :pam_authentication + @@pam_authentication = false + mattr_accessor :pam_controlled_service + @@pam_controlled_service = nil + + class Strategies::PamAuthenticatable + def valid? + super && ::Devise.pam_authentication + end + end +end + Devise.setup do |config| config.warden do |manager| manager.default_strategies(scope: :user).unshift :two_factor_authenticatable @@ -96,7 +109,7 @@ Devise.setup do |config| # given strategies, for example, `config.http_authenticatable = [:database]` will # enable it only for database authentication. The supported strategies are: # :database = Support basic authentication with authentication key + password - config.http_authenticatable = [:database] + config.http_authenticatable = [:pam, :database] # If 401 status code should be returned for AJAX requests. True by default. # config.http_authenticatable_on_xhr = true @@ -301,4 +314,23 @@ Devise.setup do |config| # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # PAM: only look for email field + config.usernamefield = nil + config.emailfield = "email" + + # authentication with pam possible + # if not enabled, all pam settings are ignored + #config.pam_authentication = true + # check if email is actually a username + config.check_at_sign = true + # suffix for email address generation (warning: without pam must provide email in the pam environment) + config.pam_default_suffix = "pam" + # name of the pam service + # pam "auth" section is evaluated + config.pam_default_service = "rpam" + # name of the pam service used for checking if an user can register + # pam "account" section is evaluated + # nil for allowing registration of pam names (not recommended) + config.pam_controlled_service = "rpam" end diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 3c5e467a2c..bb78ae21a0 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -53,6 +53,7 @@ de: severity: Gewichtung type: Importtyp username: Profilname + username_or_email: Profilname oder Email interactions: must_be_follower: Benachrichtigungen von Nicht-Folgenden blockieren must_be_following: Benachrichtigungen von Profilen blockieren, denen ich nicht folge diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 143daaa298..c56334d563 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -53,6 +53,7 @@ en: severity: Severity type: Import type username: Username + username_or_email: Username or Email interactions: must_be_follower: Block notifications from non-followers must_be_following: Block notifications from people you don't follow diff --git a/db/migrate/20180109143959_add_remember_token_to_users.rb b/db/migrate/20180109143959_add_remember_token_to_users.rb new file mode 100644 index 0000000000..662905bcbb --- /dev/null +++ b/db/migrate/20180109143959_add_remember_token_to_users.rb @@ -0,0 +1,5 @@ +class AddRememberTokenToUsers < ActiveRecord::Migration[5.1] + def change + add_column :users, :remember_token, :string, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d1722fa299..a411de20ff 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.define(version: 20180106000232) do +ActiveRecord::Schema.define(version: 20180109143959) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -486,6 +486,7 @@ ActiveRecord::Schema.define(version: 20180106000232) do t.boolean "disabled", default: false, null: false t.boolean "moderator", default: false, null: false t.bigint "invite_id" + t.string "remember_token" t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true