Auth: implement auto_sign_up for auth.jwt (#43502)

Co-authored-by: James Brown <jbrown@easypost.com>
This commit is contained in:
Emil Tullstedt 2022-01-13 17:15:22 +01:00 committed by GitHub
parent 45287b4129
commit 25736b6afb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 118 additions and 4 deletions

View File

@ -564,6 +564,7 @@ jwk_set_file =
cache_ttl = 60m
expected_claims = {}
key_file =
auto_sign_up = false
#################################### Auth LDAP ###########################
[auth.ldap]

View File

@ -548,6 +548,7 @@
;cache_ttl = 60m
;expected_claims = {"aud": ["foo", "bar"]}
;key_file = /path/to/key/file
;auto_sign_up = false
#################################### Auth LDAP ##########################
[auth.ldap]

View File

@ -44,8 +44,13 @@ username_claim = sub
# Specify a claim to use as an email to sign in.
email_claim = sub
# auto-create users if they are not already matched
# auto_sign_up = true
```
If `auto_sign_up` is enabled, then the `sub` claim is used as the "external Auth ID". The `name` claim is used as the user's full name if it is present.
## Signature verification
JSON web token integrity needs to be verified so cryptographic signature is used for this purpose. So we expect that every token must be signed with some known cryptographic key.

View File

@ -5,11 +5,12 @@ import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/contexthandler"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
)
func TestMiddlewareJWTAuth(t *testing.T) {
@ -29,6 +30,10 @@ func TestMiddlewareJWTAuth(t *testing.T) {
cfg.JWTAuthEmailClaim = "foo-email"
}
configureAutoSignUp := func(cfg *setting.Cfg) {
cfg.JWTAuthAutoSignUp = true
}
token := "some-token"
middlewareScenario(t, "Valid token with valid login claim", func(t *testing.T, sc *scenarioContext) {
@ -37,6 +42,7 @@ func TestMiddlewareJWTAuth(t *testing.T) {
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{
"sub": myUsername,
"foo-username": myUsername,
}, nil
}
@ -64,6 +70,7 @@ func TestMiddlewareJWTAuth(t *testing.T) {
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{
"sub": myEmail,
"foo-email": myEmail,
}, nil
}
@ -85,11 +92,72 @@ func TestMiddlewareJWTAuth(t *testing.T) {
assert.Equal(t, myEmail, sc.context.Email)
}, configure, configureEmailClaim)
middlewareScenario(t, "Valid token with no user and auto_sign_up disabled", func(t *testing.T, sc *scenarioContext) {
myEmail := "vladimir@example.com"
var verifiedToken string
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{
"sub": myEmail,
"name": "Vladimir Example",
"foo-email": myEmail,
}, nil
}
bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error {
return models.ErrUserNotFound
})
sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec()
assert.Equal(t, verifiedToken, token)
assert.Equal(t, 401, sc.resp.Code)
assert.Equal(t, contexthandler.UserNotFound, sc.respJson["message"])
}, configure, configureEmailClaim)
middlewareScenario(t, "Valid token with no user and auto_sign_up enabled", func(t *testing.T, sc *scenarioContext) {
myEmail := "vladimir@example.com"
var verifiedToken string
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{
"sub": myEmail,
"name": "Vladimir Example",
"foo-email": myEmail,
}, nil
}
bus.AddHandler("get-sign-user", func(ctx context.Context, query *models.GetSignedInUserQuery) error {
query.Result = &models.SignedInUser{
UserId: id,
OrgId: orgID,
Email: query.Email,
}
return nil
})
bus.AddHandler("upsert-user", func(ctx context.Context, command *models.UpsertUserCommand) error {
command.Result = &models.User{
Id: id,
Name: command.ExternalUser.Name,
Email: command.ExternalUser.Email,
}
return nil
})
sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec()
assert.Equal(t, verifiedToken, token)
assert.Equal(t, 200, sc.resp.Code)
assert.True(t, sc.context.IsSignedIn)
assert.Equal(t, orgID, sc.context.OrgId)
assert.Equal(t, id, sc.context.UserId)
assert.Equal(t, myEmail, sc.context.Email)
}, configure, configureEmailClaim, configureAutoSignUp)
middlewareScenario(t, "Valid token without a login claim", func(t *testing.T, sc *scenarioContext) {
var verifiedToken string
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{"foo": "bar"}, nil
return models.JWTClaims{
"sub": "baz",
"foo": "bar",
}, nil
}
sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec()
@ -102,7 +170,10 @@ func TestMiddlewareJWTAuth(t *testing.T) {
var verifiedToken string
sc.jwtAuthService.VerifyProvider = func(ctx context.Context, token string) (models.JWTClaims, error) {
verifiedToken = token
return models.JWTClaims{"foo": "bar"}, nil
return models.JWTClaims{
"sub": "baz",
"foo": "bar",
}, nil
}
sc.fakeReq("GET", "/").withJWTAuthHeader(token).exec()

View File

@ -9,6 +9,7 @@ import (
)
const InvalidJWT = "Invalid JWT"
const UserNotFound = "User not found"
func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64) bool {
if !h.Cfg.JWTAuthEnabled || h.Cfg.JWTAuthHeaderName == "" {
@ -29,11 +30,29 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64)
query := models.GetSignedInUserQuery{OrgId: orgId}
sub, _ := claims["sub"].(string)
if sub == "" {
ctx.Logger.Warn("Got a JWT without the mandatory 'sub' claim", "error", err)
ctx.JsonApiErr(401, InvalidJWT, err)
return true
}
extUser := &models.ExternalUserInfo{
AuthModule: "jwt",
AuthId: sub,
}
if key := h.Cfg.JWTAuthUsernameClaim; key != "" {
query.Login, _ = claims[key].(string)
extUser.Login, _ = claims[key].(string)
}
if key := h.Cfg.JWTAuthEmailClaim; key != "" {
query.Email, _ = claims[key].(string)
extUser.Email, _ = claims[key].(string)
}
if name, _ := claims["name"].(string); name != "" {
extUser.Name = name
}
if query.Login == "" && query.Email == "" {
@ -42,6 +61,18 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64)
return true
}
if h.Cfg.JWTAuthAutoSignUp {
upsert := &models.UpsertUserCommand{
ReqContext: ctx,
SignupAllowed: h.Cfg.JWTAuthAutoSignUp,
ExternalUser: extUser,
}
if err := bus.Dispatch(ctx.Req.Context(), upsert); err != nil {
ctx.Logger.Error("Failed to upsert JWT user", "error", err)
return false
}
}
if err := bus.Dispatch(ctx.Req.Context(), &query); err != nil {
if errors.Is(err, models.ErrUserNotFound) {
ctx.Logger.Debug(
@ -50,10 +81,11 @@ func (h *ContextHandler) initContextWithJWT(ctx *models.ReqContext, orgId int64)
"username_claim", query.Login,
)
err = login.ErrInvalidCredentials
ctx.JsonApiErr(401, UserNotFound, err)
} else {
ctx.Logger.Error("Failed to get signed in user", "error", err)
ctx.JsonApiErr(401, InvalidJWT, err)
}
ctx.JsonApiErr(401, InvalidJWT, err)
return true
}

View File

@ -96,6 +96,8 @@ func GetAuthProviderLabel(authModule string) string {
return "SAML"
case "ldap", "":
return "LDAP"
case "jwt":
return "JWT"
default:
return "OAuth"
}

View File

@ -319,6 +319,7 @@ type Cfg struct {
JWTAuthCacheTTL time.Duration
JWTAuthKeyFile string
JWTAuthJWKSetFile string
JWTAuthAutoSignUp bool
// Dataproxy
SendUserHeader bool
@ -1308,6 +1309,7 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
cfg.JWTAuthCacheTTL = authJWT.Key("cache_ttl").MustDuration(time.Minute * 60)
cfg.JWTAuthKeyFile = valueAsString(authJWT, "key_file", "")
cfg.JWTAuthJWKSetFile = valueAsString(authJWT, "jwk_set_file", "")
cfg.JWTAuthAutoSignUp = authJWT.Key("auto_sign_up").MustBool(false)
authProxy := iniFile.Section("auth.proxy")
AuthProxyEnabled = authProxy.Key("enabled").MustBool(false)