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
This commit is contained in:
Jeremy Wharton 2022-07-19 04:26:18 -05:00 committed by Storj Robot
parent b722c29e77
commit 3f26cc599f
29 changed files with 587 additions and 145 deletions

View File

@ -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 {

View File

@ -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.

View File

@ -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,
}

View File

@ -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)

View File

@ -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),
})

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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

View File

@ -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{} {

View File

@ -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,
})

View File

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

View File

@ -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)
}

View File

@ -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

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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: ""

View File

@ -38,7 +38,8 @@
<meta name="new-access-grant-flow" content="{{ .NewAccessGrantFlow }}">
<meta name="new-billing-screen" content="{{ .NewBillingScreen }}">
<meta name="inactivity-timer-enabled" content="{{ .InactivityTimerEnabled }}">
<meta name="inactivity-timer-delay" content="{{ .InactivityTimerDelay }}">
<meta name="inactivity-timer-duration" content="{{ .InactivityTimerDuration }}">
<meta name="inactivity-timer-viewer-enabled" content="{{ .InactivityTimerViewerEnabled }}">
<meta name="optional-signup-success-url" content="{{ .OptionalSignupSuccessURL }}">
<meta name="homepage-url" content="{{ .HomepageURL }}">
<meta name="native-token-payments-enabled" content="{{ .NativeTokenPaymentsEnabled }}">

View File

@ -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<string> {
public async token(email: string, password: string, captchaResponse: string, mfaPasscode: string, mfaRecoveryCode: string): Promise<TokenInfo> {
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<Date> {
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.")
}
}

View File

@ -0,0 +1,137 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<VModal :on-close="onClose">
<template #content>
<div class="modal">
<Icon class="modal__icon" />
<h1 class="modal__title">Your session is about to expire due to inactivity in <span class="modal__title__timer">{{ seconds }} second{{ seconds != 1 ? 's' : '' }}</span></h1>
<p class="modal__info">Do you want to stay logged in?</p>
<div class="modal__buttons">
<VButton
label="Stay Logged In"
height="40px"
font-size="13px"
class="modal__buttons__button"
:on-press="withLoading(onContinue)"
:disabled="isLoading"
/>
<VButton
label="Log out"
height="40px"
font-size="13px"
is-transparent="true"
class="modal__buttons__button logout"
:on-press="withLoading(onLogout)"
:disabled="isLoading"
/>
</div>
</div>
</template>
</VModal>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import VButton from '@/components/common/VButton.vue';
import VModal from '@/components/common/VModal.vue';
import Icon from "@/../static/images/session/inactivityTimer.svg";
// @vue/component
@Component({
components: {
VButton,
VModal,
Icon,
},
})
export default class InactivityModal extends Vue {
@Prop({default: () => {}})
private readonly onContinue: () => Promise<void>;
@Prop({default: () => {}})
private readonly onLogout: () => Promise<void>;
@Prop({default: () => {}})
private readonly onClose: () => void;
@Prop({default: 60})
private readonly initialSeconds: number;
private seconds = 0;
private isLoading = false;
/**
* Lifecycle hook after initial render.
* Starts timer that decreases number of seconds until session expiration.
*/
public async mounted(): Promise<void> {
this.seconds = this.initialSeconds;
const id: ReturnType<typeof setInterval> = setInterval(() => {
if (--this.seconds <= 0) clearInterval(id);
}, 1000);
}
/**
* Returns a function that disables modal interaction during execution.
*/
private withLoading(fn: () => Promise<void>): () => Promise<void> {
return async () => {
if (this.isLoading) return;
this.isLoading = true;
await fn();
this.isLoading = false;
}
}
}
</script>
<style scoped lang="scss">
.modal {
max-width: 500px;
padding: 32px;
box-sizing: border-box;
font-family: 'font_regular', sans-serif;
text-align: left;
&__icon {
margin-bottom: 24px;
}
&__title {
font-family: 'font_bold', sans-serif;
font-size: 28px;
line-height: 36px;
letter-spacing: -0.02em;
color: #000;
margin-bottom: 8px;
&__timer {
color: #ff458b;
}
}
&__info {
font-family: 'font_regular', sans-serif;
font-size: 16px;
line-height: 24px;
color: #000;
margin-bottom: 16px;
}
&__buttons {
display: flex;
flex-direction: row;
&__button {
padding: 16px;
box-sizing: border-box;
letter-spacing: -0.02em;
&.logout {
margin-left: 8px;
}
}
}
}
</style>

View File

@ -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',

View File

@ -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,
) {}
}

