From 4f94ad4a75225ae05a5a0550c2d92eb72b03240e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 7 Apr 2026 13:45:17 +0530 Subject: [PATCH] feat: ensure signup verification [UPM-14] (#13858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, signing up gave immediate access to the app. Now, unconfirmed users are redirected to a verification page where they can resend the confirmation email. - After signup, the user is routed to `/auth/verify-email` instead of the dashboard - After login, unconfirmed users are redirected to the verification page - The dashboard route guard catches unconfirmed users and redirects them - `active_for_authentication?` is removed from the sessions controller so unconfirmed users can authenticate — the frontend gates access instead - If the user visits the verification page after already confirming, they're automatically redirected to the dashboard - No session is issued until the user is verified
Demo

#### Fresh Signup https://github.com/user-attachments/assets/abb735e5-7c8e-44a2-801c-96d9e4823e51 #### Google Fresh Signup https://github.com/user-attachments/assets/ab9e389a-a604-4a9d-b492-219e6d94ee3f #### Create new account from Dashboard https://github.com/user-attachments/assets/c456690d-1946-4e0b-834b-ad8efcea8369

--------- Co-authored-by: Muhsin Keloth --- app/controllers/api/v1/accounts_controller.rb | 23 +++- .../auth/resend_confirmations_controller.rb | 18 +++ .../dashboard/i18n/locale/en/signup.json | 9 +- app/javascript/v3/api/auth.js | 8 +- .../auth/signup/components/Signup/Form.vue | 8 +- .../v3/views/auth/verify-email/Index.vue | 110 ++++++++++++++++++ app/javascript/v3/views/routes.js | 10 ++ config/initializers/rack_attack.rb | 14 ++- config/routes.rb | 2 + .../api/v1/accounts_controller_spec.rb | 52 ++++++++- .../resend_confirmations_controller_spec.rb | 74 ++++++++++++ 11 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 app/controllers/auth/resend_confirmations_controller.rb create mode 100644 app/javascript/v3/views/auth/verify-email/Index.vue create mode 100644 spec/controllers/auth/resend_confirmations_controller_spec.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 3e513a4b2..2d14fe7ca 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -31,8 +31,18 @@ class Api::V1::AccountsController < Api::BaseController user: current_user ).perform if @user - send_auth_headers(@user) - render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + # Authenticated users (dashboard "add account") and api_only signups + # need the full response with account_id. API-only deployments have no + # frontend to handle the email confirmation flow, so they need auth + # tokens to proceed. + # Unauthenticated web signup returns only the email — no session is + # created until the user confirms via the email link. + if current_user || api_only_signup? + send_auth_headers(@user) + render 'api/v1/accounts/create', format: :json, locals: { resource: @user } + else + render json: { email: @user.email } + end else render_error_response(CustomExceptions::Account::SignupFailed.new({})) end @@ -103,6 +113,15 @@ class Api::V1::AccountsController < Api::BaseController raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled? end + def api_only_signup? + # CW_API_ONLY_SERVER is the canonical flag for API-only deployments. + # ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose. + # Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get + # typecasts it to boolean, coercing 'api_only' to true. + ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || + InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only' + end + def validate_captcha raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? end diff --git a/app/controllers/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -0,0 +1,18 @@ +# Unauthenticated endpoint for resending confirmation emails during signup. +# This is a standalone controller (not on DeviseOverrides::ConfirmationsController) +# because OmniAuth middleware intercepts all POST /auth/* routes as provider +# callbacks, and Devise controller filters cause 307 redirects for custom actions. +# Inherits from ActionController::API to avoid both issues entirely. +# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha. +class Auth::ResendConfirmationsController < ActionController::API + def create + return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid? + + email = params[:email] + return head(:ok) unless email.is_a?(String) + + user = User.from_email(email.strip.downcase) + user&.send_confirmation_instructions unless user&.confirmed? + head :ok + end +end diff --git a/app/javascript/dashboard/i18n/locale/en/signup.json b/app/javascript/dashboard/i18n/locale/en/signup.json index 4a90fd322..238a1f061 100644 --- a/app/javascript/dashboard/i18n/locale/en/signup.json +++ b/app/javascript/dashboard/i18n/locale/en/signup.json @@ -45,6 +45,13 @@ "ERROR_MESSAGE": "Could not connect to Woot server. Please try again." }, "SUBMIT": "Create account", - "HAVE_AN_ACCOUNT": "Already have an account?" + "HAVE_AN_ACCOUNT": "Already have an account?", + "VERIFY_EMAIL": { + "TITLE": "Check your inbox", + "DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.", + "RESEND": "Resend verification email", + "RESEND_SUCCESS": "Verification email sent. Please check your inbox.", + "RESEND_ERROR": "Could not send verification email. Please try again." + } } } diff --git a/app/javascript/v3/api/auth.js b/app/javascript/v3/api/auth.js index cccd9468e..c4ef40b1c 100644 --- a/app/javascript/v3/api/auth.js +++ b/app/javascript/v3/api/auth.js @@ -57,7 +57,6 @@ export const register = async creds => { password: creds.password, h_captcha_client_response: creds.hCaptchaClientResponse, }); - setAuthCredentials(response); return response.data; } catch (error) { throwErrorMessage(error); @@ -65,6 +64,13 @@ export const register = async creds => { return null; }; +export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => { + return wootAPI.post('resend_confirmation', { + email, + h_captcha_client_response: hCaptchaClientResponse, + }); +}; + export const verifyPasswordToken = async ({ confirmationToken }) => { try { const response = await wootAPI.post('auth/confirmation', { diff --git a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue index cc6606dad..89d1e58f6 100644 --- a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue +++ b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue @@ -4,8 +4,8 @@ import { useVuelidate } from '@vuelidate/core'; import { required, minLength, email } from '@vuelidate/validators'; import { useStore } from 'vuex'; import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; import { useAlert } from 'dashboard/composables'; -import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; import VueHcaptcha from '@hcaptcha/vue3-hcaptcha'; import FormInput from '../../../../../components/Form/Input.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -19,6 +19,7 @@ const MIN_PASSWORD_LENGTH = 6; const store = useStore(); const { t } = useI18n(); +const router = useRouter(); const hCaptcha = ref(null); const isPasswordFocused = ref(false); @@ -76,7 +77,10 @@ const performRegistration = async () => { isSignupInProgress.value = true; try { await register(credentials); - window.location = DEFAULT_REDIRECT_URL; + router.push({ + name: 'auth_verify_email', + state: { email: credentials.email }, + }); } catch (error) { const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE'); if (globalConfig.value.hCaptchaSiteKey) { diff --git a/app/javascript/v3/views/auth/verify-email/Index.vue b/app/javascript/v3/views/auth/verify-email/Index.vue new file mode 100644 index 000000000..5d9ad1233 --- /dev/null +++ b/app/javascript/v3/views/auth/verify-email/Index.vue @@ -0,0 +1,110 @@ + + + diff --git a/app/javascript/v3/views/routes.js b/app/javascript/v3/views/routes.js index 1be56dcb3..11fbcb0f3 100644 --- a/app/javascript/v3/views/routes.js +++ b/app/javascript/v3/views/routes.js @@ -5,6 +5,7 @@ import SamlLogin from './login/Saml.vue'; import Signup from './auth/signup/Index.vue'; import ResetPassword from './auth/reset/password/Index.vue'; import Confirmation from './auth/confirmation/Index.vue'; +import VerifyEmail from './auth/verify-email/Index.vue'; import PasswordEdit from './auth/password/Edit.vue'; export default [ @@ -48,6 +49,15 @@ export default [ redirectUrl: route.query.route_url, }), }, + { + path: frontendURL('auth/verify-email'), + name: 'auth_verify_email', + component: VerifyEmail, + meta: { ignoreSession: true }, + props: () => ({ + email: window.history.state?.email || '', + }), + }, { path: frontendURL('auth/password/edit'), name: 'auth_password_edit', diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index db7be0e43..15e78af9b 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -120,8 +120,20 @@ class Rack::Attack end end - ## Resend confirmation throttling + ## Resend confirmation throttling (unauthenticated) throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req| + req.ip if req.path_without_extentions == '/resend_confirmation' && req.post? + end + + throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req| + if req.path_without_extentions == '/resend_confirmation' && req.post? + email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence + email.to_s.downcase.gsub(/\s+/, '') + end + end + + ## Resend confirmation throttling (authenticated) + throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req| req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post? end diff --git a/config/routes.rb b/config/routes.rb index 3e868d6d8..31453b157 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,8 @@ Rails.application.routes.draw do omniauth_callbacks: 'devise_overrides/omniauth_callbacks' }, via: [:get, :post] + post 'resend_confirmation', to: 'auth/resend_confirmations#create' + ## renders the frontend paths only if its not an api only server if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) root to: 'api#index' diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index d76f22187..d93503418 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -26,8 +26,8 @@ RSpec.describe 'Accounts API', type: :request do expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password])) expect(account_builder).to have_received(:perform) - expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid') - expect(response.body).to include('en') + expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid') + expect(response.parsed_body['email']).to eq(email) end end @@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do as: :json expect(ChatwootCaptcha).to have_received(:new).with('123') - expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid') - expect(response.body).to include('en') + expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid') + expect(response.parsed_body['email']).to eq(email) end end @@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do end end + context 'when an authenticated user creates a second account' do + let(:existing_user) { create(:user, password: 'Password1!') } + + it 'returns the full response with account_id' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + post api_v1_accounts_url, + params: { account_name: 'Second Account', email: existing_user.email, + user_full_name: existing_user.name, password: 'Password1!' }, + headers: existing_user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body.dig('data', 'account_id')).to be_present + end + end + end + context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do it 'responds 404 on requests' do params = { account_name: 'test', email: email, user_full_name: user_full_name } @@ -105,7 +122,17 @@ RSpec.describe 'Accounts API', type: :request do end context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do - it 'does not respond 404 on requests' do + before do + GlobalConfig.clear_cache + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + end + + after do + InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all + GlobalConfig.clear_cache + end + + it 'returns auth headers and full response for api_only signup' do params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' } with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do post api_v1_accounts_url, @@ -113,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do as: :json expect(response).to have_http_status(:success) + expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid') + end + end + end + + context 'when CW_API_ONLY_SERVER is true' do + it 'returns auth headers and full response' do + params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' } + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do + post api_v1_accounts_url, + params: params, + as: :json + + expect(response).to have_http_status(:success) + expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid') end end end diff --git a/spec/controllers/auth/resend_confirmations_controller_spec.rb b/spec/controllers/auth/resend_confirmations_controller_spec.rb new file mode 100644 index 000000000..f7df9c128 --- /dev/null +++ b/spec/controllers/auth/resend_confirmations_controller_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe 'Resend Confirmations API', type: :request do + describe 'POST /resend_confirmation' do + let(:email) { 'unconfirmed@example.com' } + + context 'when the user exists and is unconfirmed' do + before { create(:user, email: email, skip_confirmation: false) } + + it 'sends confirmation instructions and returns 200' do + expect do + post '/resend_confirmation', params: { email: email }, as: :json + end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions) + + expect(response).to have_http_status(:ok) + end + end + + context 'when the user exists and is already confirmed' do + before { create(:user, email: email) } + + it 'returns 200 without sending confirmation' do + expect do + post '/resend_confirmation', params: { email: email }, as: :json + end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions) + + expect(response).to have_http_status(:ok) + end + end + + context 'when the email does not exist' do + it 'returns 200 without leaking email existence' do + post '/resend_confirmation', params: { email: 'nobody@example.com' }, as: :json + + expect(response).to have_http_status(:ok) + end + end + + context 'when hCaptcha is configured' do + before do + create(:user, email: email, skip_confirmation: false) + allow(ChatwootCaptcha).to receive(:new).and_return(captcha) + end + + context 'with a valid captcha response' do + let(:captcha) { instance_double(ChatwootCaptcha, valid?: true) } + + it 'sends confirmation instructions' do + expect do + post '/resend_confirmation', + params: { email: email, h_captcha_client_response: 'valid-token' }, + as: :json + end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions) + + expect(response).to have_http_status(:ok) + end + end + + context 'with an invalid captcha response' do + let(:captcha) { instance_double(ChatwootCaptcha, valid?: false) } + + it 'returns 200 without sending confirmation' do + expect do + post '/resend_confirmation', + params: { email: email, h_captcha_client_response: 'bad-token' }, + as: :json + end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions) + + expect(response).to have_http_status(:ok) + end + end + end + end +end