From 62cfb7265fc06edf47b39aee16063a07f3495bb8 Mon Sep 17 00:00:00 2001 From: Dario Pranjic Date: Mon, 22 Sep 2025 16:08:51 +0200 Subject: [PATCH] Implement email verification feature with associated services and mailers --- .../mutations/users/email_verification.rb | 20 +++++++ app/graphql/types/mutation_type.rb | 1 + app/mailers/user_mailer.rb | 10 ++++ app/models/audit_event.rb | 2 + app/models/user.rb | 4 ++ app/policies/namespace_policy.rb | 2 + app/policies/user_policy.rb | 2 + .../users/email_verification_send_service.rb | 45 ++++++++++++++++ .../users/email_verification_service.rb | 41 ++++++++++++++ app/services/users/register_service.rb | 8 +++ app/services/users/update_service.rb | 9 ++++ .../user_mailer/email_verification.html.erb | 1 + .../user_mailer/email_verification.text.erb | 1 + ...919201730_add_email_verified_at_to_user.rb | 7 +++ db/schema_migrations/20250919201730 | 1 + db/structure.sql | 1 + .../mutation/usersemailverification.md | 20 +++++++ .../users/email_verification_spec.rb | 7 +++ .../previews/application_mailer_preview.rb | 9 ++++ spec/mailers/previews/user_mailer_preview.rb | 9 ++++ .../mutation/users/verify_email_spec.rb | 54 +++++++++++++++++++ .../organizations/update_service_spec.rb | 8 +-- spec/services/runtimes/update_service_spec.rb | 8 +-- .../users/email_verification_service_spec.rb | 54 +++++++++++++++++++ spec/services/users/register_service_spec.rb | 26 +++++++++ spec/services/users/update_service_spec.rb | 13 +++++ spec/support/shared_examples/sends_email.rb | 4 +- 27 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 app/graphql/mutations/users/email_verification.rb create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/services/users/email_verification_send_service.rb create mode 100644 app/services/users/email_verification_service.rb create mode 100644 app/views/user_mailer/email_verification.html.erb create mode 100644 app/views/user_mailer/email_verification.text.erb create mode 100644 db/migrate/20250919201730_add_email_verified_at_to_user.rb create mode 100644 db/schema_migrations/20250919201730 create mode 100644 docs/graphql/mutation/usersemailverification.md create mode 100644 spec/graphql/mutations/users/email_verification_spec.rb create mode 100644 spec/mailers/previews/application_mailer_preview.rb create mode 100644 spec/mailers/previews/user_mailer_preview.rb create mode 100644 spec/requests/graphql/mutation/users/verify_email_spec.rb create mode 100644 spec/services/users/email_verification_service_spec.rb diff --git a/app/graphql/mutations/users/email_verification.rb b/app/graphql/mutations/users/email_verification.rb new file mode 100644 index 00000000..34160f2f --- /dev/null +++ b/app/graphql/mutations/users/email_verification.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module Users + class EmailVerification < BaseMutation + description 'Verify your email when changing it or signing up' + + field :user, Types::UserType, null: true, description: 'The user whose email was verified' + + argument :token, String, required: true, description: 'The email verification token' + + def resolve(token:) + ::Users::EmailVerificationService.new( + current_authentication, + token + ).execute.to_mutation_response(success_key: :user) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 22914406..5085ce4f 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -35,6 +35,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::EmailVerification mount_mutation Mutations::Users::Login mount_mutation Mutations::Users::Logout mount_mutation Mutations::Users::Register diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 00000000..b50a0386 --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class UserMailer < ApplicationMailer + def email_verification + @user = params[:user] + @verification_code = params[:verification_code] + + mail(to: @user.email, subject: 'Email verification') + end +end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 865c9d62..442f9136 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -35,6 +35,8 @@ class AuditEvent < ApplicationRecord flow_created: 31, flow_updated: 32, flow_deleted: 33, + email_verification_sent: 34, + email_verified: 35, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/models/user.rb b/app/models/user.rb index ca60b72a..2046772d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -55,4 +55,8 @@ def validate_mfa!(mfa) end [mfa_passed, mfa_type] end + + generates_token_for :email_verification, expires_in: 15.minutes do + email + end end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 3b391d17..4bc521c0 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -42,6 +42,8 @@ class NamespacePolicy < BasePolicy customizable_permission :delete_runtime customizable_permission :rotate_runtime_token customizable_permission :assign_role_projects + customizable_permission :verify_email + customizable_permission :send_verification_email end NamespacePolicy.prepend_extensions diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 77e91d3d..0baecbf7 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -17,5 +17,7 @@ class UserPolicy < BasePolicy enable :manage_mfa enable :update_user enable :update_attachment_avatar + enable :verify_email + enable :send_verification_email end end diff --git a/app/services/users/email_verification_send_service.rb b/app/services/users/email_verification_send_service.rb new file mode 100644 index 00000000..6f445b8e --- /dev/null +++ b/app/services/users/email_verification_send_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Users + class EmailVerificationSendService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :user + + def initialize(current_authentication, user) + @current_authentication = current_authentication + @user = user + end + + def execute + unless Ability.allowed?(current_authentication, :send_verification_email, user) + return ServiceResponse.error(message: 'Missing permission', payload: :missing_permission) + end + + transactional do |t| + user.email_verified_at = nil + unless user.save + t.rollback_and_return! ServiceResponse.error(message: 'Failed to set email to unverified', + payload: user.errors) + end + + UserMailer.with( + user: user, + verification_code: user.generate_token_for(:email_verification) + ).email_verification.deliver_later + + AuditService.audit( + :email_verification_sent, + author_id: current_authentication.user.id, + entity: user, + target: user, + details: { + email: user.email, + } + ) + + ServiceResponse.success(message: 'Successfully sent email verification', payload: user) + end + end + end +end diff --git a/app/services/users/email_verification_service.rb b/app/services/users/email_verification_service.rb new file mode 100644 index 00000000..f8a98c11 --- /dev/null +++ b/app/services/users/email_verification_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Users + class EmailVerificationService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :verification_code + + def initialize(current_authentication, verification_code) + @current_authentication = current_authentication + @verification_code = verification_code + end + + def execute + user = User.find_by_token_for(:email_verification, verification_code) + + unless Ability.allowed?(current_authentication, :verify_email, user) + return ServiceResponse.error(message: 'Missing permission', payload: :missing_permission) + end + + transactional do |t| + user.email_verified_at = Time.zone.now + unless user.save + t.rollback_and_return! ServiceResponse.error(message: 'Failed to set email to verified', payload: user.errors) + end + + AuditService.audit( + :email_verified, + author_id: current_authentication.user.id, + entity: user, + target: user, + details: { + email: user.email, + } + ) + + ServiceResponse.success(message: 'Successfully verified email', payload: user) + end + end + end +end diff --git a/app/services/users/register_service.rb b/app/services/users/register_service.rb index e178cc5c..3943f06a 100644 --- a/app/services/users/register_service.rb +++ b/app/services/users/register_service.rb @@ -28,6 +28,14 @@ def execute payload: user_session.errors) end + response = EmailVerificationSendService.new(Sagittarius::Authentication.new(:session, user_session), + user).execute + + unless response.success? + t.rollback_and_return! ServiceResponse.error(message: 'Failed to send verification email', + payload: response.payload) + end + AuditService.audit( :user_registered, author_id: user.id, diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index d5256708..a6abc2d2 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -60,6 +60,15 @@ def execute ) end + if params.key?(:email) + response = EmailVerificationSendService.new(current_authentication, user).execute + + unless response.success? + t.rollback_and_return! ServiceResponse.error(message: 'Failed to send verification email', + payload: response.payload) + end + end + AuditService.audit( :user_updated, author_id: current_authentication.user.id, diff --git a/app/views/user_mailer/email_verification.html.erb b/app/views/user_mailer/email_verification.html.erb new file mode 100644 index 00000000..c9f2daf8 --- /dev/null +++ b/app/views/user_mailer/email_verification.html.erb @@ -0,0 +1 @@ +

Click here to verify your email

diff --git a/app/views/user_mailer/email_verification.text.erb b/app/views/user_mailer/email_verification.text.erb new file mode 100644 index 00000000..0a10aa1b --- /dev/null +++ b/app/views/user_mailer/email_verification.text.erb @@ -0,0 +1 @@ +Click here to verify your email: https://code0.tech/verify?code=<%= @verification_code %> diff --git a/db/migrate/20250919201730_add_email_verified_at_to_user.rb b/db/migrate/20250919201730_add_email_verified_at_to_user.rb new file mode 100644 index 00000000..19689cf7 --- /dev/null +++ b/db/migrate/20250919201730_add_email_verified_at_to_user.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEmailVerifiedAtToUser < Code0::ZeroTrack::Database::Migration[1.0] + def change + add_column :users, :email_verified_at, :datetime_with_timezone + end +end diff --git a/db/schema_migrations/20250919201730 b/db/schema_migrations/20250919201730 new file mode 100644 index 00000000..57952dff --- /dev/null +++ b/db/schema_migrations/20250919201730 @@ -0,0 +1 @@ +f1a90f8b00e992db78b5b4d7dd3bbe0235ef9758e5c96b848a0c025df40e3da1 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2020d8da..86066e59 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -847,6 +847,7 @@ CREATE TABLE users ( updated_at timestamp with time zone NOT NULL, admin boolean DEFAULT false NOT NULL, totp_secret text, + email_verified_at timestamp with time zone, CONSTRAINT check_3bedaaa612 CHECK ((char_length(email) <= 255)), CONSTRAINT check_56606ce552 CHECK ((char_length(username) <= 50)), CONSTRAINT check_60346c5299 CHECK ((char_length(lastname) <= 50)), diff --git a/docs/graphql/mutation/usersemailverification.md b/docs/graphql/mutation/usersemailverification.md new file mode 100644 index 00000000..5c7331b0 --- /dev/null +++ b/docs/graphql/mutation/usersemailverification.md @@ -0,0 +1,20 @@ +--- +title: usersEmailVerification +--- + +Verify your email when changing it or signing up + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `token` | [`String!`](../scalar/string.md) | The email verification token | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../union/error.md) | Errors encountered during execution of the mutation. | +| `user` | [`User`](../object/user.md) | The user whose email was verified | diff --git a/spec/graphql/mutations/users/email_verification_spec.rb b/spec/graphql/mutations/users/email_verification_spec.rb new file mode 100644 index 00000000..581022ec --- /dev/null +++ b/spec/graphql/mutations/users/email_verification_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Users::EmailVerification do + it { expect(described_class.graphql_name).to eq('UsersEmailVerification') } +end diff --git a/spec/mailers/previews/application_mailer_preview.rb b/spec/mailers/previews/application_mailer_preview.rb new file mode 100644 index 00000000..6febbbf3 --- /dev/null +++ b/spec/mailers/previews/application_mailer_preview.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class ApplicationMailerPreview < ActionMailer::Preview + def test_mail + user = User.first || FactoryBot.create(:user) + ApplicationMailer.with(user: user).test_mail + end +end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb new file mode 100644 index 00000000..941c2eeb --- /dev/null +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + def email_verification + user = User.first || FactoryBot.create(:user) + UserMailer.with(user: user, verification_code: user.generate_token_for(:email_verification)).email_verification + end +end diff --git a/spec/requests/graphql/mutation/users/verify_email_spec.rb b/spec/requests/graphql/mutation/users/verify_email_spec.rb new file mode 100644 index 00000000..f357f746 --- /dev/null +++ b/spec/requests/graphql/mutation/users/verify_email_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'usersEmailVerification Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: user } + + let(:mutation) do + <<~QUERY + mutation($input: UsersEmailVerificationInput!) { + usersEmailVerification(input: $input) { + #{error_query} + user { + id + } + } + } + QUERY + end + + let(:user) { create(:user) } + + let(:input) do + { + token: user.generate_token_for(:email_verification), + } + end + + let(:variables) { { input: input } } + + context 'when token is valid' do + it 'creates runtime' do + expect(user.email_verified_at).to be_nil + mutate! + + expect(graphql_data_at(:users_email_verification, :user, :id)).to be_present + expect(user.reload.email_verified_at).not_to be_nil + + is_expected.to create_audit_event( + :email_verified, + author_id: user.id, + entity_id: user.id, + entity_type: 'User', + details: { + email: user.email, + }, + target_id: user.id, + target_type: 'User' + ) + end + end +end diff --git a/spec/services/organizations/update_service_spec.rb b/spec/services/organizations/update_service_spec.rb index 108e7014..3ae61a95 100644 --- a/spec/services/organizations/update_service_spec.rb +++ b/spec/services/organizations/update_service_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Organizations::UpdateService do subject(:service_response) { described_class.new(create_authentication(current_user), organization, params).execute } - shared_examples 'does not update' do + shared_examples 'user doesnt verify' do it { is_expected.to be_error } it 'does not update organization' do @@ -22,7 +22,7 @@ { name: generate(:organization_name) } end - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end context 'when params are invalid' do @@ -36,13 +36,13 @@ context 'when name is to long' do let(:params) { { name: generate(:organization_name) + ('*' * 50) } } - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end context 'when name is to short' do let(:params) { { name: 'a' } } - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end end diff --git a/spec/services/runtimes/update_service_spec.rb b/spec/services/runtimes/update_service_spec.rb index 25402b68..ebaf9ec0 100644 --- a/spec/services/runtimes/update_service_spec.rb +++ b/spec/services/runtimes/update_service_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Runtimes::UpdateService do subject(:service_response) { described_class.new(create_authentication(current_user), runtime, params).execute } - shared_examples 'does not update' do + shared_examples 'user doesnt verify' do it { is_expected.to be_error } it 'does not update organization' do @@ -22,7 +22,7 @@ { name: generate(:runtime_name) } end - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end context 'when params are invalid' do @@ -32,13 +32,13 @@ context 'when name is to long' do let(:params) { { name: generate(:runtime_name) + ('*' * 50) } } - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end context 'when name is to short' do let(:params) { { name: 'a' } } - it_behaves_like 'does not update' + it_behaves_like 'user doesnt verify' end end diff --git a/spec/services/users/email_verification_service_spec.rb b/spec/services/users/email_verification_service_spec.rb new file mode 100644 index 00000000..a8c4de3f --- /dev/null +++ b/spec/services/users/email_verification_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::EmailVerificationService do + subject(:service_response) do + described_class.new(create_authentication(current_user), authentication_token).execute + end + + let(:current_user) { create(:user) } + let(:authentication_token) { current_user&.generate_token_for(:email_verification) } + + shared_examples 'user doesnt verify' do + it { is_expected.to be_error } + + it { expect { service_response }.not_to create_audit_event } + it { expect(current_user&.reload&.email_verified_at).to be_nil } + end + + context 'when user does not exist' do + let(:current_user) { nil } + + it_behaves_like 'user doesnt verify' + end + + context 'when params are invalid' do + context 'when token is invalid' do + let(:authentication_token) { 'invalidtoken' } + + it { expect(service_response.payload).to eq(:missing_permission) } + + it_behaves_like 'user doesnt verify' + end + end + + context 'when user and params are valid' do + it { is_expected.to be_success } + it { expect(service_response.payload.reload).to be_valid } + + it 'updates user' do + expect { service_response }.to change { current_user.reload.email_verified_at }.from(nil) + end + + it do + is_expected.to create_audit_event( + :email_verified, + author_id: current_user.id, + entity_type: 'User', + details: { email: current_user.email }, + target_type: 'User' + ) + end + end +end diff --git a/spec/services/users/register_service_spec.rb b/spec/services/users/register_service_spec.rb index c240ac41..a012b29f 100644 --- a/spec/services/users/register_service_spec.rb +++ b/spec/services/users/register_service_spec.rb @@ -10,6 +10,19 @@ let(:email) { generate(:email) } let(:password) { generate(:password) } + it_behaves_like 'sends an email' do + let(:token) { SecureRandom.base64(10) } + before do + # rubocop:disable RSpec/AnyInstance -- No other way to mock this + allow_any_instance_of(User).to receive(:generate_token_for).with(:email_verification).and_return(token) + # rubocop:enable RSpec/AnyInstance + end + + let(:mailer_class) { UserMailer } + let(:mail_method) { :email_verification } + let(:mail_params) { { verification_code: token, user: instance_of(User) } } + end + it { is_expected.to be_success } it do @@ -47,6 +60,19 @@ it { is_expected.not_to be_success } it { expect(service_response.message).to eq('User is invalid') } it { expect { service_response }.not_to create_audit_event } + + it_behaves_like 'sends no email' do + let(:token) { SecureRandom.base64(10) } + before do + # rubocop:disable RSpec/AnyInstance -- No other way to mock this + allow_any_instance_of(User).to receive(:generate_token_for).with(:email_verification).and_return(token) + # rubocop:enable RSpec/AnyInstance + end + + let(:mailer_class) { UserMailer } + let(:mail_method) { :email_verification } + let(:mail_params) { { verification_code: token, user: instance_of(User) } } + end end context 'when user is invalid' do diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index 988e72f9..82433f30 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -13,6 +13,19 @@ it { is_expected.to be_error } it { expect { service_response }.not_to create_audit_event } + + it_behaves_like 'sends no email' do + let(:token) { SecureRandom.base64(10) } + before do + if current_user.present? + allow(current_user).to receive(:generate_token_for).with(:email_verification).and_return(token) + end + end + + let(:mailer_class) { UserMailer } + let(:mail_method) { :email_verification } + let(:mail_params) { { verification_code: token, user: current_user } } + end end context 'when user does not exist' do diff --git a/spec/support/shared_examples/sends_email.rb b/spec/support/shared_examples/sends_email.rb index 793d14be..5298a56a 100644 --- a/spec/support/shared_examples/sends_email.rb +++ b/spec/support/shared_examples/sends_email.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'sends an email' do let(:mailer_class) { nil } - let(:mai_method) { nil } + let(:mail_method) { nil } let(:mail_params) { nil } it do @@ -21,7 +21,7 @@ RSpec.shared_examples 'sends no email' do let(:mailer_class) { nil } - let(:mai_method) { nil } + let(:mail_method) { nil } let(:mail_params) { nil } it do