mirror of https://github.com/grafana/grafana
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>pull/51083/head
parent
2de72c1c39
commit
0c8ad80575
@ -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 |
||||
} |
@ -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) |
||||
}) |
||||
} |
||||
} |
Loading…
Reference in new issue