diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 5537e7e8998..ce6e9f20a47 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -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 diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index ae93c60aa65..63f76c9e80b 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -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) } diff --git a/pkg/services/authn/authntest/mock.go b/pkg/services/authn/authntest/mock.go index a3e35ec6770..7bb7f1c92df 100644 --- a/pkg/services/authn/authntest/mock.go +++ b/pkg/services/authn/authntest/mock.go @@ -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 +} diff --git a/pkg/services/authn/clients/grafana.go b/pkg/services/authn/clients/grafana.go index a14be60f163..ca830973627 100644 --- a/pkg/services/authn/clients/grafana.go +++ b/pkg/services/authn/clients/grafana.go @@ -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) { diff --git a/pkg/services/authn/clients/grafana_test.go b/pkg/services/authn/clients/grafana_test.go index 4d17bfa8795..918888f5a23 100644 --- a/pkg/services/authn/clients/grafana_test.go +++ b/pkg/services/authn/clients/grafana_test.go @@ -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) diff --git a/pkg/services/authn/clients/ldap.go b/pkg/services/authn/clients/ldap.go index 0a4a9bfbf8a..0e8f14a5be0 100644 --- a/pkg/services/authn/clients/ldap.go +++ b/pkg/services/authn/clients/ldap.go @@ -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, + }, + }, + } +} diff --git a/pkg/services/authn/clients/ldap_test.go b/pkg/services/authn/clients/ldap_test.go index 91971225519..e59516f2cf1 100644 --- a/pkg/services/authn/clients/ldap_test.go +++ b/pkg/services/authn/clients/ldap_test.go @@ -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 +} diff --git a/pkg/services/authn/clients/proxy.go b/pkg/services/authn/clients/proxy.go new file mode 100644 index 00000000000..1f8a7d0ad69 --- /dev/null +++ b/pkg/services/authn/clients/proxy.go @@ -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 +} diff --git a/pkg/services/authn/clients/proxy_test.go b/pkg/services/authn/clients/proxy_test.go new file mode 100644 index 00000000000..62a72e32ff8 --- /dev/null +++ b/pkg/services/authn/clients/proxy_test.go @@ -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)) + }) + } +} diff --git a/pkg/services/contexthandler/auth_proxy_test.go b/pkg/services/contexthandler/auth_proxy_test.go index 72981b2e32b..38b9f3e27dd 100644 --- a/pkg/services/contexthandler/auth_proxy_test.go +++ b/pkg/services/contexthandler/auth_proxy_test.go @@ -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{} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index aad500e1451..4888c6cee86 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -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")