diff --git a/app/graphql/mutations/users/mfa/totp/disable.rb b/app/graphql/mutations/users/mfa/totp/disable.rb new file mode 100644 index 000000000..601cc58e0 --- /dev/null +++ b/app/graphql/mutations/users/mfa/totp/disable.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Users + module Mfa + module Totp + class Disable < BaseMutation + description 'Disables TOTP MFA for the user' + + field :user, ::Types::UserType, null: true, description: 'The modified user' + + argument :current_totp, String, + required: true, + description: 'The current totp at the time to verify the mfa authentication device' + + def resolve(current_totp:) + ::Users::Mfa::Totp::DisableService.new( + current_authentication, + current_totp + ).execute.to_mutation_response(success_key: :user) + end + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index c0a46099a..43d846d4e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -41,6 +41,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Users::Mfa::BackupCodes::Rotate mount_mutation Mutations::Users::Mfa::Totp::GenerateSecret mount_mutation Mutations::Users::Mfa::Totp::ValidateSecret + mount_mutation Mutations::Users::Mfa::Totp::Disable mount_mutation Mutations::Users::Create mount_mutation Mutations::Users::Delete mount_mutation Mutations::Users::EmailVerification diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 3cf894e47..ce07640fd 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -44,6 +44,7 @@ class AuditEvent < ApplicationRecord user_deleted: 38, user_created: 39, project_module_configurations_updated: 40, + mfa_disabled: 41, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/services/error_code.rb b/app/services/error_code.rb index 421555c25..6183c10a3 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -34,6 +34,7 @@ def self.error_codes invalid_login_data: { description: 'Invalid login data provided' }, user_blocked: { description: 'The user is blocked from accessing the application' }, totp_secret_already_set: { description: 'This user already has TOTP set up' }, + totp_secret_not_set: { description: 'This user does not have TOTP set up' }, invalid_totp_secret: { description: 'The TOTP secret is invalid or cannot be verified' }, wrong_totp: { description: 'Invalid TOTP code provided' }, invalid_verification_code: { description: 'Invalid verification code provided' }, diff --git a/app/services/users/mfa/totp/disable_service.rb b/app/services/users/mfa/totp/disable_service.rb new file mode 100644 index 000000000..c0ce1c9cd --- /dev/null +++ b/app/services/users/mfa/totp/disable_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Users + module Mfa + module Totp + class DisableService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :current_user, :current_totp + + def initialize(current_authentication, current_totp) + @current_authentication = current_authentication + @current_user = current_authentication.user + @current_totp = current_totp + end + + def execute + unless Ability.allowed?(current_authentication, :manage_mfa, current_user) + return ServiceResponse.error(error_code: :missing_permission) + end + + return ServiceResponse.error(error_code: :totp_secret_not_set) if current_user.totp_secret.nil? + + totp = ROTP::TOTP.new(current_user.totp_secret) + + return ServiceResponse.error(error_code: :wrong_totp) unless totp.verify(current_totp) + + transactional do + current_user.totp_secret = nil + unless current_user.save + return ServiceResponse.error(message: 'Error while saving user', error_code: :invalid_user, + details: current_user.errors) + end + + AuditService.audit( + :mfa_disabled, + author_id: current_user.id, + entity: current_user, + details: { type: :totp }, + target: current_user + ) + + ServiceResponse.success(message: 'TOTP disabled', + payload: current_user) + end + end + end + end + end +end diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 26c983a46..550a9a224 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -100,6 +100,7 @@ Represents the available error responses | `SECONDARY_LEVEL_NOT_FOUND` | **Deprecated:** Outdated concept | | `TERTIARY_LEVEL_EXCEEDS_PARAMETERS` | **Deprecated:** Outdated concept | | `TOTP_SECRET_ALREADY_SET` | This user already has TOTP set up | +| `TOTP_SECRET_NOT_SET` | This user does not have TOTP set up | | `UNMODIFIABLE_FIELD` | The user is not permitted to modify this field | | `UNSUPPORTED_AUTHENTICATION` | The current authentication is not supported for this operation | | `USER_BLOCKED` | The user is blocked from accessing the application | diff --git a/docs/graphql/mutation/usersmfatotpdisable.md b/docs/graphql/mutation/usersmfatotpdisable.md new file mode 100644 index 000000000..d9b64c648 --- /dev/null +++ b/docs/graphql/mutation/usersmfatotpdisable.md @@ -0,0 +1,20 @@ +--- +title: usersMfaTotpDisable +--- + +Disables TOTP MFA for the user + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `currentTotp` | [`String!`](../scalar/string.md) | The current totp at the time to verify the mfa authentication device | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `user` | [`User`](../object/user.md) | The modified user | diff --git a/spec/graphql/mutations/users/mfa/totp/disable_spec.rb b/spec/graphql/mutations/users/mfa/totp/disable_spec.rb new file mode 100644 index 000000000..671a965b7 --- /dev/null +++ b/spec/graphql/mutations/users/mfa/totp/disable_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Users::Mfa::Totp::Disable do + it { expect(described_class.graphql_name).to eq('UsersMfaTotpDisable') } +end diff --git a/spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb b/spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb new file mode 100644 index 000000000..7f059202b --- /dev/null +++ b/spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'usersMfaTotpDisable Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: UsersMfaTotpDisableInput!) { + usersMfaTotpDisable(input: $input) { + #{error_query} + user { + id + } + } + } + QUERY + end + + let(:input) do + { + currentTotp: current_totp, + } + end + + let(:variables) { { input: input } } + let(:secret) { ROTP::Base32.random } + let(:current_user) { create(:user, totp_secret: secret) } + + context 'when totp is valid' do + let(:current_totp) { ROTP::TOTP.new(secret).now } + + it 'disables totp' do + mutate! + + expect(graphql_data_at(:users_mfa_totp_disable, :user, :id)).to be_present + expect(current_user.reload.totp_secret).to be_nil + end + end +end diff --git a/spec/services/users/mfa/totp/disable_service_spec.rb b/spec/services/users/mfa/totp/disable_service_spec.rb new file mode 100644 index 000000000..b7dcefbba --- /dev/null +++ b/spec/services/users/mfa/totp/disable_service_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Mfa::Totp::DisableService do + subject(:service_response) do + described_class.new(create_authentication(current_user), current_totp).execute + end + + context 'when user is nil' do + let(:current_user) { nil } + let(:current_totp) { nil } + + it { is_expected.not_to be_success } + it { expect(service_response.payload[:error_code]).to eq(:missing_permission) } + end + + context 'when user is valid but totp secret is not set' do + let(:current_user) { create(:user) } + let(:current_totp) { nil } + + it { is_expected.not_to be_success } + it { expect(service_response.payload[:error_code]).to eq(:totp_secret_not_set) } + end + + context 'when user is valid and totp secret is set but totp is wrong' do + let(:secret) { ROTP::Base32.random } + let(:current_user) { create(:user, totp_secret: secret) } + let(:current_totp) { '00000' } + + it { is_expected.not_to be_success } + it { expect(service_response.payload[:error_code]).to eq(:wrong_totp) } + it { is_expected.not_to create_audit_event } + end + + context 'when user is valid and totp is valid' do + let(:secret) { ROTP::Base32.random } + let(:current_user) { create(:user, totp_secret: secret) } + let(:current_totp) { ROTP::TOTP.new(secret).now } + + it { is_expected.to be_success } + + it { + expect do + service_response + end.to change { + current_user.reload.totp_secret + }.from(secret).to(nil) + } + + it { + is_expected.to create_audit_event( + :mfa_disabled, + author_id: current_user.id, + entity_type: 'User', + entity_id: current_user.id, + target_type: 'User', + target_id: current_user.id, + details: { type: 'totp' } + ) + } + end +end