[FEATURE] Delay 1FA Authentication (#993)
* adaptively delay 1FA by the actual execution time of authentication * should grow and shrink over time as successful attempts are made * uses the average of the last 10 successful attempts to calculate * starts at an average of 1000ms * minimum is 250ms * a random delay is added to the largest of avg or minimum * the random delay is between 0ms and 85ms * bump LDAP suite to 80s timeout * bump regulation scenario to 45s * add mutex locking * amend logging * add docs * add tests Co-authored-by: Clément Michaud <clement.michaud34@gmail.com>pull/1027/head^2
parent
147d0879e3
commit
469daedd36
|
@ -28,6 +28,19 @@ that the attacker must also require the certificate to retrieve the cookies.
|
|||
Note that using [HSTS] has consequences. That's why you should read the blog
|
||||
post nginx has written on [HSTS].
|
||||
|
||||
## Protection against username enumeration
|
||||
|
||||
Authelia adaptively delays authentication attempts based on the mean (average) of the
|
||||
previous 10 successful attempts, and a small random interval to make it even harder to
|
||||
determine if the attempt was successful. On start it is assumed that the last 10 attempts
|
||||
took 1000ms, this quickly grows or shrinks to the correct value over time regardless of the
|
||||
authentication backend.
|
||||
|
||||
The cost of this is low since in the instance of a user not existing it just sleeps to delay
|
||||
the login. Lastly the absolute minimum time authentication can take is 250ms. Both of these measures
|
||||
also have the added effect of creating an additional delay for all authentication attempts reducing
|
||||
the likelihood a password can be brute-forced even if regulation settings are too permissive.
|
||||
|
||||
## Protections against password cracking (File authentication provider)
|
||||
|
||||
Authelia implements a variety of measures to prevent an attacker cracking passwords if they
|
||||
|
|
|
@ -39,3 +39,7 @@ const testInactivity = "10"
|
|||
const testRedirectionURL = "http://redirection.local"
|
||||
const testResultAllow = "allow"
|
||||
const testUsername = "john"
|
||||
|
||||
const movingAverageWindow = 10
|
||||
const msMinimumDelay1FA = float64(250)
|
||||
const msMaximumRandomDelay = int64(85)
|
||||
|
|
|
@ -2,6 +2,9 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
|
@ -10,9 +13,63 @@ import (
|
|||
"github.com/authelia/authelia/internal/session"
|
||||
)
|
||||
|
||||
func movingAverageIteration(value time.Duration, successful bool, movingAverageCursor *int, execDurationMovingAverage *[]time.Duration, mutex sync.Locker) float64 {
|
||||
mutex.Lock()
|
||||
if successful {
|
||||
(*execDurationMovingAverage)[*movingAverageCursor] = value
|
||||
*movingAverageCursor = (*movingAverageCursor + 1) % movingAverageWindow
|
||||
}
|
||||
|
||||
var sum int64
|
||||
|
||||
for _, v := range *execDurationMovingAverage {
|
||||
sum += v.Milliseconds()
|
||||
}
|
||||
mutex.Unlock()
|
||||
|
||||
return float64(sum / movingAverageWindow)
|
||||
}
|
||||
|
||||
func calculateActualDelay(ctx *middlewares.AutheliaCtx, execDuration time.Duration, avgExecDurationMs float64, successful *bool) float64 {
|
||||
randomDelayMs := float64(rand.Int63n(msMaximumRandomDelay))
|
||||
totalDelayMs := math.Max(avgExecDurationMs, msMinimumDelay1FA) + randomDelayMs
|
||||
actualDelayMs := math.Max(totalDelayMs-float64(execDuration.Milliseconds()), 1.0)
|
||||
ctx.Logger.Tracef("attempt successful: %t, exec duration: %d, avg execution duration: %d, random delay ms: %d, total delay ms: %d, actual delay ms: %d", *successful, execDuration.Milliseconds(), int64(avgExecDurationMs), int64(randomDelayMs), int64(totalDelayMs), int64(actualDelayMs))
|
||||
|
||||
return actualDelayMs
|
||||
}
|
||||
|
||||
func delayToPreventTimingAttacks(ctx *middlewares.AutheliaCtx, requestTime time.Time, successful *bool, movingAverageCursor *int, execDurationMovingAverage *[]time.Duration, mutex sync.Locker) {
|
||||
execDuration := time.Since(requestTime)
|
||||
avgExecDurationMs := movingAverageIteration(execDuration, *successful, movingAverageCursor, execDurationMovingAverage, mutex)
|
||||
actualDelayMs := calculateActualDelay(ctx, execDuration, avgExecDurationMs, successful)
|
||||
time.Sleep(time.Duration(actualDelayMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
// FirstFactorPost is the handler performing the first factory.
|
||||
//nolint:gocyclo // TODO: Consider refactoring time permitting.
|
||||
func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
||||
func FirstFactorPost(msInitialDelay time.Duration, delayEnabled bool) middlewares.RequestHandler {
|
||||
var execDurationMovingAverage = make([]time.Duration, movingAverageWindow)
|
||||
|
||||
var movingAverageCursor = 0
|
||||
|
||||
var mutex = &sync.Mutex{}
|
||||
|
||||
for i := range execDurationMovingAverage {
|
||||
execDurationMovingAverage[i] = msInitialDelay * time.Millisecond
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
return func(ctx *middlewares.AutheliaCtx) {
|
||||
var successful bool
|
||||
|
||||
requestTime := time.Now()
|
||||
|
||||
if delayEnabled {
|
||||
defer delayToPreventTimingAttacks(ctx, requestTime, &successful, &movingAverageCursor, &execDurationMovingAverage, mutex)
|
||||
}
|
||||
|
||||
bodyJSON := firstFactorRequestBody{}
|
||||
err := ctx.ParseBody(&bodyJSON)
|
||||
|
||||
|
@ -124,5 +181,8 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
successful = true
|
||||
|
||||
Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,9 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -30,7 +32,7 @@ func (s *FirstFactorSuite) TearDownTest() {
|
|||
}
|
||||
|
||||
func (s *FirstFactorSuite) TestShouldFailIfBodyIsNil() {
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// No body
|
||||
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
||||
|
@ -42,7 +44,7 @@ func (s *FirstFactorSuite) TestShouldFailIfBodyIsInBadFormat() {
|
|||
s.mock.Ctx.Request.SetBodyString(`{
|
||||
"username": "test"
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
assert.Equal(s.T(), "Unable to validate body: password: non zero value required", s.mock.Hook.LastEntry().Message)
|
||||
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||
|
@ -67,7 +69,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
assert.Equal(s.T(), "Error while checking password for user test: Failed", s.mock.Hook.LastEntry().Message)
|
||||
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||
|
@ -93,7 +95,7 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCrede
|
|||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
}
|
||||
|
||||
func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
||||
|
@ -117,7 +119,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
assert.Equal(s.T(), "Error while retrieving details from user test: Failed", s.mock.Hook.LastEntry().Message)
|
||||
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||
|
@ -139,7 +141,7 @@ func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
assert.Equal(s.T(), "Unable to mark authentication: failed", s.mock.Hook.LastEntry().Message)
|
||||
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||
|
@ -170,7 +172,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||
|
@ -210,7 +212,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||
|
@ -253,7 +255,7 @@ func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSess
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": true
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||
|
@ -323,7 +325,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTarget
|
|||
"password": "hello",
|
||||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
||||
|
@ -343,7 +345,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUns
|
|||
"targetURL": "http://notsafe.local"
|
||||
}`)
|
||||
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
||||
|
@ -363,7 +365,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedA
|
|||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
|
@ -393,7 +395,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvi
|
|||
"keepMeLoggedIn": false
|
||||
}`)
|
||||
|
||||
FirstFactorPost(s.mock.Ctx)
|
||||
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||
|
||||
// Respond with 200.
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
|
@ -403,3 +405,57 @@ func TestFirstFactorSuite(t *testing.T) {
|
|||
suite.Run(t, new(FirstFactorSuite))
|
||||
suite.Run(t, new(FirstFactorRedirectionSuite))
|
||||
}
|
||||
|
||||
func TestFirstFactorDelayAverages(t *testing.T) {
|
||||
execDuration := time.Millisecond * 500
|
||||
oneSecond := time.Millisecond * 1000
|
||||
durations := []time.Duration{oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond}
|
||||
cursor := 0
|
||||
mutex := &sync.Mutex{}
|
||||
avgExecDuration := movingAverageIteration(execDuration, false, &cursor, &durations, mutex)
|
||||
assert.Equal(t, avgExecDuration, float64(1000))
|
||||
|
||||
execDurations := []time.Duration{
|
||||
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
||||
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
||||
time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500,
|
||||
}
|
||||
|
||||
current := float64(1000)
|
||||
|
||||
// Execute at 500ms for 12 requests.
|
||||
for _, execDuration = range execDurations {
|
||||
// Should not dip below 500, and should decrease in value by 50 each iteration.
|
||||
if current > 500 {
|
||||
current -= 50
|
||||
}
|
||||
|
||||
avgExecDuration := movingAverageIteration(execDuration, true, &cursor, &durations, mutex)
|
||||
assert.Equal(t, avgExecDuration, current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstFactorDelayCalculations(t *testing.T) {
|
||||
mock := mocks.NewMockAutheliaCtx(t)
|
||||
successful := false
|
||||
|
||||
execDuration := 500 * time.Millisecond
|
||||
avgExecDurationMs := 1000.0
|
||||
expectedMinimumDelayMs := avgExecDurationMs - float64(execDuration.Milliseconds())
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
delay := calculateActualDelay(mock.Ctx, execDuration, avgExecDurationMs, &successful)
|
||||
assert.True(t, delay >= expectedMinimumDelayMs)
|
||||
assert.True(t, delay <= expectedMinimumDelayMs+float64(msMaximumRandomDelay))
|
||||
}
|
||||
|
||||
execDuration = 5 * time.Millisecond
|
||||
avgExecDurationMs = 5.0
|
||||
expectedMinimumDelayMs = msMinimumDelay1FA - float64(execDuration.Milliseconds())
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
delay := calculateActualDelay(mock.Ctx, execDuration, avgExecDurationMs, &successful)
|
||||
assert.True(t, delay >= expectedMinimumDelayMs)
|
||||
assert.True(t, delay <= expectedMinimumDelayMs+float64(msMaximumRandomDelay))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
|||
router.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend)))
|
||||
router.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend)))
|
||||
|
||||
router.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost))
|
||||
router.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost(1000, true)))
|
||||
router.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost))
|
||||
|
||||
// Only register endpoints if forgot password is not disabled.
|
||||
|
|
|
@ -47,7 +47,7 @@ func (s *RegulationScenario) SetupTest() {
|
|||
}
|
||||
|
||||
func (s *RegulationScenario) TestShouldBanUserAfterTooManyAttempt() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.doVisitLoginPage(ctx, s.T(), "")
|
||||
|
|
|
@ -57,7 +57,7 @@ func init() {
|
|||
SetUp: setup,
|
||||
SetUpTimeout: 5 * time.Minute,
|
||||
OnSetupTimeout: displayAutheliaLogs,
|
||||
TestTimeout: 1 * time.Minute,
|
||||
TestTimeout: 80 * time.Second,
|
||||
TearDown: teardown,
|
||||
TearDownTimeout: 2 * time.Minute,
|
||||
OnError: displayAutheliaLogs,
|
||||
|
|
Loading…
Reference in New Issue