diff --git a/devenv/docker/blocks/auth/oauth/readme.md b/devenv/docker/blocks/auth/oauth/readme.md index e0966a8b717..60c87d8d508 100644 --- a/devenv/docker/blocks/auth/oauth/readme.md +++ b/devenv/docker/blocks/auth/oauth/readme.md @@ -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: diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index 3d1f381ea80..e59e1fa6817 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -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 diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 7b497c9d024..20c3749c50f 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -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. diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index b708d89f349..8d707623fb6 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -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 } } diff --git a/pkg/services/authn/authnimpl/usersync/orgsync_test.go b/pkg/services/authn/authnimpl/usersync/orgsync_test.go index 118595d032d..be38661da37 100644 --- a/pkg/services/authn/authnimpl/usersync/orgsync_test.go +++ b/pkg/services/authn/authnimpl/usersync/orgsync_test.go @@ -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, diff --git a/pkg/services/authn/authnimpl/usersync/usersync.go b/pkg/services/authn/authnimpl/usersync/usersync.go index 942299815fa..2fd6803440a 100644 --- a/pkg/services/authn/authnimpl/usersync/usersync.go +++ b/pkg/services/authn/authnimpl/usersync/usersync.go @@ -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 } diff --git a/pkg/services/authn/authnimpl/usersync/usersync_test.go b/pkg/services/authn/authnimpl/usersync/usersync_test.go index f8e2488f111..634f6b3c93d 100644 --- a/pkg/services/authn/authnimpl/usersync/usersync_test.go +++ b/pkg/services/authn/authnimpl/usersync/usersync_test.go @@ -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, + }, }, }, }, diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go new file mode 100644 index 00000000000..4d4f5256918 --- /dev/null +++ b/pkg/services/authn/clients/jwt.go @@ -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 +} diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go new file mode 100644 index 00000000000..7b8cea2e86f --- /dev/null +++ b/pkg/services/authn/clients/jwt_test.go @@ -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) + }) + } +} diff --git a/pkg/services/authn/clients/ldap.go b/pkg/services/authn/clients/ldap.go index fb868191e85..8c2ba538a8d 100644 --- a/pkg/services/authn/clients/ldap.go +++ b/pkg/services/authn/clients/ldap.go @@ -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 } diff --git a/pkg/services/authn/clients/ldap_test.go b/pkg/services/authn/clients/ldap_test.go index c29435b9e76..305d5d407c8 100644 --- a/pkg/services/authn/clients/ldap_test.go +++ b/pkg/services/authn/clients/ldap_test.go @@ -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"), + }, }, }, }, diff --git a/pkg/services/authn/clients/session_test.go b/pkg/services/authn/clients/session_test.go index 515b76899d0..65b7bb62326 100644 --- a/pkg/services/authn/clients/session_test.go +++ b/pkg/services/authn/clients/session_test.go @@ -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, diff --git a/pkg/services/contexthandler/auth_jwt.go b/pkg/services/contexthandler/auth_jwt.go index b671d816d68..25ed124412b 100644 --- a/pkg/services/contexthandler/auth_jwt.go +++ b/pkg/services/contexthandler/auth_jwt.go @@ -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 } diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index f341e462088..b31a0e8b72a 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -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 diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 7ef0e559cbc..c88aa92c7dd 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -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" diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 09d6b5b48d7..d9c460f9302 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -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) {