From 2d61d532d8fb84ee63bb92b83c954a361aadd251 Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 21 Aug 2026 15:46:28 +0200 Subject: [PATCH 1/3] feat: implemented disable totp for a user --- .../mutations/users/mfa/totp/disable.rb | 26 ++++++++ app/graphql/types/mutation_type.rb | 1 + app/models/audit_event.rb | 1 + app/services/error_code.rb | 1 + .../users/mfa/totp/disable_service.rb | 50 +++++++++++++++ .../mutations/users/mfa/totp/disable_spec.rb | 7 +++ .../users/mfa/totp/disable_mutation_spec.rb | 43 +++++++++++++ .../users/mfa/totp/disable_service_spec.rb | 63 +++++++++++++++++++ 8 files changed, 192 insertions(+) create mode 100644 app/graphql/mutations/users/mfa/totp/disable.rb create mode 100644 app/services/users/mfa/totp/disable_service.rb create mode 100644 spec/graphql/mutations/users/mfa/totp/disable_spec.rb create mode 100644 spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb create mode 100644 spec/services/users/mfa/totp/disable_service_spec.rb 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/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 From 30a5b949eb5eb89910980687dcf83db4540cff1c Mon Sep 17 00:00:00 2001 From: Raphael Date: Fri, 21 Aug 2026 15:49:43 +0200 Subject: [PATCH 2/3] docs: regenerated --- docs/graphql/enum/errorcodeenum.md | 1 + docs/graphql/mutation/usersmfatotpdisable.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 docs/graphql/mutation/usersmfatotpdisable.md 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 | From 702fdf47a1f795e38d661e3766d1f7e7bc1f7eea Mon Sep 17 00:00:00 2001 From: Raphael Date: Sun, 23 Aug 2026 13:45:43 +0200 Subject: [PATCH 3/3] feat: used mfa instead --- .../mutations/users/mfa/totp/disable.rb | 8 ++--- .../users/mfa/totp/disable_service.rb | 12 ++++---- docs/graphql/mutation/usersmfatotpdisable.md | 2 +- .../users/mfa/totp/disable_mutation_spec.rb | 5 +++- .../users/mfa/totp/disable_service_spec.rb | 30 +++++++++++++++---- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/app/graphql/mutations/users/mfa/totp/disable.rb b/app/graphql/mutations/users/mfa/totp/disable.rb index 601cc58e0..bf6043425 100644 --- a/app/graphql/mutations/users/mfa/totp/disable.rb +++ b/app/graphql/mutations/users/mfa/totp/disable.rb @@ -9,14 +9,12 @@ class Disable < BaseMutation 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' + argument :mfa, Types::Input::MfaInput, required: true, description: 'The data of the mfa validation' - def resolve(current_totp:) + def resolve(mfa:) ::Users::Mfa::Totp::DisableService.new( current_authentication, - current_totp + mfa ).execute.to_mutation_response(success_key: :user) end end diff --git a/app/services/users/mfa/totp/disable_service.rb b/app/services/users/mfa/totp/disable_service.rb index c0ce1c9cd..026b9ab0f 100644 --- a/app/services/users/mfa/totp/disable_service.rb +++ b/app/services/users/mfa/totp/disable_service.rb @@ -6,12 +6,12 @@ module Totp class DisableService include Sagittarius::Database::Transactional - attr_reader :current_authentication, :current_user, :current_totp + attr_reader :current_authentication, :current_user, :mfa - def initialize(current_authentication, current_totp) + def initialize(current_authentication, mfa) @current_authentication = current_authentication @current_user = current_authentication.user - @current_totp = current_totp + @mfa = mfa end def execute @@ -21,9 +21,9 @@ def execute return ServiceResponse.error(error_code: :totp_secret_not_set) if current_user.totp_secret.nil? - totp = ROTP::TOTP.new(current_user.totp_secret) + mfa_passed, mfa_type = current_user.validate_mfa!(mfa) - return ServiceResponse.error(error_code: :wrong_totp) unless totp.verify(current_totp) + return ServiceResponse.error(error_code: :mfa_failed) unless mfa_passed transactional do current_user.totp_secret = nil @@ -36,7 +36,7 @@ def execute :mfa_disabled, author_id: current_user.id, entity: current_user, - details: { type: :totp }, + details: { type: mfa_type }, target: current_user ) diff --git a/docs/graphql/mutation/usersmfatotpdisable.md b/docs/graphql/mutation/usersmfatotpdisable.md index d9b64c648..8631b3d91 100644 --- a/docs/graphql/mutation/usersmfatotpdisable.md +++ b/docs/graphql/mutation/usersmfatotpdisable.md @@ -9,7 +9,7 @@ Disables TOTP MFA for the user | 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 | +| `mfa` | [`MfaInput!`](../input_object/mfainput.md) | The data of the mfa validation | ## Fields 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 index 7f059202b..0e3169882 100644 --- a/spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb +++ b/spec/requests/graphql/mutation/users/mfa/totp/disable_mutation_spec.rb @@ -22,7 +22,10 @@ let(:input) do { - currentTotp: current_totp, + mfa: { + type: 'TOTP', + value: current_totp, + }, } end diff --git a/spec/services/users/mfa/totp/disable_service_spec.rb b/spec/services/users/mfa/totp/disable_service_spec.rb index b7dcefbba..09bda164c 100644 --- a/spec/services/users/mfa/totp/disable_service_spec.rb +++ b/spec/services/users/mfa/totp/disable_service_spec.rb @@ -4,12 +4,12 @@ RSpec.describe Users::Mfa::Totp::DisableService do subject(:service_response) do - described_class.new(create_authentication(current_user), current_totp).execute + described_class.new(create_authentication(current_user), mfa).execute end context 'when user is nil' do let(:current_user) { nil } - let(:current_totp) { nil } + let(:mfa) { nil } it { is_expected.not_to be_success } it { expect(service_response.payload[:error_code]).to eq(:missing_permission) } @@ -17,7 +17,7 @@ context 'when user is valid but totp secret is not set' do let(:current_user) { create(:user) } - let(:current_totp) { nil } + let(:mfa) { nil } it { is_expected.not_to be_success } it { expect(service_response.payload[:error_code]).to eq(:totp_secret_not_set) } @@ -26,17 +26,35 @@ 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' } + let(:mfa) { { type: :totp, value: '00000' } } it { is_expected.not_to be_success } - it { expect(service_response.payload[:error_code]).to eq(:wrong_totp) } + it { expect(service_response.payload[:error_code]).to eq(:mfa_failed) } it { is_expected.not_to create_audit_event } end + context 'when user is valid and mfa via backup code is valid' do + let(:secret) { ROTP::Base32.random } + let(:current_user) { create(:user, totp_secret: secret) } + let(:mfa) { { type: :backup_code, value: 'valid-code' } } + + before { create(:backup_code, user: current_user, token: 'valid-code') } + + it { is_expected.to be_success } + + it { + expect do + service_response + end.to change { + current_user.reload.totp_secret + }.from(secret).to(nil) + } + 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 } + let(:mfa) { { type: :totp, value: ROTP::TOTP.new(secret).now } } it { is_expected.to be_success }