Auth: Allow expiration of API keys (#17678)

* Modify backend to allow expiration of API Keys

* Add middleware test for expired api keys

* Modify frontend to enable expiration of API Keys

* Fix frontend tests

* Fix migration and add index for `expires` field

* Add api key tests for database access

* Substitude time.Now() by a mock for test usage

* Front-end modifications

* Change input label to `Time to live`
* Change input behavior to comply with the other similar
* Add tooltip

* Modify AddApiKey api call response

Expiration should be *time.Time instead of string

* Present expiration date in the selected timezone

* Use kbn for transforming intervals to seconds

* Use `assert` library for tests

* Frontend fixes

Add checks for empty/undefined/null values

* Change expires column from datetime to integer

* Restrict api key duration input

It should be interval not number

* AddApiKey must complain if SecondsToLive is negative

* Declare ErrInvalidApiKeyExpiration

* Move configuration to auth section

* Update docs

* Eliminate alias for models in modified files

* Omit expiration from api response if empty

* Eliminate Goconvey from test file

* Fix test

Do not sleep, use mocked timeNow() instead

* Remove index for expires from api_key table

The index should be anyway on both org_id and expires fields.
However this commit eliminates completely the index for now
since not many rows are expected to be in this table.

* Use getTimeZone function

* Minor change in api key listing

The frontend should display a message instead of empty string
if the key does not expire.
This commit is contained in:
Sofia Papagiannaki 2019-06-26 09:47:03 +03:00 committed by GitHub
parent 19185bd0af
commit dc9ec7dc91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 432 additions and 154 deletions

View File

@ -287,6 +287,9 @@ signout_redirect_url =
# This setting is ignored if multiple OAuth providers are configured.
oauth_auto_login = false
# limit of api_key seconds to live before expiration
api_key_max_seconds_to_live = -1
#################################### Anonymous Auth ######################
[auth.anonymous]
# enable anonymous access

View File

@ -63,6 +63,9 @@ login_maximum_lifetime_days = 30
# How often should auth tokens be rotated for authenticated users when being active. The default is each 10 minutes.
token_rotation_interval_minutes = 10
# The maximum lifetime (seconds) an api key can be used. If it is set all the api keys should have limited lifetime that is lower than this value.
api_key_max_seconds_to_live = -1
```
### Anonymous authentication

View File

@ -82,7 +82,8 @@ Content-Type: application/json
{
"id": 1,
"name": "TestAdmin",
"role": "Admin"
"role": "Admin",
"expiration": "2019-06-26T10:52:03+03:00"
}
]
```
@ -101,7 +102,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"name": "mykey",
"role": "Admin"
"role": "Admin",
"secondsToLive": 86400
}
```
@ -109,6 +111,12 @@ JSON Body schema:
- **name** The key name
- **role** Sets the access level/Grafana Role for the key. Can be one of the following values: `Viewer`, `Editor` or `Admin`.
- **secondsToLive** Sets the key expiration in seconds. It is optional. If it is a positive number an expiration date for the key is set. If it is null, zero or is omitted completely (unless `api_key_max_seconds_to_live` configuration option is set) the key will never expire.
Error statuses:
- **400** `api_key_max_seconds_to_live` is set but no `secondsToLive` is specified or `secondsToLive` is greater than this value.
- **500** The key was unable to be stored in the database.
**Example Response**:

View File

@ -46,6 +46,7 @@ export interface DateTimeDuration {
hours: () => number;
minutes: () => number;
seconds: () => number;
asSeconds: () => number;
}
export interface DateTime extends Object {

View File

@ -6,7 +6,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
)
func (hs *HTTPServer) registerRoutes() {
@ -105,7 +105,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboard/snapshots/", reqSignedIn, hs.Index)
// api for dashboard snapshots
r.Post("/api/snapshots/", bind(m.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
r.Post("/api/snapshots/", bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot)
r.Get("/api/snapshot/shared-options/", GetSharingOptions)
r.Get("/api/snapshots/:key", GetDashboardSnapshot)
r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey))
@ -120,7 +120,7 @@ func (hs *HTTPServer) registerRoutes() {
// user (signed in)
apiRoute.Group("/user", func(userRoute routing.RouteRegister) {
userRoute.Get("/", Wrap(GetSignedInUser))
userRoute.Put("/", bind(m.UpdateUserCommand{}), Wrap(UpdateSignedInUser))
userRoute.Put("/", bind(models.UpdateUserCommand{}), Wrap(UpdateSignedInUser))
userRoute.Post("/using/:id", Wrap(UserSetUsingOrg))
userRoute.Get("/orgs", Wrap(GetSignedInUserOrgList))
userRoute.Get("/teams", Wrap(GetSignedInUserTeamList))
@ -128,7 +128,7 @@ func (hs *HTTPServer) registerRoutes() {
userRoute.Post("/stars/dashboard/:id", Wrap(StarDashboard))
userRoute.Delete("/stars/dashboard/:id", Wrap(UnstarDashboard))
userRoute.Put("/password", bind(m.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword))
userRoute.Put("/password", bind(models.ChangeUserPasswordCommand{}), Wrap(ChangeUserPassword))
userRoute.Get("/quotas", Wrap(GetUserQuotas))
userRoute.Put("/helpflags/:id", Wrap(SetHelpFlag))
// For dev purpose
@ -138,7 +138,7 @@ func (hs *HTTPServer) registerRoutes() {
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))
userRoute.Post("/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken))
})
// users (admin permission required)
@ -150,18 +150,18 @@ func (hs *HTTPServer) registerRoutes() {
usersRoute.Get("/:id/orgs", Wrap(GetUserOrgList))
// query parameters /users/lookup?loginOrEmail=admin@example.com
usersRoute.Get("/lookup", Wrap(GetUserByLoginOrEmail))
usersRoute.Put("/:id", bind(m.UpdateUserCommand{}), Wrap(UpdateUser))
usersRoute.Put("/:id", bind(models.UpdateUserCommand{}), Wrap(UpdateUser))
usersRoute.Post("/:id/using/:orgId", Wrap(UpdateUserActiveOrg))
}, reqGrafanaAdmin)
// team (admin permission required)
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam))
teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
teamsRoute.Post("/", bind(models.CreateTeamCommand{}), Wrap(hs.CreateTeam))
teamsRoute.Put("/:teamId", bind(models.UpdateTeamCommand{}), Wrap(hs.UpdateTeam))
teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID))
teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
teamsRoute.Post("/:teamId/members", bind(models.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember))
teamsRoute.Put("/:teamId/members/:userId", bind(models.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences))
@ -183,8 +183,8 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Put("/", bind(dtos.UpdateOrgForm{}), Wrap(UpdateOrgCurrent))
orgRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddressCurrent))
orgRoute.Post("/users", quota("user"), bind(m.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg))
orgRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg))
orgRoute.Post("/users", quota("user"), bind(models.AddOrgUserCommand{}), Wrap(AddOrgUserToCurrentOrg))
orgRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUserForCurrentOrg))
orgRoute.Delete("/users/:userId", Wrap(RemoveOrgUserForCurrentOrg))
// invites
@ -203,7 +203,7 @@ func (hs *HTTPServer) registerRoutes() {
})
// create new org
apiRoute.Post("/orgs", quota("org"), bind(m.CreateOrgCommand{}), Wrap(CreateOrg))
apiRoute.Post("/orgs", quota("org"), bind(models.CreateOrgCommand{}), Wrap(CreateOrg))
// search all orgs
apiRoute.Get("/orgs", reqGrafanaAdmin, Wrap(SearchOrgs))
@ -215,11 +215,11 @@ func (hs *HTTPServer) registerRoutes() {
orgsRoute.Put("/address", bind(dtos.UpdateOrgAddressForm{}), Wrap(UpdateOrgAddress))
orgsRoute.Delete("/", Wrap(DeleteOrgByID))
orgsRoute.Get("/users", Wrap(GetOrgUsers))
orgsRoute.Post("/users", bind(m.AddOrgUserCommand{}), Wrap(AddOrgUser))
orgsRoute.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser))
orgsRoute.Post("/users", bind(models.AddOrgUserCommand{}), Wrap(AddOrgUser))
orgsRoute.Patch("/users/:userId", bind(models.UpdateOrgUserCommand{}), Wrap(UpdateOrgUser))
orgsRoute.Delete("/users/:userId", Wrap(RemoveOrgUser))
orgsRoute.Get("/quotas", Wrap(GetOrgQuotas))
orgsRoute.Put("/quotas/:target", bind(m.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota))
orgsRoute.Put("/quotas/:target", bind(models.UpdateOrgQuotaCmd{}), Wrap(UpdateOrgQuota))
}, reqGrafanaAdmin)
// orgs (admin routes)
@ -230,20 +230,20 @@ func (hs *HTTPServer) registerRoutes() {
// auth api keys
apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) {
keysRoute.Get("/", Wrap(GetAPIKeys))
keysRoute.Post("/", quota("api_key"), bind(m.AddApiKeyCommand{}), Wrap(AddAPIKey))
keysRoute.Post("/", quota("api_key"), bind(models.AddApiKeyCommand{}), Wrap(hs.AddAPIKey))
keysRoute.Delete("/:id", Wrap(DeleteAPIKey))
}, reqOrgAdmin)
// Preferences
apiRoute.Group("/preferences", func(prefRoute routing.RouteRegister) {
prefRoute.Post("/set-home-dash", bind(m.SavePreferencesCommand{}), Wrap(SetHomeDashboard))
prefRoute.Post("/set-home-dash", bind(models.SavePreferencesCommand{}), Wrap(SetHomeDashboard))
})
// Data sources
apiRoute.Group("/datasources", func(datasourceRoute routing.RouteRegister) {
datasourceRoute.Get("/", Wrap(GetDataSources))
datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
datasourceRoute.Post("/", quota("data_source"), bind(models.AddDataSourceCommand{}), Wrap(AddDataSource))
datasourceRoute.Put("/:id", bind(models.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById))
datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
@ -258,7 +258,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Get("/:pluginId/dashboards/", Wrap(GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", bind(m.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
pluginRoute.Post("/:pluginId/settings", bind(models.UpdatePluginSettingCmd{}), Wrap(UpdatePluginSetting))
}, reqOrgAdmin)
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
@ -269,11 +269,11 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
folderRoute.Get("/", Wrap(GetFolders))
folderRoute.Get("/id/:id", Wrap(GetFolderByID))
folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder))
folderRoute.Post("/", bind(models.CreateFolderCommand{}), Wrap(hs.CreateFolder))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", Wrap(GetFolderByUID))
folderUidRoute.Put("/", bind(m.UpdateFolderCommand{}), Wrap(UpdateFolder))
folderUidRoute.Put("/", bind(models.UpdateFolderCommand{}), Wrap(UpdateFolder))
folderUidRoute.Delete("/", Wrap(DeleteFolder))
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
@ -293,7 +293,7 @@ func (hs *HTTPServer) registerRoutes() {
dashboardRoute.Post("/calculate-diff", bind(dtos.CalculateDiffOptions{}), Wrap(CalculateDashboardDiff))
dashboardRoute.Post("/db", bind(m.SaveDashboardCommand{}), Wrap(hs.PostDashboard))
dashboardRoute.Post("/db", bind(models.SaveDashboardCommand{}), Wrap(hs.PostDashboard))
dashboardRoute.Get("/home", Wrap(GetHomeDashboard))
dashboardRoute.Get("/tags", GetDashboardTags)
dashboardRoute.Post("/import", bind(dtos.ImportDashboardCommand{}), Wrap(ImportDashboard))
@ -322,8 +322,8 @@ func (hs *HTTPServer) registerRoutes() {
playlistRoute.Get("/:id/items", ValidateOrgPlaylist, Wrap(GetPlaylistItems))
playlistRoute.Get("/:id/dashboards", ValidateOrgPlaylist, Wrap(GetPlaylistDashboards))
playlistRoute.Delete("/:id", reqEditorRole, ValidateOrgPlaylist, Wrap(DeletePlaylist))
playlistRoute.Put("/:id", reqEditorRole, bind(m.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist))
playlistRoute.Post("/", reqEditorRole, bind(m.CreatePlaylistCommand{}), Wrap(CreatePlaylist))
playlistRoute.Put("/:id", reqEditorRole, bind(models.UpdatePlaylistCommand{}), ValidateOrgPlaylist, Wrap(UpdatePlaylist))
playlistRoute.Post("/", reqEditorRole, bind(models.CreatePlaylistCommand{}), Wrap(CreatePlaylist))
})
// Search
@ -348,12 +348,12 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) {
alertNotifications.Post("/test", bind(dtos.NotificationTestCommand{}), Wrap(NotificationTest))
alertNotifications.Post("/", bind(m.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification))
alertNotifications.Put("/:notificationId", bind(m.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification))
alertNotifications.Post("/", bind(models.CreateAlertNotificationCommand{}), Wrap(CreateAlertNotification))
alertNotifications.Put("/:notificationId", bind(models.UpdateAlertNotificationCommand{}), Wrap(UpdateAlertNotification))
alertNotifications.Get("/:notificationId", Wrap(GetAlertNotificationByID))
alertNotifications.Delete("/:notificationId", Wrap(DeleteAlertNotification))
alertNotifications.Get("/uid/:uid", Wrap(GetAlertNotificationByUID))
alertNotifications.Put("/uid/:uid", bind(m.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID))
alertNotifications.Put("/uid/:uid", bind(models.UpdateAlertNotificationWithUidCommand{}), Wrap(UpdateAlertNotificationByUID))
alertNotifications.Delete("/uid/:uid", Wrap(DeleteAlertNotificationByUID))
}, reqEditorRole)
@ -384,13 +384,13 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Post("/users/:id/disable", Wrap(hs.AdminDisableUser))
adminRoute.Post("/users/:id/enable", Wrap(AdminEnableUser))
adminRoute.Get("/users/:id/quotas", Wrap(GetUserQuotas))
adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
adminRoute.Put("/users/:id/quotas/:target", bind(models.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))
adminRoute.Post("/users/:id/revoke-auth-token", bind(models.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
adminRoute.Post("/provisioning/dashboards/reload", Wrap(hs.AdminProvisioningReloadDasboards))
adminRoute.Post("/provisioning/datasources/reload", Wrap(hs.AdminProvisioningReloadDatasources))

View File

@ -4,32 +4,39 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
"time"
)
func GetAPIKeys(c *m.ReqContext) Response {
query := m.GetApiKeysQuery{OrgId: c.OrgId}
func GetAPIKeys(c *models.ReqContext) Response {
query := models.GetApiKeysQuery{OrgId: c.OrgId}
if err := bus.Dispatch(&query); err != nil {
return Error(500, "Failed to list api keys", err)
}
result := make([]*m.ApiKeyDTO, len(query.Result))
result := make([]*models.ApiKeyDTO, len(query.Result))
for i, t := range query.Result {
result[i] = &m.ApiKeyDTO{
Id: t.Id,
Name: t.Name,
Role: t.Role,
var expiration *time.Time = nil
if t.Expires != nil {
v := time.Unix(*t.Expires, 0)
expiration = &v
}
result[i] = &models.ApiKeyDTO{
Id: t.Id,
Name: t.Name,
Role: t.Role,
Expiration: expiration,
}
}
return JSON(200, result)
}
func DeleteAPIKey(c *m.ReqContext) Response {
func DeleteAPIKey(c *models.ReqContext) Response {
id := c.ParamsInt64(":id")
cmd := &m.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId}
cmd := &models.DeleteApiKeyCommand{Id: id, OrgId: c.OrgId}
err := bus.Dispatch(cmd)
if err != nil {
@ -39,17 +46,28 @@ func DeleteAPIKey(c *m.ReqContext) Response {
return Success("API key deleted")
}
func AddAPIKey(c *m.ReqContext, cmd m.AddApiKeyCommand) Response {
func (hs *HTTPServer) AddAPIKey(c *models.ReqContext, cmd models.AddApiKeyCommand) Response {
if !cmd.Role.IsValid() {
return Error(400, "Invalid role specified", nil)
}
if hs.Cfg.ApiKeyMaxSecondsToLive != -1 {
if cmd.SecondsToLive == 0 {
return Error(400, "Number of seconds before expiration should be set", nil)
}
if cmd.SecondsToLive > hs.Cfg.ApiKeyMaxSecondsToLive {
return Error(400, "Number of seconds before expiration is greater than the global limit", nil)
}
}
cmd.OrgId = c.OrgId
newKeyInfo := apikeygen.New(cmd.OrgId, cmd.Name)
cmd.Key = newKeyInfo.HashedKey
if err := bus.Dispatch(&cmd); err != nil {
if err == models.ErrInvalidApiKeyExpiration {
return Error(400, err.Error(), nil)
}
return Error(500, "Failed to add API key", err)
}

View File

@ -14,26 +14,28 @@ import (
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
var getTime = time.Now
var (
ReqGrafanaAdmin = Auth(&AuthOptions{ReqSignedIn: true, ReqGrafanaAdmin: true})
ReqSignedIn = Auth(&AuthOptions{ReqSignedIn: true})
ReqEditorRole = RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN)
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
ReqEditorRole = RoleAuth(models.ROLE_EDITOR, models.ROLE_ADMIN)
ReqOrgAdmin = RoleAuth(models.ROLE_ADMIN)
)
func GetContextHandler(
ats m.UserTokenService,
ats models.UserTokenService,
remoteCache *remotecache.RemoteCache,
) macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
ctx := &models.ReqContext{
Context: c,
SignedInUser: &m.SignedInUser{},
SignedInUser: &models.SignedInUser{},
IsSignedIn: false,
AllowAnonymous: false,
SkipCache: false,
@ -68,19 +70,19 @@ func GetContextHandler(
// update last seen every 5min
if ctx.ShouldUpdateLastSeenAt() {
ctx.Logger.Debug("Updating last user_seen_at", "user_id", ctx.UserId)
if err := bus.Dispatch(&m.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil {
if err := bus.Dispatch(&models.UpdateUserLastSeenAtCommand{UserId: ctx.UserId}); err != nil {
ctx.Logger.Error("Failed to update last_seen_at", "error", err)
}
}
}
}
func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
func initContextWithAnonymousUser(ctx *models.ReqContext) bool {
if !setting.AnonymousEnabled {
return false
}
orgQuery := m.GetOrgByNameQuery{Name: setting.AnonymousOrgName}
orgQuery := models.GetOrgByNameQuery{Name: setting.AnonymousOrgName}
if err := bus.Dispatch(&orgQuery); err != nil {
log.Error(3, "Anonymous access organization error: '%s': %s", setting.AnonymousOrgName, err)
return false
@ -88,14 +90,14 @@ func initContextWithAnonymousUser(ctx *m.ReqContext) bool {
ctx.IsSignedIn = false
ctx.AllowAnonymous = true
ctx.SignedInUser = &m.SignedInUser{IsAnonymous: true}
ctx.OrgRole = m.RoleType(setting.AnonymousOrgRole)
ctx.SignedInUser = &models.SignedInUser{IsAnonymous: true}
ctx.OrgRole = models.RoleType(setting.AnonymousOrgRole)
ctx.OrgId = orgQuery.Result.Id
ctx.OrgName = orgQuery.Result.Name
return true
}
func initContextWithApiKey(ctx *m.ReqContext) bool {
func initContextWithApiKey(ctx *models.ReqContext) bool {
var keyString string
if keyString = getApiKey(ctx); keyString == "" {
return false
@ -109,7 +111,7 @@ func initContextWithApiKey(ctx *m.ReqContext) bool {
}
// fetch key
keyQuery := m.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId}
keyQuery := models.GetApiKeyByNameQuery{KeyName: decoded.Name, OrgId: decoded.OrgId}
if err := bus.Dispatch(&keyQuery); err != nil {
ctx.JsonApiErr(401, "Invalid API key", err)
return true
@ -123,15 +125,21 @@ func initContextWithApiKey(ctx *m.ReqContext) bool {
return true
}
// check for expiration
if apikey.Expires != nil && *apikey.Expires <= getTime().Unix() {
ctx.JsonApiErr(401, "Expired API key", err)
return true
}
ctx.IsSignedIn = true
ctx.SignedInUser = &m.SignedInUser{}
ctx.SignedInUser = &models.SignedInUser{}
ctx.OrgRole = apikey.Role
ctx.ApiKeyId = apikey.Id
ctx.OrgId = apikey.OrgId
return true
}
func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
func initContextWithBasicAuth(ctx *models.ReqContext, orgId int64) bool {
if !setting.BasicAuthEnabled {
return false
@ -148,7 +156,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
return true
}
loginQuery := m.GetUserByLoginQuery{LoginOrEmail: username}
loginQuery := models.GetUserByLoginQuery{LoginOrEmail: username}
if err := bus.Dispatch(&loginQuery); err != nil {
ctx.JsonApiErr(401, "Basic auth failed", err)
return true
@ -156,13 +164,13 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
user := loginQuery.Result
loginUserQuery := m.LoginUserQuery{Username: username, Password: password, User: user}
loginUserQuery := models.LoginUserQuery{Username: username, Password: password, User: user}
if err := bus.Dispatch(&loginUserQuery); err != nil {
ctx.JsonApiErr(401, "Invalid username or password", err)
return true
}
query := m.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId}
query := models.GetSignedInUserQuery{UserId: user.Id, OrgId: orgId}
if err := bus.Dispatch(&query); err != nil {
ctx.JsonApiErr(401, "Authentication error", err)
return true
@ -173,7 +181,7 @@ func initContextWithBasicAuth(ctx *m.ReqContext, orgId int64) bool {
return true
}
func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext, orgID int64) bool {
func initContextWithToken(authTokenService models.UserTokenService, ctx *models.ReqContext, orgID int64) bool {
rawToken := ctx.GetCookie(setting.LoginCookieName)
if rawToken == "" {
return false
@ -186,7 +194,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext
return false
}
query := m.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID}
query := models.GetSignedInUserQuery{UserId: token.UserId, OrgId: orgID}
if err := bus.Dispatch(&query); err != nil {
ctx.Logger.Error("failed to get user with id", "userId", token.UserId, "error", err)
return false
@ -209,7 +217,7 @@ func initContextWithToken(authTokenService m.UserTokenService, ctx *m.ReqContext
return true
}
func WriteSessionCookie(ctx *m.ReqContext, value string, maxLifetimeDays int) {
func WriteSessionCookie(ctx *models.ReqContext, value string, maxLifetimeDays int) {
if setting.Env == setting.DEV {
ctx.Logger.Info("new token", "unhashed token", value)
}

View File

@ -13,7 +13,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/remotecache"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@ -21,6 +21,19 @@ import (
"gopkg.in/macaron.v1"
)
func mockGetTime() {
var timeSeed int64
getTime = func() time.Time {
fakeNow := time.Unix(timeSeed, 0)
timeSeed++
return fakeNow
}
}
func resetGetTime() {
getTime = time.Now
}
func TestMiddleWareSecurityHeaders(t *testing.T) {
setting.ERR_TEMPLATE_NAME = "error-template"
@ -83,7 +96,7 @@ func TestMiddlewareContext(t *testing.T) {
})
middlewareScenario(t, "middleware should add Cache-Control header for requests with html response", func(sc *scenarioContext) {
sc.handler(func(c *m.ReqContext) {
sc.handler(func(c *models.ReqContext) {
data := &dtos.IndexViewData{
User: &dtos.CurrentUser{},
Settings: map[string]interface{}{},
@ -125,20 +138,20 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "Using basic auth", func(sc *scenarioContext) {
bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error {
query.Result = &m.User{
bus.AddHandler("test", func(query *models.GetUserByLoginQuery) error {
query.Result = &models.User{
Password: util.EncodePassword("myPass", "salt"),
Salt: "salt",
}
return nil
})
bus.AddHandler("test", func(loginUserQuery *m.LoginUserQuery) error {
bus.AddHandler("test", func(loginUserQuery *models.LoginUserQuery) error {
return nil
})
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
@ -156,8 +169,8 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "Valid api key", func(sc *scenarioContext) {
keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error {
query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash}
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash}
return nil
})
@ -170,15 +183,15 @@ func TestMiddlewareContext(t *testing.T) {
Convey("Should init middleware context", func() {
So(sc.context.IsSignedIn, ShouldEqual, true)
So(sc.context.OrgId, ShouldEqual, 12)
So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR)
So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR)
})
})
middlewareScenario(t, "Valid api key, but does not match db hash", func(sc *scenarioContext) {
keyhash := "something_not_matching"
bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error {
query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash}
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash}
return nil
})
@ -190,11 +203,34 @@ func TestMiddlewareContext(t *testing.T) {
})
})
middlewareScenario(t, "Valid api key, but expired", func(sc *scenarioContext) {
mockGetTime()
defer resetGetTime()
keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
// api key expired one second before
expires := getTime().Add(-1 * time.Second).Unix()
query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash,
Expires: &expires}
return nil
})
sc.fakeReq("GET", "/").withValidApiKey().exec()
Convey("Should return 401", func() {
So(sc.resp.Code, ShouldEqual, 401)
So(sc.respJson["message"], ShouldEqual, "Expired API key")
})
})
middlewareScenario(t, "Valid api key via Basic auth", func(sc *scenarioContext) {
keyhash := util.EncodePassword("v5nAwpMafFP6znaS4urhdWDLS5511M42", "asd")
bus.AddHandler("test", func(query *m.GetApiKeyByNameQuery) error {
query.Result = &m.ApiKey{OrgId: 12, Role: m.ROLE_EDITOR, Key: keyhash}
bus.AddHandler("test", func(query *models.GetApiKeyByNameQuery) error {
query.Result = &models.ApiKey{OrgId: 12, Role: models.ROLE_EDITOR, Key: keyhash}
return nil
})
@ -208,20 +244,20 @@ func TestMiddlewareContext(t *testing.T) {
Convey("Should init middleware context", func() {
So(sc.context.IsSignedIn, ShouldEqual, true)
So(sc.context.OrgId, ShouldEqual, 12)
So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR)
So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR)
})
})
middlewareScenario(t, "Non-expired auth token in cookie which not are being rotated", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
return &models.UserToken{
UserId: 12,
UnhashedToken: unhashedToken,
}, nil
@ -244,19 +280,19 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "Non-expired auth token in cookie which are being rotated", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
return &models.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
sc.userAuthTokenService.TryRotateTokenProvider = func(ctx context.Context, userToken *models.UserToken, clientIP, userAgent string) (bool, error) {
userToken.UnhashedToken = "rotated"
return true, nil
}
@ -291,8 +327,8 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "Invalid/expired auth token in cookie", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*m.UserToken, error) {
return nil, m.ErrUserTokenNotFound
sc.userAuthTokenService.LookupTokenProvider = func(ctx context.Context, unhashedToken string) (*models.UserToken, error) {
return nil, models.ErrUserTokenNotFound
}
sc.fakeReq("GET", "/").exec()
@ -307,12 +343,12 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "When anonymous access is enabled", func(sc *scenarioContext) {
setting.AnonymousEnabled = true
setting.AnonymousOrgName = "test"
setting.AnonymousOrgRole = string(m.ROLE_EDITOR)
setting.AnonymousOrgRole = string(models.ROLE_EDITOR)
bus.AddHandler("test", func(query *m.GetOrgByNameQuery) error {
bus.AddHandler("test", func(query *models.GetOrgByNameQuery) error {
So(query.Name, ShouldEqual, "test")
query.Result = &m.Org{Id: 2, Name: "test"}
query.Result = &models.Org{Id: 2, Name: "test"}
return nil
})
@ -321,7 +357,7 @@ func TestMiddlewareContext(t *testing.T) {
Convey("should init context with org info", func() {
So(sc.context.UserId, ShouldEqual, 0)
So(sc.context.OrgId, ShouldEqual, 2)
So(sc.context.OrgRole, ShouldEqual, m.ROLE_EDITOR)
So(sc.context.OrgRole, ShouldEqual, models.ROLE_EDITOR)
})
Convey("context signed in should be false", func() {
@ -339,8 +375,8 @@ func TestMiddlewareContext(t *testing.T) {
name := "markelog"
middlewareScenario(t, "should not sync the user if it's in the cache", func(sc *scenarioContext) {
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: query.UserId}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 4, UserId: query.UserId}
return nil
})
@ -362,16 +398,16 @@ func TestMiddlewareContext(t *testing.T) {
setting.LDAPEnabled = false
setting.AuthProxyAutoSignUp = true
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
if query.UserId > 0 {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
query.Result = &models.SignedInUser{OrgId: 4, UserId: 33}
return nil
}
return m.ErrUserNotFound
return models.ErrUserNotFound
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error {
cmd.Result = &models.User{Id: 33}
return nil
})
@ -389,13 +425,13 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario(t, "should get an existing user from header", func(sc *scenarioContext) {
setting.LDAPEnabled = false
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 2, UserId: 12}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 12}
bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error {
cmd.Result = &models.User{Id: 12}
return nil
})
@ -414,13 +450,13 @@ func TestMiddlewareContext(t *testing.T) {
setting.AuthProxyWhitelist = "192.168.1.0/24, 2001::0/120"
setting.LDAPEnabled = false
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error {
cmd.Result = &models.User{Id: 33}
return nil
})
@ -440,13 +476,13 @@ func TestMiddlewareContext(t *testing.T) {
setting.AuthProxyWhitelist = "8.8.8.8"
setting.LDAPEnabled = false
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 4, UserId: 33}
bus.AddHandler("test", func(query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{OrgId: 4, UserId: 33}
return nil
})
bus.AddHandler("test", func(cmd *m.UpsertUserCommand) error {
cmd.Result = &m.User{Id: 33}
bus.AddHandler("test", func(cmd *models.UpsertUserCommand) error {
cmd.Result = &models.User{Id: 33}
return nil
})
@ -489,7 +525,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) {
sc.m.Use(OrgRedirect())
sc.defaultHandler = func(c *m.ReqContext) {
sc.defaultHandler = func(c *models.ReqContext) {
sc.context = c
if sc.handlerFunc != nil {
sc.handlerFunc(sc.context)
@ -504,7 +540,7 @@ func middlewareScenario(t *testing.T, desc string, fn scenarioFunc) {
type scenarioContext struct {
m *macaron.Macaron
context *m.ReqContext
context *models.ReqContext
resp *httptest.ResponseRecorder
apiKey string
authHeader string
@ -587,4 +623,4 @@ func (sc *scenarioContext) exec() {
}
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
type handlerFunc func(c *models.ReqContext)

View File

@ -6,6 +6,7 @@ import (
)
var ErrInvalidApiKey = errors.New("Invalid API Key")
var ErrInvalidApiKeyExpiration = errors.New("Negative value for SecondsToLive")
type ApiKey struct {
Id int64
@ -15,15 +16,17 @@ type ApiKey struct {
Role RoleType
Created time.Time
Updated time.Time
Expires *int64
}
// ---------------------
// COMMANDS
type AddApiKeyCommand struct {
Name string `json:"name" binding:"Required"`
Role RoleType `json:"role" binding:"Required"`
OrgId int64 `json:"-"`
Key string `json:"-"`
Name string `json:"name" binding:"Required"`
Role RoleType `json:"role" binding:"Required"`
OrgId int64 `json:"-"`
Key string `json:"-"`
SecondsToLive int64 `json:"secondsToLive"`
Result *ApiKey `json:"-"`
}
@ -45,8 +48,9 @@ type DeleteApiKeyCommand struct {
// QUERIES
type GetApiKeysQuery struct {
OrgId int64
Result []*ApiKey
OrgId int64
IncludeInvalid bool
Result []*ApiKey
}
type GetApiKeyByNameQuery struct {
@ -64,7 +68,8 @@ type GetApiKeyByIdQuery struct {
// DTO & Projections
type ApiKeyDTO struct {
Id int64 `json:"id"`
Name string `json:"name"`
Role RoleType `json:"role"`
Id int64 `json:"id"`
Name string `json:"name"`
Role RoleType `json:"role"`
Expiration *time.Time `json:"expiration,omitempty"`
}

View File

@ -5,7 +5,7 @@ import (
"time"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models"
)
func init() {
@ -16,14 +16,18 @@ func init() {
bus.AddHandler("sql", AddApiKey)
}
func GetApiKeys(query *m.GetApiKeysQuery) error {
sess := x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name")
func GetApiKeys(query *models.GetApiKeysQuery) error {
sess := x.Limit(100, 0).Where("org_id=? and ( expires IS NULL or expires >= ?)",
query.OrgId, timeNow().Unix()).Asc("name")
if query.IncludeInvalid {
sess = x.Limit(100, 0).Where("org_id=?", query.OrgId).Asc("name")
}
query.Result = make([]*m.ApiKey, 0)
query.Result = make([]*models.ApiKey, 0)
return sess.Find(&query.Result)
}
func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error {
func DeleteApiKeyCtx(ctx context.Context, cmd *models.DeleteApiKeyCommand) error {
return withDbSession(ctx, func(sess *DBSession) error {
var rawSql = "DELETE FROM api_key WHERE id=? and org_id=?"
_, err := sess.Exec(rawSql, cmd.Id, cmd.OrgId)
@ -31,15 +35,24 @@ func DeleteApiKeyCtx(ctx context.Context, cmd *m.DeleteApiKeyCommand) error {
})
}
func AddApiKey(cmd *m.AddApiKeyCommand) error {
func AddApiKey(cmd *models.AddApiKeyCommand) error {
return inTransaction(func(sess *DBSession) error {
t := m.ApiKey{
updated := timeNow()
var expires *int64 = nil
if cmd.SecondsToLive > 0 {
v := updated.Add(time.Second * time.Duration(cmd.SecondsToLive)).Unix()
expires = &v
} else if cmd.SecondsToLive < 0 {
return models.ErrInvalidApiKeyExpiration
}
t := models.ApiKey{
OrgId: cmd.OrgId,
Name: cmd.Name,
Role: cmd.Role,
Key: cmd.Key,
Created: time.Now(),
Updated: time.Now(),
Created: updated,
Updated: updated,
Expires: expires,
}
if _, err := sess.Insert(&t); err != nil {
@ -50,28 +63,28 @@ func AddApiKey(cmd *m.AddApiKeyCommand) error {
})
}
func GetApiKeyById(query *m.GetApiKeyByIdQuery) error {
var apikey m.ApiKey
func GetApiKeyById(query *models.GetApiKeyByIdQuery) error {
var apikey models.ApiKey
has, err := x.Id(query.ApiKeyId).Get(&apikey)
if err != nil {
return err
} else if !has {
return m.ErrInvalidApiKey
return models.ErrInvalidApiKey
}
query.Result = &apikey
return nil
}
func GetApiKeyByName(query *m.GetApiKeyByNameQuery) error {
var apikey m.ApiKey
func GetApiKeyByName(query *models.GetApiKeyByNameQuery) error {
var apikey models.ApiKey
has, err := x.Where("org_id=? AND name=?", query.OrgId, query.KeyName).Get(&apikey)
if err != nil {
return err
} else if !has {
return m.ErrInvalidApiKey
return models.ErrInvalidApiKey
}
query.Result = &apikey

View File

@ -1,31 +1,117 @@
package sqlstore
import (
"github.com/grafana/grafana/pkg/models"
"github.com/stretchr/testify/assert"
"testing"
. "github.com/smartystreets/goconvey/convey"
m "github.com/grafana/grafana/pkg/models"
"time"
)
func TestApiKeyDataAccess(t *testing.T) {
mockTimeNow()
defer resetTimeNow()
Convey("Testing API Key data access", t, func() {
t.Run("Testing API Key data access", func(t *testing.T) {
InitTestDB(t)
Convey("Given saved api key", func() {
cmd := m.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"}
t.Run("Given saved api key", func(t *testing.T) {
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "hello", Key: "asd"}
err := AddApiKey(&cmd)
So(err, ShouldBeNil)
assert.Nil(t, err)
Convey("Should be able to get key by name", func() {
query := m.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1}
t.Run("Should be able to get key by name", func(t *testing.T) {
query := models.GetApiKeyByNameQuery{KeyName: "hello", OrgId: 1}
err = GetApiKeyByName(&query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
assert.Nil(t, err)
assert.NotNil(t, query.Result)
})
})
t.Run("Add non expiring key", func(t *testing.T) {
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0}
err := AddApiKey(&cmd)
assert.Nil(t, err)
query := models.GetApiKeyByNameQuery{KeyName: "non-expiring", OrgId: 1}
err = GetApiKeyByName(&query)
assert.Nil(t, err)
assert.Nil(t, query.Result.Expires)
})
t.Run("Add an expiring key", func(t *testing.T) {
//expires in one hour
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "expiring-in-an-hour", Key: "asd2", SecondsToLive: 3600}
err := AddApiKey(&cmd)
assert.Nil(t, err)
query := models.GetApiKeyByNameQuery{KeyName: "expiring-in-an-hour", OrgId: 1}
err = GetApiKeyByName(&query)
assert.Nil(t, err)
assert.True(t, *query.Result.Expires >= timeNow().Unix())
// timeNow() has been called twice since creation; once by AddApiKey and once by GetApiKeyByName
// therefore two seconds should be subtracted by next value retuned by timeNow()
// that equals the number by which timeSeed has been advanced
then := timeNow().Add(-2 * time.Second)
expected := then.Add(1 * time.Hour).UTC().Unix()
assert.Equal(t, *query.Result.Expires, expected)
})
t.Run("Add a key with negative lifespan", func(t *testing.T) {
//expires in one day
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key-with-negative-lifespan", Key: "asd3", SecondsToLive: -3600}
err := AddApiKey(&cmd)
assert.EqualError(t, err, models.ErrInvalidApiKeyExpiration.Error())
query := models.GetApiKeyByNameQuery{KeyName: "key-with-negative-lifespan", OrgId: 1}
err = GetApiKeyByName(&query)
assert.EqualError(t, err, "Invalid API Key")
})
t.Run("Add keys", func(t *testing.T) {
//never expires
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "key1", Key: "key1", SecondsToLive: 0}
err := AddApiKey(&cmd)
assert.Nil(t, err)
//expires in 1s
cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key2", Key: "key2", SecondsToLive: 1}
err = AddApiKey(&cmd)
assert.Nil(t, err)
//expires in one hour
cmd = models.AddApiKeyCommand{OrgId: 1, Name: "key3", Key: "key3", SecondsToLive: 3600}
err = AddApiKey(&cmd)
assert.Nil(t, err)
// advance mocked getTime by 1s
timeNow()
query := models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: false}
err = GetApiKeys(&query)
assert.Nil(t, err)
for _, k := range query.Result {
if k.Name == "key2" {
t.Fatalf("key2 should not be there")
}
}
query = models.GetApiKeysQuery{OrgId: 1, IncludeInvalid: true}
err = GetApiKeys(&query)
assert.Nil(t, err)
found := false
for _, k := range query.Result {
if k.Name == "key2" {
found = true
}
}
assert.True(t, found)
})
})
}

View File

@ -78,4 +78,8 @@ func addApiKeyMigrations(mg *Migrator) {
{Name: "key", Type: DB_Varchar, Length: 190, Nullable: false},
{Name: "role", Type: DB_NVarchar, Length: 255, Nullable: false},
}))
mg.AddMigration("Add expires to api_key table", NewAddColumnMigration(apiKeyV2, &Column{
Name: "expires", Type: DB_BigInt, Nullable: true,
}))
}

View File

@ -259,6 +259,8 @@ type Cfg struct {
RemoteCacheOptions *RemoteCacheOptions
EditorsCanAdmin bool
ApiKeyMaxSecondsToLive int64
}
type CommandLineArgs struct {
@ -795,6 +797,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
LoginMaxLifetimeDays = auth.Key("login_maximum_lifetime_days").MustInt(30)
cfg.LoginMaxLifetimeDays = LoginMaxLifetimeDays
cfg.ApiKeyMaxSecondsToLive = auth.Key("api_key_max_seconds_to_live").MustInt64(-1)
cfg.TokenRotationIntervalMinutes = auth.Key("token_rotation_interval_minutes").MustInt(10)
if cfg.TokenRotationIntervalMinutes < 2 {

View File

@ -12,9 +12,34 @@ import ApiKeysAddedModal from './ApiKeysAddedModal';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DeleteButton, Input } from '@grafana/ui';
import { DeleteButton, EventsWithValidation, FormLabel, Input, ValidationEvents } from '@grafana/ui';
import { NavModel } from '@grafana/data';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { store } from 'app/store/store';
import kbn from 'app/core/utils/kbn';
// Utils
import { dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper';
import { getTimeZone } from 'app/features/profile/state/selectors';
const timeRangeValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: value => {
if (!value) {
return true;
}
try {
kbn.interval_to_seconds(value);
return true;
} catch {
return false;
}
},
errorMessage: 'Not a valid duration',
},
],
};
export interface Props {
navModel: NavModel;
@ -36,13 +61,18 @@ export interface State {
enum ApiKeyStateProps {
Name = 'name',
Role = 'role',
SecondsToLive = 'secondsToLive',
}
const initialApiKeyState = {
name: '',
role: OrgRole.Viewer,
secondsToLive: '',
};
const tooltipText =
'The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y';
export class ApiKeysPage extends PureComponent<Props, any> {
constructor(props) {
super(props);
@ -81,6 +111,9 @@ export class ApiKeysPage extends PureComponent<Props, any> {
});
};
// make sure that secondsToLive is number or null
const secondsToLive = this.state.newApiKey['secondsToLive'];
this.state.newApiKey['secondsToLive'] = secondsToLive ? kbn.interval_to_seconds(secondsToLive) : null;
this.props.addApiKey(this.state.newApiKey, openModal);
this.setState((prevState: State) => {
return {
@ -130,6 +163,17 @@ export class ApiKeysPage extends PureComponent<Props, any> {
);
}
formatDate(date, format?) {
if (!date) {
return 'No expiration date';
}
date = isDateTime(date) ? date : dateTime(date);
format = format || 'YYYY-MM-DD HH:mm:ss';
const timezone = getTimeZone(store.getState().user);
return timezone.isUtc ? date.utc().format(format) : date.format(format);
}
renderAddApiKeyForm() {
const { newApiKey, isAdding } = this.state;
@ -170,6 +214,17 @@ export class ApiKeysPage extends PureComponent<Props, any> {
</select>
</span>
</div>
<div className="gf-form max-width-21">
<FormLabel tooltip={tooltipText}>Time to live</FormLabel>
<Input
type="text"
className="gf-form-input"
placeholder="1d"
validationEvents={timeRangeValidationEvents}
value={newApiKey.secondsToLive}
onChange={evt => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.SecondsToLive)}
/>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-primary">Add</button>
</div>
@ -211,6 +266,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
<tr>
<th>Name</th>
<th>Role</th>
<th>Expires</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
@ -221,6 +277,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>{this.formatDate(key.expiration)}</td>
<td>
<DeleteButton onConfirm={() => this.onDeleteApiKey(key)} />
</td>

View File

@ -7,6 +7,8 @@ export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => {
id: i,
name: `test-${i}`,
role: OrgRole.Viewer,
secondsToLive: null,
expiration: '2019-06-04',
});
}
@ -18,5 +20,7 @@ export const getMockKey = (): ApiKey => {
id: 1,
name: 'test',
role: OrgRole.Admin,
secondsToLive: null,
expiration: '2019-06-04',
};
};

View File

@ -130,6 +130,32 @@ exports[`Render should render CTA if there are no API keys 1`] = `
</select>
</span>
</div>
<div
className="gf-form max-width-21"
>
<Component
tooltip="The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y"
>
Time to live
</Component>
<Input
className="gf-form-input"
onChange={[Function]}
placeholder="1d"
type="text"
validationEvents={
Object {
"onBlur": Array [
Object {
"errorMessage": "Not a valid duration",
"rule": [Function],
},
],
}
}
value=""
/>
</div>
<div
className="gf-form"
>

View File

@ -4,11 +4,14 @@ export interface ApiKey {
id: number;
name: string;
role: OrgRole;
secondsToLive: number;
expiration: string;
}
export interface NewApiKey {
name: string;
role: OrgRole;
secondsToLive: number;
}
export interface ApiKeysState {