Commit b34f3e78 authored by Imre Farkas's avatar Imre Farkas

Add FortiAuthenticator as OTP method

Currently behind the :forti_authenticator feature flag.
parent 8ae07187
...@@ -67,7 +67,10 @@ class Admin::SessionsController < ApplicationController ...@@ -67,7 +67,10 @@ class Admin::SessionsController < ApplicationController
end end
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
valid_otp_attempt = user.validate_and_consume_otp!(user_params[:otp_attempt]) otp_validation_result =
::Users::ValidateOtpService.new(user).execute(user_params[:otp_attempt])
valid_otp_attempt = otp_validation_result[:status] == :success
return valid_otp_attempt if Gitlab::Database.read_only? return valid_otp_attempt if Gitlab::Database.read_only?
valid_otp_attempt || user.invalidate_otp_backup_code!(user_params[:otp_attempt]) valid_otp_attempt || user.invalidate_otp_backup_code!(user_params[:otp_attempt])
......
...@@ -47,7 +47,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -47,7 +47,10 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end end
def create def create
if current_user.validate_and_consume_otp!(params[:pin_code]) otp_validation_result =
::Users::ValidateOtpService.new(current_user).execute(params[:pin_code])
if otp_validation_result[:status] == :success
ActiveSession.destroy_all_but_current(current_user, session) ActiveSession.destroy_all_but_current(current_user, session)
Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! do |user| Users::UpdateService.new(current_user, user: current_user, otp_required_for_login: true).execute! do |user|
......
...@@ -264,8 +264,11 @@ class SessionsController < Devise::SessionsController ...@@ -264,8 +264,11 @@ class SessionsController < Devise::SessionsController
end end
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) || otp_validation_result =
user.invalidate_otp_backup_code!(user_params[:otp_attempt]) ::Users::ValidateOtpService.new(user).execute(user_params[:otp_attempt])
return true if otp_validation_result[:status] == :success
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end end
def log_audit_event(user, resource, options = {}) def log_audit_event(user, resource, options = {})
......
...@@ -793,7 +793,7 @@ class User < ApplicationRecord ...@@ -793,7 +793,7 @@ class User < ApplicationRecord
end end
def two_factor_otp_enabled? def two_factor_otp_enabled?
otp_required_for_login? otp_required_for_login? || Feature.enabled?(:forti_authenticator, self)
end end
def two_factor_u2f_enabled? def two_factor_u2f_enabled?
......
# frozen_string_literal: true
module Users
class ValidateOtpService < BaseService
def initialize(current_user)
@current_user = current_user
@strategy = if Feature.enabled?(:forti_authenticator, current_user)
::Gitlab::Auth::Otp::Strategies::FortiAuthenticator.new(current_user)
else
::Gitlab::Auth::Otp::Strategies::Devise.new(current_user)
end
end
def execute(otp_code)
strategy.validate(otp_code)
rescue StandardError => ex
Gitlab::ErrorTracking.log_exception(ex)
error(message: ex.message)
end
private
attr_reader :strategy
end
end
---
name: forti_authenticator
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45055
rollout_issue_url:
type: development
group: group::access
default_enabled: false
...@@ -1022,6 +1022,21 @@ production: &base ...@@ -1022,6 +1022,21 @@ production: &base
# cas3: # cas3:
# session_duration: 28800 # session_duration: 28800
# FortiAuthenticator settings
forti_authenticator:
# Allow using FortiAuthenticator as OTP provider
enabled: false
# Host and port of FortiAuthenticator instance
# host: forti_authenticator.example.com
# port: 443
# Username for accessing FortiAuthenticator API
# username: john
# Access token for FortiAuthenticator API
# access_token: 123s3cr3t456
# Shared file storage settings # Shared file storage settings
shared: shared:
# path: /mnt/gitlab # Default: shared # path: /mnt/gitlab # Default: shared
......
...@@ -766,6 +766,13 @@ Gitlab.ee do ...@@ -766,6 +766,13 @@ Gitlab.ee do
Settings.smartcard['san_extensions'] = false if Settings.smartcard['san_extensions'].nil? Settings.smartcard['san_extensions'] = false if Settings.smartcard['san_extensions'].nil?
end end
#
# FortiAuthenticator
#
Settings['forti_authenticator'] ||= Settingslogic.new({})
Settings.forti_authenticator['enabled'] = false if Settings.forti_authenticator['enabled'].nil?
Settings.forti_authenticator['port'] = 443 if Settings.forti_authenticator['port'].to_i == 0
# #
# Extra customization # Extra customization
# #
......
# frozen_string_literal: true
module Gitlab
module Auth
module Otp
module Strategies
class Base
def initialize(user)
@user = user
end
private
attr_reader :user
def success
{ status: :success }
end
def error(message, http_status = nil)
result = { message: message,
status: :error }
result[:http_status] = http_status if http_status
result
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Auth
module Otp
module Strategies
class Devise < Base
def validate(otp_code)
user.validate_and_consume_otp!(otp_code) ? success : error('invalid OTP code')
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Auth
module Otp
module Strategies
class FortiAuthenticator < Base
def validate(otp_code)
body = { username: user.username,
token_code: otp_code }
response = Gitlab::HTTP.post(
auth_url,
headers: { 'Content-Type': 'application/json' },
body: body.to_json,
basic_auth: api_credentials)
# Successful authentication results in HTTP 200: OK
# https://docs.fortinet.com/document/fortiauthenticator/6.2.0/rest-api-solution-guide/704555/authentication-auth
response.ok? ? success : error(message: response.message, http_status: response.code)
end
private
def auth_url
host = ::Gitlab.config.forti_authenticator.host
port = ::Gitlab.config.forti_authenticator.port
path = 'api/v1/auth/'
"https://#{host}:#{port}/#{path}"
end
def api_credentials
{ username: ::Gitlab.config.forti_authenticator.username,
password: ::Gitlab.config.forti_authenticator.token }
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Auth::Otp::Strategies::Devise do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
subject(:validate) { described_class.new(user).validate(otp_code) }
it 'calls Devise' do
expect(user).to receive(:validate_and_consume_otp!).with(otp_code)
validate
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Auth::Otp::Strategies::FortiAuthenticator do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
let(:host) { 'forti_authenticator.example.com' }
let(:port) { '444' }
let(:api_username) { 'janedoe' }
let(:api_token) { 's3cr3t' }
let(:forti_authenticator_auth_url) { "https://#{host}:#{port}/api/v1/auth/" }
subject(:validate) { described_class.new(user).validate(otp_code) }
before do
stub_feature_flags(forti_authenticator: true)
stub_forti_authenticator_config(
host: host,
port: port,
username: api_username,
token: api_token
)
request_body = { username: user.username,
token_code: otp_code }
stub_request(:post, forti_authenticator_auth_url)
.with(body: JSON(request_body), headers: { 'Content-Type' => 'application/json' })
.to_return(status: response_status, body: '', headers: {})
end
context 'successful validation' do
let(:response_status) { 200 }
it 'returns success' do
expect(validate[:status]).to eq(:success)
end
end
context 'unsuccessful validation' do
let(:response_status) { 401 }
it 'returns error' do
expect(validate[:status]).to eq(:error)
end
end
def stub_forti_authenticator_config(forti_authenticator_settings)
allow(::Gitlab.config.forti_authenticator).to(receive_messages(forti_authenticator_settings))
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Users::ValidateOtpService do
let_it_be(:user) { create(:user) }
let(:otp_code) { 42 }
subject(:validate) { described_class.new(user).execute(otp_code) }
context 'Devise' do
it 'calls Devise strategy' do
expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::Devise) do |strategy|
expect(strategy).to receive(:validate).with(otp_code).once
end
validate
end
end
context 'FortiAuthenticator' do
before do
stub_feature_flags(forti_authenticator: true)
end
it 'calls FortiAuthenticator strategy' do
expect_next_instance_of(::Gitlab::Auth::Otp::Strategies::FortiAuthenticator) do |strategy|
expect(strategy).to receive(:validate).with(otp_code).once
end
validate
end
end
end
...@@ -212,6 +212,10 @@ RSpec.configure do |config| ...@@ -212,6 +212,10 @@ RSpec.configure do |config|
# for now whilst we migrate as much as we can over the GraphQL # for now whilst we migrate as much as we can over the GraphQL
stub_feature_flags(merge_request_widget_graphql: false) stub_feature_flags(merge_request_widget_graphql: false)
# Using FortiAuthenticator as OTP provider is disabled by default in
# tests, until we introduce it in user settings
stub_feature_flags(forti_authenticator: false)
enable_rugged = example.metadata[:enable_rugged].present? enable_rugged = example.metadata[:enable_rugged].present?
# Disable Rugged features by default # Disable Rugged features by default
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment