Repository: authelia/authelia (go)

Issue:
Add a time desync detection for TOTP
## Feature Request

### Description

Many users fail to authenticate with TOTP and this is usually because of time desync between the browser and the client. We should be able to detect this case and log it for the user to fix it before retrying.

### Implementation Proposal

When validating the TOTP, the date and time retrieved by the browser could be added to the payload and the server could validate the time received is not too far from the current time on the server. If it's the case, it would probably make sense to display a message to the user saying something like: "Synchronize you local clock before retrying". 

Pull request description:
feat: ntp synchronization check on startup
feat: add NTP synchronization check

Usually TOTP fails because of the host is out of sync, this feature will check if the server is synchronized and prints out to the user a success or failed check on startup.
If failed (the host is out of sync), the process will exit and the user should fix the problem.

Fixes #2068.

Patch:
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
+<div markdown="1">
+type: string
+{: .label .label-config .label-purple } 
+default: time.cloudflare.com:123
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+</div>
+
+Determines the address of the NTP server to retrieve the time from. The format is `<host>:<port>`, and both of these are
+required.
+
+### version
+<div markdown="1">
+type: integer
+{: .label .label-config .label-purple } 
+default: 4
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+</div>
+
+Determines the NTP verion supported. Valid values are 3 or 4.
+
+### max_desync
+<div markdown="1">
+type: duration
+{: .label .label-config .label-purple } 
+default: 3s
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+</div>
+
+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
+<div markdown="1">
+type: boolean
+{: .label .label-config .label-purple } 
+default: false
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+</div>
+
+Setting this to true will disable the startup check entirely.
+
+### disable_failure
+<div markdown="1">
+type: boolean
+{: .label .label-config .label-purple } 
+default: false
+{: .label .label-config .label-blue }
+required: no
+{: .label .label-config .label-green }
+</div>
+
+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/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/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/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
 ...


Test patch:
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/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/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()))
+}