feat: ensure signup verification [UPM-14] (#13858)
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 <details><summary>Demo</summary> <p> #### 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 </p> </details> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
18
app/controllers/auth/resend_confirmations_controller.rb
Normal file
18
app/controllers/auth/resend_confirmations_controller.rb
Normal file
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
110
app/javascript/v3/views/auth/verify-email/Index.vue
Normal file
110
app/javascript/v3/views/auth/verify-email/Index.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { resendConfirmation } from '../../../api/auth';
|
||||
|
||||
const props = defineProps({
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
if (!props.email) {
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const isResendingEmail = ref(false);
|
||||
const hCaptcha = ref(null);
|
||||
let captchaToken = '';
|
||||
|
||||
const performResend = async () => {
|
||||
isResendingEmail.value = true;
|
||||
try {
|
||||
await resendConfirmation({
|
||||
email: props.email,
|
||||
hCaptchaClientResponse: captchaToken,
|
||||
});
|
||||
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_ERROR'));
|
||||
} finally {
|
||||
isResendingEmail.value = false;
|
||||
captchaToken = '';
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.reset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendEmail = () => {
|
||||
if (isResendingEmail.value) return;
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.execute();
|
||||
} else {
|
||||
performResend();
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaVerified = token => {
|
||||
captchaToken = token;
|
||||
performResend();
|
||||
};
|
||||
|
||||
const onCaptchaError = () => {
|
||||
isResendingEmail.value = false;
|
||||
captchaToken = '';
|
||||
hCaptcha.value.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<section
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('REGISTER.VERIFY_EMAIL.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-n-slate-11">
|
||||
{{ $t('REGISTER.VERIFY_EMAIL.DESCRIPTION', { email }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<VueHcaptcha
|
||||
v-if="globalConfig.hCaptchaSiteKey"
|
||||
ref="hCaptcha"
|
||||
size="invisible"
|
||||
:sitekey="globalConfig.hCaptchaSiteKey"
|
||||
@verify="onCaptchaVerified"
|
||||
@error="onCaptchaError"
|
||||
@expired="onCaptchaError"
|
||||
@challenge-expired="onCaptchaError"
|
||||
@closed="onCaptchaError"
|
||||
/>
|
||||
<NextButton
|
||||
lg
|
||||
type="button"
|
||||
data-testid="resend_email_button"
|
||||
class="w-full"
|
||||
:label="$t('REGISTER.VERIFY_EMAIL.RESEND')"
|
||||
:is-loading="isResendingEmail"
|
||||
@click="handleResendEmail"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user