mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Authn: JWT client (#61157)
* add jwt client * alias JWT verifier * debug implementation * add tests for jwt client * add constant for JWT module * Feedback Co-authored-by: Kalle Persson <kalle.persson@grafana.com> Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com> Co-authored-by: Kalle Persson <kalle.persson@grafana.com> Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
@@ -44,12 +44,15 @@ Here is the conf you need to add to your configuration file (conf/custom.ini):
|
||||
[auth.jwt]
|
||||
enabled = true
|
||||
header_name = X-JWT-Assertion
|
||||
username_claim = login
|
||||
username_claim = preferred_username
|
||||
email_claim = email
|
||||
jwk_set_file = devenv/docker/blocks/auth/oauth/jwks.json
|
||||
cache_ttl = 60m
|
||||
expect_claims = {"iss": "http://localhost:8087/auth/realms/grafana", "azp": "grafana-oauth"}
|
||||
expect_claims = {"iss": "http://localhost:8087/realms/grafana", "azp": "grafana-oauth"}
|
||||
auto_sign_up = true
|
||||
role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer'
|
||||
role_attribute_strict = true
|
||||
allow_assign_grafana_admin = true
|
||||
```
|
||||
|
||||
You can obtain a jwt token by using the following command for oauth-admin:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
@@ -72,3 +73,5 @@ type UserTokenService interface {
|
||||
type UserTokenBackgroundService interface {
|
||||
registry.BackgroundService
|
||||
}
|
||||
|
||||
type JWTVerifierService = models.JWTService
|
||||
|
||||
@@ -18,17 +18,26 @@ import (
|
||||
|
||||
const (
|
||||
ClientAPIKey = "auth.client.api-key" // #nosec G101
|
||||
ClientSession = "auth.client.session"
|
||||
ClientAnonymous = "auth.client.anonymous"
|
||||
ClientBasic = "auth.client.basic"
|
||||
ClientJWT = "auth.client.jwt"
|
||||
ClientRender = "auth.client.render"
|
||||
ClientSession = "auth.client.session"
|
||||
)
|
||||
|
||||
// ClientParams are hints to the auth service about how to handle the identity management
|
||||
// from the authenticating client.
|
||||
type ClientParams struct {
|
||||
SyncUser bool
|
||||
SyncTeamMembers bool
|
||||
AllowSignUp bool
|
||||
// Update the internal representation of the entity from the identity provided
|
||||
SyncUser bool
|
||||
// Add entity to teams
|
||||
SyncTeamMembers bool
|
||||
// Create entity in the DB if it doesn't exist
|
||||
AllowSignUp bool
|
||||
// EnableDisabledUsers is a hint to the auth service that it should reenable disabled users
|
||||
EnableDisabledUsers bool
|
||||
// LookUpParams are the arguments used to look up the entity in the DB.
|
||||
LookUpParams models.UserLookupParams
|
||||
}
|
||||
|
||||
type PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error
|
||||
@@ -98,9 +107,6 @@ type Identity struct {
|
||||
// AuthId is the unique identifier for the entity in the external system.
|
||||
// Empty if the identity is provided by Grafana.
|
||||
AuthID string
|
||||
// LookUpParams are the arguments used to look up the entity in the DB.
|
||||
// Empty if the identity is provided by Grafana. TODO: move to client params
|
||||
LookUpParams models.UserLookupParams
|
||||
// IsDisabled is true if the entity is disabled.
|
||||
IsDisabled bool
|
||||
// HelpFlags1 is the help flags for the entity.
|
||||
|
||||
@@ -34,6 +34,7 @@ func ProvideService(
|
||||
orgService org.Service, sessionService auth.UserTokenService,
|
||||
accessControlService accesscontrol.Service,
|
||||
apikeyService apikey.Service, userService user.Service,
|
||||
jwtService auth.JWTVerifierService,
|
||||
loginAttempts loginattempt.Service, quotaService quota.Service,
|
||||
authInfoService login.AuthInfoService, renderService rendering.Service,
|
||||
) *Service {
|
||||
@@ -72,6 +73,10 @@ func ProvideService(
|
||||
s.clients[authn.ClientBasic] = clients.ProvideBasic(loginAttempts, passwordClients...)
|
||||
}
|
||||
|
||||
if s.cfg.JWTAuthEnabled {
|
||||
s.clients[authn.ClientJWT] = clients.ProvideJWT(jwtService, cfg)
|
||||
}
|
||||
|
||||
// FIXME (jguer): move to User package
|
||||
userSyncService := sync.ProvideUserSync(userService, authInfoService, quotaService)
|
||||
orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService)
|
||||
@@ -117,6 +122,7 @@ func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Requ
|
||||
|
||||
for _, hook := range s.postAuthHooks {
|
||||
if err := hook(ctx, identity, r); err != nil {
|
||||
s.log.FromContext(ctx).Warn("post auth hook failed", "error", err, "id", identity)
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ func TestOrgSync_SyncOrgUser(t *testing.T) {
|
||||
Email: "test",
|
||||
OrgRoles: map[int64]roletype.RoleType{1: org.RoleAdmin, 2: org.RoleEditor},
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,13 +95,13 @@ func TestOrgSync_SyncOrgUser(t *testing.T) {
|
||||
OrgRoles: map[int64]roletype.RoleType{1: org.RoleAdmin, 2: org.RoleEditor},
|
||||
OrgID: 1, //set using org
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s *UserSync) SyncUser(ctx context.Context, id *authn.Identity, _ *authn.Re
|
||||
}
|
||||
|
||||
// Does user exist in the database?
|
||||
usr, errUserInDB := s.UserInDB(ctx, &id.AuthModule, &id.AuthID, id.LookUpParams)
|
||||
usr, errUserInDB := s.UserInDB(ctx, &id.AuthModule, &id.AuthID, id.ClientParams.LookUpParams)
|
||||
if errUserInDB != nil && !errors.Is(errUserInDB, user.ErrUserNotFound) {
|
||||
return errUserInDB
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func (s *UserSync) LookupByOneOf(ctx context.Context, params *models.UserLookupP
|
||||
}
|
||||
}
|
||||
|
||||
if usr == nil {
|
||||
if usr == nil || usr.ID == 0 { // id check as safeguard against returning empty user
|
||||
return nil, user.ErrUserNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -110,12 +110,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
ClientParams: authn.ClientParams{
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
ClientParams: authn.ClientParams{},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
@@ -124,12 +125,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
ClientParams: authn.ClientParams{
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
ClientParams: authn.ClientParams{},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,13 +149,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -164,13 +166,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -189,13 +191,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: ptrString("test"),
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: ptrString("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,12 +208,12 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: ptrString("test"),
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: ptrString("test"),
|
||||
},
|
||||
SyncUser: true,
|
||||
},
|
||||
},
|
||||
@@ -231,13 +233,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(1),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(1),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -248,13 +250,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(1),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(1),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -274,13 +276,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Login: "test",
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -291,13 +293,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Name: "test",
|
||||
Email: "test",
|
||||
IsGrafanaAdmin: ptrBool(false),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -318,13 +320,13 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Email: "test",
|
||||
AuthModule: "oauth",
|
||||
AuthID: "2032",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -348,15 +350,15 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Email: "test_create",
|
||||
AuthModule: "oauth",
|
||||
AuthID: "2032",
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test_create"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test_create"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -369,15 +371,15 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
AuthModule: "oauth",
|
||||
AuthID: "2032",
|
||||
IsGrafanaAdmin: ptrBool(true),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test_create"),
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: ptrString("test_create"),
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -398,14 +400,14 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Email: "test_mod",
|
||||
IsDisabled: false,
|
||||
IsGrafanaAdmin: ptrBool(true),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(3),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(3),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -417,14 +419,14 @@ func TestUserSync_SyncUser(t *testing.T) {
|
||||
Email: "test_mod",
|
||||
IsDisabled: false,
|
||||
IsGrafanaAdmin: ptrBool(true),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(3),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: ptrInt64(3),
|
||||
Email: nil,
|
||||
Login: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
208
pkg/services/authn/clients/jwt.go
Normal file
208
pkg/services/authn/clients/jwt.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jmespath/go-jmespath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
var _ authn.Client = new(JWT)
|
||||
|
||||
var (
|
||||
ErrJWTInvalid = errutil.NewBase(errutil.StatusUnauthorized,
|
||||
"jwt.invalid", errutil.WithPublicMessage("Failed to verify JWT"))
|
||||
ErrJWTMissingClaim = errutil.NewBase(errutil.StatusUnauthorized,
|
||||
"jwt.missing_claim", errutil.WithPublicMessage("Missing mandatory claim in JWT"))
|
||||
ErrJWTInvalidRole = errutil.NewBase(errutil.StatusForbidden,
|
||||
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim"))
|
||||
)
|
||||
|
||||
func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT {
|
||||
return &JWT{
|
||||
cfg: cfg,
|
||||
log: log.New(authn.ClientJWT),
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
type JWT struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
jwtService auth.JWTVerifierService
|
||||
}
|
||||
|
||||
func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||
jwtToken := s.retrieveToken(r.HTTPRequest)
|
||||
|
||||
claims, err := s.jwtService.Verify(ctx, jwtToken)
|
||||
if err != nil {
|
||||
s.log.Debug("Failed to verify JWT", "error", err)
|
||||
return nil, ErrJWTInvalid.Errorf("failed to verify JWT: %w", err)
|
||||
}
|
||||
|
||||
sub, _ := claims["sub"].(string)
|
||||
if sub == "" {
|
||||
s.log.Warn("Got a JWT without the mandatory 'sub' claim", "error", err)
|
||||
return nil, ErrJWTMissingClaim.Errorf("missing mandatory 'sub' claim in JWT")
|
||||
}
|
||||
|
||||
id := &authn.Identity{
|
||||
AuthModule: login.JWTModule,
|
||||
AuthID: sub,
|
||||
OrgRoles: map[int64]org.RoleType{},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: false,
|
||||
EnableDisabledUsers: false,
|
||||
}}
|
||||
|
||||
if key := s.cfg.JWTAuthUsernameClaim; key != "" {
|
||||
id.Login, _ = claims[key].(string)
|
||||
id.ClientParams.LookUpParams.Login = &id.Login
|
||||
}
|
||||
if key := s.cfg.JWTAuthEmailClaim; key != "" {
|
||||
id.Email, _ = claims[key].(string)
|
||||
id.ClientParams.LookUpParams.Email = &id.Email
|
||||
}
|
||||
|
||||
if name, _ := claims["name"].(string); name != "" {
|
||||
id.Name = name
|
||||
}
|
||||
|
||||
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
|
||||
if s.cfg.JWTAuthRoleAttributeStrict && !role.IsValid() {
|
||||
s.log.Warn("extracted Role is invalid", "role", role, "auth_id", id.AuthID)
|
||||
return nil, ErrJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role)
|
||||
}
|
||||
|
||||
if role.IsValid() {
|
||||
var orgID int64
|
||||
// FIXME (jguer): GetIDForNewUser already has the auto assign information
|
||||
// just neeeds the org role. Find a meaningful way to pass this default
|
||||
// role to it (that doesn't involve id.OrgRoles[0] = role)
|
||||
if s.cfg.AutoAssignOrg && s.cfg.AutoAssignOrgId > 0 {
|
||||
orgID = int64(s.cfg.AutoAssignOrgId)
|
||||
s.log.Debug("The user has a role assignment and organization membership is auto-assigned",
|
||||
"role", role, "orgId", orgID)
|
||||
} else {
|
||||
orgID = int64(1)
|
||||
s.log.Debug("The user has a role assignment and organization membership is not auto-assigned",
|
||||
"role", role, "orgId", orgID)
|
||||
}
|
||||
|
||||
id.OrgRoles[orgID] = role
|
||||
if s.cfg.JWTAuthAllowAssignGrafanaAdmin {
|
||||
id.IsGrafanaAdmin = &grafanaAdmin
|
||||
}
|
||||
}
|
||||
|
||||
if id.Login == "" || id.Email == "" {
|
||||
s.log.Debug("Failed to get an authentication claim from JWT",
|
||||
"login", id.Login, "email", id.Email)
|
||||
return nil, ErrJWTMissingClaim.Errorf("missing login or email claim in JWT")
|
||||
}
|
||||
|
||||
if s.cfg.JWTAuthAutoSignUp {
|
||||
id.ClientParams.AllowSignUp = true
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// retrieveToken retrieves the JWT token from the request.
|
||||
func (s *JWT) retrieveToken(httpRequest *http.Request) string {
|
||||
jwtToken := httpRequest.Header.Get(s.cfg.JWTAuthHeaderName)
|
||||
if jwtToken == "" && s.cfg.JWTAuthURLLogin {
|
||||
jwtToken = httpRequest.URL.Query().Get("auth_token")
|
||||
}
|
||||
// Strip the 'Bearer' prefix if it exists.
|
||||
return strings.TrimPrefix(jwtToken, "Bearer ")
|
||||
}
|
||||
|
||||
func (s *JWT) Test(ctx context.Context, r *authn.Request) bool {
|
||||
if !s.cfg.JWTAuthEnabled || s.cfg.JWTAuthHeaderName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
jwtToken := s.retrieveToken(r.HTTPRequest)
|
||||
|
||||
if jwtToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// The header is Authorization and the token does not look like a JWT,
|
||||
// this is likely an API key. Pass it on.
|
||||
if s.cfg.JWTAuthHeaderName == "Authorization" && !looksLikeJWT(jwtToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func looksLikeJWT(token string) bool {
|
||||
// A JWT must have 3 parts separated by `.`.
|
||||
parts := strings.Split(token, ".")
|
||||
return len(parts) == 3
|
||||
}
|
||||
|
||||
const roleGrafanaAdmin = "GrafanaAdmin"
|
||||
|
||||
func (s *JWT) extractRoleAndAdmin(claims map[string]interface{}) (org.RoleType, bool) {
|
||||
if s.cfg.JWTAuthRoleAttributePath == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
role, err := searchClaimsForStringAttr(s.cfg.JWTAuthRoleAttributePath, claims)
|
||||
if err != nil || role == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if role == roleGrafanaAdmin {
|
||||
return org.RoleAdmin, true
|
||||
}
|
||||
return org.RoleType(role), false
|
||||
}
|
||||
|
||||
func searchClaimsForStringAttr(attributePath string, claims map[string]interface{}) (string, error) {
|
||||
val, err := searchClaimsForAttr(attributePath, claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
strVal, ok := val.(string)
|
||||
if ok {
|
||||
return strVal, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func searchClaimsForAttr(attributePath string, claims map[string]interface{}) (interface{}, error) {
|
||||
if attributePath == "" {
|
||||
return "", errors.New("no attribute path specified")
|
||||
}
|
||||
|
||||
if len(claims) == 0 {
|
||||
return "", errors.New("empty claims provided")
|
||||
}
|
||||
|
||||
val, err := jmespath.Search(attributePath, claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to search claims with provided path: %q: %w", attributePath, err)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
175
pkg/services/authn/clients/jwt_test.go
Normal file
175
pkg/services/authn/clients/jwt_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestAuthenticateJWT(t *testing.T) {
|
||||
jwtService := &models.FakeJWTService{
|
||||
VerifyProvider: func(context.Context, string) (models.JWTClaims, error) {
|
||||
return models.JWTClaims{
|
||||
"sub": "1234567890",
|
||||
"email": "eai.doe@cor.po",
|
||||
"preferred_username": "eai-doe",
|
||||
"name": "Eai Doe",
|
||||
"roles": "Admin",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
jwtHeaderName := "X-Forwarded-User"
|
||||
wantID := &authn.Identity{
|
||||
OrgID: 0,
|
||||
OrgCount: 0,
|
||||
OrgName: "",
|
||||
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleAdmin},
|
||||
ID: "",
|
||||
Login: "eai-doe",
|
||||
Name: "Eai Doe",
|
||||
Email: "eai.doe@cor.po",
|
||||
IsGrafanaAdmin: boolPtr(false),
|
||||
AuthModule: "jwt",
|
||||
AuthID: "1234567890",
|
||||
IsDisabled: false,
|
||||
HelpFlags1: 0,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
SyncTeamMembers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
UserID: nil,
|
||||
Email: stringPtr("eai.doe@cor.po"),
|
||||
Login: stringPtr("eai-doe"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := &setting.Cfg{
|
||||
JWTAuthEnabled: true,
|
||||
JWTAuthHeaderName: jwtHeaderName,
|
||||
JWTAuthEmailClaim: "email",
|
||||
JWTAuthUsernameClaim: "preferred_username",
|
||||
JWTAuthAutoSignUp: true,
|
||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
||||
JWTAuthRoleAttributeStrict: true,
|
||||
JWTAuthRoleAttributePath: "roles",
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
validHTTPReq := &http.Request{
|
||||
Header: map[string][]string{
|
||||
jwtHeaderName: {"sample-token"}},
|
||||
}
|
||||
|
||||
id, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||
OrgID: 1,
|
||||
HTTPRequest: validHTTPReq,
|
||||
Resp: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, wantID, id, fmt.Sprintf("%+v", id))
|
||||
}
|
||||
|
||||
func TestJWTTest(t *testing.T) {
|
||||
jwtService := &models.FakeJWTService{}
|
||||
jwtHeaderName := "X-Forwarded-User"
|
||||
validFormatToken := "sample.token.valid"
|
||||
invalidFormatToken := "sampletokeninvalid"
|
||||
|
||||
type testCase struct {
|
||||
desc string
|
||||
reqHeaderName string
|
||||
cfgHeaderName string
|
||||
urlLogin bool
|
||||
token string
|
||||
want bool
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
desc: "valid",
|
||||
reqHeaderName: jwtHeaderName,
|
||||
cfgHeaderName: jwtHeaderName,
|
||||
token: validFormatToken,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "not in the right header",
|
||||
reqHeaderName: "other-header",
|
||||
cfgHeaderName: jwtHeaderName,
|
||||
token: validFormatToken,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "valid format in Authorization",
|
||||
reqHeaderName: "Authorization",
|
||||
cfgHeaderName: "Authorization",
|
||||
token: validFormatToken,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid format in Authorization",
|
||||
reqHeaderName: "Authorization",
|
||||
cfgHeaderName: "Authorization",
|
||||
token: invalidFormatToken,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "url login enabled",
|
||||
reqHeaderName: "other-header",
|
||||
cfgHeaderName: jwtHeaderName,
|
||||
urlLogin: true,
|
||||
token: validFormatToken,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "url login enabled",
|
||||
reqHeaderName: "other-header",
|
||||
cfgHeaderName: jwtHeaderName,
|
||||
urlLogin: false,
|
||||
token: validFormatToken,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
cfg := &setting.Cfg{
|
||||
JWTAuthEnabled: true,
|
||||
JWTAuthURLLogin: tc.urlLogin,
|
||||
JWTAuthHeaderName: tc.cfgHeaderName,
|
||||
JWTAuthAutoSignUp: true,
|
||||
JWTAuthAllowAssignGrafanaAdmin: true,
|
||||
JWTAuthRoleAttributeStrict: true,
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
httpReq := &http.Request{
|
||||
URL: &url.URL{RawQuery: "auth_token=" + tc.token},
|
||||
Header: map[string][]string{
|
||||
tc.reqHeaderName: {tc.token}},
|
||||
}
|
||||
|
||||
got := jwtClient.Test(context.Background(), &authn.Request{
|
||||
OrgID: 1,
|
||||
HTTPRequest: httpReq,
|
||||
Resp: nil,
|
||||
})
|
||||
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,16 +48,16 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, orgID int64, username,
|
||||
IsGrafanaAdmin: info.IsGrafanaAdmin,
|
||||
AuthModule: info.AuthModule,
|
||||
AuthID: info.AuthId,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Login: &info.Login,
|
||||
Email: &info.Email,
|
||||
},
|
||||
Groups: info.Groups,
|
||||
Groups: info.Groups,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: c.cfg.LDAPAllowSignup,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Login: &info.Login,
|
||||
Email: &info.Email,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ func TestLDAP_AuthenticatePassword(t *testing.T) {
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: false,
|
||||
EnableDisabledUsers: true,
|
||||
},
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Email: strPtr("test@test.com"),
|
||||
Login: strPtr("test"),
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Email: strPtr("test@test.com"),
|
||||
Login: strPtr("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"github.com/grafana/grafana/pkg/models/usertoken"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
@@ -104,7 +103,6 @@ func TestSession_Authenticate(t *testing.T) {
|
||||
Email: "sample_user@samples.iwz",
|
||||
OrgID: 1,
|
||||
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleEditor},
|
||||
LookUpParams: models.UserLookupParams{},
|
||||
IsGrafanaAdmin: boolPtr(false),
|
||||
},
|
||||
wantErr: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/login"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/jmespath/go-jmespath"
|
||||
@@ -20,6 +22,27 @@ const (
|
||||
)
|
||||
|
||||
func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool {
|
||||
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||
identity, ok, err := h.authnService.Authenticate(ctx.Req.Context(),
|
||||
authn.ClientJWT,
|
||||
&authn.Request{HTTPRequest: ctx.Req, Resp: ctx.Resp, OrgID: orgId})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
newCtx := WithAuthHTTPHeader(ctx.Req.Context(), h.Cfg.JWTAuthHeaderName)
|
||||
*ctx.Req = *ctx.Req.WithContext(newCtx)
|
||||
|
||||
if err != nil {
|
||||
writeErr(ctx, err)
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.SignedInUser = identity.SignedInUser()
|
||||
ctx.IsSignedIn = true
|
||||
return true
|
||||
}
|
||||
|
||||
if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func ProvideService(cfg *setting.Cfg, tokenService auth.UserTokenService, jwtSer
|
||||
type ContextHandler struct {
|
||||
Cfg *setting.Cfg
|
||||
AuthTokenService auth.UserTokenService
|
||||
JWTAuthService models.JWTService
|
||||
JWTAuthService auth.JWTVerifierService
|
||||
RemoteCache *remotecache.RemoteCache
|
||||
RenderService rendering.Service
|
||||
SQLStore db.DB
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
SAMLAuthModule = "auth.saml"
|
||||
LDAPAuthModule = "ldap"
|
||||
AuthProxyAuthModule = "authproxy"
|
||||
JWTModule = "jwt"
|
||||
)
|
||||
|
||||
func GetAuthProviderLabel(authModule string) string {
|
||||
@@ -39,7 +40,7 @@ func GetAuthProviderLabel(authModule string) string {
|
||||
return "SAML"
|
||||
case LDAPAuthModule, "": // FIXME: verify this situation doesn't exist anymore
|
||||
return "LDAP"
|
||||
case "jwt":
|
||||
case JWTModule:
|
||||
return "JWT"
|
||||
case AuthProxyAuthModule:
|
||||
return "Auth Proxy"
|
||||
|
||||
@@ -217,7 +217,11 @@ func (ss *sqlStore) GetByLogin(ctx context.Context, query *user.GetUserByLoginQu
|
||||
return nil
|
||||
})
|
||||
|
||||
return usr, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return usr, nil
|
||||
}
|
||||
|
||||
func (ss *sqlStore) GetByEmail(ctx context.Context, query *user.GetUserByEmailQuery) (*user.User, error) {
|
||||
|
||||
Reference in New Issue
Block a user