[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
|
Note that using [HSTS] has consequences. That's why you should read the blog
|
||||||
post nginx has written on [HSTS].
|
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)
|
## Protections against password cracking (File authentication provider)
|
||||||
|
|
||||||
Authelia implements a variety of measures to prevent an attacker cracking passwords if they
|
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 testRedirectionURL = "http://redirection.local"
|
||||||
const testResultAllow = "allow"
|
const testResultAllow = "allow"
|
||||||
const testUsername = "john"
|
const testUsername = "john"
|
||||||
|
|
||||||
|
const movingAverageWindow = 10
|
||||||
|
const msMinimumDelay1FA = float64(250)
|
||||||
|
const msMaximumRandomDelay = int64(85)
|
||||||
|
|
|
@ -2,6 +2,9 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/authelia/authelia/internal/authentication"
|
"github.com/authelia/authelia/internal/authentication"
|
||||||
|
@ -10,9 +13,63 @@ import (
|
||||||
"github.com/authelia/authelia/internal/session"
|
"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.
|
// FirstFactorPost is the handler performing the first factory.
|
||||||
//nolint:gocyclo // TODO: Consider refactoring time permitting.
|
//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{}
|
bodyJSON := firstFactorRequestBody{}
|
||||||
err := ctx.ParseBody(&bodyJSON)
|
err := ctx.ParseBody(&bodyJSON)
|
||||||
|
|
||||||
|
@ -124,5 +181,8 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
successful = true
|
||||||
|
|
||||||
Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
|
Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,9 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"github.com/golang/mock/gomock"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -30,7 +32,7 @@ func (s *FirstFactorSuite) TearDownTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FirstFactorSuite) TestShouldFailIfBodyIsNil() {
|
func (s *FirstFactorSuite) TestShouldFailIfBodyIsNil() {
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// No body
|
// No body
|
||||||
assert.Equal(s.T(), "Unable to parse body: unexpected end of JSON input", s.mock.Hook.LastEntry().Message)
|
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(`{
|
s.mock.Ctx.Request.SetBodyString(`{
|
||||||
"username": "test"
|
"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)
|
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.")
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||||
|
@ -67,7 +69,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderCheckPasswordFail() {
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": true
|
"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)
|
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.")
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||||
|
@ -93,7 +95,7 @@ func (s *FirstFactorSuite) TestShouldCheckAuthenticationIsMarkedWhenInvalidCrede
|
||||||
"keepMeLoggedIn": true
|
"keepMeLoggedIn": true
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
||||||
|
@ -117,7 +119,7 @@ func (s *FirstFactorSuite) TestShouldFailIfUserProviderGetDetailsFail() {
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": true
|
"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)
|
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.")
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||||
|
@ -139,7 +141,7 @@ func (s *FirstFactorSuite) TestShouldFailIfAuthenticationMarkFail() {
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": true
|
"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)
|
assert.Equal(s.T(), "Unable to mark authentication: failed", s.mock.Hook.LastEntry().Message)
|
||||||
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
s.mock.Assert401KO(s.T(), "Authentication failed. Check your credentials.")
|
||||||
|
@ -170,7 +172,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() {
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": true
|
"keepMeLoggedIn": true
|
||||||
}`)
|
}`)
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
@ -210,7 +212,7 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": false
|
"keepMeLoggedIn": false
|
||||||
}`)
|
}`)
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
@ -253,7 +255,7 @@ func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSess
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": true
|
"keepMeLoggedIn": true
|
||||||
}`)
|
}`)
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
|
||||||
|
@ -323,7 +325,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenNoTarget
|
||||||
"password": "hello",
|
"password": "hello",
|
||||||
"keepMeLoggedIn": false
|
"keepMeLoggedIn": false
|
||||||
}`)
|
}`)
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
||||||
|
@ -343,7 +345,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUns
|
||||||
"targetURL": "http://notsafe.local"
|
"targetURL": "http://notsafe.local"
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
s.mock.Assert200OK(s.T(), redirectResponse{Redirect: "https://default.local"})
|
||||||
|
@ -363,7 +365,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedA
|
||||||
"keepMeLoggedIn": false
|
"keepMeLoggedIn": false
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
s.mock.Assert200OK(s.T(), nil)
|
s.mock.Assert200OK(s.T(), nil)
|
||||||
|
@ -393,7 +395,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvi
|
||||||
"keepMeLoggedIn": false
|
"keepMeLoggedIn": false
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
FirstFactorPost(s.mock.Ctx)
|
FirstFactorPost(0, false)(s.mock.Ctx)
|
||||||
|
|
||||||
// Respond with 200.
|
// Respond with 200.
|
||||||
s.mock.Assert200OK(s.T(), nil)
|
s.mock.Assert200OK(s.T(), nil)
|
||||||
|
@ -403,3 +405,57 @@ func TestFirstFactorSuite(t *testing.T) {
|
||||||
suite.Run(t, new(FirstFactorSuite))
|
suite.Run(t, new(FirstFactorSuite))
|
||||||
suite.Run(t, new(FirstFactorRedirectionSuite))
|
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.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend)))
|
||||||
router.HEAD("/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))
|
router.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost))
|
||||||
|
|
||||||
// Only register endpoints if forgot password is not disabled.
|
// Only register endpoints if forgot password is not disabled.
|
||||||
|
|
|
@ -47,7 +47,7 @@ func (s *RegulationScenario) SetupTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RegulationScenario) TestShouldBanUserAfterTooManyAttempt() {
|
func (s *RegulationScenario) TestShouldBanUserAfterTooManyAttempt() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
s.doVisitLoginPage(ctx, s.T(), "")
|
s.doVisitLoginPage(ctx, s.T(), "")
|
||||||
|
|
|
@ -57,7 +57,7 @@ func init() {
|
||||||
SetUp: setup,
|
SetUp: setup,
|
||||||
SetUpTimeout: 5 * time.Minute,
|
SetUpTimeout: 5 * time.Minute,
|
||||||
OnSetupTimeout: displayAutheliaLogs,
|
OnSetupTimeout: displayAutheliaLogs,
|
||||||
TestTimeout: 1 * time.Minute,
|
TestTimeout: 80 * time.Second,
|
||||||
TearDown: teardown,
|
TearDown: teardown,
|
||||||
TearDownTimeout: 2 * time.Minute,
|
TearDownTimeout: 2 * time.Minute,
|
||||||
OnError: displayAutheliaLogs,
|
OnError: displayAutheliaLogs,
|
||||||
|
|
Loading…
Reference in New Issue