mirror of
https://github.com/grafana/grafana.git
synced 2024-11-25 10:20:29 -06:00
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:
parent
0d70eb18ac
commit
2324597d8d
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,7 @@ const (
|
||||
ClientJWT = "auth.client.jwt"
|
||||
ClientRender = "auth.client.render"
|
||||
ClientSession = "auth.client.session"
|
||||
ClientForm = "auth.client.form"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
@ -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"))
|
||||
)
|
||||
|
42
pkg/services/authn/clients/form.go
Normal file
42
pkg/services/authn/clients/form.go
Normal 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
|
||||
}
|
48
pkg/services/authn/clients/form_test.go
Normal file
48
pkg/services/authn/clients/form_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
|
67
pkg/services/authn/clients/password.go
Normal file
67
pkg/services/authn/clients/password.go
Normal 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)
|
||||
}
|
82
pkg/services/authn/clients/password_test.go
Normal file
82
pkg/services/authn/clients/password_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user