From 8029e48588215b5ec4a54c60866ba994bf036cf2 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 8 Mar 2019 15:15:17 +0100 Subject: [PATCH 1/3] support get user tokens/revoke all user tokens in UserTokenService --- pkg/middleware/middleware_test.go | 67 +++-------------------- pkg/middleware/org_redirect_test.go | 4 +- pkg/middleware/quota_test.go | 5 +- pkg/middleware/recovery_test.go | 3 +- pkg/models/user_token.go | 3 ++ pkg/services/auth/auth_token.go | 51 ++++++++++++++++++ pkg/services/auth/auth_token_test.go | 41 ++++++++++++++ pkg/services/auth/testing.go | 81 ++++++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 pkg/services/auth/testing.go diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1fbd303bebd..2fc8e0c456f 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -11,6 +11,7 @@ import ( msession "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/session" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: unhashedToken, @@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", }, nil } - sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { + sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) { userToken.UnhashedToken = "rotated" return true, nil } @@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) { middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) { sc.withTokenSessionCookie("token") - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return nil, m.ErrUserTokenNotFound } @@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) { })) session.Init(&msession.Options{}, 0) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine session.StartSessionGC = func() {} @@ -595,7 +596,7 @@ type scenarioContext struct { handlerFunc handlerFunc defaultHandler macaron.Handler url string - userAuthTokenService *fakeUserAuthTokenService + userAuthTokenService *auth.FakeUserAuthTokenService req *http.Request } @@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() { type scenarioFunc func(c *scenarioContext) type handlerFunc func(c *m.ReqContext) - -type fakeUserAuthTokenService struct { - createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error) - tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error) - lookupTokenProvider func(unhashedToken string) (*m.UserToken, error) - revokeTokenProvider func(token *m.UserToken) error - activeAuthTokenCount func() (int64, error) -} - -func newFakeUserAuthTokenService() *fakeUserAuthTokenService { - return &fakeUserAuthTokenService{ - createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return false, nil - }, - lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) { - return &m.UserToken{ - UserId: 0, - UnhashedToken: "", - }, nil - }, - revokeTokenProvider: func(token *m.UserToken) error { - return nil - }, - activeAuthTokenCount: func() (int64, error) { - return 10, nil - }, - } -} - -func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) { - return s.createTokenProvider(userId, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) { - return s.lookupTokenProvider(unhashedToken) -} - -func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) { - return s.tryRotateTokenProvider(token, clientIP, userAgent) -} - -func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error { - return s.revokeTokenProvider(token) -} - -func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) { - return s.activeAuthTokenCount() -} diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go index e01d1a68d21..fe5b2736035 100644 --- a/pkg/middleware/org_redirect_test.go +++ b/pkg/middleware/org_redirect_test.go @@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 0, UnhashedToken: "", @@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index 52b696cf037..0ba42e708bc 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -3,6 +3,7 @@ package middleware import ( "testing" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/bus" @@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) { }, } - fakeAuthTokenService := newFakeUserAuthTokenService() + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() qs := "a.QuotaService{ AuthTokenService: fakeAuthTokenService, } @@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) { return nil }) - sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { + sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) { return &m.UserToken{ UserId: 12, UnhashedToken: "", diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go index 6736d699a39..00f3b7a3032 100644 --- a/pkg/middleware/recovery_test.go +++ b/pkg/middleware/recovery_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" macaron "gopkg.in/macaron.v1" @@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) { Delims: macaron.Delims{Left: "[[", Right: "]]"}, })) - sc.userAuthTokenService = newFakeUserAuthTokenService() + sc.userAuthTokenService = auth.NewFakeUserAuthTokenService() sc.m.Use(GetContextHandler(sc.userAuthTokenService)) // mock out gc goroutine sc.m.Use(OrgRedirect()) diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 388bc2dd4a2..22f92cb21d2 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -29,5 +29,8 @@ type UserTokenService interface { LookupToken(unhashedToken string) (*UserToken, error) TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error) RevokeToken(token *UserToken) error + RevokeAllUserTokens(userId int64) error ActiveTokenCount() (int64, error) + GetUserToken(userId, userTokenId int64) (*UserToken, error) + GetUserTokens(userId int64) ([]*UserToken, error) } diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index 648575d54cd..255866a9ba0 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error { return nil } +func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error { + sql := `DELETE from user_auth_token WHERE user_id = ?` + res, err := s.SQLStore.NewSession().Exec(sql, userId) + if err != nil { + return err + } + + affected, err := res.RowsAffected() + if err != nil { + return err + } + + s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected) + + return nil +} + +func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + var token userAuthToken + exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token) + if err != nil { + return nil, err + } + + if !exists { + return nil, models.ErrUserTokenNotFound + } + + var result models.UserToken + token.toUserToken(&result) + + return &result, nil +} + +func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + var tokens []*userAuthToken + err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens) + if err != nil { + return nil, err + } + + result := []*models.UserToken{} + for _, token := range tokens { + var userToken models.UserToken + token.toUserToken(&userToken) + result = append(result, &userToken) + } + + return result, nil +} + func (s *UserAuthTokenService) createdAfterParam() int64 { tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour return getTime().Add(-tokenMaxLifetime).Unix() diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 49e7acc3a5b..33eb309ad18 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) { err = userAuthTokenService.RevokeToken(userToken) So(err, ShouldEqual, models.ErrUserTokenNotFound) }) + + Convey("When creating an additional token", func() { + userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent") + So(err, ShouldBeNil) + So(userToken2, ShouldNotBeNil) + + Convey("Can get first user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken.Id) + }) + + Convey("Can get second user token", func() { + token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id) + So(err, ShouldBeNil) + So(token, ShouldNotBeNil) + So(token.Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can get user tokens", func() { + tokens, err := userAuthTokenService.GetUserTokens(userID) + So(err, ShouldBeNil) + So(tokens, ShouldHaveLength, 2) + So(tokens[0].Id, ShouldEqual, userToken.Id) + So(tokens[1].Id, ShouldEqual, userToken2.Id) + }) + + Convey("Can revoke all user tokens", func() { + err := userAuthTokenService.RevokeAllUserTokens(userID) + So(err, ShouldBeNil) + + model, err := ctx.getAuthTokenByID(userToken.Id) + So(err, ShouldBeNil) + So(model, ShouldBeNil) + + model2, err := ctx.getAuthTokenByID(userToken2.Id) + So(err, ShouldBeNil) + So(model2, ShouldBeNil) + }) + }) }) Convey("expires correctly", func() { diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/testing.go new file mode 100644 index 00000000000..68e65466c3d --- /dev/null +++ b/pkg/services/auth/testing.go @@ -0,0 +1,81 @@ +package auth + +import "github.com/grafana/grafana/pkg/models" + +type FakeUserAuthTokenService struct { + CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error) + TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error) + LookupTokenProvider func(unhashedToken string) (*models.UserToken, error) + RevokeTokenProvider func(token *models.UserToken) error + RevokeAllUserTokensProvider func(userId int64) error + ActiveAuthTokenCount func() (int64, error) + GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error) + GetUserTokensProvider func(userId int64) ([]*models.UserToken, error) +} + +func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { + return &FakeUserAuthTokenService{ + CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return false, nil + }, + LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) { + return &models.UserToken{ + UserId: 0, + UnhashedToken: "", + }, nil + }, + RevokeTokenProvider: func(token *models.UserToken) error { + return nil + }, + RevokeAllUserTokensProvider: func(userId int64) error { + return nil + }, + ActiveAuthTokenCount: func() (int64, error) { + return 10, nil + }, + GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) { + return nil, nil + }, + GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) { + return nil, nil + }, + } +} + +func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) { + return s.CreateTokenProvider(userId, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) { + return s.LookupTokenProvider(unhashedToken) +} + +func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) { + return s.TryRotateTokenProvider(token, clientIP, userAgent) +} + +func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error { + return s.RevokeTokenProvider(token) +} + +func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error { + return s.RevokeAllUserTokensProvider(userId) +} + +func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) { + return s.ActiveAuthTokenCount() +} + +func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) { + return s.GetUserTokenProvider(userId, userTokenId) +} + +func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) { + return s.GetUserTokensProvider(userId) +} From 0cd5a6772d188fa5bf1ada0c1cb0e7597f3579ac Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 8 Mar 2019 15:15:38 +0100 Subject: [PATCH 2/3] feat(api): support list/revoke auth token in admin/current user api --- pkg/api/admin_users.go | 23 +++ pkg/api/admin_users_test.go | 138 +++++++++++++++++ pkg/api/api.go | 7 + pkg/api/common_test.go | 16 +- pkg/api/dtos/user_token.go | 12 ++ pkg/api/user_token.go | 110 ++++++++++++++ pkg/api/user_token_test.go | 294 ++++++++++++++++++++++++++++++++++++ pkg/models/user_token.go | 8 +- 8 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 pkg/api/dtos/user_token.go create mode 100644 pkg/api/user_token.go create mode 100644 pkg/api/user_token_test.go diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index c16c2f126f8..4ad8a2b84ab 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) { c.JsonOK("User deleted") } + +// POST /api/admin/users/:id/logout +func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + + if c.UserId == userID { + return Error(400, "You cannot logout yourself", nil) + } + + return server.logoutUserFromAllDevicesInternal(userID) +} + +// GET /api/admin/users/:id/auth-tokens +func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response { + userID := c.ParamsInt64(":id") + return server.getUserAuthTokensInternal(c, userID) +} + +// POST /api/admin/users/:id/revoke-auth-token +func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response { + userID := c.ParamsInt64(":id") + return server.revokeUserAuthTokenInternal(c, userID, cmd) +} diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go index 0b94a64b3fb..b94f09b0b75 100644 --- a/pkg/api/admin_users_test.go +++ b/pkg/api/admin_users_test.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" . "github.com/smartystreets/goconvey/convey" ) @@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 400) }) }) + + Convey("When a server admin attempts to logout himself from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When a server admin gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) } func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { @@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT fn(sc) }) } + +func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminLogoutUser(c) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminRevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.AdminGetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 81ea83eae61..f3dc35b6b06 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() { userRoute.Get("/preferences", Wrap(GetUserPreferences)) userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences)) + + userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens)) + userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken)) }) // users (admin permission required) @@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() { adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota)) adminRoute.Get("/stats", AdminGetStats) adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts)) + + adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser)) + adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens)) + adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken)) }, reqGrafanaAdmin) // rendering diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 3f3a50aae69..4e0b0dcd998 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" @@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map } type scenarioContext struct { - m *macaron.Macaron - context *m.ReqContext - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string + m *macaron.Macaron + context *m.ReqContext + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string + userAuthTokenService *auth.FakeUserAuthTokenService } func (sc *scenarioContext) exec() { diff --git a/pkg/api/dtos/user_token.go b/pkg/api/dtos/user_token.go new file mode 100644 index 00000000000..1542421e2f6 --- /dev/null +++ b/pkg/api/dtos/user_token.go @@ -0,0 +1,12 @@ +package dtos + +import "time" + +type UserToken struct { + Id int64 `json:"id"` + IsActive bool `json:"isActive"` + ClientIp string `json:"clientIp"` + UserAgent string `json:"userAgent"` + CreatedAt time.Time `json:"createdAt"` + SeenAt time.Time `json:"seenAt"` +} diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go new file mode 100644 index 00000000000..2f74eedea5d --- /dev/null +++ b/pkg/api/user_token.go @@ -0,0 +1,110 @@ +package api + +import ( + "time" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +// GET /api/user/auth-tokens +func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response { + return server.getUserAuthTokensInternal(c, c.UserId) +} + +// POST /api/user/revoke-auth-token +func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response { + return server.revokeUserAuthTokenInternal(c, c.UserId, cmd) +} + +func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Could not read user from database", err) + } + + err := server.AuthTokenService.RevokeAllUserTokens(userID) + if err != nil { + return Error(500, "Failed to logout user", err) + } + + return JSON(200, util.DynMap{ + "message": "User logged out", + }) +} + +func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + tokens, err := server.AuthTokenService.GetUserTokens(userID) + if err != nil { + return Error(500, "Failed to get user auth tokens", err) + } + + result := []*dtos.UserToken{} + for _, token := range tokens { + isActive := false + if c.UserToken != nil && c.UserToken.Id == token.Id { + isActive = true + } + + result = append(result, &dtos.UserToken{ + Id: token.Id, + IsActive: isActive, + ClientIp: token.ClientIp, + UserAgent: token.UserAgent, + CreatedAt: time.Unix(token.CreatedAt, 0), + SeenAt: time.Unix(token.SeenAt, 0), + }) + } + + return JSON(200, result) +} + +func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response { + userQuery := models.GetUserByIdQuery{Id: userID} + + if err := bus.Dispatch(&userQuery); err != nil { + if err == models.ErrUserNotFound { + return Error(404, "User not found", err) + } + return Error(500, "Failed to get user", err) + } + + token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to get user auth token", err) + } + + if c.UserToken != nil && c.UserToken.Id == token.Id { + return Error(400, "Cannot revoke active user auth token", nil) + } + + err = server.AuthTokenService.RevokeToken(token) + if err != nil { + if err == models.ErrUserTokenNotFound { + return Error(404, "User auth token not found", err) + } + return Error(500, "Failed to revoke user auth token", err) + } + + return JSON(200, util.DynMap{ + "message": "User auth token revoked", + }) +} diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go new file mode 100644 index 00000000000..111070dca92 --- /dev/null +++ b/pkg/api/user_token_test.go @@ -0,0 +1,294 @@ +package api + +import ( + "testing" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/auth" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserTokenApiEndpoint(t *testing.T) { + Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + + revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When current user gets auth tokens for a non-existing user", t, func() { + userId := int64(0) + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + userId = cmd.Id + return m.ErrUserNotFound + }) + + getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) { + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + So(userId, ShouldEqual, 200) + }) + }) + + Convey("When logout an existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When logout a non-existing user from all devices", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + return m.ErrUserNotFound + }) + + logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) { + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 404) + }) + }) + + Convey("When revoke an auth token for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: 200} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 1} + + revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return &m.UserToken{Id: 2}, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + + Convey("When revoke the active auth token used by himself", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2} + token := &m.UserToken{Id: 2} + + revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) { + sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) { + return token, nil + } + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) + + Convey("When gets auth tokens for a user", t, func() { + bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error { + cmd.Result = &m.User{Id: TestUserID} + return nil + }) + + currentToken := &m.UserToken{Id: 1} + + getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) { + tokens := []*m.UserToken{ + { + Id: 1, + ClientIp: "127.0.0.1", + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + { + Id: 2, + ClientIp: "127.0.0.2", + UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + CreatedAt: time.Now().Unix(), + SeenAt: time.Now().Unix(), + }, + } + sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) { + return tokens, nil + } + sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + result := sc.ToJSON() + So(result.MustArray(), ShouldHaveLength, 2) + + resultOne := result.GetIndex(0) + So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id) + So(resultOne.Get("isActive").MustBool(), ShouldBeTrue) + So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1") + So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36") + So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339)) + So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339)) + + resultTwo := result.GetIndex(1) + So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id) + So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse) + So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2") + So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1") + So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339)) + So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339)) + }) + }) +} + +func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.RevokeUserAuthToken(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext(url) + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = userId + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.GetUserAuthTokens(c) + }) + + sc.m.Get(routePattern, sc.defaultHandler) + + fn(sc) + }) +} + +func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: auth.NewFakeUserAuthTokenService(), + } + + sc := setupScenarioContext("/") + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + + return hs.logoutUserFromAllDevicesInternal(userId) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.revokeUserAuthTokenInternal(c, userId, cmd) + }) + + sc.m.Post("/", sc.defaultHandler) + + fn(sc) + }) +} + +func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + fakeAuthTokenService := auth.NewFakeUserAuthTokenService() + + hs := HTTPServer{ + Bus: bus.GetBus(), + AuthTokenService: fakeAuthTokenService, + } + + sc := setupScenarioContext("/") + sc.userAuthTokenService = fakeAuthTokenService + sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = m.ROLE_ADMIN + sc.context.UserToken = token + + return hs.getUserAuthTokensInternal(c, TestUserID) + }) + + sc.m.Get("/", sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go index 22f92cb21d2..8c3e7985995 100644 --- a/pkg/models/user_token.go +++ b/pkg/models/user_token.go @@ -1,6 +1,8 @@ package models -import "errors" +import ( + "errors" +) // Typed errors var ( @@ -23,6 +25,10 @@ type UserToken struct { UnhashedToken string } +type RevokeAuthTokenCmd struct { + AuthTokenId int64 `json:"authTokenId"` +} + // UserTokenService are used for generating and validating user tokens type UserTokenService interface { CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error) From 80ce11a4a433a755a66b9b7782892d2b5e1436cd Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 8 Mar 2019 15:15:57 +0100 Subject: [PATCH 3/3] docs: update admin and user http api documentation --- docs/sources/http_api/admin.md | 102 +++++++++++++++++++++++++++++++++ docs/sources/http_api/user.md | 72 +++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index a27fd2aac14..c2d540c452b 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -341,3 +341,105 @@ Content-Type: application/json {"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100} ``` + +## Auth tokens for User + +`GET /api/admin/users/:id/auth-tokens` + +Return a list of all auth tokens (devices) that the user currently have logged in from. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +GET /api/admin/users/1/auth-tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 361, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + "createdAt": "2019-03-05T21:22:54+01:00", + "seenAt": "2019-03-06T19:41:06+01:00" + }, + { + "id": 364, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "createdAt": "2019-03-06T19:41:19+01:00", + "seenAt": "2019-03-06T19:41:21+01:00" + } +] +``` + +## Revoke auth token for User + +`POST /api/admin/users/:id/revoke-auth-token` + +Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in +and will be required to authenticate again upon next activity. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +POST /api/admin/users/1/revoke-auth-token HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "authTokenId": 364 +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +``` + +## Logout User + +`POST /api/admin/users/:id/logout` + +Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in +and will be required to authenticate again upon next activity. + +Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. + +**Example Request**: + +```http +POST /api/admin/users/1/logout HTTP/1.1 +Accept: application/json +Content-Type: application/json +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +``` diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 669e8003247..a81f608c2f5 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -478,3 +478,75 @@ Content-Type: application/json {"message":"Dashboard unstarred"} ``` + +## Auth tokens of the actual User + +`GET /api/user/auth-tokens` + +Return a list of all auth tokens (devices) that the actual user currently have logged in from. + +**Example Request**: + +```http +GET /api/user/auth-tokens HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +[ + { + "id": 361, + "isActive": true, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36", + "createdAt": "2019-03-05T21:22:54+01:00", + "seenAt": "2019-03-06T19:41:06+01:00" + }, + { + "id": 364, + "isActive": false, + "clientIp": "127.0.0.1", + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + "createdAt": "2019-03-06T19:41:19+01:00", + "seenAt": "2019-03-06T19:41:21+01:00" + } +] +``` + +## Revoke an auth token of the actual User + +`POST /api/user/revoke-auth-token` + +Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in +and will be required to authenticate again upon next activity. + +**Example Request**: + +```http +POST /api/user/revoke-auth-token HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "authTokenId": 364 +} +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{ + "message": "User auth token revoked" +} +```