diff --git a/config.template.yml b/config.template.yml
index b349511fe..e21d4291a 100644
--- a/config.template.yml
+++ b/config.template.yml
@@ -108,6 +108,29 @@ duo_api:
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl
+##
+## NTP Configuration
+##
+## This is used to validate the servers time is accurate enough to validate TOTP.
+ntp:
+ ## NTP server address.
+ address: "time.cloudflare.com:123"
+
+ ## NTP version.
+ version: 4
+
+ ## Maximum allowed time offset between the host and the NTP server.
+ max_desync: 3s
+
+ ## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you
+ ## set this to true, and can operate in a truly offline mode.
+ disable_startup_check: false
+
+ ## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with
+ ## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup
+ ## will continue regardless of results.
+ disable_failure: false
+
##
## Authentication Backend Provider Configuration
##
diff --git a/docs/configuration/ntp.md b/docs/configuration/ntp.md
new file mode 100644
index 000000000..20fbe34f9
--- /dev/null
+++ b/docs/configuration/ntp.md
@@ -0,0 +1,91 @@
+---
+layout: default
+title: NTP
+parent: Configuration
+nav_order: 9
+---
+
+# NTP
+
+Authelia has the ability to check the system time against an NTP server. Currently this only occurs at startup. This
+section configures and tunes the settings for this check which is primarily used to ensure [TOTP](./one-time-password.md)
+can be accurately validated.
+
+In the instance of inability to contact the NTP server Authelia will just log an error and will continue to run.
+
+## Configuration
+
+```yaml
+ntp:
+ address: "time.cloudflare.com:123"
+ version: 3
+ max_desync: 3s
+ disable_startup_check: false
+ disable_failure: false
+```
+
+## Options
+
+### address
+
+type: string
+{: .label .label-config .label-purple }
+default: time.cloudflare.com:123
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+Determines the address of the NTP server to retrieve the time from. The format is `:`, and both of these are
+required.
+
+### version
+
+type: integer
+{: .label .label-config .label-purple }
+default: 4
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+Determines the NTP verion supported. Valid values are 3 or 4.
+
+### max_desync
+
+type: duration
+{: .label .label-config .label-purple }
+default: 3s
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+This is used to tune the acceptable desync from the time reported from the NTP server. This uses our
+[duration notation](./index.md#duration-notation-format) format.
+
+### disable_startup_check
+
+type: boolean
+{: .label .label-config .label-purple }
+default: false
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+Setting this to true will disable the startup check entirely.
+
+### disable_failure
+
+type: boolean
+{: .label .label-config .label-purple }
+default: false
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+
+
+Setting this to true will allow Authelia to start and just log an error instead of exiting. The default is that if
+Authelia can contact the NTP server successfully, and the time reported by the server is greater than what is configured
+in [max_desync](#max_desync) that Authelia fails to start and logs a fatal error.
\ No newline at end of file
diff --git a/docs/configuration/one-time-password.md b/docs/configuration/one-time-password.md
index 67f367bc8..e6ff8c400 100644
--- a/docs/configuration/one-time-password.md
+++ b/docs/configuration/one-time-password.md
@@ -2,7 +2,7 @@
layout: default
title: Time-based One-Time Password
parent: Configuration
-nav_order: 15
+nav_order: 16
---
# Time-based One-Time Password
@@ -80,3 +80,13 @@ For example the default of 1 has a total of 3 keys valid. A value of 2 has 5 one
valid.
It is recommended to keep this value set to 0 or 1, the minimum is 0.
+
+## System time accuracy
+
+It's important to note that if the system time is not accurate enough then clients will seemingly not generate valid
+passwords for TOTP. Conversely this is the same when the client time is not accurate enough. This is due to the Time-based
+One Time Passwords being time-based.
+
+Authelia by default checks the system time against an [NTP server](./ntp.md#address) on startup. This helps to prevent
+a time synchronization issue on the server being an issue. There is however no effective and reliable way to check the
+clients.
\ No newline at end of file
diff --git a/docs/configuration/regulation.md b/docs/configuration/regulation.md
index 14abfa0bb..5f3815fc1 100644
--- a/docs/configuration/regulation.md
+++ b/docs/configuration/regulation.md
@@ -2,7 +2,7 @@
layout: default
title: Regulation
parent: Configuration
-nav_order: 9
+nav_order: 10
---
# Regulation
diff --git a/docs/configuration/secrets.md b/docs/configuration/secrets.md
index 5a2d2dce4..08f751830 100644
--- a/docs/configuration/secrets.md
+++ b/docs/configuration/secrets.md
@@ -2,7 +2,7 @@
layout: default
title: Secrets
parent: Configuration
-nav_order: 10
+nav_order: 11
---
# Secrets
diff --git a/docs/configuration/server.md b/docs/configuration/server.md
index 3acbb5095..1ccae5f6d 100644
--- a/docs/configuration/server.md
+++ b/docs/configuration/server.md
@@ -2,7 +2,7 @@
layout: default
title: Server
parent: Configuration
-nav_order: 11
+nav_order: 12
---
# Server
diff --git a/docs/configuration/session/index.md b/docs/configuration/session/index.md
index 34bb6d0a3..07aa23d95 100644
--- a/docs/configuration/session/index.md
+++ b/docs/configuration/session/index.md
@@ -2,7 +2,7 @@
layout: default
title: Session
parent: Configuration
-nav_order: 12
+nav_order: 13
has_children: true
---
diff --git a/docs/configuration/storage/index.md b/docs/configuration/storage/index.md
index 43a711d5f..347fa26b5 100644
--- a/docs/configuration/storage/index.md
+++ b/docs/configuration/storage/index.md
@@ -2,7 +2,7 @@
layout: default
title: Storage Backends
parent: Configuration
-nav_order: 13
+nav_order: 14
has_children: true
---
diff --git a/docs/configuration/theme.md b/docs/configuration/theme.md
index 3929ac8e9..58ff93bc7 100644
--- a/docs/configuration/theme.md
+++ b/docs/configuration/theme.md
@@ -2,7 +2,7 @@
layout: default
title: Theme
parent: Configuration
-nav_order: 14
+nav_order: 15
---
# Theme
diff --git a/internal/commands/root.go b/internal/commands/root.go
index c2fc0a8e3..c36176d29 100644
--- a/internal/commands/root.go
+++ b/internal/commands/root.go
@@ -12,6 +12,7 @@ import (
"github.com/authelia/authelia/v4/internal/logging"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/notification"
+ "github.com/authelia/authelia/v4/internal/ntp"
"github.com/authelia/authelia/v4/internal/oidc"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/server"
@@ -80,7 +81,10 @@ func cmdRootRun(_ *cobra.Command, _ []string) {
server.Start(*config, providers)
}
+//nolint:gocyclo // TODO: Consider refactoring time permitting.
func getProviders(config *schema.Configuration) (providers middlewares.Providers, warnings []error, errors []error) {
+ logger := logging.Logger()
+
autheliaCertPool, warnings, errors := utils.NewX509CertPool(config.CertificatesDirectory)
if len(warnings) != 0 || len(errors) != 0 {
return providers, warnings, errors
@@ -133,6 +137,11 @@ func getProviders(config *schema.Configuration) (providers middlewares.Providers
}
}
+ var ntpProvider *ntp.Provider
+ if config.NTP != nil {
+ ntpProvider = ntp.NewProvider(config.NTP)
+ }
+
clock := utils.RealClock{}
authorizer := authorization.NewAuthorizer(config)
sessionProvider := session.NewProvider(config.Session, autheliaCertPool)
@@ -143,12 +152,32 @@ func getProviders(config *schema.Configuration) (providers middlewares.Providers
errors = append(errors, err)
}
+ var failed bool
+ if !config.NTP.DisableStartupCheck && authorizer.IsSecondFactorEnabled() {
+ failed, err = ntpProvider.StartupCheck()
+
+ if err != nil {
+ logger.Errorf("Failed to check time against the NTP server: %+v", err)
+ }
+
+ if failed {
+ if config.NTP.DisableFailure {
+ logger.Error("The system time is outside the maximum desynchronization when compared to the time reported by the NTP server, this may cause issues in validating TOTP secrets")
+ } else {
+ logger.Fatal("The system time is outside the maximum desynchronization when compared to the time reported by the NTP server")
+ }
+ } else {
+ logger.Debug("The system time is within the maximum desynchronization when compared to the time reported by the NTP server")
+ }
+ }
+
return middlewares.Providers{
Authorizer: authorizer,
UserProvider: userProvider,
Regulator: regulator,
OpenIDConnect: oidcProvider,
StorageProvider: storageProvider,
+ NTP: ntpProvider,
Notifier: notifier,
SessionProvider: sessionProvider,
}, warnings, errors
diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml
index b349511fe..e21d4291a 100644
--- a/internal/configuration/config.template.yml
+++ b/internal/configuration/config.template.yml
@@ -108,6 +108,29 @@ duo_api:
## Secret can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
secret_key: 1234567890abcdefghifjkl
+##
+## NTP Configuration
+##
+## This is used to validate the servers time is accurate enough to validate TOTP.
+ntp:
+ ## NTP server address.
+ address: "time.cloudflare.com:123"
+
+ ## NTP version.
+ version: 4
+
+ ## Maximum allowed time offset between the host and the NTP server.
+ max_desync: 3s
+
+ ## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you
+ ## set this to true, and can operate in a truly offline mode.
+ disable_startup_check: false
+
+ ## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with
+ ## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup
+ ## will continue regardless of results.
+ disable_failure: false
+
##
## Authentication Backend Provider Configuration
##
diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go
index 47339ce70..60ed4f4b8 100644
--- a/internal/configuration/schema/configuration.go
+++ b/internal/configuration/schema/configuration.go
@@ -22,6 +22,7 @@ type Configuration struct {
TOTP *TOTPConfiguration `koanf:"totp"`
DuoAPI *DuoAPIConfiguration `koanf:"duo_api"`
AccessControl AccessControlConfiguration `koanf:"access_control"`
+ NTP *NTPConfiguration `koanf:"ntp"`
Regulation *RegulationConfiguration `koanf:"regulation"`
Storage StorageConfiguration `koanf:"storage"`
Notifier *NotifierConfiguration `koanf:"notifier"`
diff --git a/internal/configuration/schema/ntp.go b/internal/configuration/schema/ntp.go
new file mode 100644
index 000000000..5ea9b67e2
--- /dev/null
+++ b/internal/configuration/schema/ntp.go
@@ -0,0 +1,17 @@
+package schema
+
+// NTPConfiguration represents the configuration related to ntp server.
+type NTPConfiguration struct {
+ Address string `koanf:"address"`
+ Version int `koanf:"version"`
+ MaximumDesync string `koanf:"max_desync"`
+ DisableStartupCheck bool `koanf:"disable_startup_check"`
+ DisableFailure bool `koanf:"disable_failure"`
+}
+
+// DefaultNTPConfiguration represents default configuration parameters for the NTP server.
+var DefaultNTPConfiguration = NTPConfiguration{
+ Address: "time.cloudflare.com:123",
+ Version: 4,
+ MaximumDesync: "3s",
+}
diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go
index d23325db0..0f5ec55b6 100644
--- a/internal/configuration/validator/configuration.go
+++ b/internal/configuration/validator/configuration.go
@@ -65,4 +65,10 @@ func ValidateConfiguration(configuration *schema.Configuration, validator *schem
}
ValidateIdentityProviders(&configuration.IdentityProviders, validator)
+
+ if configuration.NTP == nil {
+ configuration.NTP = &schema.DefaultNTPConfiguration
+ }
+
+ ValidateNTP(configuration.NTP, validator)
}
diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go
index e238b7f5b..b6339ca33 100644
--- a/internal/configuration/validator/const.go
+++ b/internal/configuration/validator/const.go
@@ -301,6 +301,13 @@ var ValidKeys = []string{
"identity_providers.oidc.clients[].scopes",
"identity_providers.oidc.clients[].grant_types",
"identity_providers.oidc.clients[].response_types",
+
+ // NTP keys.
+ "ntp.address",
+ "ntp.version",
+ "ntp.max_desync",
+ "ntp.disable_startup_check",
+ "ntp.disable_failure",
}
var replacedKeys = map[string]string{
diff --git a/internal/configuration/validator/ntp.go b/internal/configuration/validator/ntp.go
new file mode 100644
index 000000000..01cdd913b
--- /dev/null
+++ b/internal/configuration/validator/ntp.go
@@ -0,0 +1,30 @@
+package validator
+
+import (
+ "fmt"
+
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/utils"
+)
+
+// ValidateNTP validates and update NTP configuration.
+func ValidateNTP(configuration *schema.NTPConfiguration, validator *schema.StructValidator) {
+ if configuration.Address == "" {
+ configuration.Address = schema.DefaultNTPConfiguration.Address
+ }
+
+ if configuration.Version == 0 {
+ configuration.Version = schema.DefaultNTPConfiguration.Version
+ } else if configuration.Version < 3 || configuration.Version > 4 {
+ validator.Push(fmt.Errorf("ntp: version must be either 3 or 4"))
+ }
+
+ if configuration.MaximumDesync == "" {
+ configuration.MaximumDesync = schema.DefaultNTPConfiguration.MaximumDesync
+ }
+
+ _, err := utils.ParseDurationString(configuration.MaximumDesync)
+ if err != nil {
+ validator.Push(fmt.Errorf("ntp: error occurred parsing NTP max_desync string: %s", err))
+ }
+}
diff --git a/internal/configuration/validator/ntp_test.go b/internal/configuration/validator/ntp_test.go
new file mode 100644
index 000000000..f2bc29c9b
--- /dev/null
+++ b/internal/configuration/validator/ntp_test.go
@@ -0,0 +1,65 @@
+package validator
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+)
+
+func newDefaultNTPConfig() schema.NTPConfiguration {
+ config := schema.NTPConfiguration{}
+ return config
+}
+
+func TestShouldSetDefaultNtpAddress(t *testing.T) {
+ validator := schema.NewStructValidator()
+ config := newDefaultNTPConfig()
+
+ ValidateNTP(&config, validator)
+
+ assert.Len(t, validator.Errors(), 0)
+ assert.Equal(t, schema.DefaultNTPConfiguration.Address, config.Address)
+}
+
+func TestShouldSetDefaultNtpVersion(t *testing.T) {
+ validator := schema.NewStructValidator()
+ config := newDefaultNTPConfig()
+
+ ValidateNTP(&config, validator)
+
+ assert.Len(t, validator.Errors(), 0)
+ assert.Equal(t, schema.DefaultNTPConfiguration.Version, config.Version)
+}
+
+func TestShouldSetDefaultNtpMaximumDesync(t *testing.T) {
+ validator := schema.NewStructValidator()
+ config := newDefaultNTPConfig()
+
+ ValidateNTP(&config, validator)
+
+ assert.Len(t, validator.Errors(), 0)
+ assert.Equal(t, schema.DefaultNTPConfiguration.MaximumDesync, config.MaximumDesync)
+}
+
+func TestShouldSetDefaultNtpDisableStartupCheck(t *testing.T) {
+ validator := schema.NewStructValidator()
+ config := newDefaultNTPConfig()
+
+ ValidateNTP(&config, validator)
+
+ assert.Len(t, validator.Errors(), 0)
+ assert.Equal(t, schema.DefaultNTPConfiguration.DisableStartupCheck, config.DisableStartupCheck)
+}
+
+func TestShouldRaiseErrorOnMaximumDesyncString(t *testing.T) {
+ validator := schema.NewStructValidator()
+ config := newDefaultNTPConfig()
+ config.MaximumDesync = "a second"
+
+ ValidateNTP(&config, validator)
+
+ assert.Len(t, validator.Errors(), 1)
+ assert.EqualError(t, validator.Errors()[0], "ntp: error occurred parsing NTP max_desync string: could not convert the input string of a second into a duration")
+}
diff --git a/internal/middlewares/types.go b/internal/middlewares/types.go
index f1e8467cf..a04209193 100644
--- a/internal/middlewares/types.go
+++ b/internal/middlewares/types.go
@@ -9,6 +9,7 @@ import (
"github.com/authelia/authelia/v4/internal/authorization"
"github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/notification"
+ "github.com/authelia/authelia/v4/internal/ntp"
"github.com/authelia/authelia/v4/internal/oidc"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session"
@@ -33,7 +34,7 @@ type Providers struct {
SessionProvider *session.Provider
Regulator *regulation.Regulator
OpenIDConnect oidc.OpenIDConnectProvider
-
+ NTP *ntp.Provider
UserProvider authentication.UserProvider
StorageProvider storage.Provider
Notifier notification.Notifier
diff --git a/internal/ntp/const.go b/internal/ntp/const.go
new file mode 100644
index 000000000..11cb5c2cb
--- /dev/null
+++ b/internal/ntp/const.go
@@ -0,0 +1,15 @@
+package ntp
+
+const (
+ ntpClientModeValue uint8 = 3 // 00000011
+ ntpLeapEnabledValue uint8 = 64 // 01000000
+ ntpVersion3Value uint8 = 24 // 00011000
+ ntpVersion4Value uint8 = 40 // 00101000
+)
+
+const ntpEpochOffset = 2208988800
+
+const (
+ ntpV3 ntpVersion = iota
+ ntpV4
+)
diff --git a/internal/ntp/ntp.go b/internal/ntp/ntp.go
new file mode 100644
index 000000000..2ca914d65
--- /dev/null
+++ b/internal/ntp/ntp.go
@@ -0,0 +1,55 @@
+package ntp
+
+import (
+ "encoding/binary"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/utils"
+)
+
+// NewProvider instantiate a ntp provider given a configuration.
+func NewProvider(config *schema.NTPConfiguration) *Provider {
+ return &Provider{config}
+}
+
+// StartupCheck checks if the system clock is not out of sync.
+func (p *Provider) StartupCheck() (failed bool, err error) {
+ conn, err := net.Dial("udp", p.config.Address)
+ if err != nil {
+ return false, fmt.Errorf("could not connect to NTP server to validate the time desync: %w", err)
+ }
+
+ defer conn.Close()
+
+ if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ return false, fmt.Errorf("could not connect to NTP server to validate the time desync: %w", err)
+ }
+
+ version := ntpV4
+ if p.config.Version == 3 {
+ version = ntpV3
+ }
+
+ req := &ntpPacket{LeapVersionMode: ntpLeapVersionClientMode(false, version)}
+
+ if err := binary.Write(conn, binary.BigEndian, req); err != nil {
+ return false, fmt.Errorf("could not write to the NTP server socket to validate the time desync: %w", err)
+ }
+
+ now := time.Now()
+
+ resp := &ntpPacket{}
+
+ if err := binary.Read(conn, binary.BigEndian, resp); err != nil {
+ return false, fmt.Errorf("could not read from the NTP server socket to validate the time desync: %w", err)
+ }
+
+ maxOffset, _ := utils.ParseDurationString(p.config.MaximumDesync)
+
+ ntpTime := ntpPacketToTime(resp)
+
+ return ntpIsOffsetTooLarge(maxOffset, now, ntpTime), nil
+}
diff --git a/internal/ntp/ntp_test.go b/internal/ntp/ntp_test.go
new file mode 100644
index 000000000..3c578b962
--- /dev/null
+++ b/internal/ntp/ntp_test.go
@@ -0,0 +1,26 @@
+package ntp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+ "github.com/authelia/authelia/v4/internal/configuration/validator"
+)
+
+func TestShouldCheckNTP(t *testing.T) {
+ config := schema.NTPConfiguration{
+ Address: "time.cloudflare.com:123",
+ Version: 4,
+ MaximumDesync: "3s",
+ DisableStartupCheck: false,
+ }
+ sv := schema.NewStructValidator()
+ validator.ValidateNTP(&config, sv)
+
+ NTP := NewProvider(&config)
+
+ checkfailed, _ := NTP.StartupCheck()
+ assert.Equal(t, false, checkfailed)
+}
diff --git a/internal/ntp/types.go b/internal/ntp/types.go
new file mode 100644
index 000000000..7aa69dad7
--- /dev/null
+++ b/internal/ntp/types.go
@@ -0,0 +1,30 @@
+package ntp
+
+import (
+ "github.com/authelia/authelia/v4/internal/configuration/schema"
+)
+
+// Provider type is the NTP provider.
+type Provider struct {
+ config *schema.NTPConfiguration
+}
+
+type ntpVersion int
+
+type ntpPacket struct {
+ LeapVersionMode uint8
+ Stratum uint8
+ Poll int8
+ Precision int8
+ RootDelay uint32
+ RootDispersion uint32
+ ReferenceID uint32
+ ReferenceTimeSeconds uint32
+ ReferenceTimeFraction uint32
+ OriginTimeSeconds uint32
+ OriginTimeFraction uint32
+ RxTimeSeconds uint32
+ RxTimeFraction uint32
+ TxTimeSeconds uint32
+ TxTimeFraction uint32
+}
diff --git a/internal/ntp/util.go b/internal/ntp/util.go
new file mode 100644
index 000000000..fd6b6a835
--- /dev/null
+++ b/internal/ntp/util.go
@@ -0,0 +1,42 @@
+package ntp
+
+import "time"
+
+// ntpLeapVersionClientMode does the mathematics to configure the leap/version/mode value of an NTP client packet.
+func ntpLeapVersionClientMode(leap bool, version ntpVersion) (lvm uint8) {
+ lvm = ntpClientModeValue
+
+ if leap {
+ lvm += ntpLeapEnabledValue
+ }
+
+ switch version {
+ case ntpV3:
+ lvm += ntpVersion3Value
+ case ntpV4:
+ lvm += ntpVersion4Value
+ }
+
+ return lvm
+}
+
+// ntpPacketToTime converts a NTP server response into a time.Time.
+func ntpPacketToTime(packet *ntpPacket) time.Time {
+ seconds := float64(packet.TxTimeSeconds) - ntpEpochOffset
+ nanoseconds := (int64(packet.TxTimeFraction) * 1e9) >> 32
+
+ return time.Unix(int64(seconds), nanoseconds)
+}
+
+// ntpIsOffsetTooLarge return true if there is offset of "offset" between two times.
+func ntpIsOffsetTooLarge(maxOffset time.Duration, first, second time.Time) (tooLarge bool) {
+ var offset time.Duration
+
+ if first.After(second) {
+ offset = first.Sub(second)
+ } else {
+ offset = second.Sub(first)
+ }
+
+ return offset > maxOffset
+}
diff --git a/internal/ntp/util_test.go b/internal/ntp/util_test.go
new file mode 100644
index 000000000..53cb9b819
--- /dev/null
+++ b/internal/ntp/util_test.go
@@ -0,0 +1,16 @@
+package ntp
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/authelia/authelia/v4/internal/utils"
+)
+
+func TestShould(t *testing.T) {
+ maxOffset, _ := utils.ParseDurationString("1s")
+ assert.True(t, ntpIsOffsetTooLarge(maxOffset, time.Now(), time.Now().Add(time.Second*2)))
+ assert.False(t, ntpIsOffsetTooLarge(maxOffset, time.Now(), time.Now()))
+}
diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml
index 19fe89f01..5dec5e49e 100644
--- a/internal/suites/Standalone/configuration.yml
+++ b/internal/suites/Standalone/configuration.yml
@@ -89,4 +89,13 @@ notifier:
port: 1025
sender: admin@example.com
disable_require_tls: true
+ntp:
+ ## NTP server address
+ address: "time.cloudflare.com:123"
+ ## ntp version
+ version: 4
+ ## "maximum desynchronization" is the allowed offset time between the host and the ntp server
+ max_desync: 3s
+ ## You can enable or disable the NTP synchronization check on startup
+ disable_startup_check: false
...