mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
AuthN: Add auth proxy client (#61555)
* AuthN: set up boilerplate for proxy client * AuthN: Implement Test for proxy client * AuthN: parse accept list in constructor * AuthN: add proxy client interface * AuthN: handle error * AuthN: Implement the proxy client interface for ldap * AuthN: change reciever name * AuthN: add grafana as a proxy client * AuthN: for error returned * AuthN: add tests for grafana proxy auth * AuthN: swap order of grafan and ldap auth * AuthN: Parse additional proxy headers in proxy client and pass down
This commit is contained in:
parent
c1d3b59643
commit
b44b6fc5c6
@ -25,6 +25,7 @@ const (
|
||||
ClientRender = "auth.client.render"
|
||||
ClientSession = "auth.client.session"
|
||||
ClientForm = "auth.client.form"
|
||||
ClientProxy = "auth.client.proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -72,6 +73,10 @@ type PasswordClient interface {
|
||||
AuthenticatePassword(ctx context.Context, r *Request, username, password string) (*Identity, error)
|
||||
}
|
||||
|
||||
type ProxyClient interface {
|
||||
AuthenticateProxy(ctx context.Context, r *Request, username string, additional map[string]string) (*Identity, error)
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
// OrgID will be populated by authn.Service
|
||||
OrgID int64
|
||||
|
@ -64,12 +64,18 @@ func ProvideService(
|
||||
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
|
||||
}
|
||||
|
||||
var proxyClients []authn.ProxyClient
|
||||
var passwordClients []authn.PasswordClient
|
||||
if !s.cfg.DisableLogin {
|
||||
passwordClients = append(passwordClients, clients.ProvideGrafana(userService))
|
||||
}
|
||||
if s.cfg.LDAPEnabled {
|
||||
passwordClients = append(passwordClients, clients.ProvideLDAP(cfg))
|
||||
ldap := clients.ProvideLDAP(cfg)
|
||||
proxyClients = append(proxyClients, ldap)
|
||||
passwordClients = append(passwordClients, ldap)
|
||||
}
|
||||
|
||||
if !s.cfg.DisableLogin {
|
||||
grafana := clients.ProvideGrafana(cfg, userService)
|
||||
proxyClients = append(proxyClients, grafana)
|
||||
passwordClients = append(passwordClients, grafana)
|
||||
}
|
||||
|
||||
// if we have password clients configure check if basic auth or form auth is enabled
|
||||
@ -84,6 +90,15 @@ func ProvideService(
|
||||
}
|
||||
}
|
||||
|
||||
if s.cfg.AuthProxyEnabled && len(proxyClients) > 0 {
|
||||
proxy, err := clients.ProvideProxy(cfg, proxyClients...)
|
||||
if err != nil {
|
||||
s.log.Error("failed to configure auth proxy", "err", err)
|
||||
} else {
|
||||
s.clients[authn.ClientProxy] = proxy
|
||||
}
|
||||
}
|
||||
|
||||
if s.cfg.JWTAuthEnabled {
|
||||
s.clients[authn.ClientJWT] = clients.ProvideJWT(jwtService, cfg)
|
||||
}
|
||||
|
@ -26,3 +26,16 @@ func (m MockClient) Test(ctx context.Context, r *authn.Request) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var _ authn.ProxyClient = new(MockProxyClient)
|
||||
|
||||
type MockProxyClient struct {
|
||||
AuthenticateProxyFunc func(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error)
|
||||
}
|
||||
|
||||
func (m MockProxyClient) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error) {
|
||||
if m.AuthenticateProxyFunc != nil {
|
||||
return m.AuthenticateProxyFunc(ctx, r, username, additional)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
@ -4,23 +4,88 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/mail"
|
||||
|
||||
"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/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var _ authn.ProxyClient = new(Grafana)
|
||||
var _ authn.PasswordClient = new(Grafana)
|
||||
|
||||
func ProvideGrafana(userService user.Service) *Grafana {
|
||||
return &Grafana{userService}
|
||||
func ProvideGrafana(cfg *setting.Cfg, userService user.Service) *Grafana {
|
||||
return &Grafana{cfg, userService}
|
||||
}
|
||||
|
||||
type Grafana struct {
|
||||
cfg *setting.Cfg
|
||||
userService user.Service
|
||||
}
|
||||
|
||||
func (c Grafana) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
|
||||
func (c *Grafana) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error) {
|
||||
identity := &authn.Identity{
|
||||
AuthModule: login.AuthProxyAuthModule,
|
||||
AuthID: username,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: c.cfg.AuthProxyAutoSignUp,
|
||||
},
|
||||
}
|
||||
|
||||
switch c.cfg.AuthProxyHeaderProperty {
|
||||
case "username":
|
||||
identity.Login = username
|
||||
addr, err := mail.ParseAddress(username)
|
||||
if err == nil {
|
||||
identity.Email = addr.Address
|
||||
}
|
||||
case "email":
|
||||
identity.Login = username
|
||||
identity.Email = username
|
||||
default:
|
||||
return nil, errInvalidProxyHeader.Errorf("invalid auth proxy header property, expected username or email but got: %s", c.cfg.AuthProxyHeaderProperty)
|
||||
}
|
||||
|
||||
if v, ok := additional[proxyFieldName]; ok {
|
||||
identity.Name = v
|
||||
}
|
||||
|
||||
if v, ok := additional[proxyFieldEmail]; ok {
|
||||
identity.Email = v
|
||||
}
|
||||
|
||||
if v, ok := additional[proxyFieldLogin]; ok {
|
||||
identity.Login = v
|
||||
}
|
||||
|
||||
if v, ok := additional[proxyFieldRole]; ok {
|
||||
role := org.RoleType(v)
|
||||
if role.IsValid() {
|
||||
orgID := int64(1)
|
||||
if c.cfg.AutoAssignOrg && c.cfg.AutoAssignOrgId > 0 {
|
||||
orgID = int64(c.cfg.AutoAssignOrgId)
|
||||
}
|
||||
identity.OrgID = orgID
|
||||
identity.OrgRoles = map[int64]org.RoleType{orgID: role}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := additional[proxyFieldGroups]; ok {
|
||||
identity.Groups = util.SplitString(v)
|
||||
}
|
||||
|
||||
identity.ClientParams.LookUpParams.Email = &identity.Email
|
||||
identity.ClientParams.LookUpParams.Login = &identity.Login
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (c *Grafana) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
|
||||
usr, err := c.userService.GetByLogin(ctx, &user.GetUserByLoginQuery{LoginOrEmail: username})
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
|
@ -2,16 +2,125 @@ package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGrafana_AuthenticateProxy(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
req *authn.Request
|
||||
username string
|
||||
proxyProperty string
|
||||
additional map[string]string
|
||||
expectedErr error
|
||||
expectedIdentity *authn.Identity
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "expect valid identity",
|
||||
username: "test",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{}},
|
||||
proxyProperty: "username",
|
||||
additional: map[string]string{
|
||||
proxyFieldName: "name",
|
||||
proxyFieldRole: "Viewer",
|
||||
proxyFieldGroups: "grp1,grp2",
|
||||
proxyFieldEmail: "email@email.com",
|
||||
},
|
||||
expectedIdentity: &authn.Identity{
|
||||
OrgID: 1,
|
||||
OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
|
||||
Login: "test",
|
||||
Name: "name",
|
||||
Email: "email@email.com",
|
||||
AuthModule: "authproxy",
|
||||
AuthID: "test",
|
||||
Groups: []string{"grp1", "grp2"},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Email: strPtr("email@email.com"),
|
||||
Login: strPtr("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should set email as both email and login when configured proxy auth header property is email",
|
||||
username: "test@test.com",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{}}},
|
||||
additional: map[string]string{},
|
||||
expectedIdentity: &authn.Identity{
|
||||
Login: "test@test.com",
|
||||
Email: "test@test.com",
|
||||
AuthModule: "authproxy",
|
||||
AuthID: "test@test.com",
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Email: strPtr("test@test.com"),
|
||||
Login: strPtr("test@test.com"),
|
||||
},
|
||||
},
|
||||
},
|
||||
proxyProperty: "email",
|
||||
},
|
||||
{
|
||||
desc: "should return error on invalid auth proxy header property",
|
||||
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{}}},
|
||||
proxyProperty: "other",
|
||||
expectedErr: errInvalidProxyHeader,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.AuthProxyAutoSignUp = true
|
||||
cfg.AuthProxyHeaderProperty = tt.proxyProperty
|
||||
c := ProvideGrafana(cfg, usertest.NewUserServiceFake())
|
||||
|
||||
identity, err := c.AuthenticateProxy(context.Background(), tt.req, tt.username, tt.additional)
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
if tt.expectedIdentity != nil {
|
||||
assert.Equal(t, tt.expectedIdentity.OrgID, identity.OrgID)
|
||||
assert.Equal(t, tt.expectedIdentity.Login, identity.Login)
|
||||
assert.Equal(t, tt.expectedIdentity.Name, identity.Name)
|
||||
assert.Equal(t, tt.expectedIdentity.Email, identity.Email)
|
||||
assert.Equal(t, tt.expectedIdentity.AuthID, identity.AuthID)
|
||||
assert.Equal(t, tt.expectedIdentity.AuthModule, identity.AuthModule)
|
||||
assert.Equal(t, tt.expectedIdentity.Groups, identity.Groups)
|
||||
|
||||
assert.Equal(t, tt.expectedIdentity.ClientParams.SyncUser, identity.ClientParams.SyncUser)
|
||||
assert.Equal(t, tt.expectedIdentity.ClientParams.AllowSignUp, identity.ClientParams.AllowSignUp)
|
||||
assert.Equal(t, tt.expectedIdentity.ClientParams.SyncTeamMembers, identity.ClientParams.SyncTeamMembers)
|
||||
assert.Equal(t, tt.expectedIdentity.ClientParams.EnableDisabledUsers, identity.ClientParams.EnableDisabledUsers)
|
||||
|
||||
assert.EqualValues(t, tt.expectedIdentity.ClientParams.LookUpParams.Email, identity.ClientParams.LookUpParams.Email)
|
||||
assert.EqualValues(t, tt.expectedIdentity.ClientParams.LookUpParams.Login, identity.ClientParams.LookUpParams.Login)
|
||||
assert.EqualValues(t, tt.expectedIdentity.ClientParams.LookUpParams.UserID, identity.ClientParams.LookUpParams.UserID)
|
||||
} else {
|
||||
assert.Nil(t, tt.expectedIdentity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrafana_AuthenticatePassword(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
@ -60,7 +169,7 @@ func TestGrafana_AuthenticatePassword(t *testing.T) {
|
||||
userService.ExpectedError = user.ErrUserNotFound
|
||||
}
|
||||
|
||||
c := ProvideGrafana(userService)
|
||||
c := ProvideGrafana(setting.NewCfg(), userService)
|
||||
identity, err := c.AuthenticatePassword(context.Background(), &authn.Request{OrgID: 1}, tt.username, tt.password)
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
assert.EqualValues(t, tt.expectedIdentity, identity)
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var _ authn.ProxyClient = new(LDAP)
|
||||
var _ authn.PasswordClient = new(LDAP)
|
||||
|
||||
func ProvideLDAP(cfg *setting.Cfg) *LDAP {
|
||||
@ -21,6 +22,19 @@ type LDAP struct {
|
||||
service ldapService
|
||||
}
|
||||
|
||||
func (c *LDAP) AuthenticateProxy(ctx context.Context, r *authn.Request, username string, _ map[string]string) (*authn.Identity, error) {
|
||||
info, err := c.service.User(username)
|
||||
if errors.Is(err, multildap.ErrDidNotFindUser) {
|
||||
return nil, errIdentityNotFound.Errorf("no user found: %w", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return identityFromLDAPInfo(r.OrgID, info, c.cfg.LDAPAllowSignup), nil
|
||||
}
|
||||
|
||||
func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
|
||||
info, err := c.service.Login(&models.LoginUserQuery{
|
||||
Username: username,
|
||||
@ -43,31 +57,12 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &authn.Identity{
|
||||
OrgID: r.OrgID,
|
||||
OrgRoles: info.OrgRoles,
|
||||
Login: info.Login,
|
||||
Name: info.Name,
|
||||
Email: info.Email,
|
||||
IsGrafanaAdmin: info.IsGrafanaAdmin,
|
||||
AuthModule: info.AuthModule,
|
||||
AuthID: info.AuthId,
|
||||
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
|
||||
return identityFromLDAPInfo(r.OrgID, info, c.cfg.LDAPAllowSignup), nil
|
||||
}
|
||||
|
||||
type ldapService interface {
|
||||
Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error)
|
||||
User(username string) (*models.ExternalUserInfo, error)
|
||||
}
|
||||
|
||||
// FIXME: remove the implementation if we convert ldap to an actual service
|
||||
@ -83,3 +78,37 @@ func (s *ldapServiceImpl) Login(query *models.LoginUserQuery) (*models.ExternalU
|
||||
|
||||
return multildap.New(cfg.Servers).Login(query)
|
||||
}
|
||||
|
||||
func (s *ldapServiceImpl) User(username string) (*models.ExternalUserInfo, error) {
|
||||
cfg, err := multildap.GetConfig(s.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, _, err := multildap.New(cfg.Servers).User(username)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func identityFromLDAPInfo(orgID int64, info *models.ExternalUserInfo, allowSignup bool) *authn.Identity {
|
||||
return &authn.Identity{
|
||||
OrgID: orgID,
|
||||
OrgRoles: info.OrgRoles,
|
||||
Login: info.Login,
|
||||
Name: info.Name,
|
||||
Email: info.Email,
|
||||
IsGrafanaAdmin: info.IsGrafanaAdmin,
|
||||
AuthModule: info.AuthModule,
|
||||
AuthID: info.AuthId,
|
||||
Groups: info.Groups,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: allowSignup,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Login: &info.Login,
|
||||
Email: &info.Email,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,74 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/ldap"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/multildap"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLDAP_AuthenticateProxy(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
username string
|
||||
expectedLDAPErr error
|
||||
expectedLDAPInfo *models.ExternalUserInfo
|
||||
expectedErr error
|
||||
expectedIdentity *authn.Identity
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "should return valid identity when found by ldap service",
|
||||
username: "test",
|
||||
expectedLDAPInfo: &models.ExternalUserInfo{
|
||||
AuthModule: login.LDAPAuthModule,
|
||||
AuthId: "123",
|
||||
Email: "test@test.com",
|
||||
Login: "test",
|
||||
Name: "test test",
|
||||
Groups: []string{"1", "2"},
|
||||
OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
|
||||
},
|
||||
expectedIdentity: &authn.Identity{
|
||||
OrgID: 1,
|
||||
OrgRoles: map[int64]org.RoleType{1: org.RoleViewer},
|
||||
Login: "test",
|
||||
Name: "test test",
|
||||
Email: "test@test.com",
|
||||
AuthModule: login.LDAPAuthModule,
|
||||
AuthID: "123",
|
||||
Groups: []string{"1", "2"},
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
SyncTeamMembers: true,
|
||||
AllowSignUp: false,
|
||||
EnableDisabledUsers: true,
|
||||
LookUpParams: models.UserLookupParams{
|
||||
Email: strPtr("test@test.com"),
|
||||
Login: strPtr("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should return error when user is not found",
|
||||
username: "test",
|
||||
expectedLDAPErr: multildap.ErrDidNotFindUser,
|
||||
expectedErr: errIdentityNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
c := &LDAP{cfg: setting.NewCfg(), service: fakeLDAPService{ExpectedInfo: tt.expectedLDAPInfo, ExpectedErr: tt.expectedLDAPErr}}
|
||||
identity, err := c.AuthenticateProxy(context.Background(), &authn.Request{OrgID: 1}, tt.username, nil)
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
assert.EqualValues(t, tt.expectedIdentity, identity)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDAP_AuthenticatePassword(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
@ -20,7 +83,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) {
|
||||
password string
|
||||
expectedErr error
|
||||
expectedLDAPErr error
|
||||
expectedInfo *models.ExternalUserInfo
|
||||
expectedLDAPInfo *models.ExternalUserInfo
|
||||
expectedIdentity *authn.Identity
|
||||
}
|
||||
|
||||
@ -29,7 +92,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) {
|
||||
desc: "should successfully authenticate with correct username and password",
|
||||
username: "test",
|
||||
password: "test123",
|
||||
expectedInfo: &models.ExternalUserInfo{
|
||||
expectedLDAPInfo: &models.ExternalUserInfo{
|
||||
AuthModule: login.LDAPAuthModule,
|
||||
AuthId: "123",
|
||||
Email: "test@test.com",
|
||||
@ -77,7 +140,7 @@ func TestLDAP_AuthenticatePassword(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
c := &LDAP{cfg: setting.NewCfg(), service: fakeLDAPService{ExpectedInfo: tt.expectedInfo, ExpectedErr: tt.expectedLDAPErr}}
|
||||
c := &LDAP{cfg: setting.NewCfg(), service: fakeLDAPService{ExpectedInfo: tt.expectedLDAPInfo, ExpectedErr: tt.expectedLDAPErr}}
|
||||
|
||||
identity, err := c.AuthenticatePassword(context.Background(), &authn.Request{OrgID: 1}, tt.username, tt.password)
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
@ -100,3 +163,7 @@ type fakeLDAPService struct {
|
||||
func (f fakeLDAPService) Login(query *models.LoginUserQuery) (*models.ExternalUserInfo, error) {
|
||||
return f.ExpectedInfo, f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f fakeLDAPService) User(username string) (*models.ExternalUserInfo, error) {
|
||||
return f.ExpectedInfo, f.ExpectedErr
|
||||
}
|
||||
|
147
pkg/services/authn/clients/proxy.go
Normal file
147
pkg/services/authn/clients/proxy.go
Normal file
@ -0,0 +1,147 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyFieldName = "Name"
|
||||
proxyFieldEmail = "Email"
|
||||
proxyFieldLogin = "Login"
|
||||
proxyFieldRole = "Role"
|
||||
proxyFieldGroups = "Groups"
|
||||
)
|
||||
|
||||
var proxyFields = [...]string{proxyFieldName, proxyFieldEmail, proxyFieldLogin, proxyFieldRole, proxyFieldGroups}
|
||||
|
||||
var (
|
||||
errNotAcceptedIP = errutil.NewBase(errutil.StatusUnauthorized, "auth-proxy.invalid-ip")
|
||||
errEmptyProxyHeader = errutil.NewBase(errutil.StatusUnauthorized, "auth-proxy.empty-header")
|
||||
errInvalidProxyHeader = errutil.NewBase(errutil.StatusInternal, "auth-proxy.invalid-proxy-header")
|
||||
)
|
||||
|
||||
var _ authn.Client = new(Proxy)
|
||||
|
||||
func ProvideProxy(cfg *setting.Cfg, clients ...authn.ProxyClient) (*Proxy, error) {
|
||||
list, err := parseAcceptList(cfg.AuthProxyWhitelist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Proxy{cfg, clients, list}, nil
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
cfg *setting.Cfg
|
||||
clients []authn.ProxyClient
|
||||
acceptedIPs []*net.IPNet
|
||||
}
|
||||
|
||||
func (c *Proxy) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
|
||||
if !c.isAllowedIP(r) {
|
||||
return nil, errNotAcceptedIP.Errorf("request ip is not in the configured accept list")
|
||||
}
|
||||
|
||||
username := getProxyHeader(r, c.cfg.AuthProxyHeaderName, c.cfg.AuthProxyHeadersEncoded)
|
||||
if len(username) == 0 {
|
||||
return nil, errEmptyProxyHeader.Errorf("no username provided in auth proxy header")
|
||||
}
|
||||
|
||||
additional := getAdditionalProxyHeaders(r, c.cfg)
|
||||
|
||||
// FIXME: add cache to prevent sync on every request
|
||||
|
||||
var clientErr error
|
||||
for _, proxyClient := range c.clients {
|
||||
var identity *authn.Identity
|
||||
identity, clientErr = proxyClient.AuthenticateProxy(ctx, r, username, additional)
|
||||
if identity != nil {
|
||||
return identity, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, clientErr
|
||||
}
|
||||
|
||||
func (c *Proxy) Test(ctx context.Context, r *authn.Request) bool {
|
||||
return len(getProxyHeader(r, c.cfg.AuthProxyHeaderName, c.cfg.AuthProxyHeadersEncoded)) != 0
|
||||
}
|
||||
|
||||
func (c *Proxy) isAllowedIP(r *authn.Request) bool {
|
||||
if len(c.acceptedIPs) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(r.HTTPRequest.RemoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
for _, v := range c.acceptedIPs {
|
||||
if v.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func parseAcceptList(s string) ([]*net.IPNet, error) {
|
||||
if len(strings.TrimSpace(s)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
addresses := strings.Split(s, ",")
|
||||
list := make([]*net.IPNet, 0, len(addresses))
|
||||
for _, addr := range addresses {
|
||||
result, err := coerceProxyAddress(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, result)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// coerceProxyAddress gets network of the presented CIDR notation
|
||||
func coerceProxyAddress(proxyAddr string) (*net.IPNet, error) {
|
||||
proxyAddr = strings.TrimSpace(proxyAddr)
|
||||
if !strings.Contains(proxyAddr, "/") {
|
||||
proxyAddr = path.Join(proxyAddr, "32")
|
||||
}
|
||||
|
||||
_, network, err := net.ParseCIDR(proxyAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse the network: %w", err)
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func getProxyHeader(r *authn.Request, headerName string, encoded bool) string {
|
||||
if r.HTTPRequest == nil {
|
||||
return ""
|
||||
}
|
||||
v := r.HTTPRequest.Header.Get(headerName)
|
||||
if encoded {
|
||||
v = util.DecodeQuotedPrintable(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getAdditionalProxyHeaders(r *authn.Request, cfg *setting.Cfg) map[string]string {
|
||||
additional := make(map[string]string, len(proxyFields))
|
||||
for _, k := range proxyFields {
|
||||
if v := getProxyHeader(r, cfg.AuthProxyHeaders[k], cfg.AuthProxyHeadersEncoded); v != "" {
|
||||
additional[k] = v
|
||||
}
|
||||
}
|
||||
return additional
|
||||
}
|
172
pkg/services/authn/clients/proxy_test.go
Normal file
172
pkg/services/authn/clients/proxy_test.go
Normal file
@ -0,0 +1,172 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestProxy_Authenticate(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
req *authn.Request
|
||||
ips string
|
||||
proxyHeader string
|
||||
proxyHeaders map[string]string
|
||||
expectedErr error
|
||||
expectedUsername string
|
||||
expectedAdditional map[string]string
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "should authenticate using passed in proxy client",
|
||||
ips: "127.0.0.1",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{
|
||||
"X-Username": {"username"},
|
||||
"X-Name": {"name"},
|
||||
"X-Email": {"email"},
|
||||
"X-Login": {"login"},
|
||||
"X-Role": {"Viewer"},
|
||||
"X-Group": {"grp1,grp2"},
|
||||
},
|
||||
RemoteAddr: "127.0.0.1:333",
|
||||
},
|
||||
},
|
||||
proxyHeader: "X-Username",
|
||||
proxyHeaders: map[string]string{
|
||||
proxyFieldName: "X-Name",
|
||||
proxyFieldEmail: "X-Email",
|
||||
proxyFieldLogin: "X-Login",
|
||||
proxyFieldRole: "X-Role",
|
||||
proxyFieldGroups: "X-Group",
|
||||
},
|
||||
expectedUsername: "username",
|
||||
expectedAdditional: map[string]string{
|
||||
proxyFieldName: "name",
|
||||
proxyFieldEmail: "email",
|
||||
proxyFieldLogin: "login",
|
||||
proxyFieldRole: "Viewer",
|
||||
proxyFieldGroups: "grp1,grp2",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should fail when proxy header is empty",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{Header: map[string][]string{
|
||||
"X-Username": {""},
|
||||
"X-Name": {"name"},
|
||||
"X-Email": {"email"},
|
||||
"X-Login": {"login"},
|
||||
"X-Role": {"Viewer"},
|
||||
"X-Group": {"grp1,grp2"},
|
||||
}},
|
||||
},
|
||||
proxyHeader: "X-Username",
|
||||
proxyHeaders: map[string]string{
|
||||
proxyFieldName: "X-Name",
|
||||
proxyFieldEmail: "X-Email",
|
||||
proxyFieldLogin: "X-Login",
|
||||
proxyFieldRole: "X-Role",
|
||||
proxyFieldGroups: "X-Group",
|
||||
},
|
||||
expectedErr: errEmptyProxyHeader,
|
||||
},
|
||||
{
|
||||
desc: "should fail when caller ip is not in accept list",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{},
|
||||
RemoteAddr: "127.0.0.2:333",
|
||||
},
|
||||
},
|
||||
ips: "127.0.0.1",
|
||||
expectedErr: errNotAcceptedIP,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.AuthProxyHeaderName = "X-Username"
|
||||
cfg.AuthProxyHeaders = tt.proxyHeaders
|
||||
cfg.AuthProxyWhitelist = tt.ips
|
||||
|
||||
calledUsername := ""
|
||||
var calledAdditional map[string]string
|
||||
|
||||
proxyClient := authntest.MockProxyClient{AuthenticateProxyFunc: func(ctx context.Context, r *authn.Request, username string, additional map[string]string) (*authn.Identity, error) {
|
||||
calledUsername = username
|
||||
calledAdditional = additional
|
||||
return nil, nil
|
||||
}}
|
||||
c, err := ProvideProxy(cfg, proxyClient)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = c.Authenticate(context.Background(), tt.req)
|
||||
assert.ErrorIs(t, err, tt.expectedErr)
|
||||
assert.Equal(t, tt.expectedUsername, calledUsername)
|
||||
assert.EqualValues(t, tt.expectedAdditional, calledAdditional)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Test(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
req *authn.Request
|
||||
expectedOK bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "should return true when proxy header exists",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{"Proxy-Header": {"some value"}},
|
||||
},
|
||||
},
|
||||
expectedOK: true,
|
||||
},
|
||||
{
|
||||
desc: "should return false when proxy header exists but has no value",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{
|
||||
Header: map[string][]string{"Proxy-Header": {""}},
|
||||
},
|
||||
},
|
||||
expectedOK: false,
|
||||
},
|
||||
{
|
||||
desc: "should return false when no proxy header is set on request",
|
||||
req: &authn.Request{
|
||||
HTTPRequest: &http.Request{Header: map[string][]string{}},
|
||||
},
|
||||
expectedOK: false,
|
||||
},
|
||||
{
|
||||
desc: "should return false when no http request is present",
|
||||
req: &authn.Request{},
|
||||
expectedOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.AuthProxyHeaderName = "Proxy-Header"
|
||||
|
||||
c, _ := ProvideProxy(cfg, nil)
|
||||
assert.Equal(t, tt.expectedOK, c.Test(context.Background(), tt.req))
|
||||
})
|
||||
}
|
||||
}
|
@ -16,6 +16,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/auth/authtest"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||
"github.com/grafana/grafana/pkg/services/contexthandler/authproxy"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/login/loginservice"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
@ -106,7 +107,7 @@ func getContextHandler(t *testing.T) *ContextHandler {
|
||||
|
||||
return ProvideService(cfg, userAuthTokenSvc, authJWTSvc, remoteCacheSvc,
|
||||
renderSvc, sqlStore, tracer, authProxy, loginService, nil, authenticator,
|
||||
&userService, orgService, nil, nil, &authntest.FakeService{})
|
||||
&userService, orgService, nil, featuremgmt.WithFeatures(), &authntest.FakeService{})
|
||||
}
|
||||
|
||||
type fakeAuthenticator struct{}
|
||||
|
@ -714,6 +714,28 @@ func (h *ContextHandler) handleError(ctx *models.ReqContext, err error, statusCo
|
||||
}
|
||||
|
||||
func (h *ContextHandler) initContextWithAuthProxy(reqContext *models.ReqContext, orgID int64) bool {
|
||||
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
|
||||
identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(), authn.ClientProxy, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeErr(reqContext, err)
|
||||
}
|
||||
|
||||
ctx := WithAuthHTTPHeader(reqContext.Req.Context(), h.Cfg.AuthProxyHeaderName)
|
||||
for _, header := range h.Cfg.AuthProxyHeaders {
|
||||
if header != "" {
|
||||
ctx = WithAuthHTTPHeader(ctx, header)
|
||||
}
|
||||
}
|
||||
|
||||
*reqContext.Req = *reqContext.Req.WithContext(ctx)
|
||||
reqContext.IsSignedIn = true
|
||||
reqContext.SignedInUser = identity.SignedInUser()
|
||||
return true
|
||||
}
|
||||
username := reqContext.Req.Header.Get(h.Cfg.AuthProxyHeaderName)
|
||||
|
||||
logger := log.New("auth.proxy")
|
||||
|
Loading…
Reference in New Issue
Block a user