mirror of https://github.com/grafana/grafana
Merge pull request #15839 from grafana/15836_revoke_auth_tokens
Support list and revoke of user auth tokens in HTTP APIpull/16007/head
commit
23852b59c9
@ -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"` |
||||
} |
@ -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", |
||||
}) |
||||
} |
@ -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) |
||||
}) |
||||
} |
@ -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) |
||||
} |
Loading…
Reference in new issue