From 3d20142292cb9894791b290496b292d760d70f0b Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 7 Dec 2019 17:40:42 +0100 Subject: [PATCH] Allow administrator to provide a Google Analytics tracking ID. Providing a GA tracking ID allows administrators to analyze how the portal is used by their users in large environments, i.e., with many users. This will make even more sense when we have users and admins management interfaces. --- config.template.yml | 5 ++ example/compose/authelia/Dockerfile.backend | 9 ++- .../authelia/docker-compose.backend-dist.yml | 2 - .../authelia/docker-compose.backend.yml | 1 - .../compose/authelia/resources/entrypoint.sh | 3 - .../compose/nginx/portal/docker-compose.yml | 2 - .../configuration/schema/configuration.go | 10 +-- .../handlers/handler_2fa_available_methods.go | 19 ------ internal/handlers/handler_configuration.go | 14 ++++ .../handlers/handler_configuration_test.go | 32 +++++++++ .../handler_extended_configuration.go | 23 +++++++ ...=> handler_extended_configuration_test.go} | 15 +++-- internal/server/server.go | 8 +-- internal/suites/BypassAll/configuration.yml | 2 +- internal/suites/Docker/configuration.yml | 2 +- internal/suites/DuoPush/configuration.yml | 2 +- internal/suites/LDAP/configuration.yml | 2 +- internal/suites/NetworkACL/configuration.yml | 2 +- .../suites/ShortTimeouts/configuration.yml | 37 +---------- internal/suites/Standalone/docker-compose.yml | 1 + internal/suites/Traefik/configuration.yml | 2 +- .../scenario_backend_protection_test.go | 5 +- internal/suites/scenario_two_factor_test.go | 31 --------- .../suites/suite_high_availability_test.go | 10 ++- internal/suites/suite_standalone.go | 2 +- internal/suites/suite_standalone_test.go | 31 +++++++++ web/package-lock.json | 13 ++++ web/package.json | 2 + web/src/App.tsx | 66 ++++++++++++------- web/src/components/Tracker.test.tsx | 11 ++++ web/src/components/Tracker.tsx | 35 ++++++++++ web/src/hooks/Configuration.ts | 10 ++- web/src/hooks/Tracking.tsx | 15 +++++ web/src/models/Configuration.ts | 8 ++- web/src/services/Api.ts | 3 + web/src/services/Configuration.ts | 20 ++++-- web/src/views/LoginPortal/LoginPortal.tsx | 4 +- .../SecondFactor/SecondFactorForm.tsx | 6 +- 38 files changed, 306 insertions(+), 159 deletions(-) delete mode 100644 internal/handlers/handler_2fa_available_methods.go create mode 100644 internal/handlers/handler_configuration.go create mode 100644 internal/handlers/handler_configuration_test.go create mode 100644 internal/handlers/handler_extended_configuration.go rename internal/handlers/{handler_2fa_available_methods_test.go => handler_extended_configuration_test.go} (70%) create mode 100644 web/src/components/Tracker.test.tsx create mode 100644 web/src/components/Tracker.tsx create mode 100644 web/src/hooks/Tracking.tsx diff --git a/config.template.yml b/config.template.yml index 4d15f6096..55d157dd2 100644 --- a/config.template.yml +++ b/config.template.yml @@ -26,6 +26,11 @@ jwt_secret: a_very_important_secret # be redirected upon successful authentication. default_redirection_url: https://home.example.com:8080/ +# Google Analytics Tracking ID to track the usage of the portal +# using a Google Analytics dashboard. +# +## google_analytics: UA-00000-01 + # TOTP Issuer Name # # This will be the issuer name displayed in Google Authenticator diff --git a/example/compose/authelia/Dockerfile.backend b/example/compose/authelia/Dockerfile.backend index 5847101b6..bd42297d2 100644 --- a/example/compose/authelia/Dockerfile.backend +++ b/example/compose/authelia/Dockerfile.backend @@ -7,4 +7,11 @@ ARG GROUP_ID RUN addgroup --gid ${GROUP_ID} dev && \ adduser --uid ${USER_ID} -G dev -D dev -USER dev \ No newline at end of file + +RUN mkdir -p /etc/authelia && chown dev:dev /etc/authelia +RUN mkdir -p /var/lib/authelia && chown dev:dev /var/lib/authelia + +USER dev + +VOLUME /etc/authelia +VOLUME /var/lib/authelia diff --git a/example/compose/authelia/docker-compose.backend-dist.yml b/example/compose/authelia/docker-compose.backend-dist.yml index 6b5a3ef64..da6148082 100644 --- a/example/compose/authelia/docker-compose.backend-dist.yml +++ b/example/compose/authelia/docker-compose.backend-dist.yml @@ -4,8 +4,6 @@ services: build: context: . dockerfile: Dockerfile - volumes: - - "/tmp/authelia:/tmp/authelia" environment: - ENVIRONMENT=dev restart: always diff --git a/example/compose/authelia/docker-compose.backend.yml b/example/compose/authelia/docker-compose.backend.yml index 0bab39b6d..2b9bf9ed0 100644 --- a/example/compose/authelia/docker-compose.backend.yml +++ b/example/compose/authelia/docker-compose.backend.yml @@ -13,7 +13,6 @@ services: - "./example/compose/authelia/resources/:/resources" - ".:/app" - "${GOPATH}:/go" - - "/tmp/authelia:/tmp/authelia" environment: - ENVIRONMENT=dev networks: diff --git a/example/compose/authelia/resources/entrypoint.sh b/example/compose/authelia/resources/entrypoint.sh index e31d1459f..e5aceee14 100755 --- a/example/compose/authelia/resources/entrypoint.sh +++ b/example/compose/authelia/resources/entrypoint.sh @@ -4,9 +4,6 @@ set -x go get github.com/cespare/reflex -mkdir -p /var/lib/authelia -mkdir -p /etc/authelia - # Sleep 10 seconds to wait the end of npm install updating web directory # and making reflex reload multiple times. sleep 10 diff --git a/example/compose/nginx/portal/docker-compose.yml b/example/compose/nginx/portal/docker-compose.yml index ac74a1ee6..b387d7f3e 100644 --- a/example/compose/nginx/portal/docker-compose.yml +++ b/example/compose/nginx/portal/docker-compose.yml @@ -5,8 +5,6 @@ services: volumes: - ./example/compose/nginx/portal/nginx.conf:/etc/nginx/nginx.conf - ./example/compose/nginx/portal/ssl:/etc/ssl - ports: - - "8080:8080" networks: authelianet: aliases: diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index 3035fcc20..31e5119c4 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -2,10 +2,12 @@ package schema // Configuration object extracted from YAML configuration file. type Configuration struct { - Port int `yaml:"port"` - LogsLevel string `yaml:"logs_level"` - JWTSecret string `yaml:"jwt_secret"` - DefaultRedirectionURL string `yaml:"default_redirection_url"` + Port int `yaml:"port"` + LogsLevel string `yaml:"logs_level"` + JWTSecret string `yaml:"jwt_secret"` + DefaultRedirectionURL string `yaml:"default_redirection_url"` + GoogleAnalyticsTrackingID string `yaml:"google_analytics"` + AuthenticationBackend AuthenticationBackendConfiguration `yaml:"authentication_backend"` Session SessionConfiguration `yaml:"session"` diff --git a/internal/handlers/handler_2fa_available_methods.go b/internal/handlers/handler_2fa_available_methods.go deleted file mode 100644 index 72ee789df..000000000 --- a/internal/handlers/handler_2fa_available_methods.go +++ /dev/null @@ -1,19 +0,0 @@ -package handlers - -import ( - "github.com/clems4ever/authelia/internal/authentication" - "github.com/clems4ever/authelia/internal/middlewares" -) - -// SecondFactorAvailableMethodsGet retrieve available 2FA methods. -// The supported methods are: "totp", "u2f", "duo" -func SecondFactorAvailableMethodsGet(ctx *middlewares.AutheliaCtx) { - availableMethods := MethodList{authentication.TOTP, authentication.U2F} - - if ctx.Configuration.DuoAPI != nil { - availableMethods = append(availableMethods, authentication.Push) - } - - ctx.Logger.Debugf("Available methods are %s", availableMethods) - ctx.SetJSONBody(availableMethods) -} diff --git a/internal/handlers/handler_configuration.go b/internal/handlers/handler_configuration.go new file mode 100644 index 000000000..1a96148e2 --- /dev/null +++ b/internal/handlers/handler_configuration.go @@ -0,0 +1,14 @@ +package handlers + +import "github.com/clems4ever/authelia/internal/middlewares" + +type ConfigurationBody struct { + GoogleAnalyticsTrackingID string `json:"ga_tracking_id,omitempty"` +} + +func ConfigurationGet(ctx *middlewares.AutheliaCtx) { + body := ConfigurationBody{ + GoogleAnalyticsTrackingID: ctx.Configuration.GoogleAnalyticsTrackingID, + } + ctx.SetJSONBody(body) +} diff --git a/internal/handlers/handler_configuration_test.go b/internal/handlers/handler_configuration_test.go new file mode 100644 index 000000000..e7532802f --- /dev/null +++ b/internal/handlers/handler_configuration_test.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "github.com/clems4ever/authelia/internal/mocks" + "github.com/stretchr/testify/suite" +) + +type ConfigurationSuite struct { + suite.Suite + + mock *mocks.MockAutheliaCtx +} + +func (s *ConfigurationSuite) SetupTest() { + s.mock = mocks.NewMockAutheliaCtx(s.T()) +} + +func (s *ConfigurationSuite) TearDownTest() { + s.mock.Close() +} + +func (s *ConfigurationSuite) TestShouldReturnConfiguredGATrackingID() { + GATrackingID := "ABC" + s.mock.Ctx.Configuration.GoogleAnalyticsTrackingID = GATrackingID + + expectedBody := ConfigurationBody{ + GoogleAnalyticsTrackingID: GATrackingID, + } + + ConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), expectedBody) +} diff --git a/internal/handlers/handler_extended_configuration.go b/internal/handlers/handler_extended_configuration.go new file mode 100644 index 000000000..bd32d6eb9 --- /dev/null +++ b/internal/handlers/handler_extended_configuration.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "github.com/clems4ever/authelia/internal/authentication" + "github.com/clems4ever/authelia/internal/middlewares" +) + +type ExtendedConfigurationBody struct { + AvailableMethods MethodList `json:"available_methods"` +} + +// ExtendedConfigurationGet get the extended configuration accessbile to authenticated users. +func ExtendedConfigurationGet(ctx *middlewares.AutheliaCtx) { + body := ExtendedConfigurationBody{} + body.AvailableMethods = MethodList{authentication.TOTP, authentication.U2F} + + if ctx.Configuration.DuoAPI != nil { + body.AvailableMethods = append(body.AvailableMethods, authentication.Push) + } + + ctx.Logger.Debugf("Available methods are %s", body.AvailableMethods) + ctx.SetJSONBody(body) +} diff --git a/internal/handlers/handler_2fa_available_methods_test.go b/internal/handlers/handler_extended_configuration_test.go similarity index 70% rename from internal/handlers/handler_2fa_available_methods_test.go rename to internal/handlers/handler_extended_configuration_test.go index 1552d80fe..c0cd06c12 100644 --- a/internal/handlers/handler_2fa_available_methods_test.go +++ b/internal/handlers/handler_extended_configuration_test.go @@ -11,7 +11,6 @@ import ( type SecondFactorAvailableMethodsFixture struct { suite.Suite - mock *mocks.MockAutheliaCtx } @@ -24,16 +23,22 @@ func (s *SecondFactorAvailableMethodsFixture) TearDownTest() { } func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() { - SecondFactorAvailableMethodsGet(s.mock.Ctx) - s.mock.Assert200OK(s.T(), []string{"totp", "u2f"}) + expectedBody := ExtendedConfigurationBody{ + AvailableMethods: []string{"totp", "u2f"}, + } + ExtendedConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), expectedBody) } func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethodsAndMobilePush() { s.mock.Ctx.Configuration = schema.Configuration{ DuoAPI: &schema.DuoAPIConfiguration{}, } - SecondFactorAvailableMethodsGet(s.mock.Ctx) - s.mock.Assert200OK(s.T(), []string{"totp", "u2f", "mobile_push"}) + expectedBody := ExtendedConfigurationBody{ + AvailableMethods: []string{"totp", "u2f", "mobile_push"}, + } + ExtendedConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), expectedBody) } func TestRunSuite(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 05def5cc4..7229a621c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,6 +32,10 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi router.GET("/api/state", autheliaMiddleware(handlers.StateGet)) + router.GET("/api/configuration", autheliaMiddleware(handlers.ConfigurationGet)) + router.GET("/api/configuration/extended", autheliaMiddleware( + middlewares.RequireFirstFactor(handlers.ExtendedConfigurationGet))) + router.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet)) router.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost)) @@ -45,10 +49,6 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi router.POST("/api/reset-password", autheliaMiddleware( handlers.ResetPasswordPost)) - // 2FA preferences and settings related endpoints. - router.GET("/api/secondfactor/available", autheliaMiddleware( - middlewares.RequireFirstFactor(handlers.SecondFactorAvailableMethodsGet))) - // Information about the user router.GET("/api/user/info", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.UserInfoGet))) diff --git a/internal/suites/BypassAll/configuration.yml b/internal/suites/BypassAll/configuration.yml index 4fd638e3e..6b04336de 100644 --- a/internal/suites/BypassAll/configuration.yml +++ b/internal/suites/BypassAll/configuration.yml @@ -20,7 +20,7 @@ session: storage: local: - path: /tmp/authelia/db.sqlite + path: /var/lib/authelia/db.sqlite # The Duo Push Notification API configuration duo_api: diff --git a/internal/suites/Docker/configuration.yml b/internal/suites/Docker/configuration.yml index e3f1cb636..adbb6982b 100644 --- a/internal/suites/Docker/configuration.yml +++ b/internal/suites/Docker/configuration.yml @@ -22,7 +22,7 @@ session: storage: local: - path: /tmp/authelia/db.sqlite3 + path: /var/lib/authelia/db.sqlite3 totp: issuer: example.com diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml index 18debd100..bff5891f7 100644 --- a/internal/suites/DuoPush/configuration.yml +++ b/internal/suites/DuoPush/configuration.yml @@ -21,7 +21,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: local: - path: /tmp/authelia/db.sqlite + path: /var/lib/authelia/db.sqlite # TOTP Issuer Name # diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index 8d11fb07c..80b988864 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -57,7 +57,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: local: - path: /tmp/authelia/db.sqlite3 + path: /var/lib/authelia/db.sqlite3 # TOTP Issuer Name # diff --git a/internal/suites/NetworkACL/configuration.yml b/internal/suites/NetworkACL/configuration.yml index 35e67a513..760acc8b1 100644 --- a/internal/suites/NetworkACL/configuration.yml +++ b/internal/suites/NetworkACL/configuration.yml @@ -21,7 +21,7 @@ session: # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: local: - path: /tmp/authelia/db.sqlite + path: /var/lib/authelia/db.sqlite # Access Control # diff --git a/internal/suites/ShortTimeouts/configuration.yml b/internal/suites/ShortTimeouts/configuration.yml index 1f0e36de7..f88ed2e79 100644 --- a/internal/suites/ShortTimeouts/configuration.yml +++ b/internal/suites/ShortTimeouts/configuration.yml @@ -20,24 +20,14 @@ session: inactivity: 5 expiration: 8 -# Configuration of the storage backend used to store data and secrets. i.e. totp data storage: local: - path: /tmp/authelia/db.sqlite + path: /var/lib/authelia/db.sqlite -# TOTP Issuer Name -# -# This will be the issuer name displayed in Google Authenticator -# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names totp: issuer: example.com -# Access Control -# -# Access control is a set of rules you can use to restrict user access to certain -# resources. access_control: - # Default policy can either be `bypass`, `one_factor`, `two_factor` or `deny`. default_policy: deny rules: @@ -70,37 +60,12 @@ access_control: subject: "user:bob" policy: two_factor -# Configuration of the authentication regulation mechanism. regulation: - # Set it to 0 to disable max_retries. max_retries: 3 - - # The user is banned if the authenticaction failed `max_retries` times in a `find_time` seconds window. find_time: 3 - - # The length of time before a banned user can login again. ban_time: 5 -# Default redirection URL -# -# Note: this parameter is optional. If not provided, user won't -# be redirected upon successful authentication. -#default_redirection_url: https://authelia.example.domain - notifier: - # For testing purpose, notifications can be sent in a file - # filesystem: - # filename: /tmp/authelia/notification.txt - - # Use your email account to send the notifications. You can use an app password. - # List of valid services can be found here: https://nodemailer.com/smtp/well-known/ - ## email: - ## username: user@example.com - ## password: yourpassword - ## sender: admin@example.com - ## service: gmail - - # Use a SMTP server for sending notifications smtp: host: smtp port: 1025 diff --git a/internal/suites/Standalone/docker-compose.yml b/internal/suites/Standalone/docker-compose.yml index 9231bb69f..305ddfc42 100644 --- a/internal/suites/Standalone/docker-compose.yml +++ b/internal/suites/Standalone/docker-compose.yml @@ -4,3 +4,4 @@ services: volumes: - "./internal/suites/Standalone/configuration.yml:/etc/authelia/configuration.yml:ro" - "./internal/suites/Standalone/users.yml:/var/lib/authelia/users.yml" + - "/tmp/authelia:/tmp/authelia" diff --git a/internal/suites/Traefik/configuration.yml b/internal/suites/Traefik/configuration.yml index fa6930ea5..201d95459 100644 --- a/internal/suites/Traefik/configuration.yml +++ b/internal/suites/Traefik/configuration.yml @@ -20,7 +20,7 @@ session: storage: local: - path: /tmp/authelia/db.sqlite + path: /var/lib/authelia/db.sqlite access_control: default_policy: bypass diff --git a/internal/suites/scenario_backend_protection_test.go b/internal/suites/scenario_backend_protection_test.go index d990c4615..52d969e79 100644 --- a/internal/suites/scenario_backend_protection_test.go +++ b/internal/suites/scenario_backend_protection_test.go @@ -45,7 +45,10 @@ func (s *BackendProtectionScenario) TestProtectionOfBackendEndpoints() { s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/user/info/2fa_method", AutheliaBaseURL), 403) s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/user/info", AutheliaBaseURL), 403) - s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/secondfactor/available", AutheliaBaseURL), 403) + s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/configuration/extended", AutheliaBaseURL), 403) + + // This is the global configuration, it's safe to let it open. + s.AssertRequestStatusCode("GET", fmt.Sprintf("%s/api/configuration", AutheliaBaseURL), 200) s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/start", AutheliaBaseURL), 403) s.AssertRequestStatusCode("POST", fmt.Sprintf("%s/api/secondfactor/u2f/identity/finish", AutheliaBaseURL), 403) diff --git a/internal/suites/scenario_two_factor_test.go b/internal/suites/scenario_two_factor_test.go index 900aab575..e01aefe68 100644 --- a/internal/suites/scenario_two_factor_test.go +++ b/internal/suites/scenario_two_factor_test.go @@ -7,8 +7,6 @@ import ( "testing" "time" - "github.com/clems4ever/authelia/internal/storage" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -49,35 +47,6 @@ func (s *TwoFactorSuite) SetupTest() { s.verifyIsHome(ctx, s.T()) } -func (s *TwoFactorSuite) TestShouldCheckUserIsAskedToRegisterDevice() { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - username := "john" - password := "password" - - // Clean up any TOTP secret already in DB - provider := storage.NewSQLiteProvider("/tmp/authelia/db.sqlite3") - require.NoError(s.T(), provider.DeleteTOTPSecret(username)) - - // Login one factor - s.doLoginOneFactor(ctx, s.T(), username, password, false, "") - - // Check the user is asked to register a new device - s.WaitElementLocatedByClassName(ctx, s.T(), "state-not-registered") - - // Then register the TOTP factor - s.doRegisterTOTP(ctx, s.T()) - // And logout - s.doLogout(ctx, s.T()) - - // Login one factor again - s.doLoginOneFactor(ctx, s.T(), username, password, false, "") - - // now the user should be asked to perform 2FA - s.WaitElementLocatedByClassName(ctx, s.T(), "state-method") -} - func (s *TwoFactorSuite) TestShouldAuthorizeSecretAfterTwoFactor() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/internal/suites/suite_high_availability_test.go b/internal/suites/suite_high_availability_test.go index f19084bb6..478dc9a6e 100644 --- a/internal/suites/suite_high_availability_test.go +++ b/internal/suites/suite_high_availability_test.go @@ -49,15 +49,15 @@ func (s *HighAvailabilityWebDriverSuite) SetupTest() { } func (s *HighAvailabilityWebDriverSuite) TestShouldKeepUserDataInDB() { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) defer cancel() secret := s.doRegisterThenLogout(ctx, s.T(), "john", "password") err := haDockerEnvironment.Restart("mariadb") - s.Assert().NoError(err) + s.Require().NoError(err) - time.Sleep(2 * time.Second) + time.Sleep(20 * time.Second) s.doLoginTwoFactor(ctx, s.T(), "john", "password", false, secret, "") s.verifyIsSecondFactorPage(ctx, s.T()) @@ -229,6 +229,10 @@ func (s *HighAvailabilitySuite) TestHighAvailabilityWebDriverSuite() { suite.Run(s.T(), NewHighAvailabilityWebDriverSuite()) } +func TestHighAvailabilityWebDriverSuite(t *testing.T) { + suite.Run(t, NewHighAvailabilityWebDriverSuite()) +} + func TestHighAvailabilitySuite(t *testing.T) { suite.Run(t, NewHighAvailabilitySuite()) } diff --git a/internal/suites/suite_standalone.go b/internal/suites/suite_standalone.go index 8e18b29e7..0306ebe65 100644 --- a/internal/suites/suite_standalone.go +++ b/internal/suites/suite_standalone.go @@ -53,7 +53,7 @@ func init() { SetUpTimeout: 5 * time.Minute, OnSetupTimeout: onSetupTimeout, TearDown: teardown, - TestTimeout: 2 * time.Minute, + TestTimeout: 3 * time.Minute, TearDownTimeout: 2 * time.Minute, Description: `This suite is used to test Authelia in a standalone configuration with in-memory sessions and a local sqlite db stored on disk`, diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index 8265844a8..afe08122c 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/clems4ever/authelia/internal/storage" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -65,6 +67,35 @@ func (s *StandaloneWebDriverSuite) TestShouldLetUserKnowHeIsAlreadyAuthenticated s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon") } +func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + username := "john" + password := "password" + + // Clean up any TOTP secret already in DB + provider := storage.NewSQLiteProvider("/tmp/authelia/db.sqlite3") + require.NoError(s.T(), provider.DeleteTOTPSecret(username)) + + // Login one factor + s.doLoginOneFactor(ctx, s.T(), username, password, false, "") + + // Check the user is asked to register a new device + s.WaitElementLocatedByClassName(ctx, s.T(), "state-not-registered") + + // Then register the TOTP factor + s.doRegisterTOTP(ctx, s.T()) + // And logout + s.doLogout(ctx, s.T()) + + // Login one factor again + s.doLoginOneFactor(ctx, s.T(), username, password, false, "") + + // now the user should be asked to perform 2FA + s.WaitElementLocatedByClassName(ctx, s.T(), "state-method") +} + type StandaloneSuite struct { suite.Suite } diff --git a/web/package-lock.json b/web/package-lock.json index 5b2609dac..96c623c06 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1566,6 +1566,14 @@ "@types/react": "*" } }, + "@types/react-ga": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/react-ga/-/react-ga-2.3.0.tgz", + "integrity": "sha512-7Vkv6wH1Kem4vkjuJxRYxDgLfokm0shugDk0W5p9C28POrsPAXezLbgP5C2tyFZ7lKARdyrCxwkdRTC1UV0dHg==", + "requires": { + "react-ga": "*" + } + }, "@types/react-router": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.3.tgz", @@ -11311,6 +11319,11 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.3.tgz", "integrity": "sha512-bOUvMWFQVk5oz8Ded9Xb7WVdEi3QGLC8tH7HmYP0Fdp4Bn3qw0tRFmr5TW6mvahzvmrK4a6bqWGfCevBflP+Xw==" }, + "react-ga": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-2.7.0.tgz", + "integrity": "sha512-AjC7UOZMvygrWTc2hKxTDvlMXEtbmA0IgJjmkhgmQQ3RkXrWR11xEagLGFGaNyaPnmg24oaIiaNPnEoftUhfXA==" + }, "react-is": { "version": "16.12.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", diff --git a/web/package.json b/web/package.json index 45189a7b8..8f480edc3 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@types/query-string": "^6.3.0", "@types/react": "16.9.12", "@types/react-dom": "16.9.4", + "@types/react-ga": "^2.3.0", "@types/react-router-dom": "^5.1.2", "axios": "^0.19.0", "chai": "^4.2.0", @@ -28,6 +29,7 @@ "query-string": "^6.9.0", "react": "^16.12.0", "react-dom": "^16.12.0", + "react-ga": "^2.7.0", "react-loading": "^2.0.3", "react-otp-input": "^1.0.1", "react-router-dom": "^5.1.2", diff --git a/web/src/App.tsx b/web/src/App.tsx index b9d78862d..83afe3db1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { BrowserRouter as Router, Route, Switch, Redirect } from "react-router-dom"; @@ -17,36 +17,52 @@ import NotificationsContext from './hooks/NotificationsContext'; import { Notification } from './models/Notifications'; import NotificationBar from './components/NotificationBar'; import SignOut from './views/LoginPortal/SignOut/SignOut'; +import { useConfiguration } from './hooks/Configuration'; +import Tracker from "./components/Tracker"; +import { useTracking } from "./hooks/Tracking"; const App: React.FC = () => { const [notification, setNotification] = useState(null as Notification | null); + const [configuration, fetchConfig, , fetchConfigError] = useConfiguration(); + const tracker = useTracking(configuration); + + useEffect(() => { + if (fetchConfigError) { + console.error(fetchConfigError); + } + }, [fetchConfigError]); + + useEffect(() => { fetchConfig() }, [fetchConfig]); + return ( - setNotification(null)} /> - - - - - - - - - - - - - - - - - - - - - - - + + setNotification(null)} /> + + + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/web/src/components/Tracker.test.tsx b/web/src/components/Tracker.test.tsx new file mode 100644 index 000000000..0ba960d7a --- /dev/null +++ b/web/src/components/Tracker.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { mount } from "enzyme"; +import Tracker from "./Tracker"; + +import { MemoryRouter as Router } from 'react-router-dom'; + +const mountWithRouter = node => mount({node}); + +it('renders without crashing', () => { + mountWithRouter(); +}); \ No newline at end of file diff --git a/web/src/components/Tracker.tsx b/web/src/components/Tracker.tsx new file mode 100644 index 000000000..a19840e1c --- /dev/null +++ b/web/src/components/Tracker.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useCallback, Fragment, ReactNode } from "react"; +import { useLocation } from "react-router"; +import ReactGA, { Tracker } from "react-ga"; + +export interface Props { + tracker: Tracker | undefined; + children?: ReactNode; +} + +export default function (props: Props) { + const location = useLocation(); + + const trackPage = useCallback((page: string) => { + if (props.tracker) { + ReactGA.set({ page }); + ReactGA.pageview(page); + } + }, [props.tracker]); + + useEffect(() => { + if (props.tracker) { + ReactGA.initialize([props.tracker]); + } + }, [props.tracker]); + + useEffect(() => { + trackPage(location.pathname + location.search); + }, [trackPage, location.pathname, location.search]); + + return ( + + {props.children} + + ) +} \ No newline at end of file diff --git a/web/src/hooks/Configuration.ts b/web/src/hooks/Configuration.ts index 44a16e0ad..18f407596 100644 --- a/web/src/hooks/Configuration.ts +++ b/web/src/hooks/Configuration.ts @@ -1,6 +1,10 @@ import { useRemoteCall } from "./RemoteCall"; -import { getAvailable2FAMethods } from "../services/Configuration"; +import { getConfiguration, getExtendedConfiguration } from "../services/Configuration"; -export function useAutheliaConfiguration() { - return useRemoteCall(getAvailable2FAMethods, []); +export function useConfiguration() { + return useRemoteCall(getConfiguration, []); +} + +export function useExtendedConfiguration() { + return useRemoteCall(getExtendedConfiguration, []); } \ No newline at end of file diff --git a/web/src/hooks/Tracking.tsx b/web/src/hooks/Tracking.tsx new file mode 100644 index 000000000..c6fe2815b --- /dev/null +++ b/web/src/hooks/Tracking.tsx @@ -0,0 +1,15 @@ +import { useState, useEffect } from "react"; +import { Configuration } from "../models/Configuration"; +import { Tracker } from "react-ga"; + +export function useTracking(configuration: Configuration | undefined) { + const [trackingIds, setTrackingIds] = useState(undefined as Tracker | undefined); + + useEffect(() => { + if (configuration && configuration.ga_tracking_id) { + setTrackingIds({ trackingId: configuration.ga_tracking_id }); + } + }, [configuration]); + + return trackingIds; +} \ No newline at end of file diff --git a/web/src/models/Configuration.ts b/web/src/models/Configuration.ts index 2784d9b37..f40766fd2 100644 --- a/web/src/models/Configuration.ts +++ b/web/src/models/Configuration.ts @@ -1,3 +1,9 @@ import { SecondFactorMethod } from "./Methods"; -export type Configuration = Set \ No newline at end of file +export interface Configuration { + ga_tracking_id: string; +} + +export interface ExtendedConfiguration { + available_methods: Set; +} \ No newline at end of file diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 22850d084..3df7ddf33 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -25,6 +25,9 @@ export const UserInfoPath = "/api/user/info"; export const UserInfo2FAMethodPath = "/api/user/info/2fa_method"; export const Available2FAMethodsPath = "/api/secondfactor/available"; +export const ConfigurationPath = "/api/configuration"; +export const ExtendedConfigurationPath = "/api/configuration/extended"; + export interface ErrorResponse { status: "KO"; message: string; diff --git a/web/src/services/Configuration.ts b/web/src/services/Configuration.ts index 47eb0425b..e77c610e3 100644 --- a/web/src/services/Configuration.ts +++ b/web/src/services/Configuration.ts @@ -1,9 +1,17 @@ import { Get } from "./Client"; -import { Available2FAMethodsPath } from "./Api"; -import { Method2FA, toEnum } from "./UserPreferences"; -import { Configuration } from "../models/Configuration"; +import { ExtendedConfigurationPath, ConfigurationPath } from "./Api"; +import { toEnum, Method2FA } from "./UserPreferences"; +import { Configuration, ExtendedConfiguration } from "../models/Configuration"; -export async function getAvailable2FAMethods(): Promise { - const methods = await Get(Available2FAMethodsPath); - return new Set(methods.map(toEnum)); +export async function getConfiguration(): Promise { + return Get(ConfigurationPath); +} + +interface ExtendedConfigurationPayload { + available_methods: Method2FA[]; +} + +export async function getExtendedConfiguration(): Promise { + const config = await Get(ExtendedConfigurationPath); + return { ...config, available_methods: new Set(config.available_methods.map(toEnum)) }; } \ No newline at end of file diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index 39aded588..dfff8f25f 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -13,7 +13,7 @@ import { useNotifications } from "../../hooks/NotificationsContext"; import { useRedirectionURL } from "../../hooks/RedirectionURL"; import { useUserPreferences as userUserInfo } from "../../hooks/UserInfo"; import { SecondFactorMethod } from "../../models/Methods"; -import { useAutheliaConfiguration } from "../../hooks/Configuration"; +import { useExtendedConfiguration } from "../../hooks/Configuration"; export default function () { const history = useHistory(); @@ -24,7 +24,7 @@ export default function () { const [state, fetchState, , fetchStateError] = useAutheliaState(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = userUserInfo(); - const [configuration, fetchConfiguration, , fetchConfigurationError] = useAutheliaConfiguration(); + const [configuration, fetchConfiguration, , fetchConfigurationError] = useExtendedConfiguration(); const redirect = useCallback((url: string) => history.push(url), [history]); diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index 3497a2438..b6472bb0e 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -18,7 +18,7 @@ import { } from "../../../Routes"; import { setPrefered2FAMethod } from "../../../services/UserPreferences"; import { UserInfo } from "../../../models/UserInfo"; -import { Configuration } from "../../../models/Configuration"; +import { ExtendedConfiguration } from "../../../models/Configuration"; import u2fApi from "u2f-api"; import { AuthenticationLevel } from "../../../services/State"; @@ -29,7 +29,7 @@ export interface Props { authenticationLevel: AuthenticationLevel; userInfo: UserInfo; - configuration: Configuration; + configuration: ExtendedConfiguration; onMethodChanged: (method: SecondFactorMethod) => void; onAuthenticationSuccess: (redirectURL: string | undefined) => void; @@ -89,7 +89,7 @@ export default function (props: Props) { showBrand> setMethodSelectionOpen(false)} onClick={handleMethodSelected} />