diff --git a/.env.template b/.env.template index d5a5ac7cf1..ee5bc8a5fe 100644 --- a/.env.template +++ b/.env.template @@ -22,6 +22,8 @@ ENJU_LEAF_DEFAULT_LOCALE=ja ENJU_LEAF_TIME_ZONE=Asia/Tokyo # ENJU_LEAF_STORAGE_BUCKET=enju-leaf # ENJU_LEAF_STORAGE_ENDPOINT=http://minio:9000 +ENJU_LEAF_2FA_ENCRYPTION_KEY= +ENJU_LEAF_ACTION_MAILER_DELIVERY_METHOD=test ENJU_LEAF_EXTRACT_TEXT= ENJU_LEAF_EXTRACT_FILESIZE_LIMIT=2097152 # ENJU_LEAF_RESOURCESYNC_BASE_URL=http://localhost:8080 diff --git a/.github/workflows/rubyonrails.yml b/.github/workflows/rubyonrails.yml index f11c038227..510352c22c 100644 --- a/.github/workflows/rubyonrails.yml +++ b/.github/workflows/rubyonrails.yml @@ -28,6 +28,8 @@ jobs: POSTGRES_USER: rails POSTGRES_PASSWORD: password CC_TEST_REPORTER_ID: c193cb8ea058a7d62fd62d6d05adaaf95f6bdf882c1039500b30b54494a36e52 + ENJU_LEAF_2FA_ENCRYPTION_KEY: 64aba18129eb855d71a785b0aca726dd5eb8f4104cc779e97f2868d9a8cf796105f4599d9320793a5dfb58cc3ccbe93b293d5db1a12cba05ab79d15d5710cb51 + SECRET_KEY_BASE: 4a54f07a6c5e604edac920225ab6f4d9a919edf8597f61ff85dbce0b3ab64433de86a54c192df1e242022c64108923d5382705a1e221c1ca39b79d902824d948 NODE_OPTIONS: --openssl-legacy-provider steps: - name: Checkout code diff --git a/Gemfile b/Gemfile index 16e13c0d0b..599a1426ed 100644 --- a/Gemfile +++ b/Gemfile @@ -98,6 +98,8 @@ gem 'kramdown' gem 'solid_queue' gem 'mission_control-jobs' gem 'acts-as-taggable-on' +gem 'devise-two-factor' +gem 'rqrcode' gem 'resync' # , github: 'nabeta/resync', branch: 'add-datetime' gem 'pretender' gem 'caxlsx' diff --git a/Gemfile.lock b/Gemfile.lock index 6fa476e093..d24bfb6f8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,6 +118,7 @@ GEM caxlsx_rails (0.6.4) actionpack (>= 3.1) caxlsx (>= 3.0) + chunky_png (1.4.0) climate_control (1.2.0) cocoon (1.2.15) concurrent-ruby (1.3.4) @@ -142,6 +143,11 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-two-factor (5.1.0) + activesupport (~> 7.0) + devise (~> 4.0) + railties (~> 7.0) + rotp (~> 6.0) diff-lcs (1.5.1) docile (1.4.1) dotenv (3.1.2) @@ -390,6 +396,11 @@ GEM xml-mapping_extensions (~> 0.4, >= 0.4.8) rexml (3.3.5) strscan + rotp (6.3.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rsolr (2.6.0) builder (>= 2.1.2) faraday (>= 0.9, < 3, != 2.0.0) @@ -538,6 +549,7 @@ DEPENDENCIES date_validator debug devise + devise-two-factor dotenv-rails factory_bot_rails (~> 6.4.0) faraday-multipart @@ -570,6 +582,7 @@ DEPENDENCIES rdf-vocab redis (>= 4.0.1) resync + rqrcode rspec-rails rspec_junit_formatter rss diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ac345ccbb9..c43e8fc738 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,6 +4,13 @@ class ApplicationController < ActionController::Base include EnjuEvent::Controller include EnjuSubject::Controller include Pundit::Authorization + before_action :configure_permitted_parameters, if: :devise_controller? after_action :verify_authorized, unless: :devise_controller? impersonates :user + + protected + + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) + end end diff --git a/app/controllers/otp_secrets_controller.rb b/app/controllers/otp_secrets_controller.rb new file mode 100644 index 0000000000..543f43cf29 --- /dev/null +++ b/app/controllers/otp_secrets_controller.rb @@ -0,0 +1,13 @@ +class OtpSecretsController < ApplicationController + before_action :skip_authorization + + def create + if current_user.validate_and_consume_otp!(params[:otp_attempt]) + current_user.update!( + otp_required_for_login: true + ) + end + + redirect_to two_factor_authentication_url + end +end diff --git a/app/controllers/two_factor_authentications_controller.rb b/app/controllers/two_factor_authentications_controller.rb new file mode 100644 index 0000000000..f1f124d739 --- /dev/null +++ b/app/controllers/two_factor_authentications_controller.rb @@ -0,0 +1,22 @@ +class TwoFactorAuthenticationsController < ApplicationController + before_action :skip_authorization + + def show + end + + def create + current_user.update!( + otp_secret: User.generate_otp_secret + ) + + redirect_to two_factor_authentication_url + end + + def destroy + current_user.update!( + otp_secret: nil, + otp_required_for_login: false + ) + redirect_to two_factor_authentication_url + end +end diff --git a/app/helpers/my_account_helper.rb b/app/helpers/my_account_helper.rb new file mode 100644 index 0000000000..0fe26f860e --- /dev/null +++ b/app/helpers/my_account_helper.rb @@ -0,0 +1,10 @@ +module MyAccountHelper + def otp_barcode(user = current_user, issuer = @library_group.display_name.localize) + qrcode = RQRCode::QRCode.new(current_user.otp_provisioning_uri(user.username, issuer: issuer)) + image_tag("data:image/png;base64,#{Base64.encode64(qrcode.as_png( + bit_depth: 1, + size: 240, + module_px_size: 12 + ).to_s)}") + end +end diff --git a/app/models/concerns/enju_seed/enju_user.rb b/app/models/concerns/enju_seed/enju_user.rb index 7cb042a043..c1106c1bff 100644 --- a/app/models/concerns/enju_seed/enju_user.rb +++ b/app/models/concerns/enju_seed/enju_user.rb @@ -24,9 +24,9 @@ module EnjuUser with_options if: :password_required? do |v| v.validates_presence_of :password v.validates_confirmation_of :password - v.validates_length_of :password, allow_blank: true, - within: Devise::password_length end + validates_length_of :password, allow_blank: true, + within: Devise::password_length before_validation :set_lock_information before_destroy :check_role_before_destroy diff --git a/app/models/user.rb b/app/models/user.rb index 6e2c5c5711..1e743054f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,7 +1,10 @@ class User < ApplicationRecord + devise :two_factor_authenticatable, :two_factor_backupable, + :otp_secret_encryption_key => ENV['ENJU_LEAF_2FA_ENCRYPTION_KEY'] + # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable - devise :database_authenticatable, :registerable, + devise :registerable, :recoverable, :rememberable, :lockable include EnjuSeed::EnjuUser include EnjuCirculation::EnjuUser @@ -14,18 +17,24 @@ class User < ApplicationRecord # # Table name: users # -# id :bigint not null, primary key -# email :string default(""), not null -# encrypted_password :string default(""), not null -# reset_password_token :string -# reset_password_sent_at :datetime -# remember_created_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# username :string -# expired_at :datetime -# failed_attempts :integer default(0) -# unlock_token :string -# locked_at :datetime -# confirmed_at :datetime +# id :bigint not null, primary key +# email :string default(""), not null +# encrypted_password :string default(""), not null +# reset_password_token :string +# reset_password_sent_at :datetime +# remember_created_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# username :string +# expired_at :datetime +# failed_attempts :integer default(0) +# unlock_token :string +# locked_at :datetime +# confirmed_at :datetime +# encrypted_otp_secret :string +# encrypted_otp_secret_iv :string +# encrypted_otp_secret_salt :string +# consumed_timestep :integer +# otp_required_for_login :boolean default(FALSE), not null +# otp_backup_codes :string is an Array # diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 0749cfcbf7..1897f6fedf 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -18,6 +18,11 @@ <%= f.password_field :password, autocomplete: "off" %> +