[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>
This commit is contained in:
James Elliott 2020-05-21 08:03:15 +10:00 committed by GitHub
parent 147d0879e3
commit 469daedd36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 251 additions and 118 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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.

View File

@ -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(), "")

View File

@ -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,