AuthN: Perform login with authn.Service (#61466)

* AuthN: Create password client wrapper and use that on in basic auth
client

* AuthN: fix basic auth client test

* AuthN: Add tests for form authentication

* API: Inject authn service

* Login: If authnService feature flag is enabled use authn login

* Login: Handle token creation errors
This commit is contained in:
Karl Persson 2023-01-17 09:11:45 +01:00 committed by GitHub
parent 0d70eb18ac
commit 2324597d8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 306 additions and 103 deletions

View File

@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware/csrf"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/querylibrary"
@ -211,6 +212,7 @@ type HTTPServer struct {
tagService tag.Service
oauthTokenService oauthtoken.OAuthTokenService
statsService stats.Service
authnService authn.Service
}
type ServerOptions struct {
@ -253,7 +255,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
accesscontrolService accesscontrol.Service, dashboardThumbsService thumbs.DashboardThumbService, navTreeService navtree.Service,
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService,
queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService,
statsService stats.Service,
statsService stats.Service, authnService authn.Service,
k8saccess k8saccess.K8SAccess, // required so that the router is registered
) (*HTTPServer, error) {
web.Env = cfg.Env
@ -360,6 +362,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
QueryLibraryService: queryLibraryService,
oauthTokenService: oauthTokenService,
statsService: statsService,
authnService: authnService,
}
if hs.Listener != nil {
hs.log.Debug("Using provided listener")

View File

@ -17,6 +17,8 @@ import (
"github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
loginService "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/user"
@ -167,6 +169,34 @@ func (hs *HTTPServer) LoginAPIPing(c *models.ReqContext) response.Response {
}
func (hs *HTTPServer) LoginPost(c *models.ReqContext) response.Response {
if hs.Features.IsEnabled(featuremgmt.FlagAuthnService) {
identity, err := hs.authnService.Login(c.Req.Context(), authn.ClientForm, &authn.Request{HTTPRequest: c.Req, Resp: c.Resp})
if err != nil {
tokenErr := &auth.CreateTokenErr{}
if errors.As(err, &tokenErr) {
return response.Error(tokenErr.StatusCode, tokenErr.ExternalErr, tokenErr.InternalErr)
}
return response.Err(err)
}
cookies.WriteSessionCookie(c, hs.Cfg, identity.SessionToken.UnhashedToken, hs.Cfg.LoginMaxLifetime)
result := map[string]interface{}{
"message": "Logged in",
}
if redirectTo := c.GetCookie("redirect_to"); len(redirectTo) > 0 {
if err := hs.ValidateRedirectTo(redirectTo); err == nil {
result["redirectUrl"] = redirectTo
} else {
c.Logger.Info("Ignored invalid redirect_to cookie value.", "url", redirectTo)
}
cookies.DeleteCookie(c.Resp, "redirect_to", hs.CookieOptionsFromCfg)
}
metrics.MApiLoginPost.Inc()
return response.JSON(http.StatusOK, result)
}
cmd := dtos.LoginCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad login data", err)

View File

@ -12,9 +12,6 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
@ -24,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
loginservice "github.com/grafana/grafana/pkg/services/login"
@ -33,6 +31,8 @@ import (
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func fakeSetIndexViewData(t *testing.T) {
@ -324,6 +324,7 @@ func TestLoginPostRedirect(t *testing.T) {
HooksService: &hooks.HooksService{},
License: &licensing.OSSLicensingService{},
AuthTokenService: authtest.NewFakeUserAuthTokenService(),
Features: featuremgmt.WithFeatures(),
}
hs.Cfg.CookieSecure = true
@ -603,6 +604,7 @@ func TestLoginPostRunLokingHook(t *testing.T) {
Cfg: setting.NewCfg(),
License: &licensing.OSSLicensingService{},
AuthTokenService: authtest.NewFakeUserAuthTokenService(),
Features: featuremgmt.WithFeatures(),
HooksService: hookService,
}

View File

@ -24,6 +24,7 @@ const (
ClientJWT = "auth.client.jwt"
ClientRender = "auth.client.render"
ClientSession = "auth.client.session"
ClientForm = "auth.client.form"
)
const (

View File

@ -65,18 +65,23 @@ func ProvideService(
}
var passwordClients []authn.PasswordClient
if !s.cfg.DisableLogin {
passwordClients = append(passwordClients, clients.ProvideGrafana(userService))
}
if s.cfg.LDAPEnabled {
passwordClients = append(passwordClients, clients.ProvideLDAP(cfg))
}
// only configure basic auth client if it is enabled, and we have at least one password client enabled
if s.cfg.BasicAuthEnabled && len(passwordClients) > 0 {
s.clients[authn.ClientBasic] = clients.ProvideBasic(loginAttempts, passwordClients...)
// if we have password clients configure check if basic auth or form auth is enabled
if len(passwordClients) > 0 {
passwordClient := clients.ProvidePassword(loginAttempts, passwordClients...)
if s.cfg.BasicAuthEnabled {
s.clients[authn.ClientBasic] = clients.ProvideBasic(passwordClient)
}
// FIXME (kalleep): Remove the global variable and stick it into cfg
if !setting.DisableLoginForm {
s.clients[authn.ClientForm] = clients.ProvideForm(passwordClient)
}
}
if s.cfg.JWTAuthEnabled {
@ -128,6 +133,8 @@ func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Requ
return nil, true, err
}
// FIXME (kalleep): Handle disabled identities
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)

View File

@ -2,30 +2,25 @@ package clients
import (
"context"
"errors"
"strings"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/loginattempt"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/web"
)
var (
errDecodingBasicAuthHeader = errutil.NewBase(errutil.StatusBadRequest, "basic-auth.invalid-header", errutil.WithPublicMessage("Invalid Basic Auth Header"))
errBasicAuthCredentials = errutil.NewBase(errutil.StatusUnauthorized, "basic-auth.invalid-credentials", errutil.WithPublicMessage("Invalid username or password"))
)
var _ authn.Client = new(Basic)
func ProvideBasic(loginAttempts loginattempt.Service, clients ...authn.PasswordClient) *Basic {
return &Basic{clients, loginAttempts}
func ProvideBasic(client authn.PasswordClient) *Basic {
return &Basic{client}
}
type Basic struct {
clients []authn.PasswordClient
loginAttempts loginattempt.Service
client authn.PasswordClient
}
func (c *Basic) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
@ -34,44 +29,10 @@ func (c *Basic) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden
return nil, errDecodingBasicAuthHeader.Errorf("failed to decode basic auth header: %w", err)
}
r.SetMeta(authn.MetaKeyUsername, username)
ok, err := c.loginAttempts.Validate(ctx, username)
if err != nil {
return nil, err
}
if !ok {
return nil, errBasicAuthCredentials.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
}
if len(password) == 0 {
return nil, errBasicAuthCredentials.Errorf("no password provided")
}
for _, pwClient := range c.clients {
identity, err := pwClient.AuthenticatePassword(ctx, r, username, password)
if err != nil {
if errors.Is(err, errIdentityNotFound) {
// continue to next password client if identity could not be found
continue
}
if errors.Is(err, errInvalidPassword) {
// only add login attempt if identity was found but the provided password was invalid
_ = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
}
return nil, errBasicAuthCredentials.Errorf("failed to authenticate identity: %w", err)
}
return identity, nil
}
return nil, errBasicAuthCredentials.Errorf("failed to authenticate identity using basic auth")
return c.client.AuthenticatePassword(ctx, r, username, password)
}
func (c *Basic) Test(ctx context.Context, r *authn.Request) bool {
if len(c.clients) == 0 {
return false
}
return looksLikeBasicAuthRequest(r)
}

View File

@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest"
"github.com/stretchr/testify/assert"
)
@ -15,8 +14,7 @@ func TestBasic_Authenticate(t *testing.T) {
type TestCase struct {
desc string
req *authn.Request
blockLogin bool
clients []authn.PasswordClient
client authn.PasswordClient
expectedErr error
expectedIdentity *authn.Identity
}
@ -25,40 +23,19 @@ func TestBasic_Authenticate(t *testing.T) {
{
desc: "should success when password client return identity",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}}},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "user:1"}}},
client: authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "user:1"}},
expectedIdentity: &authn.Identity{ID: "user:1"},
},
{
desc: "should success when found in second client",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}}},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "user:2"}}},
expectedIdentity: &authn.Identity{ID: "user:2"},
},
{
desc: "should fail for empty password",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "")}}}},
expectedErr: errBasicAuthCredentials,
},
{
desc: "should if login is blocked by to many attempts",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "")}}}},
blockLogin: true,
expectedErr: errBasicAuthCredentials,
},
{
desc: "should fail when not found in any clients",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}}},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}},
expectedErr: errBasicAuthCredentials,
desc: "should fail when basic auth header could not be decoded",
req: &authn.Request{HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {}}}},
expectedErr: errDecodingBasicAuthHeader,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideBasic(
loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin},
tt.clients...,
)
c := ProvideBasic(tt.client)
identity, err := c.Authenticate(context.Background(), tt.req)
if tt.expectedErr != nil {
@ -74,10 +51,9 @@ func TestBasic_Authenticate(t *testing.T) {
func TestBasic_Test(t *testing.T) {
type TestCase struct {
desc string
req *authn.Request
noClients bool
expected bool
desc string
req *authn.Request
expected bool
}
tests := []TestCase{
@ -92,18 +68,6 @@ func TestBasic_Test(t *testing.T) {
},
expected: true,
},
{
desc: "should fail when no password client is configured",
req: &authn.Request{
HTTPRequest: &http.Request{
Header: map[string][]string{
authorizationHeaderName: {encodeBasicAuth("user", "password")},
},
},
},
noClients: true,
expected: false,
},
{
desc: "should fail when no http request is passed",
req: &authn.Request{},
@ -124,10 +88,7 @@ func TestBasic_Test(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideBasic(loginattempttest.FakeLoginAttemptService{}, authntest.FakePasswordClient{})
if tt.noClients {
c.clients = nil
}
c := ProvideBasic(authntest.FakePasswordClient{})
assert.Equal(t, tt.expected, c.Test(context.Background(), tt.req))
})
}

View File

@ -10,5 +10,4 @@ const (
var (
errIdentityNotFound = errutil.NewBase(errutil.StatusNotFound, "identity.not-found")
errInvalidPassword = errutil.NewBase(errutil.StatusBadRequest, "identity.invalid-password", errutil.WithPublicMessage("Invalid password or username"))
)

View File

@ -0,0 +1,42 @@
package clients
import (
"context"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/web"
)
var (
errBadForm = errutil.NewBase(errutil.StatusBadRequest, "form-auth.invalid", errutil.WithPublicMessage("bad login data"))
)
var _ authn.Client = new(Form)
func ProvideForm(client authn.PasswordClient) *Form {
return &Form{client}
}
type Form struct {
client authn.PasswordClient
}
type loginForm struct {
Username string `json:"user" binding:"Required"`
Password string `json:"password" binding:"Required"`
}
func (f *Form) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
form := loginForm{}
if err := web.Bind(r.HTTPRequest, &form); err != nil {
return nil, errBadForm.Errorf("failed to parse request: %w", err)
}
return f.client.AuthenticatePassword(ctx, r, form.Username, form.Password)
}
func (f *Form) Test(ctx context.Context, r *authn.Request) bool {
// FIXME: How should we detect this??
// Maybe create client test interface and not all clients has to implement this??
return true
}

View File

@ -0,0 +1,48 @@
package clients
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
)
func TestForm_Authenticate(t *testing.T) {
type testCase struct {
desc string
req *authn.Request
expectedErr error
}
tests := []testCase{
{
desc: "should success on valid request",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"user": "test", "password": "test"}`)),
}},
},
{
desc: "should return error for bad request",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(`{}`)),
}},
expectedErr: errBadForm,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvideForm(&authntest.FakePasswordClient{})
_, err := c.Authenticate(context.Background(), tt.req)
assert.ErrorIs(t, err, tt.expectedErr)
})
}
}

View File

@ -28,6 +28,7 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern
})
if errors.Is(err, multildap.ErrCouldNotFindUser) {
// FIXME: disable user in grafana if not found
return nil, errIdentityNotFound.Errorf("no user found: %w", err)
}
@ -35,7 +36,6 @@ func (c *LDAP) AuthenticatePassword(ctx context.Context, r *authn.Request, usern
r.SetMeta(authn.MetaKeyAuthModule, "ldap")
if errors.Is(err, multildap.ErrInvalidCredentials) {
// FIXME: disable user in grafana if not found
return nil, errInvalidPassword.Errorf("invalid password: %w", err)
}

View File

@ -0,0 +1,67 @@
package clients
import (
"context"
"errors"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/loginattempt"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/web"
)
var (
errEmptyPassword = errutil.NewBase(errutil.StatusBadRequest, "password-auth.empty", errutil.WithPublicMessage("Invalid username or password"))
errPasswordAuthFailed = errutil.NewBase(errutil.StatusBadRequest, "password-auth.failed", errutil.WithPublicMessage("Invalid username or password"))
errInvalidPassword = errutil.NewBase(errutil.StatusBadRequest, "password-auth.invalid", errutil.WithPublicMessage("Invalid password or username"))
errLoginAttemptBlocked = errutil.NewBase(errutil.StatusUnauthorized, "login-attempt.blocked", errutil.WithPublicMessage("Invalid username or password"))
)
var _ authn.PasswordClient = new(Password)
func ProvidePassword(loginAttempts loginattempt.Service, clients ...authn.PasswordClient) *Password {
return &Password{loginAttempts, clients}
}
type Password struct {
loginAttempts loginattempt.Service
clients []authn.PasswordClient
}
func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, username, password string) (*authn.Identity, error) {
r.SetMeta(authn.MetaKeyUsername, username)
ok, err := c.loginAttempts.Validate(ctx, username)
if err != nil {
return nil, err
}
if !ok {
return nil, errLoginAttemptBlocked.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked")
}
if len(password) == 0 {
return nil, errEmptyPassword.Errorf("no password provided")
}
var clientErr error
for _, pwClient := range c.clients {
var identity *authn.Identity
identity, clientErr = pwClient.AuthenticatePassword(ctx, r, username, password)
// for invalid password or if the identity is not found by a client continue to next one
if errors.Is(clientErr, errInvalidPassword) || errors.Is(clientErr, errIdentityNotFound) {
continue
}
if clientErr != nil {
return nil, errPasswordAuthFailed.Errorf("failed to authenticate identity: %w", clientErr)
}
return identity, nil
}
if errors.Is(clientErr, errInvalidPassword) {
_ = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest))
}
return nil, errPasswordAuthFailed.Errorf("failed to authenticate identity: %w", clientErr)
}

View File

@ -0,0 +1,82 @@
package clients
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
)
func TestPassword_AuthenticatePassword(t *testing.T) {
type TestCase struct {
desc string
username string
password string
req *authn.Request
blockLogin bool
clients []authn.PasswordClient
expectedErr error
expectedIdentity *authn.Identity
}
tests := []TestCase{
{
desc: "should success when password client return identity",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "user:1"}}},
expectedIdentity: &authn.Identity{ID: "user:1"},
},
{
desc: "should success when found in second client",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "user:2"}}},
expectedIdentity: &authn.Identity{ID: "user:2"},
},
{
desc: "should fail for empty password",
username: "test",
password: "",
req: &authn.Request{},
expectedErr: errEmptyPassword,
},
{
desc: "should if login is blocked by to many attempts",
username: "test",
password: "test",
req: &authn.Request{},
blockLogin: true,
expectedErr: errLoginAttemptBlocked,
},
{
desc: "should fail when not found in any clients",
username: "test",
password: "test",
req: &authn.Request{},
clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}},
expectedErr: errPasswordAuthFailed,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...)
identity, err := c.AuthenticatePassword(context.Background(), tt.req, tt.username, tt.password)
if tt.expectedErr != nil {
assert.ErrorIs(t, err, tt.expectedErr)
assert.Nil(t, identity)
} else {
assert.NoError(t, err)
assert.EqualValues(t, *tt.expectedIdentity, *identity)
}
})
}
}