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:
Joan López de la Franca Beltran 2021-03-16 17:44:02 +01:00 committed by GitHub
parent a1c7e0630d
commit 610999cfa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 286 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
`,
};
});