From 3f26cc599f0ecc916786d1aaeb674af48b3347ad Mon Sep 17 00:00:00 2001 From: Jeremy Wharton Date: Tue, 19 Jul 2022 04:26:18 -0500 Subject: [PATCH] satellite/console,web/satellite: invalidate sessions after inactivity Sessions now expire after a much shorter amount of time, requiring clients to issue API requests for session extension. This is handled behind the scenes as the user interacts with the page, but once session expiration is imminent, a modal appears which informs the user of his inactivity and presents him with the choice of loging out or preserving his session. Change-Id: I68008d45859c814a835d65d882ad5ad2199d618e --- cmd/storj-sim/console.go | 8 +- satellite/console/consoleauth/sessions.go | 2 + .../consoleweb/consoleapi/apikeys_test.go | 4 +- .../console/consoleweb/consoleapi/auth.go | 52 ++++- .../consoleweb/consoleapi/auth_test.go | 6 +- .../consoleweb/consoleapi/buckets_test.go | 4 +- .../consoleweb/consoleapi/usagelimits_test.go | 8 +- .../consoleweb/consoleql/mutation_test.go | 12 +- .../consoleweb/consoleql/query_test.go | 8 +- .../console/consoleweb/consolewebauth/auth.go | 23 ++- .../console/consoleweb/endpoints_test.go | 8 +- satellite/console/consoleweb/server.go | 29 +-- satellite/console/consoleweb/server_test.go | 4 +- satellite/console/service.go | 109 ++++++---- satellite/console/service_test.go | 10 +- satellite/console/users.go | 7 + satellite/oidc/integration_test.go | 4 +- satellite/satellitedb/webappsessions.go | 15 ++ scripts/testdata/satellite-config.yaml.lock | 19 +- web/satellite/index.html | 3 +- web/satellite/src/api/auth.ts | 29 ++- .../src/components/modals/InactivityModal.vue | 137 +++++++++++++ web/satellite/src/store/modules/users.ts | 1 + web/satellite/src/types/users.ts | 10 + web/satellite/src/utils/localData.ts | 14 ++ web/satellite/src/views/DashboardArea.vue | 188 +++++++++++++++--- web/satellite/src/views/LoginArea.vue | 7 +- .../static/images/session/inactivityTimer.svg | 7 + .../__snapshots__/DashboardArea.spec.ts.snap | 4 + 29 files changed, 587 insertions(+), 145 deletions(-) create mode 100644 web/satellite/src/components/modals/InactivityModal.vue create mode 100644 web/satellite/static/images/session/inactivityTimer.svg diff --git a/cmd/storj-sim/console.go b/cmd/storj-sim/console.go index 98b3a5163..4ddf7e74e 100644 --- a/cmd/storj-sim/console.go +++ b/cmd/storj-sim/console.go @@ -184,13 +184,15 @@ func (ce *consoleEndpoints) tryLogin(ctx context.Context) (string, error) { resp.StatusCode, tryReadLine(resp.Body)) } - var token string - err = json.NewDecoder(resp.Body).Decode(&token) + var tokenInfo struct { + Token string `json:"token"` + } + err = json.NewDecoder(resp.Body).Decode(&tokenInfo) if err != nil { return "", errs.Wrap(err) } - return token, nil + return tokenInfo.Token, nil } func (ce *consoleEndpoints) tryCreateAndActivateUser(ctx context.Context) error { diff --git a/satellite/console/consoleauth/sessions.go b/satellite/console/consoleauth/sessions.go index 9a0c6b139..e3b736d32 100644 --- a/satellite/console/consoleauth/sessions.go +++ b/satellite/console/consoleauth/sessions.go @@ -22,6 +22,8 @@ type WebappSessions interface { DeleteBySessionID(ctx context.Context, sessionID uuid.UUID) error // DeleteAllByUserID deletes all webapp sessions by user ID. DeleteAllByUserID(ctx context.Context, userID uuid.UUID) (int64, error) + // UpdateExpiration updates the expiration time of the session. + UpdateExpiration(ctx context.Context, sessionID uuid.UUID, expiresAt time.Time) (err error) } // WebappSession represents a session on the satellite web app. diff --git a/satellite/console/consoleweb/consoleapi/apikeys_test.go b/satellite/console/consoleweb/consoleapi/apikeys_test.go index 311a32cf0..2659bc73e 100644 --- a/satellite/console/consoleweb/consoleapi/apikeys_test.go +++ b/satellite/console/consoleweb/consoleapi/apikeys_test.go @@ -58,7 +58,7 @@ func Test_DeleteAPIKeyByNameAndProjectID(t *testing.T) { require.NoError(t, err) // we are using full name as a password - token, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) client := http.Client{} @@ -70,7 +70,7 @@ func Test_DeleteAPIKeyByNameAndProjectID(t *testing.T) { cookie := http.Cookie{ Name: "_tokenKey", Path: "/", - Value: token.String(), + Value: tokenInfo.Token.String(), Expires: expire, } diff --git a/satellite/console/consoleweb/consoleapi/auth.go b/satellite/console/consoleweb/consoleapi/auth.go index 911ab7fd5..a582ee0ec 100644 --- a/satellite/console/consoleweb/consoleapi/auth.go +++ b/satellite/console/consoleweb/consoleapi/auth.go @@ -100,7 +100,7 @@ func (a *Auth) Token(w http.ResponseWriter, r *http.Request) { return } - token, err := a.service.Token(ctx, tokenRequest) + tokenInfo, err := a.service.Token(ctx, tokenRequest) if err != nil { if console.ErrMFAMissing.Has(err) { serveCustomJSONError(a.log, w, http.StatusOK, err, a.getUserErrorMessage(err)) @@ -111,10 +111,13 @@ func (a *Auth) Token(w http.ResponseWriter, r *http.Request) { return } - a.cookieAuth.SetTokenCookie(w, token) + a.cookieAuth.SetTokenCookie(w, *tokenInfo) w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(token.String()) + err = json.NewEncoder(w).Encode(struct { + console.TokenInfo + Token string `json:"token"` + }{*tokenInfo, tokenInfo.Token.String()}) if err != nil { a.log.Error("token handler could not encode token response", zap.Error(ErrAuthAPI.Wrap(err))) return @@ -128,13 +131,19 @@ func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - token, err := a.cookieAuth.GetToken(r) + tokenInfo, err := a.cookieAuth.GetToken(r) if err != nil { a.serveJSONError(w, err) return } - err = a.service.DeleteSessionByToken(ctx, token) + id, err := uuid.FromBytes(tokenInfo.Token.Payload) + if err != nil { + a.serveJSONError(w, err) + return + } + + err = a.service.DeleteSession(ctx, id) if err != nil { a.serveJSONError(w, err) return @@ -774,6 +783,39 @@ func (a *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) { } } +// RefreshSession refreshes the user's session. +func (a *Auth) RefreshSession(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + tokenInfo, err := a.cookieAuth.GetToken(r) + if err != nil { + a.serveJSONError(w, err) + return + } + + id, err := uuid.FromBytes(tokenInfo.Token.Payload) + if err != nil { + a.serveJSONError(w, err) + return + } + + tokenInfo.ExpiresAt, err = a.service.RefreshSession(ctx, id) + if err != nil { + a.serveJSONError(w, err) + return + } + + a.cookieAuth.SetTokenCookie(w, tokenInfo) + + err = json.NewEncoder(w).Encode(tokenInfo.ExpiresAt) + if err != nil { + a.log.Error("could not encode refreshed session expiration date", zap.Error(ErrAuthAPI.Wrap(err))) + return + } +} + // serveJSONError writes JSON error to response output stream. func (a *Auth) serveJSONError(w http.ResponseWriter, err error) { status := a.getStatusCode(err) diff --git a/satellite/console/consoleweb/consoleapi/auth_test.go b/satellite/console/consoleweb/consoleapi/auth_test.go index a6dc7923c..8e6300c9c 100644 --- a/satellite/console/consoleweb/consoleapi/auth_test.go +++ b/satellite/console/consoleweb/consoleapi/auth_test.go @@ -342,9 +342,9 @@ func TestMFAEndpoints(t *testing.T) { }, 1) require.NoError(t, err) - token, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) - require.NotEmpty(t, token) + require.NotEmpty(t, tokenInfo.Token) type data struct { Passcode string `json:"passcode"` @@ -370,7 +370,7 @@ func TestMFAEndpoints(t *testing.T) { req.AddCookie(&http.Cookie{ Name: "_tokenKey", Path: "/", - Value: token.String(), + Value: tokenInfo.Token.String(), Expires: time.Now().AddDate(0, 0, 1), }) diff --git a/satellite/console/consoleweb/consoleapi/buckets_test.go b/satellite/console/consoleweb/consoleapi/buckets_test.go index c4ce492e2..9364176fc 100644 --- a/satellite/console/consoleweb/consoleapi/buckets_test.go +++ b/satellite/console/consoleweb/consoleapi/buckets_test.go @@ -64,7 +64,7 @@ func Test_AllBucketNames(t *testing.T) { require.NoError(t, err) // we are using full name as a password - token, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) client := http.Client{} @@ -76,7 +76,7 @@ func Test_AllBucketNames(t *testing.T) { cookie := http.Cookie{ Name: "_tokenKey", Path: "/", - Value: token.String(), + Value: tokenInfo.Token.String(), Expires: expire, } diff --git a/satellite/console/consoleweb/consoleapi/usagelimits_test.go b/satellite/console/consoleweb/consoleapi/usagelimits_test.go index 72e2d097a..232a98700 100644 --- a/satellite/console/consoleweb/consoleapi/usagelimits_test.go +++ b/satellite/console/consoleweb/consoleapi/usagelimits_test.go @@ -72,7 +72,7 @@ func Test_TotalUsageLimits(t *testing.T) { require.NoError(t, err) // we are using full name as a password - token, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) client := http.Client{} @@ -89,7 +89,7 @@ func Test_TotalUsageLimits(t *testing.T) { cookie := http.Cookie{ Name: "_tokenKey", Path: "/", - Value: token.String(), + Value: tokenInfo.Token.String(), Expires: expire, } @@ -186,7 +186,7 @@ func Test_DailyUsage(t *testing.T) { satelliteSys.Accounting.Tally.Loop.TriggerWait() // we are using full name as a password - token, err := satelliteSys.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := satelliteSys.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) client := http.DefaultClient @@ -203,7 +203,7 @@ func Test_DailyUsage(t *testing.T) { cookie := http.Cookie{ Name: "_tokenKey", Path: "/", - Value: token.String(), + Value: tokenInfo.Token.String(), Expires: expire, } diff --git a/satellite/console/consoleweb/consoleql/mutation_test.go b/satellite/console/consoleweb/consoleql/mutation_test.go index 863556447..d581afc75 100644 --- a/satellite/console/consoleweb/consoleql/mutation_test.go +++ b/satellite/console/consoleweb/consoleql/mutation_test.go @@ -117,7 +117,9 @@ func TestGraphqlMutation(t *testing.T) { console.Config{ PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5, - SessionDuration: time.Hour, + Session: console.SessionConfig{ + Duration: time.Hour, + }, }, ) require.NoError(t, err) @@ -166,10 +168,10 @@ func TestGraphqlMutation(t *testing.T) { _, err = service.ActivateAccount(ctx, activationToken) require.NoError(t, err) - token, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) + tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) require.NoError(t, err) - userCtx, err := service.TokenAuth(ctx, token, time.Now()) + userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now()) require.NoError(t, err) testQuery := func(t *testing.T, query string) (interface{}, error) { @@ -190,10 +192,10 @@ func TestGraphqlMutation(t *testing.T) { return result.Data, nil } - token, err = service.Token(ctx, console.AuthUser{Email: rootUser.Email, Password: createUser.Password}) + tokenInfo, err = service.Token(ctx, console.AuthUser{Email: rootUser.Email, Password: createUser.Password}) require.NoError(t, err) - userCtx, err = service.TokenAuth(ctx, token, time.Now()) + userCtx, err = service.TokenAuth(ctx, tokenInfo.Token, time.Now()) require.NoError(t, err) var projectIDField string diff --git a/satellite/console/consoleweb/consoleql/query_test.go b/satellite/console/consoleweb/consoleql/query_test.go index 2fc2ceccf..9bc4a8f48 100644 --- a/satellite/console/consoleweb/consoleql/query_test.go +++ b/satellite/console/consoleweb/consoleql/query_test.go @@ -101,7 +101,9 @@ func TestGraphqlQuery(t *testing.T) { console.Config{ PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5, - SessionDuration: time.Hour, + Session: console.SessionConfig{ + Duration: time.Hour, + }, }, ) require.NoError(t, err) @@ -160,10 +162,10 @@ func TestGraphqlQuery(t *testing.T) { rootUser.Email = "mtest@mail.test" }) - token, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) + tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) require.NoError(t, err) - userCtx, err := service.TokenAuth(ctx, token, time.Now()) + userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now()) require.NoError(t, err) testQuery := func(t *testing.T, query string) interface{} { diff --git a/satellite/console/consoleweb/consolewebauth/auth.go b/satellite/console/consoleweb/consolewebauth/auth.go index 7082546da..dce135211 100644 --- a/satellite/console/consoleweb/consolewebauth/auth.go +++ b/satellite/console/consoleweb/consolewebauth/auth.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "storj.io/storj/satellite/console" "storj.io/storj/satellite/console/consoleauth" ) @@ -29,28 +30,30 @@ func NewCookieAuth(settings CookieSettings) *CookieAuth { } // GetToken retrieves token from request. -func (auth *CookieAuth) GetToken(r *http.Request) (consoleauth.Token, error) { +func (auth *CookieAuth) GetToken(r *http.Request) (console.TokenInfo, error) { cookie, err := r.Cookie(auth.settings.Name) if err != nil { - return consoleauth.Token{}, err + return console.TokenInfo{}, err } token, err := consoleauth.FromBase64URLString(cookie.Value) if err != nil { - return consoleauth.Token{}, err + return console.TokenInfo{}, err } - return token, nil + return console.TokenInfo{ + Token: token, + ExpiresAt: cookie.Expires, + }, nil } // SetTokenCookie sets parametrized token cookie that is not accessible from js. -func (auth *CookieAuth) SetTokenCookie(w http.ResponseWriter, token consoleauth.Token) { +func (auth *CookieAuth) SetTokenCookie(w http.ResponseWriter, tokenInfo console.TokenInfo) { http.SetCookie(w, &http.Cookie{ - Name: auth.settings.Name, - Value: token.String(), - Path: auth.settings.Path, - // TODO: get expiration from token - Expires: time.Now().Add(time.Hour * 24), + Name: auth.settings.Name, + Value: tokenInfo.Token.String(), + Path: auth.settings.Path, + Expires: tokenInfo.ExpiresAt, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) diff --git a/satellite/console/consoleweb/endpoints_test.go b/satellite/console/consoleweb/endpoints_test.go index 530e93bb6..88e3ff833 100644 --- a/satellite/console/consoleweb/endpoints_test.go +++ b/satellite/console/consoleweb/endpoints_test.go @@ -868,10 +868,12 @@ func (test *test) login(email, password string) Response { cookie := findCookie(resp, "_tokenKey") require.NotNil(test.t, cookie) - var rawToken string - require.NoError(test.t, json.Unmarshal([]byte(body), &rawToken)) + var tokenInfo struct { + Token string `json:"token"` + } + require.NoError(test.t, json.Unmarshal([]byte(body), &tokenInfo)) require.Equal(test.t, http.StatusOK, resp.StatusCode) - require.Equal(test.t, rawToken, cookie.Value) + require.Equal(test.t, tokenInfo.Token, cookie.Value) return resp } diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 8dbeda619..303a2e6f0 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -96,8 +96,6 @@ type Config struct { NewAccessGrantFlow bool `help:"indicates if new access grant flow should be used" default:"true"` NewBillingScreen bool `help:"indicates if new billing screens should be used" default:"false"` GeneratedAPIEnabled bool `help:"indicates if generated console api should be used" default:"false"` - InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"false"` - InactivityTimerDelay int `help:"inactivity timer delay in seconds" default:"600"` OptionalSignupSuccessURL string `help:"optional url to external registration success page" default:""` HomepageURL string `help:"url link to storj.io homepage" default:"https://www.storj.io"` NativeTokenPaymentsEnabled bool `help:"indicates if storj native token payments system is enabled" default:"false"` @@ -179,12 +177,12 @@ func (a *apiAuth) IsAuthenticated(ctx context.Context, r *http.Request, isCookie // cookieAuth returns an authenticated context by session cookie. func (a *apiAuth) cookieAuth(ctx context.Context, r *http.Request) (context.Context, error) { - token, err := a.server.cookieAuth.GetToken(r) + tokenInfo, err := a.server.cookieAuth.GetToken(r) if err != nil { return nil, err } - return a.server.service.TokenAuth(ctx, token, time.Now()) + return a.server.service.TokenAuth(ctx, tokenInfo.Token, time.Now()) } // cookieAuth returns an authenticated context by api key. @@ -280,12 +278,13 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc authRouter.Handle("/mfa/disable", server.withAuth(http.HandlerFunc(authController.DisableUserMFA))).Methods(http.MethodPost) authRouter.Handle("/mfa/generate-secret-key", server.withAuth(http.HandlerFunc(authController.GenerateMFASecretKey))).Methods(http.MethodPost) authRouter.Handle("/mfa/generate-recovery-codes", server.withAuth(http.HandlerFunc(authController.GenerateMFARecoveryCodes))).Methods(http.MethodPost) - authRouter.HandleFunc("/logout", authController.Logout).Methods(http.MethodPost) + authRouter.Handle("/logout", server.withAuth(http.HandlerFunc(authController.Logout))).Methods(http.MethodPost) authRouter.Handle("/token", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Token))).Methods(http.MethodPost) authRouter.Handle("/register", server.ipRateLimiter.Limit(http.HandlerFunc(authController.Register))).Methods(http.MethodPost, http.MethodOptions) authRouter.Handle("/forgot-password/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ForgotPassword))).Methods(http.MethodPost) authRouter.Handle("/resend-email/{email}", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResendEmail))).Methods(http.MethodPost) authRouter.Handle("/reset-password", server.ipRateLimiter.Limit(http.HandlerFunc(authController.ResetPassword))).Methods(http.MethodPost) + authRouter.Handle("/refresh-session", server.withAuth(http.HandlerFunc(authController.RefreshSession))).Methods(http.MethodPost) paymentController := consoleapi.NewPayments(logger, service) paymentsRouter := router.PathPrefix("/api/v0/payments").Subrouter() @@ -451,7 +450,8 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { NewAccessGrantFlow bool NewBillingScreen bool InactivityTimerEnabled bool - InactivityTimerDelay int + InactivityTimerDuration int + InactivityTimerViewerEnabled bool OptionalSignupSuccessURL string HomepageURL string NativeTokenPaymentsEnabled bool @@ -492,8 +492,9 @@ func (server *Server) appHandler(w http.ResponseWriter, r *http.Request) { data.NewObjectsFlow = server.config.NewObjectsFlow data.NewAccessGrantFlow = server.config.NewAccessGrantFlow data.NewBillingScreen = server.config.NewBillingScreen - data.InactivityTimerEnabled = server.config.InactivityTimerEnabled - data.InactivityTimerDelay = server.config.InactivityTimerDelay + data.InactivityTimerEnabled = server.config.Session.InactivityTimerEnabled + data.InactivityTimerDuration = server.config.Session.InactivityTimerDuration + data.InactivityTimerViewerEnabled = server.config.Session.InactivityTimerViewerEnabled data.OptionalSignupSuccessURL = server.config.OptionalSignupSuccessURL data.HomepageURL = server.config.HomepageURL data.NativeTokenPaymentsEnabled = server.config.NativeTokenPaymentsEnabled @@ -526,12 +527,12 @@ func (server *Server) withAuth(handler http.Handler) http.Handler { } }() - token, err := server.cookieAuth.GetToken(r) + tokenInfo, err := server.cookieAuth.GetToken(r) if err != nil { return } - newCtx, err := server.service.TokenAuth(ctx, token, time.Now()) + newCtx, err := server.service.TokenAuth(ctx, tokenInfo.Token, time.Now()) if err != nil { return } @@ -554,13 +555,13 @@ func (server *Server) bucketUsageReportHandler(w http.ResponseWriter, r *http.Re var err error defer mon.Task()(&ctx)(&err) - token, err := server.cookieAuth.GetToken(r) + tokenInfo, err := server.cookieAuth.GetToken(r) if err != nil { server.serveError(w, http.StatusUnauthorized) return } - ctx, err = server.service.TokenAuth(ctx, token, time.Now()) + ctx, err = server.service.TokenAuth(ctx, tokenInfo.Token, time.Now()) if err != nil { server.serveError(w, http.StatusUnauthorized) return @@ -708,13 +709,13 @@ func (server *Server) accountActivationHandler(w http.ResponseWriter, r *http.Re return } - token, err := server.service.GenerateSessionToken(ctx, user.ID, user.Email, ip, r.UserAgent()) + tokenInfo, err := server.service.GenerateSessionToken(ctx, user.ID, user.Email, ip, r.UserAgent()) if err != nil { server.serveError(w, http.StatusInternalServerError) return } - server.cookieAuth.SetTokenCookie(w, token) + server.cookieAuth.SetTokenCookie(w, *tokenInfo) http.Redirect(w, r, server.config.ExternalAddress, http.StatusTemporaryRedirect) } diff --git a/satellite/console/consoleweb/server_test.go b/satellite/console/consoleweb/server_test.go index 7197e0af6..1b85d6bfc 100644 --- a/satellite/console/consoleweb/server_test.go +++ b/satellite/console/consoleweb/server_test.go @@ -121,10 +121,10 @@ func TestUserIDRateLimiter(t *testing.T) { require.NoError(t, err) // sat.AddUser sets password to full name. - token, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := sat.API.Console.Service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) - tokenStr := token.String() + tokenStr := tokenInfo.Token.String() if userNum == 1 { firstToken = tokenStr diff --git a/satellite/console/service.go b/satellite/console/service.go index 9810e4362..c7e487fcf 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -157,9 +157,9 @@ type Config struct { AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"` LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"` FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"` - SessionDuration time.Duration `help:"duration a session is valid for" default:"168h"` UsageLimits UsageLimitsConfig Captcha CaptchaConfig + Session SessionConfig } // CaptchaConfig contains configurations for login/registration captcha system. @@ -181,6 +181,14 @@ type SingleCaptchaConfig struct { SecretKey string `help:"captcha secret key"` } +// SessionConfig contains configurations for session management. +type SessionConfig struct { + InactivityTimerEnabled bool `help:"indicates if session can be timed out due inactivity" default:"false"` + InactivityTimerDuration int `help:"inactivity timer delay in seconds" default:"600"` + InactivityTimerViewerEnabled bool `help:"indicates whether remaining session time is shown for debugging" default:"false"` + Duration time.Duration `help:"duration a session is valid for (superseded by inactivity timer delay if inactivity timer is enabled)" default:"168h"` +} + // Payments separates all payment related functionality. type Payments struct { service *Service @@ -800,24 +808,30 @@ func (s *Service) GeneratePasswordRecoveryToken(ctx context.Context, id uuid.UUI } // GenerateSessionToken creates a new session and returns the string representation of its token. -func (s *Service) GenerateSessionToken(ctx context.Context, userID uuid.UUID, email, ip, userAgent string) (_ consoleauth.Token, err error) { +func (s *Service) GenerateSessionToken(ctx context.Context, userID uuid.UUID, email, ip, userAgent string) (_ *TokenInfo, err error) { defer mon.Task()(&ctx)(&err) sessionID, err := uuid.New() if err != nil { - return consoleauth.Token{}, Error.Wrap(err) + return nil, Error.Wrap(err) } - _, err = s.store.WebappSessions().Create(ctx, sessionID, userID, ip, userAgent, time.Now().Add(s.config.SessionDuration)) + duration := s.config.Session.Duration + if s.config.Session.InactivityTimerEnabled { + duration = time.Duration(s.config.Session.InactivityTimerDuration) * time.Second + } + expiresAt := time.Now().Add(duration) + + _, err = s.store.WebappSessions().Create(ctx, sessionID, userID, ip, userAgent, expiresAt) if err != nil { - return consoleauth.Token{}, err + return nil, err } token := consoleauth.Token{Payload: sessionID.Bytes()} signature, err := s.tokens.SignToken(token) if err != nil { - return consoleauth.Token{}, err + return nil, err } token.Signature = signature @@ -825,7 +839,10 @@ func (s *Service) GenerateSessionToken(ctx context.Context, userID uuid.UUID, em s.analytics.TrackSignedIn(userID, email) - return token, nil + return &TokenInfo{ + Token: token, + ExpiresAt: expiresAt, + }, nil } // ActivateAccount - is a method for activating user account after registration. @@ -977,7 +994,7 @@ func (s *Service) RevokeResetPasswordToken(ctx context.Context, resetPasswordTok } // Token authenticates User by credentials and returns session token. -func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleauth.Token, err error) { +func (s *Service) Token(ctx context.Context, request AuthUser) (response *TokenInfo, err error) { defer mon.Task()(&ctx)(&err) mon.Counter("login_attempt").Inc(1) //mon:locked @@ -986,11 +1003,11 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut valid, _, err := s.loginCaptchaHandler.Verify(ctx, request.CaptchaResponse, request.IP) if err != nil { mon.Counter("login_user_captcha_error").Inc(1) //mon:locked - return consoleauth.Token{}, ErrCaptcha.Wrap(err) + return nil, ErrCaptcha.Wrap(err) } if !valid { mon.Counter("login_user_captcha_unsuccessful").Inc(1) //mon:locked - return consoleauth.Token{}, ErrCaptcha.New("captcha validation unsuccessful") + return nil, ErrCaptcha.New("captcha validation unsuccessful") } } @@ -1003,7 +1020,7 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut mon.Counter("login_email_invalid").Inc(1) //mon:locked s.auditLog(ctx, "login: failed invalid email", nil, request.Email) } - return consoleauth.Token{}, ErrLoginCredentials.New(credentialsErrMsg) + return nil, ErrLoginCredentials.New(credentialsErrMsg) } now := time.Now() @@ -1011,7 +1028,7 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut if user.LoginLockoutExpiration.After(now) { mon.Counter("login_locked_out").Inc(1) //mon:locked s.auditLog(ctx, "login: failed account locked out", &user.ID, request.Email) - return consoleauth.Token{}, ErrLoginCredentials.New(credentialsErrMsg) + return nil, ErrLoginCredentials.New(credentialsErrMsg) } handleLockAccount := func() error { @@ -1040,18 +1057,18 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut if err != nil { err = handleLockAccount() if err != nil { - return consoleauth.Token{}, err + return nil, err } mon.Counter("login_invalid_password").Inc(1) //mon:locked s.auditLog(ctx, "login: failed password invalid", &user.ID, user.Email) - return consoleauth.Token{}, ErrLoginPassword.New(credentialsErrMsg) + return nil, ErrLoginPassword.New(credentialsErrMsg) } if user.MFAEnabled { if request.MFARecoveryCode != "" && request.MFAPasscode != "" { mon.Counter("login_mfa_conflict").Inc(1) //mon:locked s.auditLog(ctx, "login: failed mfa conflict", &user.ID, user.Email) - return consoleauth.Token{}, ErrMFAConflict.New(mfaConflictErrMsg) + return nil, ErrMFAConflict.New(mfaConflictErrMsg) } if request.MFARecoveryCode != "" { @@ -1067,11 +1084,11 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut if !found { err = handleLockAccount() if err != nil { - return consoleauth.Token{}, err + return nil, err } mon.Counter("login_mfa_recovery_failure").Inc(1) //mon:locked s.auditLog(ctx, "login: failed mfa recovery", &user.ID, user.Email) - return consoleauth.Token{}, ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg) + return nil, ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg) } mon.Counter("login_mfa_recovery_success").Inc(1) //mon:locked @@ -1082,32 +1099,32 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut MFARecoveryCodes: &user.MFARecoveryCodes, }) if err != nil { - return consoleauth.Token{}, err + return nil, err } } else if request.MFAPasscode != "" { valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, now) if err != nil { err = handleLockAccount() if err != nil { - return consoleauth.Token{}, err + return nil, err } - return consoleauth.Token{}, ErrMFAPasscode.Wrap(err) + return nil, ErrMFAPasscode.Wrap(err) } if !valid { err = handleLockAccount() if err != nil { - return consoleauth.Token{}, err + return nil, err } mon.Counter("login_mfa_passcode_failure").Inc(1) //mon:locked s.auditLog(ctx, "login: failed mfa passcode invalid", &user.ID, user.Email) - return consoleauth.Token{}, ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg) + return nil, ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg) } mon.Counter("login_mfa_passcode_success").Inc(1) //mon:locked } else { mon.Counter("login_mfa_missing").Inc(1) //mon:locked s.auditLog(ctx, "login: failed mfa missing", &user.ID, user.Email) - return consoleauth.Token{}, ErrMFAMissing.New(mfaRequiredErrMsg) + return nil, ErrMFAMissing.New(mfaRequiredErrMsg) } } @@ -1119,18 +1136,18 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token consoleaut LoginLockoutExpiration: &loginLockoutExpirationPtr, }) if err != nil { - return consoleauth.Token{}, err + return nil, err } } - token, err = s.GenerateSessionToken(ctx, user.ID, user.Email, request.IP, request.UserAgent) + response, err = s.GenerateSessionToken(ctx, user.ID, user.Email, request.IP, request.UserAgent) if err != nil { - return consoleauth.Token{}, err + return nil, err } mon.Counter("login_success").Inc(1) //mon:locked - return token, nil + return response, nil } // UpdateUsersFailedLoginState updates User's failed login state. @@ -2853,22 +2870,28 @@ func findMembershipByProjectID(memberships []ProjectMember, projectID uuid.UUID) return ProjectMember{}, false } -// DeleteSessionByToken removes the session corresponding to the given token from the database. -func (s *Service) DeleteSessionByToken(ctx context.Context, token consoleauth.Token) (err error) { +// DeleteSession removes the session from the database. +func (s *Service) DeleteSession(ctx context.Context, sessionID uuid.UUID) (err error) { defer mon.Task()(&ctx)(&err) - valid, err := s.tokens.ValidateToken(token) - if err != nil { - return err - } - if !valid { - return ErrValidation.New("Invalid session token.") - } - - id, err := uuid.FromBytes(token.Payload) - if err != nil { - return err - } - - return s.store.WebappSessions().DeleteBySessionID(ctx, id) + return Error.Wrap(s.store.WebappSessions().DeleteBySessionID(ctx, sessionID)) +} + +// RefreshSession resets the expiration time of the session. +func (s *Service) RefreshSession(ctx context.Context, sessionID uuid.UUID) (expiresAt time.Time, err error) { + defer mon.Task()(&ctx)(&err) + + _, err = s.getUserAndAuditLog(ctx, "refresh session") + if err != nil { + return time.Time{}, Error.Wrap(err) + } + + expiresAt = time.Now().Add(time.Duration(s.config.Session.InactivityTimerDuration) * time.Second) + + err = s.store.WebappSessions().UpdateExpiration(ctx, sessionID, expiresAt) + if err != nil { + return time.Time{}, err + } + + return expiresAt, nil } diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index 79f6aa71e..0e8ff7564 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -926,7 +926,7 @@ func TestSessionExpiration(t *testing.T) { SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0, Reconfigure: testplanet.Reconfigure{ Satellite: func(log *zap.Logger, index int, config *satellite.Config) { - config.Console.SessionDuration = time.Hour + config.Console.Session.Duration = time.Hour }, }, }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { @@ -940,20 +940,20 @@ func TestSessionExpiration(t *testing.T) { require.NoError(t, err) // Session should be added to DB after token request - token, err := service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) + tokenInfo, err := service.Token(ctx, console.AuthUser{Email: user.Email, Password: user.FullName}) require.NoError(t, err) - _, err = service.TokenAuth(ctx, token, time.Now()) + _, err = service.TokenAuth(ctx, tokenInfo.Token, time.Now()) require.NoError(t, err) - sessionID, err := uuid.FromBytes(token.Payload) + sessionID, err := uuid.FromBytes(tokenInfo.Token.Payload) require.NoError(t, err) _, err = sat.DB.Console().WebappSessions().GetBySessionID(ctx, sessionID) require.NoError(t, err) // Session should be removed from DB after it has expired - _, err = service.TokenAuth(ctx, token, time.Now().Add(2*time.Hour)) + _, err = service.TokenAuth(ctx, tokenInfo.Token, time.Now().Add(2*time.Hour)) require.True(t, console.ErrTokenExpiration.Has(err)) _, err = sat.DB.Console().WebappSessions().GetBySessionID(ctx, sessionID) diff --git a/satellite/console/users.go b/satellite/console/users.go index 6d7dda118..8914a6f59 100644 --- a/satellite/console/users.go +++ b/satellite/console/users.go @@ -12,6 +12,7 @@ import ( "storj.io/common/memory" "storj.io/common/uuid" + "storj.io/storj/satellite/console/consoleauth" ) // Users exposes methods to manage User table in database. @@ -122,6 +123,12 @@ type AuthUser struct { UserAgent string `json:"-"` } +// TokenInfo holds info for user authentication token responses. +type TokenInfo struct { + consoleauth.Token `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` +} + // UserStatus - is used to indicate status of the users account. type UserStatus int diff --git a/satellite/oidc/integration_test.go b/satellite/oidc/integration_test.go index 1fb3d77ea..d125f0a76 100644 --- a/satellite/oidc/integration_test.go +++ b/satellite/oidc/integration_test.go @@ -124,7 +124,7 @@ func TestOIDC(t *testing.T) { user, err = sat.API.Console.Service.ActivateAccount(ctx, activationToken) require.NoError(t, err) - sessionToken, err := sat.API.Console.Service.GenerateSessionToken(ctx, user.ID, user.Email, "", "") + tokenInfo, err := sat.API.Console.Service.GenerateSessionToken(ctx, user.ID, user.Email, "", "") require.NoError(t, err) // Set up a test project and bucket @@ -246,7 +246,7 @@ func TestOIDC(t *testing.T) { { body := strings.NewReader(consent.Encode()) - send(t, body, &token, http.StatusOK, authEndpoint, http.MethodPost, sessionToken.String(), "application/x-www-form-urlencoded") + send(t, body, &token, http.StatusOK, authEndpoint, http.MethodPost, tokenInfo.Token.String(), "application/x-www-form-urlencoded") } require.Equal(t, "Bearer", token.TokenType) diff --git a/satellite/satellitedb/webappsessions.go b/satellite/satellitedb/webappsessions.go index a68684079..b5c61eace 100644 --- a/satellite/satellitedb/webappsessions.go +++ b/satellite/satellitedb/webappsessions.go @@ -32,6 +32,21 @@ func (db *webappSessions) Create(ctx context.Context, id, userID uuid.UUID, addr return getSessionFromDBX(dbxSession) } +// UpdateExpiration updates the expiration time of the session. +func (db *webappSessions) UpdateExpiration(ctx context.Context, sessionID uuid.UUID, expiresAt time.Time) (err error) { + defer mon.Task()(&ctx)(&err) + + _, err = db.db.Update_WebappSession_By_Id( + ctx, + dbx.WebappSession_Id(sessionID.Bytes()), + dbx.WebappSession_Update_Fields{ + ExpiresAt: dbx.WebappSession_ExpiresAt(expiresAt), + }, + ) + + return err +} + // GetBySessionID gets the session info from the session ID. func (db *webappSessions) GetBySessionID(ctx context.Context, sessionID uuid.UUID) (session consoleauth.WebappSession, err error) { defer mon.Task()(&ctx)(&err) diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index e31492fad..fc2fd7495 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -187,12 +187,6 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0 # url link to storj.io homepage # console.homepage-url: https://www.storj.io -# inactivity timer delay in seconds -# console.inactivity-timer-delay: 600 - -# indicates if session can be timed out due inactivity -# console.inactivity-timer-enabled: false - # indicates if satellite is in beta # console.is-beta-satellite: false @@ -265,8 +259,17 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0 # used to communicate with web crawlers and other web robots # console.seo: "User-agent: *\nDisallow: \nDisallow: /cgi-bin/" -# duration a session is valid for -# console.session-duration: 168h0m0s +# duration a session is valid for (superseded by inactivity timer delay if inactivity timer is enabled) +# console.session.duration: 168h0m0s + +# inactivity timer delay in seconds +# console.session.inactivity-timer-duration: 600 + +# indicates if session can be timed out due inactivity +# console.session.inactivity-timer-enabled: false + +# indicates whether remaining session time is shown for debugging +# console.session.inactivity-timer-viewer-enabled: false # path to static resources # console.static-dir: "" diff --git a/web/satellite/index.html b/web/satellite/index.html index 59f8412ca..7ff72ab5d 100644 --- a/web/satellite/index.html +++ b/web/satellite/index.html @@ -38,7 +38,8 @@ - + + diff --git a/web/satellite/src/api/auth.ts b/web/satellite/src/api/auth.ts index fa986e9f4..abb55e0f5 100644 --- a/web/satellite/src/api/auth.ts +++ b/web/satellite/src/api/auth.ts @@ -5,7 +5,7 @@ import { ErrorBadRequest } from '@/api/errors/ErrorBadRequest'; import { ErrorMFARequired } from '@/api/errors/ErrorMFARequired'; import { ErrorTooManyRequests } from '@/api/errors/ErrorTooManyRequests'; import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; -import { UpdatedUser, User, UsersApi } from '@/types/users'; +import { TokenInfo, UpdatedUser, User, UsersApi } from '@/types/users'; import { HttpClient } from '@/utils/httpClient'; /** @@ -47,7 +47,7 @@ export class AuthHttpApi implements UsersApi { * @param mfaRecoveryCode - MFA recovery code * @throws Error */ - public async token(email: string, password: string, captchaResponse: string, mfaPasscode: string, mfaRecoveryCode: string): Promise { + public async token(email: string, password: string, captchaResponse: string, mfaPasscode: string, mfaRecoveryCode: string): Promise { const path = `${this.ROOT_PATH}/token`; const body = { email, @@ -60,11 +60,11 @@ export class AuthHttpApi implements UsersApi { const response = await this.http.post(path, JSON.stringify(body)); if (response.ok) { const result = await response.json(); - if (typeof result !== 'string') { + if (result.error) { throw new ErrorMFARequired(); } - return result; + return new TokenInfo(result.token, new Date(result.expiresAt)); } const result = await response.json(); @@ -421,4 +421,25 @@ export class AuthHttpApi implements UsersApi { throw new Error(errMsg); } } + + /** + * Used to refresh the expiration time of the current session. + * + * @returns new expiration timestamp + * @throws Error + */ + public async refreshSession(): Promise { + const path = `${this.ROOT_PATH}/refresh-session`; + const response = await this.http.post(path, null); + + if (response.ok) { + return new Date(await response.json()); + } + + if (response.status === 401) { + throw new ErrorUnauthorized(); + } + + throw new Error("Unable to refresh session.") + } } diff --git a/web/satellite/src/components/modals/InactivityModal.vue b/web/satellite/src/components/modals/InactivityModal.vue new file mode 100644 index 000000000..82da2a83e --- /dev/null +++ b/web/satellite/src/components/modals/InactivityModal.vue @@ -0,0 +1,137 @@ +// Copyright (C) 2022 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + diff --git a/web/satellite/src/store/modules/users.ts b/web/satellite/src/store/modules/users.ts index 5e6135780..ef4ddf5c0 100644 --- a/web/satellite/src/store/modules/users.ts +++ b/web/satellite/src/store/modules/users.ts @@ -6,6 +6,7 @@ import { MetaUtils } from '@/utils/meta'; import {StoreModule} from "@/types/store"; export const USER_ACTIONS = { + LOGIN: 'loginUser', UPDATE: 'updateUser', GET: 'getUser', ENABLE_USER_MFA: 'enableUserMFA', diff --git a/web/satellite/src/types/users.ts b/web/satellite/src/types/users.ts index 9043cdce4..8f5c2bb8d 100644 --- a/web/satellite/src/types/users.ts +++ b/web/satellite/src/types/users.ts @@ -105,3 +105,13 @@ export class DisableMFARequest { public recoveryCode: string = '', ) {} } + +/** + * TokenInfo represents an authentication token response. + */ +export class TokenInfo { + public constructor( + public token: string, + public expiresAt: Date, + ) {} +} diff --git a/web/satellite/src/utils/localData.ts b/web/satellite/src/utils/localData.ts index 35161b744..0c17c683d 100644 --- a/web/satellite/src/utils/localData.ts +++ b/web/satellite/src/utils/localData.ts @@ -11,6 +11,7 @@ export class LocalData { private static demoBucketCreated = 'demoBucketCreated'; private static bucketGuideHidden = 'bucketGuideHidden'; private static billingNotificationAcknowledged = 'billingNotificationAcknowledged'; + private static sessionExpirationDate = 'sessionExpirationDate'; public static getUserId(): string | null { return localStorage.getItem(LocalData.userId); @@ -83,6 +84,19 @@ export class LocalData { public static setBillingNotificationAcknowledged(): void { localStorage.setItem(LocalData.billingNotificationAcknowledged, 'true'); } + + public static getSessionExpirationDate(): Date | null { + const data: string | null = localStorage.getItem(LocalData.sessionExpirationDate); + if (data) { + return new Date(data); + } + + return null; + } + + public static setSessionExpirationDate(date: Date): void { + localStorage.setItem(LocalData.sessionExpirationDate, date.toISOString()); + } } /** diff --git a/web/satellite/src/views/DashboardArea.vue b/web/satellite/src/views/DashboardArea.vue index 30d36013e..8f42439fe 100644 --- a/web/satellite/src/views/DashboardArea.vue +++ b/web/satellite/src/views/DashboardArea.vue @@ -27,6 +27,16 @@ +
+

Remaining session time: {{ debugTimerText }}

+
+ @@ -38,6 +48,7 @@ import AllModals from "@/components/modals/AllModals.vue"; import PaidTierBar from '@/components/infoBars/PaidTierBar.vue'; import MFARecoveryCodeBar from '@/components/infoBars/MFARecoveryCodeBar.vue'; import BetaSatBar from '@/components/infoBars/BetaSatBar.vue'; +import InactivityModal from "@/components/modals/InactivityModal.vue"; import NavigationArea from '@/components/navigation/NavigationArea.vue'; import BillingNotification from "@/components/notifications/BillingNotification.vue"; import ProjectInfoBar from "@/components/infoBars/ProjectInfoBar.vue"; @@ -81,13 +92,28 @@ const { BetaSatBar, ProjectInfoBar, BillingNotification, + InactivityModal, }, }) export default class DashboardArea extends Vue { - // List of DOM events that resets inactivity timer. - private readonly resetActivityEvents: string[] = ['keypress', 'mousemove', 'mousedown', 'touchmove']; private readonly auth: AuthHttpApi = new AuthHttpApi(); - private inactivityTimerId: ReturnType; + + // Properties concerning session refreshing, inactivity notification, and automatic logout + private readonly resetActivityEvents: string[] = ['keypress', 'mousemove', 'mousedown', 'touchmove']; + private readonly sessionDuration: number = parseInt(MetaUtils.getMetaContent('inactivity-timer-duration')) * 1000; + private inactivityTimerId: ReturnType | null; + private inactivityModalShown = false; + private inactivityModalTime = 60000; + private sessionRefreshInterval: number = this.sessionDuration/2; + private sessionRefreshTimerId: ReturnType | null; + private isSessionActive = false; + private isSessionRefreshing = false; + + // Properties concerning the session timer popup used for debugging + private readonly debugTimerShown = MetaUtils.getMetaContent('inactivity-timer-viewer-enabled') == 'true'; + private debugTimerText = ""; + private debugTimerId: ReturnType | null; + // Minimum number of recovery codes before the recovery code warning bar is shown. public recoveryCodeWarningThreshold = 4; @@ -98,7 +124,9 @@ export default class DashboardArea extends Vue { * Pre fetches user`s and project information. */ public async mounted(): Promise { - this.setupInactivityTimers(); + this.$store.subscribeAction((action) => { + if (action.type == USER_ACTIONS.CLEAR) this.clearSessionTimers(); + }); if (LocalData.getBillingNotificationAcknowledged()) { this.$store.commit(APP_STATE_MUTATIONS.CLOSE_BILLING_NOTIFICATION); @@ -113,7 +141,12 @@ export default class DashboardArea extends Vue { try { await this.$store.dispatch(USER_ACTIONS.GET); + this.setupSessionTimers(); } catch (error) { + this.$store.subscribeAction((action) => { + if (action.type == USER_ACTIONS.LOGIN) this.setupSessionTimers(); + }) + if (!(error instanceof ErrorUnauthorized)) { await this.$store.dispatch(APP_STATE_ACTIONS.CHANGE_STATE, AppState.ERROR); await this.$notify.error(error.message); @@ -204,6 +237,13 @@ export default class DashboardArea extends Vue { this.$store.commit(APP_STATE_MUTATIONS.TOGGLE_IS_ADD_PM_MODAL_SHOWN); } + /** + * Disables session inactivity modal visibility. + */ + public closeInactivityModal(): void { + this.inactivityModalShown = false; + } + /** * Checks if stored project is in fetched projects array and selects it. * Selects first fetched project if check is not successful. @@ -296,52 +336,130 @@ export default class DashboardArea extends Vue { } /** - * Sets up timer id with given delay. + * Refreshes session and resets session timers. */ - private startInactivityTimer(): void { - const inactivityTimerDelayInSeconds = MetaUtils.getMetaContent('inactivity-timer-delay'); + private async refreshSession(): Promise { + this.isSessionRefreshing = true; + + try { + LocalData.setSessionExpirationDate(await this.auth.refreshSession()); + } catch (error) { + await this.$notify.error((error instanceof ErrorUnauthorized) ? 'Your session was timed out.' : error.message); + await this.handleInactive(); + this.isSessionRefreshing = false; + return; + } - this.inactivityTimerId = setTimeout(this.handleInactive, parseInt(inactivityTimerDelayInSeconds) * 1000); + this.clearSessionTimers(); + this.restartSessionTimers(); + this.inactivityModalShown = false; + this.isSessionActive = false; + this.isSessionRefreshing = false; } /** - * Performs logout and cleans event listeners. + * Performs logout and cleans event listeners and session timers. */ private async handleInactive(): Promise { + this.analytics.pageVisit(RouteConfig.Login.path); + await this.$router.push(RouteConfig.Login.path); + + this.resetActivityEvents.forEach((eventName: string) => { + document.removeEventListener(eventName, this.onSessionActivity); + }); + this.clearSessionTimers(); + this.inactivityModalShown = false; + try { await this.auth.logout(); - this.resetActivityEvents.forEach((eventName: string) => { - document.removeEventListener(eventName, this.resetInactivityTimer); - }); - this.analytics.pageVisit(RouteConfig.Login.path); - await this.$router.push(RouteConfig.Login.path); - await this.$notify.notify('Your session was timed out.'); } catch (error) { + if (error instanceof ErrorUnauthorized) return; + await this.$notify.error(error.message); } } /** - * Resets inactivity timer. + * Resets inactivity timer and refreshes session if necessary. */ - private resetInactivityTimer(): void { - clearTimeout(this.inactivityTimerId); - this.startInactivityTimer(); + private async onSessionActivity(): Promise { + if (this.inactivityModalShown || this.isSessionActive) return; + + if (this.sessionRefreshTimerId == null && !this.isSessionRefreshing) { + await this.refreshSession(); + } + this.isSessionActive = true; } /** - * Adds DOM event listeners and starts timer. + * Adds DOM event listeners and starts session timers. */ - private setupInactivityTimers(): void { + private setupSessionTimers(): void { const isInactivityTimerEnabled = MetaUtils.getMetaContent('inactivity-timer-enabled'); if (isInactivityTimerEnabled === 'false') return; - this.resetActivityEvents.forEach((eventName: string) => { - document.addEventListener(eventName, this.resetInactivityTimer, false); - }); + const expiresAt = LocalData.getSessionExpirationDate(); - this.startInactivityTimer(); + if (expiresAt) { + this.resetActivityEvents.forEach((eventName: string) => { + document.addEventListener(eventName, this.onSessionActivity, false); + }); + + if (expiresAt.getTime() - this.sessionDuration + this.sessionRefreshInterval < Date.now()) { + this.refreshSession(); + } + + this.restartSessionTimers(); + } + } + + /** + * Restarts timers associated with session refreshing and inactivity. + */ + private restartSessionTimers(): void { + this.sessionRefreshTimerId = setTimeout(async () => { + this.sessionRefreshTimerId = null; + if (this.isSessionActive) { + await this.refreshSession(); + } + }, this.sessionRefreshInterval); + + this.inactivityTimerId = setTimeout(() => { + if (this.isSessionActive) return; + this.inactivityModalShown = true; + this.inactivityTimerId = setTimeout(async () => { + this.handleInactive(); + await this.$notify.notify('Your session was timed out.'); + }, this.inactivityModalTime); + }, this.sessionDuration - this.inactivityModalTime); + + if (!this.debugTimerShown) return; + + const debugTimer = () => { + const expiresAt = LocalData.getSessionExpirationDate(); + + if (expiresAt) { + const ms = Math.max(0, expiresAt.getTime() - Date.now()); + const secs = Math.floor(ms/1000)%60; + + this.debugTimerText = `${Math.floor(ms/60000)}:${(secs<10 ? '0' : '')+secs}`; + + if (ms > 1000) { + this.debugTimerId = setTimeout(debugTimer, 1000); + } + } + }; + debugTimer(); + } + + /** + * Clears timers associated with session refreshing and inactivity. + */ + private clearSessionTimers(): void { + [this.inactivityTimerId, this.sessionRefreshTimerId, this.debugTimerId].forEach(id => { + if (id != null) clearTimeout(id); + }); } } @@ -410,6 +528,26 @@ export default class DashboardArea extends Vue { } } } + + &__debug-timer { + display: flex; + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + padding: 16px; + z-index: 10000; + background-color: #fec; + font-family: 'font_regular', sans-serif; + font-size: 14px; + border: 1px solid #ffd78a; + border-radius: 10px; + box-shadow: 0 7px 20px rgba(0 0 0 / 15%); + + &__bold { + font-family: 'font_medium', sans-serif; + } + } } .no-nav { diff --git a/web/satellite/src/views/LoginArea.vue b/web/satellite/src/views/LoginArea.vue index 381a24c6e..5daa96cf9 100644 --- a/web/satellite/src/views/LoginArea.vue +++ b/web/satellite/src/views/LoginArea.vue @@ -185,6 +185,9 @@ import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { ErrorBadRequest } from "@/api/errors/ErrorBadRequest"; import { MetaUtils } from '@/utils/meta'; import { AnalyticsHttpApi } from '@/api/analytics'; +import { USER_ACTIONS } from '@/store/modules/users'; +import { TokenInfo } from '@/types/users'; +import { LocalData } from '@/utils/localData'; interface ClearInput { clearInput(): void; @@ -413,7 +416,8 @@ export default class Login extends Vue { } try { - await this.auth.token(this.email, this.password, this.captchaResponseToken, this.passcode, this.recoveryCode); + const tokenInfo: TokenInfo = await this.auth.token(this.email, this.password, this.captchaResponseToken, this.passcode, this.recoveryCode); + LocalData.setSessionExpirationDate(tokenInfo.expiresAt); } catch (error) { if (this.$refs.recaptcha) { this.$refs.recaptcha.reset(); @@ -453,6 +457,7 @@ export default class Login extends Vue { return; } + await this.$store.dispatch(USER_ACTIONS.LOGIN); await this.$store.dispatch(APP_STATE_ACTIONS.CHANGE_STATE, AppState.LOADING); this.isLoading = false; diff --git a/web/satellite/static/images/session/inactivityTimer.svg b/web/satellite/static/images/session/inactivityTimer.svg new file mode 100644 index 000000000..692ed626c --- /dev/null +++ b/web/satellite/static/images/session/inactivityTimer.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/satellite/tests/unit/views/__snapshots__/DashboardArea.spec.ts.snap b/web/satellite/tests/unit/views/__snapshots__/DashboardArea.spec.ts.snap index d02f87e60..46fe5d165 100644 --- a/web/satellite/tests/unit/views/__snapshots__/DashboardArea.spec.ts.snap +++ b/web/satellite/tests/unit/views/__snapshots__/DashboardArea.spec.ts.snap @@ -20,6 +20,8 @@ exports[`Dashboard renders correctly when data is loaded 1`] = ` + + `; @@ -29,6 +31,8 @@ exports[`Dashboard renders correctly when data is loading 1`] = `
+ + `;