mirror of https://github.com/docusealco/docuseal
CP-10370 authentication (#15)
* Add external_id fields to accounts and users tables Adds external_account_id and external_user_id fields to support integration with external ATS systems. These fields will map DocuSeal accounts/users to their corresponding ATS entities. * Add external ID support to Account and User models Implements find_or_create_by_external_id methods for both Account and User models to support automatic provisioning from external ATS systems. Users now have access tokens for authentication. * Add external authentication API endpoint Creates /api/external_auth/get_user_token endpoint for external API systems to authenticate users and receive access tokens. * Refactor authentication to support token-based login Replaces demo user authentication and setup redirect logic with token-based authentication via params, session, or X-Auth-Token header. Users do not login, they are just authenticated via token. * Replace authenticate_user! with authenticate_via_token! Refactored controllers to use authenticate_via_token! instead of authenticate_user! for authentication. Added authenticate_via_token! method to ApiBaseController. * Update controller authentication and authorization logic Removed and replaced several before_action and authorization checks in ExportController, SetupController, and TemplateDocumentsController. * Add external authentication API endpoint * Add IframeAuthentication concern for AJAX requests in iframe context * Create shared concern to handle authentication from HTTP referer * Extracts auth token from referer URL when AJAX requests don't include token * Supports Vue component requests within iframes * Remove old user authentication from dashboard controller * Quick fix for request changes Now that we have scoped users, we're changing this to compare to the template authot * rubocop fixes * Add and update authentication and model specs Introduces new specs for iframe authentication, account, user, application controller, and external auth API. * add safe navigation and remove dead methodpull/544/head
parent
ba325ec5a4
commit
4ec9e7fc5e
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ExternalAuthController < Api::ApiBaseController
|
||||
skip_before_action :authenticate_via_token!
|
||||
skip_authorization_check
|
||||
|
||||
def user_token
|
||||
account = Account.find_or_create_by_external_id(
|
||||
params[:account][:external_id]&.to_i,
|
||||
name: params[:account][:name],
|
||||
locale: params[:account][:locale] || 'en-US',
|
||||
timezone: params[:account][:timezone] || 'UTC'
|
||||
)
|
||||
|
||||
user = User.find_or_create_by_external_id(
|
||||
account,
|
||||
params[:user][:external_id]&.to_i,
|
||||
email: params[:user][:email],
|
||||
first_name: params[:user][:first_name],
|
||||
last_name: params[:user][:last_name],
|
||||
role: 'admin'
|
||||
)
|
||||
|
||||
render json: { access_token: user.access_token.token }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("External auth error: #{e.message}")
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
render json: { error: 'Internal server error' }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module IframeAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Custom authentication for iframe context
|
||||
# AJAX requests from Vue components don't include the auth token that's in the iframe URL,
|
||||
# so we extract it from the HTTP referer header as a fallback
|
||||
def authenticate_from_referer
|
||||
return if signed_in?
|
||||
|
||||
token = params[:auth_token] || session[:auth_token] || request.headers['X-Auth-Token']
|
||||
|
||||
# If no token found, extract from referer URL (iframe page has the token)
|
||||
if token.blank? && request.referer.present?
|
||||
referer_uri = URI.parse(request.referer)
|
||||
referer_params = CGI.parse(referer_uri.query || '')
|
||||
token = referer_params['auth_token']&.first
|
||||
end
|
||||
|
||||
if token.present?
|
||||
sha256 = Digest::SHA256.hexdigest(token)
|
||||
user = User.joins(:access_token).active.find_by(access_token: { sha256: sha256 })
|
||||
|
||||
return unless user
|
||||
|
||||
sign_in(user)
|
||||
session[:auth_token] = token
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.error "#{self.class.name}: Authentication failed"
|
||||
render json: { error: 'Authentication required' }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,9 @@
|
||||
class AddExternalIdsToAccountsAndUsers < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :accounts, :external_account_id, :integer
|
||||
add_column :users, :external_user_id, :integer
|
||||
|
||||
add_index :accounts, :external_account_id, unique: true
|
||||
add_index :users, :external_user_id, unique: true
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe IframeAuthentication do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:token) { user.access_token.token }
|
||||
|
||||
let(:controller_class) do
|
||||
Class.new(ApplicationController) do
|
||||
include IframeAuthentication
|
||||
end
|
||||
end
|
||||
|
||||
let(:controller) { controller_class.new }
|
||||
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}, referer: nil) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive_messages(
|
||||
request: request_double,
|
||||
params: {},
|
||||
session: {},
|
||||
signed_in?: false,
|
||||
sign_in: nil,
|
||||
render: nil
|
||||
)
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
describe '#authenticate_from_referer' do
|
||||
it 'does nothing when already signed in' do
|
||||
allow(controller).to receive(:signed_in?).and_return(true)
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).not_to have_received(:sign_in)
|
||||
end
|
||||
|
||||
it 'authenticates with valid params token' do
|
||||
allow(controller).to receive(:params).and_return({ auth_token: token })
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
|
||||
it 'authenticates with valid session token' do
|
||||
allow(controller).to receive(:session).and_return({ auth_token: token })
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
|
||||
it 'authenticates with valid header token' do
|
||||
allow(request_double).to receive(:headers).and_return({ 'X-Auth-Token' => token })
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
|
||||
it 'authenticates with token from referer URL' do
|
||||
allow(request_double).to receive(:referer).and_return("https://example.com?auth_token=#{token}")
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
|
||||
it 'does nothing with invalid token' do
|
||||
allow(controller).to receive(:params).and_return({ auth_token: 'invalid' })
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).not_to have_received(:sign_in)
|
||||
expect(controller).not_to have_received(:render)
|
||||
end
|
||||
|
||||
it 'renders error with no token' do
|
||||
controller.send(:authenticate_from_referer)
|
||||
expect(controller).to have_received(:render).with(
|
||||
json: { error: 'Authentication required' },
|
||||
status: :unauthorized
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account do
|
||||
describe 'validations' do
|
||||
it 'is valid with valid attributes' do
|
||||
account = build(:account)
|
||||
expect(account).to be_valid
|
||||
end
|
||||
|
||||
it 'validates uniqueness of external_account_id when present' do
|
||||
create(:account, external_account_id: 123)
|
||||
duplicate = build(:account, external_account_id: 123)
|
||||
expect(duplicate).not_to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '.find_or_create_by_external_id' do
|
||||
let(:external_id) { 123 }
|
||||
let(:attributes) { { name: 'Test Account' } }
|
||||
|
||||
it 'finds existing account by external_account_id' do
|
||||
existing_account = create(:account, external_account_id: external_id)
|
||||
result = described_class.find_or_create_by_external_id(external_id, attributes)
|
||||
expect(result).to eq(existing_account)
|
||||
end
|
||||
|
||||
it 'creates new account when none exists' do
|
||||
result = described_class.find_or_create_by_external_id(external_id, attributes)
|
||||
expect(result.external_account_id).to eq(external_id)
|
||||
expect(result.name).to eq('Test Account')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#testing?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'delegates to linked_account_account' do
|
||||
linked_account_account = instance_double(AccountLinkedAccount, testing?: true)
|
||||
allow(account).to receive(:linked_account_account).and_return(linked_account_account)
|
||||
|
||||
expect(account.testing?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#default_template_folder' do
|
||||
it 'creates default folder when none exists' do
|
||||
account = create(:account)
|
||||
create(:user, account: account)
|
||||
|
||||
expect do
|
||||
folder = account.default_template_folder
|
||||
expect(folder.name).to eq(TemplateFolder::DEFAULT_NAME)
|
||||
expect(folder).to be_persisted
|
||||
end.to change(account.template_folders, :count).by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User do
|
||||
describe 'validations' do
|
||||
it 'is valid with valid attributes' do
|
||||
user = build(:user)
|
||||
expect(user).to be_valid
|
||||
end
|
||||
|
||||
it 'validates email format' do
|
||||
user = build(:user, email: 'invalid-email')
|
||||
expect(user).not_to be_valid
|
||||
end
|
||||
|
||||
it 'validates uniqueness of external_user_id when present' do
|
||||
account = create(:account)
|
||||
create(:user, account: account, external_user_id: 123)
|
||||
duplicate = build(:user, account: account, external_user_id: 123)
|
||||
expect(duplicate).not_to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '.find_or_create_by_external_id' do
|
||||
let(:account) { create(:account) }
|
||||
let(:external_id) { 123 }
|
||||
let(:attributes) { { first_name: 'Test', last_name: 'User', email: 'test@example.com' } }
|
||||
|
||||
it 'finds existing user by external_user_id' do
|
||||
existing_user = create(:user, account: account, external_user_id: external_id)
|
||||
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
|
||||
expect(result).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'creates new user when none exists' do
|
||||
result = described_class.find_or_create_by_external_id(account, external_id, attributes)
|
||||
expect(result.external_user_id).to eq(external_id)
|
||||
expect(result.first_name).to eq('Test')
|
||||
expect(result.email).to eq('test@example.com')
|
||||
expect(result.password).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active_for_authentication?' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
it 'returns true when user and account are active' do
|
||||
expect(user.active_for_authentication?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when user is archived' do
|
||||
user.update!(archived_at: 1.day.ago)
|
||||
expect(user.active_for_authentication?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when account is archived' do
|
||||
account.update!(archived_at: 1.day.ago)
|
||||
expect(user.active_for_authentication?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#initials' do
|
||||
it 'returns initials from first and last name' do
|
||||
user = build(:user, first_name: 'John', last_name: 'Doe')
|
||||
expect(user.initials).to eq('JD')
|
||||
end
|
||||
|
||||
it 'handles missing names' do
|
||||
user = build(:user, first_name: 'John', last_name: nil)
|
||||
expect(user.initials).to eq('J')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#full_name' do
|
||||
it 'combines first and last name' do
|
||||
user = build(:user, first_name: 'John', last_name: 'Doe')
|
||||
expect(user.full_name).to eq('John Doe')
|
||||
end
|
||||
|
||||
it 'handles missing names' do
|
||||
user = build(:user, first_name: 'John', last_name: nil)
|
||||
expect(user.full_name).to eq('John')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#friendly_name' do
|
||||
it 'returns formatted name with email when full name present' do
|
||||
user = build(:user, first_name: 'John', last_name: 'Doe', email: 'john@example.com')
|
||||
expect(user.friendly_name).to eq('"John Doe" <john@example.com>')
|
||||
end
|
||||
|
||||
it 'returns just email when no full name' do
|
||||
user = build(:user, first_name: nil, last_name: nil, email: 'john@example.com')
|
||||
expect(user.friendly_name).to eq('john@example.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe 'ApplicationController' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:token) { user.access_token.token }
|
||||
|
||||
describe 'token authentication methods' do
|
||||
let(:controller) { ApplicationController.new }
|
||||
|
||||
let(:request_double) { instance_double(ActionDispatch::Request, headers: {}) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive_messages(
|
||||
request: request_double,
|
||||
params: {},
|
||||
session: {},
|
||||
signed_in?: false
|
||||
)
|
||||
end
|
||||
|
||||
describe '#maybe_authenticate_via_token' do
|
||||
it 'signs in user with valid token in header' do
|
||||
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
|
||||
allow(controller).to receive(:request).and_return(request_double_with_token)
|
||||
allow(controller).to receive(:sign_in)
|
||||
|
||||
controller.send(:maybe_authenticate_via_token)
|
||||
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
|
||||
it 'does nothing with invalid token' do
|
||||
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
|
||||
allow(controller).to receive(:request).and_return(request_double_with_invalid)
|
||||
allow(controller).to receive(:sign_in)
|
||||
|
||||
controller.send(:maybe_authenticate_via_token)
|
||||
|
||||
expect(controller).not_to have_received(:sign_in)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#authenticate_via_token!' do
|
||||
it 'renders error with no token' do
|
||||
allow(controller).to receive(:render)
|
||||
|
||||
controller.send(:authenticate_via_token!)
|
||||
|
||||
expect(controller).to have_received(:render).with(
|
||||
json: { error: 'Authentication required. Please provide a valid auth_token.' },
|
||||
status: :unauthorized
|
||||
)
|
||||
end
|
||||
|
||||
it 'renders error with invalid token' do
|
||||
request_double_with_invalid = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => 'invalid' })
|
||||
allow(controller).to receive(:request).and_return(request_double_with_invalid)
|
||||
allow(controller).to receive(:render)
|
||||
|
||||
controller.send(:authenticate_via_token!)
|
||||
|
||||
expect(controller).to have_received(:render).with(
|
||||
json: { error: 'Authentication required. Please provide a valid auth_token.' },
|
||||
status: :unauthorized
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not render error with valid token' do
|
||||
request_double_with_token = instance_double(ActionDispatch::Request, headers: { 'X-Auth-Token' => token })
|
||||
allow(controller).to receive(:request).and_return(request_double_with_token)
|
||||
allow(controller).to receive_messages(sign_in: nil, render: nil)
|
||||
|
||||
controller.send(:authenticate_via_token!)
|
||||
|
||||
expect(controller).not_to have_received(:render)
|
||||
expect(controller).to have_received(:sign_in).with(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'API authentication' do
|
||||
context 'with valid token' do
|
||||
it 'authenticates user' do
|
||||
get '/api/submissions', headers: { 'X-Auth-Token': token }
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid token' do
|
||||
it 'returns API-specific error message' do
|
||||
get '/api/submissions', headers: { 'X-Auth-Token': 'invalid_token' }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Not authenticated' })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe 'External Auth API' do
|
||||
describe 'POST /api/external_auth/user_token' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
account: {
|
||||
external_id: '123',
|
||||
name: 'Test Company'
|
||||
},
|
||||
user: {
|
||||
external_id: '456',
|
||||
email: 'test@example.com',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns success with access token' do
|
||||
post '/api/external_auth/user_token', params: valid_params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body).to have_key('access_token')
|
||||
end
|
||||
|
||||
it 'returns error when params cause exception' do
|
||||
allow(Account).to receive(:find_or_create_by_external_id).and_raise(StandardError.new('Test error'))
|
||||
|
||||
post '/api/external_auth/user_token', params: valid_params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue