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} />