View File

@ -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());
}
}
/**

View File

@ -27,6 +27,16 @@
</div>
</div>
</div>
<div v-if="debugTimerShown && !isLoading" class="dashboard__debug-timer">
<p>Remaining session time: <b class="dashboard__debug-timer__bold">{{ debugTimerText }}</b></p>
</div>
<InactivityModal
v-if="inactivityModalShown"
:on-continue="refreshSession"
:on-logout="handleInactive"
:on-close="closeInactivityModal"
:initial-seconds="inactivityModalTime/1000"
/>
<AllModals />
</div>
</template>
@ -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<typeof setTimeout>;
// 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<typeof setTimeout> | null;
private inactivityModalShown = false;
private inactivityModalTime = 60000;
private sessionRefreshInterval: number = this.sessionDuration/2;
private sessionRefreshTimerId: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<void> {
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);
});
}
}
</script>
@ -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 {

View File

@ -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;

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.3077 0H37.3541C46.2136 0 49.7287 0.981957 53.1765 2.82587C56.6243 4.66978 59.3302 7.37564 61.1741 10.8234L61.3143 11.0889C63.0447 14.4107 63.9753 17.9199 64 26.3077V37.3541C64 46.2136 63.018 49.7287 61.1741 53.1765C59.3302 56.6244 56.6243 59.3302 53.1765 61.1741L52.9111 61.3143C49.5892 63.0447 46.08 63.9753 37.6923 64H26.6459C17.7864 64 14.2713 63.018 10.8234 61.1741C7.37564 59.3302 4.66978 56.6244 2.82587 53.1765L2.68573 52.9111C0.955318 49.5893 0.024675 46.0801 0 37.6923V26.6459C0 17.7864 0.981957 14.2713 2.82587 10.8234C4.66978 7.37564 7.37564 4.66978 10.8234 2.82587L11.0889 2.68573C14.4107 0.955318 17.9199 0.024675 26.3077 0Z" fill="#0218A7"/>
<path d="M32.2645 55.0085C45.1177 55.0085 55.5372 44.5889 55.5372 31.7357C55.5372 18.8825 45.1177 8.46289 32.2645 8.46289C19.4113 8.46289 8.9917 18.8825 8.9917 31.7357C8.9917 44.5889 19.4113 55.0085 32.2645 55.0085Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.2645 51.8548C43.3759 51.8548 52.3835 42.8472 52.3835 31.7357C52.3835 25.8749 36.3178 34.3287 32.3189 30.6516C28.7363 27.3573 37.5151 11.6167 32.2645 11.6167C21.1531 11.6167 12.1455 20.6243 12.1455 31.7357C12.1455 42.8472 21.1531 51.8548 32.2645 51.8548Z" fill="#FF458B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.2643 11.6167C33.7057 11.6167 34.8742 12.7852 34.8742 14.2267V31.7356C34.8742 33.1771 33.7057 34.3456 32.2643 34.3456C30.8228 34.3456 29.6543 33.1771 29.6543 31.7356V14.2267C29.6543 12.7852 30.8228 11.6167 32.2643 11.6167Z" fill="#0149FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.3291 31.6813C52.3291 33.1228 51.1606 34.2914 49.7191 34.2914H32.2101C30.7686 34.2914 29.6001 33.1228 29.6001 31.6813C29.6001 30.2399 30.7686 29.0713 32.2101 29.0713L49.7191 29.0713C51.1606 29.0713 52.3291 30.2399 52.3291 31.6813Z" fill="#0149FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -20,6 +20,8 @@ exports[`Dashboard renders correctly when data is loaded 1`] = `
</div>
</div>
</div>
<!---->
<!---->
<allmodals-stub></allmodals-stub>
</div>
`;
@ -29,6 +31,8 @@ exports[`Dashboard renders correctly when data is loading 1`] = `
<div class="loading-overlay active">
<loaderimage-stub class="loading-icon"></loaderimage-stub>
</div>
<!---->
<!---->
<allmodals-stub></allmodals-stub>
</div>
`;