Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/graphql/mutations/users/mfa/totp/disable.rb
Original file line number Diff line number Diff line change
@@ -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'
Comment on lines +12 to +14

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use Types::Input::MfaInput here. You should be able to deactivate TOTP by verifying any configured MFA method (for example using a backup code in case you don't have access to your TOTP code generator)

You can look in the Users::Update mutation and service for that


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
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/audit_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/services/error_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
50 changes: 50 additions & 0 deletions app/services/users/mfa/totp/disable_service.rb
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +24 to +26

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is current_user.validate_mfa!(mfa)


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
1 change: 1 addition & 0 deletions docs/graphql/enum/errorcodeenum.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/graphql/mutation/usersmfatotpdisable.md
Original file line number Diff line number Diff line change
@@ -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 |
7 changes: 7 additions & 0 deletions spec/graphql/mutations/users/mfa/totp/disable_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions spec/services/users/mfa/totp/disable_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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