mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Auth: Allow soft token revocation (#31601)
* Add revoked_at field to user auth token to allow soft revokes * Allow soft token revocations * Update token revocations and tests * Return error info on revokedTokenErr * Override session cookie only when no revokedErr nor API request * Display modal on revoked token error * Feedback: Refactor TokenRevokedModal to FC * Add GetUserRevokedTokens into UserTokenService * Backendsrv: adds tests and refactors soft token path * Apply feedback * Write redirect cookie on token revoked error * Update TokenRevokedModal style * Return meaningful error info * Some UI changes * Update backend_srv tests * Minor style fix on backend_srv tests * Replace deprecated method usage to publish events * Fix backend_srv tests * Apply suggestions from code review Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> * Minor style fix after PR suggestion commit * Apply suggestions from code review Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com> * Prettier fixes Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com> Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com>
This commit is contained in:
parent
a1c7e0630d
commit
610999cfa2
@ -283,7 +283,7 @@ func (hs *HTTPServer) Logout(c *models.ReqContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken)
|
err := hs.AuthTokenService.RevokeToken(c.Req.Context(), c.UserToken, false)
|
||||||
if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) {
|
if err != nil && !errors.Is(err, models.ErrUserTokenNotFound) {
|
||||||
hs.log.Error("failed to revoke auth token", "error", err)
|
hs.log.Error("failed to revoke auth token", "error", err)
|
||||||
}
|
}
|
||||||
|
@ -132,7 +132,7 @@ func (hs *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID i
|
|||||||
return response.Error(400, "Cannot revoke active user auth token", nil)
|
return response.Error(400, "Cannot revoke active user auth token", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token)
|
err = hs.AuthTokenService.RevokeToken(c.Req.Context(), token, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, models.ErrUserTokenNotFound) {
|
if errors.Is(err, models.ErrUserTokenNotFound) {
|
||||||
return response.Error(404, "User auth token not found", err)
|
return response.Error(404, "User auth token not found", err)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -34,6 +35,27 @@ func notAuthorized(c *models.ReqContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeRedirectCookie(c)
|
||||||
|
c.Redirect(setting.AppSubUrl + "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenRevoked(c *models.ReqContext, err *models.TokenRevokedError) {
|
||||||
|
if c.IsApiRequest() {
|
||||||
|
c.JSON(401, map[string]interface{}{
|
||||||
|
"message": "Token revoked",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"id": "ERR_TOKEN_REVOKED",
|
||||||
|
"maxConcurrentSessions": err.MaxConcurrentSessions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRedirectCookie(c)
|
||||||
|
c.Redirect(setting.AppSubUrl + "/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRedirectCookie(c *models.ReqContext) {
|
||||||
redirectTo := c.Req.RequestURI
|
redirectTo := c.Req.RequestURI
|
||||||
if setting.AppSubUrl != "" && !strings.HasPrefix(redirectTo, setting.AppSubUrl) {
|
if setting.AppSubUrl != "" && !strings.HasPrefix(redirectTo, setting.AppSubUrl) {
|
||||||
redirectTo = setting.AppSubUrl + c.Req.RequestURI
|
redirectTo = setting.AppSubUrl + c.Req.RequestURI
|
||||||
@ -43,7 +65,6 @@ func notAuthorized(c *models.ReqContext) {
|
|||||||
redirectTo = removeForceLoginParams(redirectTo)
|
redirectTo = removeForceLoginParams(redirectTo)
|
||||||
|
|
||||||
cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil)
|
cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil)
|
||||||
c.Redirect(setting.AppSubUrl + "/login")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`)
|
var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`)
|
||||||
@ -90,6 +111,13 @@ func Auth(options *AuthOptions) macaron.Handler {
|
|||||||
requireLogin := !c.AllowAnonymous || forceLogin || options.ReqNoAnonynmous
|
requireLogin := !c.AllowAnonymous || forceLogin || options.ReqNoAnonynmous
|
||||||
|
|
||||||
if !c.IsSignedIn && options.ReqSignedIn && requireLogin {
|
if !c.IsSignedIn && options.ReqSignedIn && requireLogin {
|
||||||
|
lookupTokenErr, hasTokenErr := c.Data["lookupTokenErr"].(error)
|
||||||
|
var revokedErr *models.TokenRevokedError
|
||||||
|
if hasTokenErr && errors.As(lookupTokenErr, &revokedErr) {
|
||||||
|
tokenRevoked(c, revokedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
notAuthorized(c)
|
notAuthorized(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,14 @@ type TokenExpiredError struct {
|
|||||||
|
|
||||||
func (e *TokenExpiredError) Error() string { return "user token expired" }
|
func (e *TokenExpiredError) Error() string { return "user token expired" }
|
||||||
|
|
||||||
|
type TokenRevokedError struct {
|
||||||
|
UserID int64
|
||||||
|
TokenID int64
|
||||||
|
MaxConcurrentSessions int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TokenRevokedError) Error() string { return "user token revoked" }
|
||||||
|
|
||||||
// UserToken represents a user token
|
// UserToken represents a user token
|
||||||
type UserToken struct {
|
type UserToken struct {
|
||||||
Id int64
|
Id int64
|
||||||
@ -45,6 +53,7 @@ type UserToken struct {
|
|||||||
RotatedAt int64
|
RotatedAt int64
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
|
RevokedAt int64
|
||||||
UnhashedToken string
|
UnhashedToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,9 +66,10 @@ type UserTokenService interface {
|
|||||||
CreateToken(ctx context.Context, user *User, clientIP net.IP, userAgent string) (*UserToken, error)
|
CreateToken(ctx context.Context, user *User, clientIP net.IP, userAgent string) (*UserToken, error)
|
||||||
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
|
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
|
||||||
TryRotateToken(ctx context.Context, token *UserToken, clientIP net.IP, userAgent string) (bool, error)
|
TryRotateToken(ctx context.Context, token *UserToken, clientIP net.IP, userAgent string) (bool, error)
|
||||||
RevokeToken(ctx context.Context, token *UserToken) error
|
RevokeToken(ctx context.Context, token *UserToken, soft bool) error
|
||||||
RevokeAllUserTokens(ctx context.Context, userId int64) error
|
RevokeAllUserTokens(ctx context.Context, userId int64) error
|
||||||
ActiveTokenCount(ctx context.Context) (int64, error)
|
ActiveTokenCount(ctx context.Context) (int64, error)
|
||||||
GetUserToken(ctx context.Context, userId, userTokenId int64) (*UserToken, error)
|
GetUserToken(ctx context.Context, userId, userTokenId int64) (*UserToken, error)
|
||||||
GetUserTokens(ctx context.Context, userId int64) ([]*UserToken, error)
|
GetUserTokens(ctx context.Context, userId int64) ([]*UserToken, error)
|
||||||
|
GetUserRevokedTokens(ctx context.Context, userId int64) ([]*UserToken, error)
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ func (s *UserAuthTokenService) ActiveTokenCount(ctx context.Context) (int64, err
|
|||||||
var err error
|
var err error
|
||||||
err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
var model userAuthToken
|
var model userAuthToken
|
||||||
count, err = dbSession.Where(`created_at > ? AND rotated_at > ?`,
|
count, err = dbSession.Where(`created_at > ? AND rotated_at > ? AND revoked_at = 0`,
|
||||||
s.createdAfterParam(),
|
s.createdAfterParam(),
|
||||||
s.rotatedAfterParam()).
|
s.rotatedAfterParam()).
|
||||||
Count(&model)
|
Count(&model)
|
||||||
@ -84,6 +84,7 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *models.Use
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
SeenAt: 0,
|
SeenAt: 0,
|
||||||
|
RevokedAt: 0,
|
||||||
AuthTokenSeen: false,
|
AuthTokenSeen: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,6 +128,13 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
|
|||||||
return nil, models.ErrUserTokenNotFound
|
return nil, models.ErrUserTokenNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if model.RevokedAt > 0 {
|
||||||
|
return nil, &models.TokenRevokedError{
|
||||||
|
UserID: model.UserId,
|
||||||
|
TokenID: model.Id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if model.CreatedAt <= s.createdAfterParam() || model.RotatedAt <= s.rotatedAfterParam() {
|
if model.CreatedAt <= s.createdAfterParam() || model.RotatedAt <= s.rotatedAfterParam() {
|
||||||
return nil, &models.TokenExpiredError{
|
return nil, &models.TokenExpiredError{
|
||||||
UserID: model.UserId,
|
UserID: model.UserId,
|
||||||
@ -278,7 +286,7 @@ func (s *UserAuthTokenService) TryRotateToken(ctx context.Context, token *models
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken) error {
|
func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error {
|
||||||
if token == nil {
|
if token == nil {
|
||||||
return models.ErrUserTokenNotFound
|
return models.ErrUserTokenNotFound
|
||||||
}
|
}
|
||||||
@ -289,10 +297,19 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rowsAffected int64
|
var rowsAffected int64
|
||||||
err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
|
||||||
rowsAffected, err = dbSession.Delete(model)
|
if soft {
|
||||||
return err
|
model.RevokedAt = getTime().Unix()
|
||||||
})
|
err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
|
rowsAffected, err = dbSession.ID(model.Id).Update(model)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
err = s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
|
rowsAffected, err = dbSession.Delete(model)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -303,7 +320,7 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *models.Us
|
|||||||
return models.ErrUserTokenNotFound
|
return models.ErrUserTokenNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
|
s.log.Debug("user auth token revoked", "tokenId", model.Id, "userId", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent, "soft", soft)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -380,7 +397,7 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64)
|
|||||||
result := []*models.UserToken{}
|
result := []*models.UserToken{}
|
||||||
err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
var tokens []*userAuthToken
|
var tokens []*userAuthToken
|
||||||
err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ?",
|
err := dbSession.Where("user_id = ? AND created_at > ? AND rotated_at > ? AND revoked_at = 0",
|
||||||
userId,
|
userId,
|
||||||
s.createdAfterParam(),
|
s.createdAfterParam(),
|
||||||
s.rotatedAfterParam()).
|
s.rotatedAfterParam()).
|
||||||
@ -403,6 +420,29 @@ func (s *UserAuthTokenService) GetUserTokens(ctx context.Context, userId int64)
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) {
|
||||||
|
result := []*models.UserToken{}
|
||||||
|
err := s.SQLStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
|
var tokens []*userAuthToken
|
||||||
|
err := dbSession.Where("user_id = ? AND revoked_at > 0", userId).Find(&tokens)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range tokens {
|
||||||
|
var userToken models.UserToken
|
||||||
|
if err := token.toUserToken(&userToken); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result = append(result, &userToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserAuthTokenService) createdAfterParam() int64 {
|
func (s *UserAuthTokenService) createdAfterParam() int64 {
|
||||||
return getTime().Add(-s.Cfg.LoginMaxLifetime).Unix()
|
return getTime().Add(-s.Cfg.LoginMaxLifetime).Unix()
|
||||||
}
|
}
|
||||||
|
@ -60,8 +60,18 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
So(userToken, ShouldBeNil)
|
So(userToken, ShouldBeNil)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("revoking existing token should delete token", func() {
|
Convey("soft revoking existing token should not delete it", func() {
|
||||||
err = userAuthTokenService.RevokeToken(context.Background(), userToken)
|
err = userAuthTokenService.RevokeToken(context.Background(), userToken, true)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
model, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(model, ShouldNotBeNil)
|
||||||
|
So(model.RevokedAt, ShouldBeGreaterThan, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("revoking existing token should delete it", func() {
|
||||||
|
err = userAuthTokenService.RevokeToken(context.Background(), userToken, false)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
model, err := ctx.getAuthTokenByID(userToken.Id)
|
model, err := ctx.getAuthTokenByID(userToken.Id)
|
||||||
@ -70,13 +80,13 @@ func TestUserAuthToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Convey("revoking nil token should return error", func() {
|
Convey("revoking nil token should return error", func() {
|
||||||
err = userAuthTokenService.RevokeToken(context.Background(), nil)
|
err = userAuthTokenService.RevokeToken(context.Background(), nil, false)
|
||||||
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("revoking non-existing token should return error", func() {
|
Convey("revoking non-existing token should return error", func() {
|
||||||
userToken.Id = 1000
|
userToken.Id = 1000
|
||||||
err = userAuthTokenService.RevokeToken(context.Background(), userToken)
|
err = userAuthTokenService.RevokeToken(context.Background(), userToken, false)
|
||||||
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
So(err, ShouldEqual, models.ErrUserTokenNotFound)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ type userAuthToken struct {
|
|||||||
RotatedAt int64
|
RotatedAt int64
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
|
RevokedAt int64
|
||||||
UnhashedToken string `xorm:"-"`
|
UnhashedToken string `xorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ func (uat *userAuthToken) fromUserToken(ut *models.UserToken) error {
|
|||||||
uat.RotatedAt = ut.RotatedAt
|
uat.RotatedAt = ut.RotatedAt
|
||||||
uat.CreatedAt = ut.CreatedAt
|
uat.CreatedAt = ut.CreatedAt
|
||||||
uat.UpdatedAt = ut.UpdatedAt
|
uat.UpdatedAt = ut.UpdatedAt
|
||||||
|
uat.RevokedAt = ut.RevokedAt
|
||||||
uat.UnhashedToken = ut.UnhashedToken
|
uat.UnhashedToken = ut.UnhashedToken
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -64,6 +66,7 @@ func (uat *userAuthToken) toUserToken(ut *models.UserToken) error {
|
|||||||
ut.RotatedAt = uat.RotatedAt
|
ut.RotatedAt = uat.RotatedAt
|
||||||
ut.CreatedAt = uat.CreatedAt
|
ut.CreatedAt = uat.CreatedAt
|
||||||
ut.UpdatedAt = uat.UpdatedAt
|
ut.UpdatedAt = uat.UpdatedAt
|
||||||
|
ut.RevokedAt = uat.RevokedAt
|
||||||
ut.UnhashedToken = uat.UnhashedToken
|
ut.UnhashedToken = uat.UnhashedToken
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -8,15 +8,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FakeUserAuthTokenService struct {
|
type FakeUserAuthTokenService struct {
|
||||||
CreateTokenProvider func(ctx context.Context, user *models.User, clientIP net.IP, userAgent string) (*models.UserToken, error)
|
CreateTokenProvider func(ctx context.Context, user *models.User, clientIP net.IP, userAgent string) (*models.UserToken, error)
|
||||||
TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error)
|
TryRotateTokenProvider func(ctx context.Context, token *models.UserToken, clientIP net.IP, userAgent string) (bool, error)
|
||||||
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error)
|
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*models.UserToken, error)
|
||||||
RevokeTokenProvider func(ctx context.Context, token *models.UserToken) error
|
RevokeTokenProvider func(ctx context.Context, token *models.UserToken, soft bool) error
|
||||||
RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error
|
RevokeAllUserTokensProvider func(ctx context.Context, userId int64) error
|
||||||
ActiveAuthTokenCount func(ctx context.Context) (int64, error)
|
ActiveAuthTokenCount func(ctx context.Context) (int64, error)
|
||||||
GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error)
|
GetUserTokenProvider func(ctx context.Context, userId, userTokenId int64) (*models.UserToken, error)
|
||||||
GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error)
|
GetUserTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error)
|
||||||
BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error
|
GetUserRevokedTokensProvider func(ctx context.Context, userId int64) ([]*models.UserToken, error)
|
||||||
|
BatchRevokedTokenProvider func(ctx context.Context, userIds []int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
|
func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
|
||||||
@ -36,7 +37,7 @@ func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
|
|||||||
UnhashedToken: "",
|
UnhashedToken: "",
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
RevokeTokenProvider: func(ctx context.Context, token *models.UserToken) error {
|
RevokeTokenProvider: func(ctx context.Context, token *models.UserToken, soft bool) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RevokeAllUserTokensProvider: func(ctx context.Context, userId int64) error {
|
RevokeAllUserTokensProvider: func(ctx context.Context, userId int64) error {
|
||||||
@ -76,8 +77,8 @@ func (s *FakeUserAuthTokenService) TryRotateToken(ctx context.Context, token *mo
|
|||||||
return s.TryRotateTokenProvider(context.Background(), token, clientIP, userAgent)
|
return s.TryRotateTokenProvider(context.Background(), token, clientIP, userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken) error {
|
func (s *FakeUserAuthTokenService) RevokeToken(ctx context.Context, token *models.UserToken, soft bool) error {
|
||||||
return s.RevokeTokenProvider(context.Background(), token)
|
return s.RevokeTokenProvider(context.Background(), token, soft)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FakeUserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
|
func (s *FakeUserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
|
||||||
@ -96,6 +97,10 @@ func (s *FakeUserAuthTokenService) GetUserTokens(ctx context.Context, userId int
|
|||||||
return s.GetUserTokensProvider(context.Background(), userId)
|
return s.GetUserTokensProvider(context.Background(), userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FakeUserAuthTokenService) GetUserRevokedTokens(ctx context.Context, userId int64) ([]*models.UserToken, error) {
|
||||||
|
return s.GetUserRevokedTokensProvider(context.Background(), userId)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FakeUserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
|
func (s *FakeUserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
|
||||||
return s.BatchRevokedTokenProvider(ctx, userIds)
|
return s.BatchRevokedTokenProvider(ctx, userIds)
|
||||||
}
|
}
|
||||||
|
@ -257,7 +257,11 @@ func (h *ContextHandler) initContextWithToken(ctx *models.ReqContext, orgID int6
|
|||||||
token, err := h.AuthTokenService.LookupToken(ctx.Req.Context(), rawToken)
|
token, err := h.AuthTokenService.LookupToken(ctx.Req.Context(), rawToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Error("Failed to look up user based on cookie", "error", err)
|
ctx.Logger.Error("Failed to look up user based on cookie", "error", err)
|
||||||
cookies.WriteSessionCookie(ctx, h.Cfg, "", -1)
|
|
||||||
|
var revokedErr *models.TokenRevokedError
|
||||||
|
if !errors.As(err, &revokedErr) || !ctx.IsApiRequest() {
|
||||||
|
cookies.WriteSessionCookie(ctx, h.Cfg, "", -1)
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Data["lookupTokenErr"] = err
|
ctx.Data["lookupTokenErr"] = err
|
||||||
return false
|
return false
|
||||||
|
@ -32,4 +32,16 @@ func addUserAuthTokenMigrations(mg *Migrator) {
|
|||||||
mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1]))
|
mg.AddMigration("add unique index user_auth_token.prev_auth_token", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[1]))
|
||||||
|
|
||||||
mg.AddMigration("add index user_auth_token.user_id", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[2]))
|
mg.AddMigration("add index user_auth_token.user_id", NewAddIndexMigration(userAuthTokenV1, userAuthTokenV1.Indices[2]))
|
||||||
|
|
||||||
|
mg.AddMigration(
|
||||||
|
"Add revoked_at to the user auth token",
|
||||||
|
NewAddColumnMigration(
|
||||||
|
userAuthTokenV1,
|
||||||
|
&Column{
|
||||||
|
Name: "revoked_at",
|
||||||
|
Type: DB_Int,
|
||||||
|
Nullable: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { from, merge, MonoTypeOperatorFunction, Observable, Subject, Subscription, throwError } from 'rxjs';
|
import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, Subscription, throwError } from 'rxjs';
|
||||||
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
|
import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators';
|
||||||
import { fromFetch } from 'rxjs/fetch';
|
import { fromFetch } from 'rxjs/fetch';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -16,6 +16,8 @@ import { isDataQuery, isLocalUrl } from '../utils/query';
|
|||||||
import { FetchQueue } from './FetchQueue';
|
import { FetchQueue } from './FetchQueue';
|
||||||
import { ResponseQueue } from './ResponseQueue';
|
import { ResponseQueue } from './ResponseQueue';
|
||||||
import { FetchQueueWorker } from './FetchQueueWorker';
|
import { FetchQueueWorker } from './FetchQueueWorker';
|
||||||
|
import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
|
||||||
|
import { ShowModalReactEvent } from '../../types/events';
|
||||||
|
|
||||||
const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id';
|
const CANCEL_ALL_REQUESTS_REQUEST_ID = 'cancel_all_requests_request_id';
|
||||||
|
|
||||||
@ -212,6 +214,19 @@ export class BackendSrv implements BackendService {
|
|||||||
const firstAttempt = i === 0 && options.retry === 0;
|
const firstAttempt = i === 0 && options.retry === 0;
|
||||||
|
|
||||||
if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) {
|
if (error.status === 401 && isLocalUrl(options.url) && firstAttempt && isSignedIn) {
|
||||||
|
if (error.data?.error?.id === 'ERR_TOKEN_REVOKED') {
|
||||||
|
this.dependencies.appEvents.publish(
|
||||||
|
new ShowModalReactEvent({
|
||||||
|
component: TokenRevokedModal,
|
||||||
|
props: {
|
||||||
|
maxConcurrentSessions: error.data?.error?.maxConcurrentSessions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return of({});
|
||||||
|
}
|
||||||
|
|
||||||
return from(this.loginPing()).pipe(
|
return from(this.loginPing()).pipe(
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
|
@ -7,6 +7,8 @@ import { BackendSrv } from '../services/backend_srv';
|
|||||||
import { ContextSrv, User } from '../services/context_srv';
|
import { ContextSrv, User } from '../services/context_srv';
|
||||||
import { describe, expect } from '../../../test/lib/common';
|
import { describe, expect } from '../../../test/lib/common';
|
||||||
import { BackendSrvRequest, FetchError } from '@grafana/runtime';
|
import { BackendSrvRequest, FetchError } from '@grafana/runtime';
|
||||||
|
import { TokenRevokedModal } from '../../features/users/TokenRevokedModal';
|
||||||
|
import { ShowModalReactEvent } from '../../types/events';
|
||||||
|
|
||||||
const getTestContext = (overides?: object) => {
|
const getTestContext = (overides?: object) => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
@ -37,6 +39,7 @@ const getTestContext = (overides?: object) => {
|
|||||||
|
|
||||||
const appEventsMock: EventBusExtended = ({
|
const appEventsMock: EventBusExtended = ({
|
||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
|
publish: jest.fn(),
|
||||||
} as any) as EventBusExtended;
|
} as any) as EventBusExtended;
|
||||||
|
|
||||||
const user: User = ({
|
const user: User = ({
|
||||||
@ -185,6 +188,36 @@ describe('backendSrv', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when making an unsuccessful call because of soft token revocation', () => {
|
||||||
|
it('then it should dispatch show Token Revoked modal event', async () => {
|
||||||
|
const url = '/api/dashboard/';
|
||||||
|
const { backendSrv, appEventsMock, logoutMock, expectRequestCallChain } = getTestContext({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'UnAuthorized',
|
||||||
|
data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } },
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
backendSrv.loginPing = jest.fn();
|
||||||
|
|
||||||
|
await backendSrv.request({ url, method: 'GET', retry: 0 }).catch(() => {
|
||||||
|
expect(appEventsMock.publish).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appEventsMock.publish).toHaveBeenCalledWith(
|
||||||
|
new ShowModalReactEvent({
|
||||||
|
component: TokenRevokedModal,
|
||||||
|
props: {
|
||||||
|
maxConcurrentSessions: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(backendSrv.loginPing).not.toHaveBeenCalled();
|
||||||
|
expect(logoutMock).not.toHaveBeenCalled();
|
||||||
|
expectRequestCallChain({ url, method: 'GET', retry: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
||||||
it('then it throw error', async () => {
|
it('then it throw error', async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
@ -394,6 +427,36 @@ describe('backendSrv', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when making an unsuccessful call because of soft token revocation', () => {
|
||||||
|
it('then it should dispatch show Token Revoked modal event', async () => {
|
||||||
|
const { backendSrv, logoutMock, appEventsMock, expectRequestCallChain } = getTestContext({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'UnAuthorized',
|
||||||
|
data: { message: 'Token revoked', error: { id: 'ERR_TOKEN_REVOKED', maxConcurrentSessions: 3 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
backendSrv.loginPing = jest.fn();
|
||||||
|
|
||||||
|
const url = '/api/dashboard/';
|
||||||
|
|
||||||
|
await backendSrv.datasourceRequest({ url, method: 'GET', retry: 0 }).catch((error) => {
|
||||||
|
expect(appEventsMock.publish).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appEventsMock.publish).toHaveBeenCalledWith(
|
||||||
|
new ShowModalReactEvent({
|
||||||
|
component: TokenRevokedModal,
|
||||||
|
props: {
|
||||||
|
maxConcurrentSessions: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(backendSrv.loginPing).not.toHaveBeenCalled();
|
||||||
|
expect(logoutMock).not.toHaveBeenCalled();
|
||||||
|
expectRequestCallChain({ url, method: 'GET', retry: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
describe('when making an unsuccessful call and conditions for retry are favorable and retry throws', () => {
|
||||||
it('then it throw error', async () => {
|
it('then it throw error', async () => {
|
||||||
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
|
const { backendSrv, logoutMock, expectRequestCallChain } = getTestContext({
|
||||||
|
66
public/app/features/users/TokenRevokedModal.tsx
Normal file
66
public/app/features/users/TokenRevokedModal.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, InfoBox, Portal, stylesFactory, useTheme } from '@grafana/ui';
|
||||||
|
import { getModalStyles } from '@grafana/ui/src/components/Modal/getModalStyles';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
maxConcurrentSessions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TokenRevokedModal = (props: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const styles = getStyles(theme);
|
||||||
|
const modalStyles = getModalStyles(theme);
|
||||||
|
|
||||||
|
const showMaxConcurrentSessions = Boolean(props.maxConcurrentSessions);
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<div className={modalStyles.modal}>
|
||||||
|
<InfoBox title="You have been automatically signed out" severity="warning" className={styles.infobox}>
|
||||||
|
<div className={styles.text}>
|
||||||
|
<p>
|
||||||
|
Your session token was automatically revoked because you have reached
|
||||||
|
<strong>
|
||||||
|
{` the maximum number of ${
|
||||||
|
showMaxConcurrentSessions ? props.maxConcurrentSessions : ''
|
||||||
|
} concurrent sessions `}
|
||||||
|
</strong>
|
||||||
|
for your account.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>To resume your session, sign in again.</strong>
|
||||||
|
Contact your administrator or visit the license page to review your quota if you are repeatedly signed out
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="md" variant="primary" onClick={redirectToLogin}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</InfoBox>
|
||||||
|
</div>
|
||||||
|
<div className={cx(modalStyles.modalBackdrop, styles.backdrop)} />
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||||
|
return {
|
||||||
|
infobox: css`
|
||||||
|
margin-bottom: 0;
|
||||||
|
`,
|
||||||
|
text: css`
|
||||||
|
margin: ${theme.spacing.sm} 0 ${theme.spacing.md};
|
||||||
|
`,
|
||||||
|
backdrop: css`
|
||||||
|
background-color: ${theme.colors.dashboardBg};
|
||||||
|
opacity: 0.8;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user