diff --git a/Gemfile b/Gemfile index a667c3383..cba1a40ca 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.0' gem 'bootsnap', '~> 1.12.0' gem 'browser', '~> 5.3.0' @@ -18,6 +19,7 @@ gem 'net-imap', require: false gem 'net-pop', require: false gem 'net-smtp', require: false gem 'omniauth', '~> 2.0.0' +gem "omniauth-identity", "~> 3.0", ">= 3.0.9" gem 'omniauth-oauth2', '~> 1.7.0' gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'paper_trail', '~> 14.0.0' @@ -30,6 +32,7 @@ gem 'rails', '~> 7.0.4', '>= 7.0.4.3' gem 'rails-i18n', '~> 7.0.0' gem 'redis-rails', '~> 5.0.0' gem 'rest-client', '~> 2.1.0' +gem 'rqrcode', '~> 2.2' gem 'sassc-rails', '~> 2.1.0' gem 'sentry-rails', '~> 5.5' gem 'sentry-ruby', '~> 5.5' diff --git a/Gemfile.lock b/Gemfile.lock index 1d59c3a6c..11176ff40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -46,6 +46,9 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) + active_model_otp (2.3.4) + activemodel + rotp (~> 6.3.0) activejob (7.0.4.3) activesupport (= 7.0.4.3) globalid (>= 0.3.6) @@ -98,6 +101,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.2.2) @@ -277,6 +281,9 @@ GEM hashie (>= 3.4.6) rack (>= 1.6.2, < 3) rack-protection + omniauth-identity (3.0.9) + bcrypt + omniauth omniauth-oauth2 (1.7.2) oauth2 (~> 1.4) omniauth (>= 1.9, < 3) @@ -388,6 +395,11 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) rexml (3.2.5) + 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) @@ -533,6 +545,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + active_model_otp (~> 2.3, >= 2.3.1) awesome_print bcrypt (~> 3.1.0) better_errors @@ -564,6 +577,7 @@ DEPENDENCIES net-pop net-smtp omniauth (~> 2.0.0) + omniauth-identity (~> 3.0, >= 3.0.9) omniauth-oauth2 (~> 1.7.0) omniauth-rails_csrf_protection (~> 1.0) paper_trail (~> 14.0.0) @@ -580,6 +594,7 @@ DEPENDENCIES rb-readline redis-rails (~> 5.0.0) rest-client (~> 2.1.0) + rqrcode (~> 2.2) rspec-rails rubocop (~> 1.30.0) rubocop-performance diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 1bf012c17..477f6a9d2 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -77,6 +77,14 @@ a { } } +.identity-input { + min-width: 18rem; +} + +.qr-code { + max-width: 20rem; +} + .footer { position: absolute; bottom: 0; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 298cc4118..4805ebc22 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,8 @@ class ApplicationController < ActionController::Base include Pundit + 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) + return full_messages.map{ |message| message.downcase }.join(", ") + end end diff --git a/app/controllers/callbacks_controller.rb b/app/controllers/callbacks_controller.rb index 04e15c358..d725803a2 100644 --- a/app/controllers/callbacks_controller.rb +++ b/app/controllers/callbacks_controller.rb @@ -6,7 +6,54 @@ 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? + identity = Identity.find_by(user_id: user.id) + if user.deactivated + render(json: { state: "password_prompt", error_message: "Uw account is gedeactiveerd, dus inloggen is niet mogelijk." }) + elsif identity && identity.otp_enabled + one_time_password = params[:verification_code] + if !one_time_password + # OTP code not present, so request it + render(json: { state: "otp_prompt" }) + elsif identity.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 + elsif identity + # no OTP enabled + sign_in(:user, user) + render(json: { state: "logged_in", redirect_url: user.roles.any? ? root_path : user_path(user.id) }) + else + # identity 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 + else + render(json: { state: "password_prompt", error_message: "Inloggen mislukt. De ingevulde gegevens zijn incorrect." }) + end + end + + def failure + error_message = "Inloggen mislukt." + if request.env['omniauth.error.strategy'].instance_of? OmniAuth::Strategies::Identity + if request.env['omniauth.error.type'].to_s == "invalid_credentials" + error_message << " De ingevulde gegevens zijn incorrect." + else + error_message << " #{request.env['omniauth.error.type'].to_s}" + end + render(json: { state: "password_prompt", error_message: error_message }) + else + render(json: { state: "password_prompt", error_message: error_message }) end end end diff --git a/app/controllers/identities_controller.rb b/app/controllers/identities_controller.rb new file mode 100644 index 000000000..30ad2cb28 --- /dev/null +++ b/app/controllers/identities_controller.rb @@ -0,0 +1,208 @@ +class IdentitiesController < ApplicationController + + def omniauth_redirect_login + redirect_to '/identities/login' + end + + def omniauth_redirect_register + redirect_to :root + end + + def create + begin + user_id = params.require(:user_id) + + user = User.find_by(id: user_id) + if !user + raise "uw account bestaat niet" + elsif user.deactivated + raise "uw account is gedeactiveerd" + elsif user.identity + 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 = Identity.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 + + identity = Identity.new(permitted_attributes.merge(user_id: user_id)) + if !identity.save + raise normalize_error_messages(identity.errors.full_messages) + end + + 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 + identity.destroy + raise "u heeft al een e-mailadres, dus u moet dat veld leeg laten" + end + user.email = params[:user][:email] + end + if !user.save + identity.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 ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.identity.#{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 + end + + def update_password + @identity = Identity.find(params[:id]) + authorize @identity + + begin + new_attributes = params.require(:identity).permit(%i[password password_confirmation]) + + if !@identity.authenticate(params.require(:identity)[:old_password]) + raise "het oude wachtwoord is fout of niet opgegeven" + elsif new_attributes[:password].blank? # identity.update(...) just does nothing instead of showing error, so we show error manually + raise "wachtwoord moet opgegeven zijn" + elsif !@identity.update(new_attributes) + raise normalize_error_messages(@identity.errors.full_messages) + end + + redirect_to user_path(@identity.user_id), flash: { success: "Wachtwoord gewijzigd." } + rescue ActionController::ParameterMissing => e + redirect_back_or_to :root, flash: { error: "Wachtwoord wijzigen mislukt: identity is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to :root, flash: { error: "Wachtwoord wijzigen mislukt: #{e.message}." } + end + end + + def enable_otp + @identity = Identity.find(params[:id]) + authorize @identity + + if @identity.authenticate_otp(params.require(:verification_code)) + @identity.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(@identity.user_id) + end + + def disable_otp + @identity = Identity.find(params[:id]) + authorize @identity + + @identity.update(otp_enabled: false) + + redirect_to user_path(@identity.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 + @identity = Identity.new() + end + + def new_activation_link + begin + 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.identity + 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.identity.#{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 + end + + def forgot_password + begin + identity = Identity.find_by(username: params.require(:username)) + if !identity + raise "uw gebruikersnaam bestaat niet" + end + + if !identity.user.email + raise "uw account heeft geen emailadres" + end + + identity.user.update(activation_token: SecureRandom.urlsafe_base64, activation_token_valid_till: 1.day.from_now) + UserMailer.forgot_password_email(identity.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.identity.#{e.param}") + redirect_back_or_to forgot_password_view_identities_path, flash: { error: "Account activeren mislukt: #{param_name.downcase} is niet aanwezig." } + rescue StandardError => e + redirect_back_or_to forgot_password_view_identities_path, flash: { error: "Account activeren mislukt: #{e.message}." } + end + end + + def reset_password_view + @activation_token = params[:activation_token] + end + + def reset_password + begin + identity = Identity.find_by(id: params.require(:id)) + if !identity + raise "uw account bestaat niet" + end + + user = identity.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 = Identity.forgot_password_url + raise "de resetlink is verlopen of ongeldig. Een nieuwe resetlink kan worden aangevraagd via #{forgot_password_url}" + end + + if params.require(:identity)[:password].blank? # identity.update(...) just does nothing instead of showing error, so we show error manually + raise "wachtwoord moet opgegeven zijn" + elsif !identity.update(params.require(:identity).permit(%i[password password_confirmation])) + raise normalize_error_messages(identity.errors.full_messages) + end + + user.update( + activation_token: nil, + activation_token_valid_till: nil + ) + redirect_to login_identities_path, flash: { success: 'Wachtwoord ingesteld!' } + rescue ActionController::ParameterMissing => e + param_name = I18n.t("activerecord.attributes.identity.#{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 + end + + def login + if current_user + redirect_to current_user.roles.any? ? root_path : user_path(current_user.id) + end + end + + private + + def permitted_attributes + params.require(:identity).permit(%i[username password password_confirmation]) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7255bf066..afcbf822b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,28 +1,38 @@ class UsersController < ApplicationController # rubocop:disable Metrics/ClassLength - before_action :authenticate_user! + before_action :authenticate_user!, except: %i[activate_account] - after_action :verify_authorized + after_action :verify_authorized, except: %i[activate_account] def index # rubocop:disable Metrics/AbcSize, Metrics/MethodLength authorize User @manual_users = User.manual.active.order(:name) + @identity_users = User.identity.active.order(:name) @amber_users = User.in_amber.active.order(:name) - @inactive_users = User.inactive.order(:name) + @not_activated_users = User.not_activated.order(:name) + @deactivated_users = User.deactivated.order(:name) @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) } + @identity_users_json = @identity_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]) + @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,26 @@ def show @user_json = @user.to_json(only: %i[id name deactivated]) @new_mutation = CreditMutation.new(user: @user) + @identity = Identity.find_by(user_id: @user.id) + if @identity + qr_code = RQRCode::QRCode.new(@identity.provisioning_uri(@identity.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 + @identity = Identity.new + end + @new_user = @user end @@ -114,6 +144,39 @@ def activities # rubocop:disable Metrics/AbcSize render json: activities_hash end + def activate_account + user = User.find(params.require(:id)) + authorize user + + if params[:activation_token] != nil + activation_token = params.require(:activation_token) + unless user.activation_token == activation_token && + user.activation_token_valid_till.try(:>, Time.zone.now) + return head :not_found + end + else + return head :not_found + end + @identity = Identity.new(user: user) + end + + def update_with_identity + @user = User.find(params[:id]) + authorize @user + + @identity = @user.identity + authorize @identity + + if @user.update(params.require(:user).permit(%i[email] + (current_user.treasurer? ? %i[name deactivated] : []), identity_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 +208,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 cd648a70b..436d61e99 100644 --- a/app/javascript/components/user/UsersTable.vue +++ b/app/javascript/components/user/UsersTable.vue @@ -1,6 +1,6 @@