diff --git a/Gemfile b/Gemfile index 41107bb89..21ed3c894 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,6 @@ source 'https://rubygems.org' +gem 'active_model_otp', '~> 2.3', '>= 2.3.1' gem 'bcrypt', '~> 3.1.20' gem 'bootsnap', '~> 1.18.4' gem 'browser', '~> 5.3.1' @@ -17,6 +18,7 @@ gem 'net-imap', require: false gem 'net-pop', require: false gem 'net-smtp', require: false gem 'omniauth', '~> 2.1.2' +gem 'omniauth-identity', '~> 3.0', '>= 3.0.9' gem 'omniauth-oauth2', '~> 1.8.0' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'paper_trail', '~> 16.0.0' @@ -29,6 +31,7 @@ gem 'rails', '~> 7.1.0' gem 'rails-i18n', '~> 7.0.10' gem 'redis-rails', '~> 5.0.2' gem 'rest-client', '~> 2.1.0' +gem 'rqrcode', '~> 2.2' gem 'sassc-rails', '~> 2.1.2' gem 'sentry-rails', '~> 5.22', '>= 5.22.1' gem 'sentry-ruby', '~> 5.22', '>= 5.22.1' diff --git a/Gemfile.lock b/Gemfile.lock index b2671b368..920a97b88 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,6 +50,9 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + active_model_otp (2.3.4) + activemodel + rotp (~> 6.3.0) activejob (7.1.5.1) activesupport (= 7.1.5.1) globalid (>= 0.3.6) @@ -113,6 +116,7 @@ GEM capistrano (>= 3.9.0) capistrano-bundler sidekiq (>= 6.0) + chunky_png (1.4.0) coderay (1.1.3) colorize (0.8.1) concurrent-ruby (1.3.4) @@ -296,6 +300,9 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection + omniauth-identity (3.0.9) + bcrypt + omniauth omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) @@ -423,6 +430,11 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) rexml (3.4.0) + rotp (6.3.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rspec (3.12.0) rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) @@ -574,6 +586,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + active_model_otp (~> 2.3, >= 2.3.1) awesome_print bcrypt (~> 3.1.20) better_errors @@ -604,6 +617,7 @@ DEPENDENCIES net-pop net-smtp omniauth (~> 2.1.2) + omniauth-identity (~> 3.0, >= 3.0.9) omniauth-oauth2 (~> 1.8.0) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) paper_trail (~> 16.0.0) @@ -620,6 +634,7 @@ DEPENDENCIES rb-readline redis-rails (~> 5.0.2) rest-client (~> 2.1.0) + rqrcode (~> 2.2) rspec-rails rubocop (~> 1.50.2) rubocop-performance diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 1bf012c17..69f742fac 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -77,6 +77,20 @@ a { } } +.sofia-account-input { + min-width: 18rem; +} + +.qr-code { + max-width: 20rem; +} + +main { + // Wanneer er veel activiteiten zijn, gaat de quote onderaan de + // pagina onder de footer, dus voeg padding toe met de hoogte van de footer + padding-bottom: 48px; +} + .footer { position: absolute; bottom: 0; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7577e83b0..226c7e3b4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,8 @@ class ApplicationController < ActionController::Base include Pundit::Authorization + protect_from_forgery with: :exception, prepend: true + before_action :set_sentry_context before_action :set_paper_trail_whodunnit before_action :set_layout_flag @@ -34,4 +36,8 @@ def set_layout_flag @show_navigationbar = true @show_extras = true end + + def normalize_error_messages(full_messages) + full_messages.map(&:downcase).join(', ') + end end diff --git a/app/controllers/callbacks_controller.rb b/app/controllers/callbacks_controller.rb index 04e15c358..4ef1db9a2 100644 --- a/app/controllers/callbacks_controller.rb +++ b/app/controllers/callbacks_controller.rb @@ -6,7 +6,62 @@ def amber_oauth2 sign_in(:user, user) redirect_to user.roles.any? ? root_path : user_path(user.id) else - redirect_to root_path, flash: { error: 'Authentication failed' } + redirect_to root_path, flash: { error: 'Inloggen gefaald.' } end end + + def identity + user = User.from_omniauth_inspect(request.env['omniauth.auth']) + + if user.persisted? + if user.deactivated + render(json: { state: 'password_prompt', error_message: 'Uw account is gedeactiveerd, dus inloggen is niet mogelijk.' }) + else + check_identity_with_user(user, SofiaAccount.find_by(user_id: user.id)) + end + else + render(json: { state: 'password_prompt', error_message: 'Inloggen mislukt. De ingevulde gegevens zijn incorrect.' }) + end + end + + def check_identity_with_user(user, sofia_account) + if sofia_account&.otp_enabled + check_identity_with_otp(user, params[:verification_code]) + elsif sofia_account + # no OTP enabled + sign_in(:user, user) + render(json: { state: 'logged_in', redirect_url: user.roles.any? ? root_path : user_path(user.id) }) + else + # sofia_account does not exist, should not be possible + render(json: { state: 'password_prompt', error_message: 'Inloggen mislukt door een error. Herlaad de pagina en probeer het nog + een keer.
Werkt het na een paar keer proberen nog steeds niet? + Neem dan contact op met de ICT-commissie.' }) + end + end + + def check_identity_with_otp(user, one_time_password) + if !one_time_password + # OTP code not present, so request it + render(json: { state: 'otp_prompt' }) + elsif sofia_account.authenticate_otp(one_time_password) + # OTP code correct + sign_in(:user, user) + render(json: { state: 'logged_in', redirect_url: user.roles.any? ? root_path : user_path(user.id) }) + else + # OTP code incorrect + render(json: { state: 'otp_prompt', error_message: 'Inloggen mislukt. De authenticatiecode is incorrect.' }) + end + end + + def failure + error_message = 'Inloggen mislukt.' + if request.env['omniauth.error.strategy'].instance_of? OmniAuth::Strategies::Identity + error_message << if request.env['omniauth.error.type'].to_s == 'invalid_credentials' + ' De ingevulde gegevens zijn incorrect.' + else + " #{request.env['omniauth.error.type']}" + end + end + render(json: { state: 'password_prompt', error_message: }) + end end diff --git a/app/controllers/sofia_accounts_controller.rb b/app/controllers/sofia_accounts_controller.rb new file mode 100644 index 000000000..156e8c884 --- /dev/null +++ b/app/controllers/sofia_accounts_controller.rb @@ -0,0 +1,215 @@ +class SofiaAccountsController < ApplicationController + def omniauth_redirect_login + redirect_to '/sofia_accounts/login' + end + + def omniauth_redirect_register + redirect_to :root + end + + def create + user_id = params.require(:user_id) + + user = User.find_by(id: user_id) + validate_user(user) + + sofia_account = SofiaAccount.new(permitted_attributes.merge(user_id:)) + raise normalize_error_messages(sofia_account.errors.full_messages) unless sofia_account.save + + user.reload + user.activation_token = nil + user.activation_token_valid_till = nil + if params[:user] && params[:user][:email] + if user.email # should not happen as the form only shows the email field when the user has no email + sofia_account.destroy + raise 'u heeft al een e-mailadres, dus u moet dat veld leeg laten' + end + user.email = params[:user][:email] + end + unless user.save + sofia_account.destroy + raise normalize_error_messages(user.errors.full_messages) + end + + sign_in(:user, user) + redirect_to user.roles.any? ? root_path : user_path(user.id), flash: { success: 'Account geactiveerd!' } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Account activeren mislukt: #{e.message}." } + end + + def validate_user(user) + if !user + raise 'uw account bestaat niet' + elsif user.deactivated + raise 'uw account is gedeactiveerd' + elsif user.sofia_account + raise 'uw account is al geactiveerd' + end + + activation_token = params.require(:activation_token) + if user.activation_token != activation_token || user.activation_token_valid_till.try(:<, Time.zone.now) + new_activation_link_url = SofiaAccount.new_activation_link_url(user.id) + raise "de activatielink is verlopen of ongeldig. Een nieuwe activatielink kan worden aangevraagd via + #{new_activation_link_url}" + end + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.sofia_account.#{e.param}") + redirect_back_or_to :root, flash: { error: "Account activeren mislukt: #{param_name.downcase} is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Account activeren mislukt: #{e.message}." } + end + + def update_password + @sofia_account = SofiaAccount.find(params[:id]) + authorize @sofia_account + + begin + new_attributes = params.require(:sofia_account).permit(%i[password password_confirmation]) + + if !@sofia_account.authenticate(params.require(:sofia_account)[:old_password]) + raise 'het oude wachtwoord is fout of niet opgegeven' + elsif new_attributes[:password].blank? + # sofia_account.update(...) just does nothing instead of showing error, so we show error manually + raise 'wachtwoord moet opgegeven zijn' + elsif !@sofia_account.update(new_attributes) + raise normalize_error_messages(@sofia_account.errors.full_messages) + end + + redirect_to user_path(@sofia_account.user_id), flash: { success: 'Wachtwoord gewijzigd.' } + rescue ActionController::ParameterMissing => e + redirect_back_or_to :root, flash: { error: 'Wachtwoord wijzigen mislukt: sofia_account is niet aanwezig.' } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Wachtwoord wijzigen mislukt: #{e.message}." } + end + end + + def enable_otp + @sofia_account = SofiaAccount.find(params[:id]) + authorize @sofia_account + + begin + if @sofia_account.authenticate_otp(params.require(:verification_code)) + @sofia_account.update(otp_enabled: true) + flash[:success] = 'Two-factor-authenticatie aangezet!' + else + flash[:error] = 'Two-factor-authenticatie aanzetten mislukt: de verificatie token is ongeldig.' + end + + redirect_to user_path(@sofia_account.user_id) + rescue ActionController::ParameterMissing + redirect_back_or_to :root, flash: { error: 'Two-factor-authenticatie aanzetten mislukt: de verificatie token is niet aanwezig.' } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Two-factor-authenticatie aanzetten mislukt: #{e.message}." } + end + end + + def disable_otp + @sofia_account = SofiaAccount.find(params[:id]) + authorize @sofia_account + + @sofia_account.update(otp_enabled: false) + + redirect_to user_path(@sofia_account.user_id) + end + + def activate_account + @user_id = params[:user_id] + @activation_token = params[:activation_token] + @user = User.find_by(id: @user_id) + @request_email = @user&.email.nil? + @sofia_account = SofiaAccount.new + end + + def new_activation_link + user = User.find_by(id: params.require(:user_id)) + if !user + raise 'uw account bestaat niet' + elsif user.deactivated + raise 'uw account is gedeactiveerd' + elsif user.sofia_account + raise 'uw account is al geactiveerd' + elsif !user.email + raise 'uw account heeft geen emailadres' + end + + user.update(activation_token: SecureRandom.urlsafe_base64, activation_token_valid_till: 1.day.from_now) + UserMailer.new_activation_link_email(user).deliver_later + @message = 'Er is een nieuwe activatielink voor uw account verstuurd naar uw emailadres.' + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.sofia_account.#{e.param}") + @message = "Uw kunt geen nieuwe activatielink aanvragen: #{param_name.downcase} is niet aanwezig." + rescue StandardError => e + @message = "Uw kunt geen nieuwe activatielink aanvragen: #{e.message}." + end + + def forgot_password + sofia_account = SofiaAccount.find_by(username: params.require(:username)) + raise 'gebruikersnaam bestaat niet' unless sofia_account + + raise 'uw account heeft geen emailadres' unless sofia_account.user.email + + sofia_account.user.update(activation_token: SecureRandom.urlsafe_base64, activation_token_valid_till: 1.day.from_now) + UserMailer.forgot_password_email(sofia_account.user).deliver_later + redirect_to :root, flash: { success: 'Een link om uw wachtwoord te resetten is verstuurd naar uw emailadres.' } + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.sofia_account.#{e.param}") + redirect_back_or_to forgot_password_view_sofia_accounts_path, + flash: { error: "Wachtwoord reset aanvragen mislukt: #{param_name.downcase} is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to forgot_password_view_sofia_accounts_path, flash: { error: "Wachtwoord reset aanvragen mislukt: #{e.message}." } + end + + def reset_password_view + @activation_token = params[:activation_token] + @sofia_account = SofiaAccount.find_by(id: params.require(:id)) + raise 'uw account bestaat niet' unless @sofia_account + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.sofia_account.#{e.param}") + redirect_back_or_to :root, flash: { error: "Wachtwoord resetten mislukt: #{param_name.downcase} is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Wachtwoord resetten mislukt: #{e.message}." } + end + + def reset_password + sofia_account = SofiaAccount.find_by(id: params.require(:id)) + raise 'uw account bestaat niet' unless sofia_account + + user = sofia_account.user + activation_token = params.require(:activation_token) + if user.activation_token != activation_token || user.activation_token_valid_till.try(:<, Time.zone.now) + forgot_password_url = SofiaAccount.forgot_password_url + raise "de resetlink is verlopen of ongeldig. Een nieuwe resetlink kan worden aangevraagd via + #{forgot_password_url}" + end + + if params.require(:sofia_account)[:password].blank? + # sofia_account.update(...) just does nothing instead of showing error, so we show error manually + raise 'wachtwoord moet opgegeven zijn' + elsif !sofia_account.update(params.require(:sofia_account).permit(%i[password password_confirmation])) + raise normalize_error_messages(sofia_account.errors.full_messages) + end + + user.update( + activation_token: nil, + activation_token_valid_till: nil + ) + redirect_to login_sofia_accounts_path, flash: { success: 'Wachtwoord ingesteld!' } + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.sofia_account.#{e.param}") + redirect_back_or_to :root, flash: { error: "Wachtwoord resetten mislukt: #{param_name.downcase} is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Wachtwoord resetten mislukt: #{e.message}." } + end + + def login + return unless current_user + + redirect_to current_user.roles.any? ? root_path : user_path(current_user.id) + end + + private + + def permitted_attributes + params.require(:sofia_account).permit(%i[username password password_confirmation]) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index dadde077c..83c9c2334 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -8,21 +8,31 @@ def index # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @manual_users = User.manual.active.order(:name).select { |u| policy(u).show? } @amber_users = User.in_amber.active.order(:name).select { |u| policy(u).show? } - @inactive_users = User.inactive.order(:name).select { |u| policy(u).show? } + @sofia_account_users = User.sofia_account.active.order(:name).select { |u| policy(u).show? } + @not_activated_users = User.not_activated.order(:name).select { |u| policy(u).show? } + @deactivated_users = User.deactivated.order(:name).select { |u| policy(u).show? } @users_credits = User.calculate_credits @manual_users_json = @manual_users.as_json(only: %w[id name]) .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } + @sofia_account_users_json = @sofia_account_users.as_json(only: %w[id name]) + .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } + @amber_users_json = @amber_users.as_json(only: %w[id name]) .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } - @inactive_users_json = @inactive_users.as_json(only: %w[id name]) - .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } + @not_activated_users_json = @not_activated_users.as_json(only: %w[id name]) + .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } + + @deactivated_users_json = @deactivated_users.as_json(only: %w[id name]) + .each { |u| u['credit'] = @users_credits.fetch(u['id'], 0) } @new_user = User.new end + include ActiveModel::OneTimePassword::InstanceMethodsOnActivation + def show @user = User.includes(:credit_mutations, roles_users: :role).find(params[:id]) authorize @user @@ -30,6 +40,27 @@ def show @user_json = @user.to_json(only: %i[id name deactivated]) @new_mutation = CreditMutation.new(user: @user) + @sofia_account = SofiaAccount.find_by(user_id: @user.id) + if @sofia_account + qr_code = RQRCode::QRCode.new(@sofia_account.provisioning_uri(@sofia_account.username, + issuer: "Streepsysteem #{Rails.application.config.x.site_association}")) + @svg_qr_code = qr_code.as_svg( + color: '000', + shape_rendering: 'crispEdges', + module_size: 10, + standalone: true, + use_path: true, + viewbox: true, + svg_attributes: { + width: '100%', + height: 'auto', + class: 'qr-code' + } + ) + else + @sofia_account = SofiaAccount.new + end + @new_user = @user end @@ -114,6 +145,23 @@ def activities # rubocop:disable Metrics/AbcSize render json: activities_hash end + def update_with_sofia_account + @user = User.find(params[:id]) + authorize @user + + @sofia_account = @user.sofia_account + authorize @sofia_account + + if @user.update(params.require(:user).permit(%i[email] + (current_user.treasurer? ? %i[name deactivated] : []), + sofia_account_attributes: %i[id username])) + flash[:success] = 'Gegevens gewijzigd' + else + flash[:error] = "Gegevens wijzigen mislukt; #{@user.errors.full_messages.join(', ')}" + end + + redirect_to @user + end + private def send_slack_users_refresh_notification @@ -145,6 +193,6 @@ def find_or_create_user(user_json) # rubocop:disable Metrics/AbcSize, Metrics/Me end def permitted_attributes - params.require(:user).permit(%w[name email]) + params.require(:user).permit(%w[name email provider]) end end diff --git a/app/javascript/components/user/UsersTable.vue b/app/javascript/components/user/UsersTable.vue index 049919cce..c3eb498ab 100644 --- a/app/javascript/components/user/UsersTable.vue +++ b/app/javascript/components/user/UsersTable.vue @@ -1,6 +1,6 @@