瀏覽代碼

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.
Sofia Papagiannaki 6 年之前
父節點
當前提交
dc9ec7dc91

+ 3 - 0
conf/defaults.ini

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

+ 3 - 0
docs/sources/auth/overview.md

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

+ 10 - 2
docs/sources/http_api/auth.md

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

+ 1 - 0
packages/grafana-ui/src/utils/moment_wrapper.ts

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

+ 31 - 31
pkg/api/api.go

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

+ 29 - 11
pkg/api/apikey.go

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

+ 29 - 21
pkg/middleware/middleware.go

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

+ 88 - 52
pkg/middleware/middleware_test.go

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

+ 14 - 9
pkg/models/apikey.go

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

+ 28 - 15
pkg/services/sqlstore/apikey.go

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

+ 98 - 12
pkg/services/sqlstore/apikey_test.go

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

+ 4 - 0
pkg/services/sqlstore/migrations/apikey_mig.go

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

+ 3 - 0
pkg/setting/setting.go

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

+ 58 - 1
public/app/features/api-keys/ApiKeysPage.tsx

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

+ 4 - 0
public/app/features/api-keys/__mocks__/apiKeysMock.ts

@@ -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',
   };
 };

+ 26 - 0
public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap

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

+ 3 - 0
public/app/types/apiKeys.ts

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