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. + + + + + + + Your session is about to expire due to inactivity in {{ seconds }} second{{ seconds != 1 ? 's' : '' }} + Do you want to stay logged in? + + + + + + + + + + + + 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`] = ` + + `;
Do you want to stay logged in?
Remaining session time: {{ debugTimerText }}