Skip to content
Merged
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
14 changes: 11 additions & 3 deletions app/graphql/mutations/users/mfa/totp/generate_secret.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ module Totp
class GenerateSecret < BaseMutation
description 'Generates an encrypted totp secret'

field :secret, String, null: true, description: 'The created and signed secret'
field :secret, String, null: true, description: 'The created secret'
field :signed_secret, String, null: true, description: 'The created and signed secret'

def resolve
::Users::Mfa::Totp::GenerateSecretService.new(current_authentication).execute
.to_mutation_response(success_key: :secret)
response = ::Users::Mfa::Totp::GenerateSecretService.new(current_authentication).execute

return response.to_mutation_response unless response.success?

{
secret: response.payload[:secret],
signed_secret: response.payload[:signed_secret],
errors: [],
}
end
end
end
Expand Down
3 changes: 3 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class User < ApplicationRecord

validates :firstname, length: { maximum: 50 }
validates :lastname, length: { maximum: 50 }

encrypts :totp_secret

validates :totp_secret, length: { maximum: 32 }

has_many :backup_codes, inverse_of: :user
Expand Down
8 changes: 6 additions & 2 deletions app/services/users/mfa/totp/generate_secret_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ def execute
totp_secret = ROTP::Base32.random

ServiceResponse.success(message: 'TOTP secret generated',
payload: Rails.application.message_verifier(:totp_secret)
.generate(totp_secret))
payload: {
signed_secret: Rails.application
.message_verifier(:totp_secret)
.generate(totp_secret, expires_in: 30.minutes),
secret: totp_secret,
})
end
end
end
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20260726000000_encrypt_totp_secret_on_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class EncryptTotpSecretOnUsers < Code0::ZeroTrack::Database::Migration[1.0]
def up
remove_index :users, :totp_secret, name: 'index_users_on_totp_secret'
end

def down
add_index :users, :totp_secret, unique: true, where: 'totp_secret IS NOT NULL',
name: 'index_users_on_totp_secret'
end
end
1 change: 1 addition & 0 deletions db/schema_migrations/20260726000000
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3152386744f06e994087067a6e0367481df2ba73f4cff9a67401074b691f3833
2 changes: 0 additions & 2 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1703,8 +1703,6 @@ CREATE UNIQUE INDEX "index_users_on_LOWER_email" ON users USING btree (lower(ema

CREATE UNIQUE INDEX "index_users_on_LOWER_username" ON users USING btree (lower(username));

CREATE UNIQUE INDEX index_users_on_totp_secret ON users USING btree (totp_secret) WHERE (totp_secret IS NOT NULL);

ALTER TABLE ONLY node_parameters
ADD CONSTRAINT fk_rails_0d79310cfa FOREIGN KEY (node_function_id) REFERENCES node_functions(id) ON DELETE CASCADE;

Expand Down
3 changes: 2 additions & 1 deletion docs/graphql/mutation/usersmfatotpgeneratesecret.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ Generates an encrypted totp secret
|------|------|-------------|
| `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. |
| `secret` | [`String`](../scalar/string.md) | The created and signed secret |
| `secret` | [`String`](../scalar/string.md) | The created secret |
| `signedSecret` | [`String`](../scalar/string.md) | The created and signed secret |
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
usersMfaTotpGenerateSecret(input: {}) {
#{error_query}
secret
signedSecret
}
}
QUERY
Expand All @@ -25,7 +26,13 @@

it 'generates secret' do
mutate!
expect(graphql_data_at(:users_mfa_totp_generate_secret, :secret)).to be_present
secret = graphql_data_at(:users_mfa_totp_generate_secret, :secret)
signed_secret = graphql_data_at(:users_mfa_totp_generate_secret, :signed_secret)

expect(secret).to be_present

signed_totp = Rails.application.message_verifier(:totp_secret).verified(signed_secret)
expect(signed_totp).to eq(secret)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
context 'when user is valid' do
let(:current_user) { create(:user) }
let(:secret) { ROTP::Base32.random }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret) }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret, expires_in: 30.minutes) }
let(:current_totp) { ROTP::TOTP.new(secret).now }

it 'validates secret and enables totp' do
Expand Down
24 changes: 21 additions & 3 deletions spec/services/users/mfa/totp/generate_secret_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,31 @@
end

context 'when totp secret is not set' do
include ActiveSupport::Testing::TimeHelpers

let(:current_user) { create(:user) }

it { is_expected.to be_success }

it 'is valid totp secret' do
totp = ROTP::TOTP.new(service_response.payload.split('--').first)
expect(totp.secret.length).to eq(48)
it 'returns a valid totp secret' do
secret = service_response.payload[:secret]
totp = ROTP::TOTP.new(secret)
expect(totp.now).to be_present
end

it 'returns a matching secret' do
signed_secret = Rails.application.message_verifier(:totp_secret).verified(
service_response.payload[:signed_secret]
)
expect(signed_secret).to eq(service_response.payload[:secret])
end

it 'returns a signed secret that expires' do
signed_secret = service_response.payload[:signed_secret]

travel_to 31.minutes.from_now do
expect(Rails.application.message_verifier(:totp_secret).verified(signed_secret)).to be_nil
end
end
end
end
Expand Down
24 changes: 22 additions & 2 deletions spec/services/users/mfa/totp/validate_secret_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,37 @@
context 'when user and secret is valid but totp is not' do
let(:current_user) { create(:user) }
let(:secret) { ROTP::Base32.random }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret) }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret, expires_in: 30.minutes) }
let(:current_totp) { '00000' }

it { is_expected.not_to be_success }
it { is_expected.not_to create_audit_event }
end

context 'when signed secret has expired' do
include ActiveSupport::Testing::TimeHelpers

# rubocop:disable RSpec/LetSetup -- referenced in the subject
let!(:current_user) { create(:user) }
let!(:secret) { ROTP::Base32.random }
let!(:signed_secret) do
Rails.application.message_verifier(:totp_secret).generate(secret, expires_in: 30.minutes)
end
let!(:current_totp) { ROTP::TOTP.new(secret).now }
# rubocop:enable RSpec/LetSetup

it 'returns invalid_totp_secret error' do
travel_to 31.minutes.from_now do
expect(service_response).not_to be_success
expect(service_response.payload[:error_code]).to eq(:invalid_totp_secret)
end
end
end

context 'when user is valid and secret is valid and totp is valid' do
let(:current_user) { create(:user) }
let(:secret) { ROTP::Base32.random }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret) }
let(:signed_secret) { Rails.application.message_verifier(:totp_secret).generate(secret, expires_in: 30.minutes) }
let(:current_totp) { ROTP::TOTP.new(secret).now }

it { is_expected.to be_success }
Expand Down