AuthN: Add session client (#60894)

* add basic session client

* populate UserToken in ReqContext

* token rotation as a post auth hook

* fixed in context handler

* add session token rotation

* add session token tests

* use namespacedID constructor
This commit is contained in:
Jo 2023-01-04 15:10:43 +00:00 committed by GitHub
parent ebb34560a4
commit a226903ec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 380 additions and 12 deletions

View File

@ -9,13 +9,16 @@ import (
"time" "time"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
const ( const (
ClientAPIKey = "auth.client.api-key" // #nosec G101 ClientAPIKey = "auth.client.api-key" // #nosec G101
ClientSession = "auth.client.session"
ClientAnonymous = "auth.client.anonymous" ClientAnonymous = "auth.client.anonymous"
ClientBasic = "auth.client.basic" ClientBasic = "auth.client.basic"
ClientRender = "auth.client.render" ClientRender = "auth.client.render"
@ -27,7 +30,8 @@ type ClientParams struct {
EnableDisabledUsers bool EnableDisabledUsers bool
} }
type PostAuthHookFn func(ctx context.Context, clientParams *ClientParams, identity *Identity) error type PostAuthHookFn func(ctx context.Context,
clientParams *ClientParams, identity *Identity, r *Request) error
type Service interface { type Service interface {
// RegisterPostAuthHook registers a hook that is called after a successful authentication. // RegisterPostAuthHook registers a hook that is called after a successful authentication.
@ -48,6 +52,9 @@ type Request struct {
// OrgID will be populated by authn.Service // OrgID will be populated by authn.Service
OrgID int64 OrgID int64
HTTPRequest *http.Request HTTPRequest *http.Request
// for use in post auth hooks
Resp web.ResponseWriter
} }
const ( const (
@ -69,12 +76,14 @@ type Identity struct {
IsGrafanaAdmin *bool IsGrafanaAdmin *bool
AuthModule string // AuthModule is the name of the external system AuthModule string // AuthModule is the name of the external system
AuthID string // AuthId is the unique identifier for the user in the external system AuthID string // AuthId is the unique identifier for the user in the external system
OAuthToken *oauth2.Token
LookUpParams models.UserLookupParams LookUpParams models.UserLookupParams
IsDisabled bool IsDisabled bool
HelpFlags1 user.HelpFlags1 HelpFlags1 user.HelpFlags1
LastSeenAt time.Time LastSeenAt time.Time
Teams []int64 Teams []int64
OAuthToken *oauth2.Token
SessionToken *auth.UserToken
} }
func (i *Identity) Role() org.RoleType { func (i *Identity) Role() org.RoleType {

View File

@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
sync "github.com/grafana/grafana/pkg/services/authn/authnimpl/usersync" sync "github.com/grafana/grafana/pkg/services/authn/authnimpl/usersync"
"github.com/grafana/grafana/pkg/services/authn/clients" "github.com/grafana/grafana/pkg/services/authn/clients"
@ -26,8 +27,11 @@ import (
var _ authn.Service = new(Service) var _ authn.Service = new(Service)
func ProvideService( func ProvideService(
cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, accessControlService accesscontrol.Service, cfg *setting.Cfg, tracer tracing.Tracer,
apikeyService apikey.Service, userService user.Service, loginAttempts loginattempt.Service, quotaService quota.Service, orgService org.Service, sessionService auth.UserTokenService,
accessControlService accesscontrol.Service,
apikeyService apikey.Service, userService user.Service,
loginAttempts loginattempt.Service, quotaService quota.Service,
authInfoService login.AuthInfoService, renderService rendering.Service, authInfoService login.AuthInfoService, renderService rendering.Service,
) *Service { ) *Service {
s := &Service{ s := &Service{
@ -41,6 +45,10 @@ func ProvideService(
s.clients[authn.ClientRender] = clients.ProvideRender(userService, renderService) s.clients[authn.ClientRender] = clients.ProvideRender(userService, renderService)
s.clients[authn.ClientAPIKey] = clients.ProvideAPIKey(apikeyService, userService) s.clients[authn.ClientAPIKey] = clients.ProvideAPIKey(apikeyService, userService)
sessionClient := clients.ProvideSession(sessionService, userService, cfg.LoginCookieName, cfg.LoginMaxLifetime)
s.clients[authn.ClientSession] = sessionClient
s.RegisterPostAuthHook(sessionClient.RefreshTokenHook)
if s.cfg.AnonymousEnabled { if s.cfg.AnonymousEnabled {
s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService) s.clients[authn.ClientAnonymous] = clients.ProvideAnonymous(cfg, orgService)
} }
@ -107,7 +115,7 @@ func (s *Service) Authenticate(ctx context.Context, client string, r *authn.Requ
params := c.ClientParams() params := c.ClientParams()
for _, hook := range s.postAuthHooks { for _, hook := range s.postAuthHooks {
if err := hook(ctx, params, identity); err != nil { if err := hook(ctx, params, identity, r); err != nil {
return nil, false, err return nil, false, err
} }
} }

View File

@ -27,7 +27,8 @@ type OrgSync struct {
log log.Logger log log.Logger
} }
func (s *OrgSync) SyncOrgUser(ctx context.Context, clientParams *authn.ClientParams, id *authn.Identity) error { func (s *OrgSync) SyncOrgUser(ctx context.Context,
clientParams *authn.ClientParams, id *authn.Identity, _ *authn.Request) error {
if !clientParams.SyncUser { if !clientParams.SyncUser {
s.log.Debug("Not syncing org user", "auth_module", id.AuthModule, "auth_id", id.AuthID) s.log.Debug("Not syncing org user", "auth_module", id.AuthModule, "auth_id", id.AuthID)
return nil return nil

View File

@ -113,7 +113,7 @@ func TestOrgSync_SyncOrgUser(t *testing.T) {
accessControl: tt.fields.accessControl, accessControl: tt.fields.accessControl,
log: tt.fields.log, log: tt.fields.log,
} }
if err := s.SyncOrgUser(tt.args.ctx, tt.args.clientParams, tt.args.id); (err != nil) != tt.wantErr { if err := s.SyncOrgUser(tt.args.ctx, tt.args.clientParams, tt.args.id, nil); (err != nil) != tt.wantErr {
t.Errorf("OrgSync.SyncOrgUser() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("OrgSync.SyncOrgUser() error = %v, wantErr %v", err, tt.wantErr)
} }

View File

@ -25,7 +25,8 @@ type UserSync struct {
} }
// SyncUser syncs a user with the database // SyncUser syncs a user with the database
func (s *UserSync) SyncUser(ctx context.Context, clientParams *authn.ClientParams, id *authn.Identity) error { func (s *UserSync) SyncUser(ctx context.Context,
clientParams *authn.ClientParams, id *authn.Identity, _ *authn.Request) error {
if !clientParams.SyncUser { if !clientParams.SyncUser {
s.log.Debug("Not syncing user", "auth_module", id.AuthModule, "auth_id", id.AuthID) s.log.Debug("Not syncing user", "auth_module", id.AuthModule, "auth_id", id.AuthID)
return nil return nil

View File

@ -430,7 +430,7 @@ func TestUserSync_SyncUser(t *testing.T) {
quotaService: tt.fields.quotaService, quotaService: tt.fields.quotaService,
log: tt.fields.log, log: tt.fields.log,
} }
err := s.SyncUser(tt.args.ctx, tt.args.clientParams, tt.args.id) err := s.SyncUser(tt.args.ctx, tt.args.clientParams, tt.args.id, nil)
if tt.wantErr { if tt.wantErr {
require.Error(t, err) require.Error(t, err)
return return

View File

@ -0,0 +1,129 @@
package clients
import (
"context"
"errors"
"net/url"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/network"
"github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
)
var _ authn.Client = new(Session)
func ProvideSession(sessionService auth.UserTokenService, userService user.Service,
cookieName string, maxLifetime time.Duration) *Session {
return &Session{
loginCookieName: cookieName,
loginMaxLifetime: maxLifetime,
sessionService: sessionService,
userService: userService,
log: log.New(authn.ClientSession),
}
}
type Session struct {
loginCookieName string
loginMaxLifetime time.Duration // jguer: should be returned by session Service on rotate
sessionService auth.UserTokenService
userService user.Service
log log.Logger
}
func (s *Session) ClientParams() *authn.ClientParams {
return &authn.ClientParams{
SyncUser: false,
AllowSignUp: false,
EnableDisabledUsers: false,
}
}
func (s *Session) Test(ctx context.Context, r *authn.Request) bool {
if s.loginCookieName == "" {
return false
}
if _, err := r.HTTPRequest.Cookie(s.loginCookieName); err != nil {
return false
}
return true
}
func (s *Session) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
unescapedCookie, err := r.HTTPRequest.Cookie(s.loginCookieName)
if err != nil {
return nil, err
}
rawSessionToken, err := url.QueryUnescape(unescapedCookie.Value)
if err != nil {
return nil, err
}
token, err := s.sessionService.LookupToken(ctx, rawSessionToken)
if err != nil {
s.log.Warn("failed to look up session from cookie", "error", err)
return nil, err
}
signedInUser, err := s.userService.GetSignedInUserWithCacheCtx(ctx,
&user.GetSignedInUserQuery{UserID: token.UserId, OrgID: r.OrgID})
if err != nil {
s.log.Error("failed to get user with id", "userId", token.UserId, "error", err)
return nil, err
}
// FIXME (jguer): oauth token refresh not implemented
identity := authn.IdentityFromSignedInUser(authn.NamespacedID(authn.NamespaceUser, signedInUser.UserID), signedInUser)
identity.SessionToken = token
return identity, nil
}
func (s *Session) RefreshTokenHook(ctx context.Context,
clientParams *authn.ClientParams, identity *authn.Identity, r *authn.Request) error {
if identity.SessionToken == nil {
return nil
}
r.Resp.Before(func(w web.ResponseWriter) {
if w.Written() || errors.Is(ctx.Err(), context.Canceled) {
return
}
// FIXME (jguer): get real values
addr := web.RemoteAddr(r.HTTPRequest)
userAgent := r.HTTPRequest.UserAgent()
// addr := reqContext.RemoteAddr()
ip, err := network.GetIPFromAddress(addr)
if err != nil {
s.log.Debug("failed to get client IP address", "addr", addr, "err", err)
ip = nil
}
rotated, err := s.sessionService.TryRotateToken(ctx, identity.SessionToken, ip, userAgent)
if err != nil {
s.log.Error("failed to rotate token", "error", err)
return
}
if rotated {
s.log.Debug("rotated session token", "user", identity.ID)
maxAge := int(s.loginMaxLifetime.Seconds())
if s.loginMaxLifetime <= 0 {
maxAge = -1
}
cookies.WriteCookie(r.Resp, s.loginCookieName, url.QueryEscape(identity.SessionToken.UnhashedToken), maxAge, nil)
}
})
return nil
}

View File

@ -0,0 +1,191 @@
package clients
import (
"context"
"net"
"net/http"
"testing"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/web"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSession_ClientParams(t *testing.T) {
s := ProvideSession(nil, nil, "", 0)
require.Equal(t, &authn.ClientParams{
SyncUser: false,
AllowSignUp: false,
EnableDisabledUsers: false,
}, s.ClientParams())
}
func TestSession_Test(t *testing.T) {
cookieName := "grafana_session"
validHTTPReq := &http.Request{
Header: map[string][]string{},
}
validHTTPReq.AddCookie(&http.Cookie{Name: cookieName, Value: "bob-the-high-entropy-token"})
s := ProvideSession(&authtest.FakeUserAuthTokenService{}, &usertest.FakeUserService{}, "", 20*time.Second)
disabled := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
assert.False(t, disabled)
s.loginCookieName = cookieName
good := s.Test(context.Background(), &authn.Request{HTTPRequest: validHTTPReq})
assert.True(t, good)
invalidHTTPReq := &http.Request{Header: map[string][]string{}}
bad := s.Test(context.Background(), &authn.Request{HTTPRequest: invalidHTTPReq})
assert.False(t, bad)
}
func TestSession_Authenticate(t *testing.T) {
cookieName := "grafana_session"
validHTTPReq := &http.Request{
Header: map[string][]string{},
}
validHTTPReq.AddCookie(&http.Cookie{Name: cookieName, Value: "bob-the-high-entropy-token"})
sampleToken := &usertoken.UserToken{
Id: 1,
UserId: 1,
AuthToken: "hashyToken",
PrevAuthToken: "prevHashyToken",
AuthTokenSeen: true,
}
sampleUser := &user.SignedInUser{
UserID: 1,
Name: "sample user",
Login: "sample_user",
Email: "sample_user@samples.iwz",
OrgID: 1,
OrgRole: roletype.RoleEditor,
}
type fields struct {
sessionService auth.UserTokenService
userService user.Service
}
type args struct {
r *authn.Request
}
tests := []struct {
name string
fields fields
args args
wantID *authn.Identity
wantErr bool
}{
{
name: "cookie not found",
fields: fields{sessionService: &authtest.FakeUserAuthTokenService{}, userService: &usertest.FakeUserService{}},
args: args{r: &authn.Request{HTTPRequest: &http.Request{}}},
wantID: nil,
wantErr: true,
},
{
name: "success",
fields: fields{sessionService: &authtest.FakeUserAuthTokenService{LookupTokenProvider: func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
return sampleToken, nil
}}, userService: &usertest.FakeUserService{ExpectedSignedInUser: sampleUser}},
args: args{r: &authn.Request{HTTPRequest: validHTTPReq}},
wantID: &authn.Identity{
SessionToken: sampleToken,
ID: "user:1",
Name: "sample user",
Login: "sample_user",
Email: "sample_user@samples.iwz",
OrgID: 1,
OrgRoles: map[int64]roletype.RoleType{1: roletype.RoleEditor},
LookUpParams: models.UserLookupParams{},
IsGrafanaAdmin: boolPtr(false),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := ProvideSession(tt.fields.sessionService, tt.fields.userService, cookieName, 20*time.Second)
got, err := s.Authenticate(context.Background(), tt.args.r)
require.True(t, (err != nil) == tt.wantErr, err)
if err != nil {
return
}
require.EqualValues(t, tt.wantID, got)
})
}
}
type fakeResponseWriter struct {
Status int
HeaderStore http.Header
}
func (f *fakeResponseWriter) Header() http.Header {
return f.HeaderStore
}
func (f *fakeResponseWriter) Write([]byte) (int, error) {
return 0, nil
}
func (f *fakeResponseWriter) WriteHeader(statusCode int) {
f.Status = statusCode
}
func TestSession_RefreshHook(t *testing.T) {
s := ProvideSession(&authtest.FakeUserAuthTokenService{
TryRotateTokenProvider: func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, error) {
token.UnhashedToken = "new-token"
return true, nil
},
}, &usertest.FakeUserService{}, "grafana-session", 20*time.Second)
sampleID := &authn.Identity{
SessionToken: &auth.UserToken{
Id: 1,
UserId: 1,
},
}
mockResponseWriter := &fakeResponseWriter{
Status: 0,
HeaderStore: map[string][]string{},
}
resp := &authn.Request{
HTTPRequest: &http.Request{
Header: map[string][]string{},
},
Resp: web.NewResponseWriter(http.MethodConnect, mockResponseWriter),
}
err := s.RefreshTokenHook(context.Background(), &authn.ClientParams{}, sampleID, resp)
require.NoError(t, err)
resp.Resp.WriteHeader(201)
require.Equal(t, 201, mockResponseWriter.Status)
assert.Equal(t, "new-token", sampleID.SessionToken.UnhashedToken)
require.Len(t, mockResponseWriter.HeaderStore, 1)
assert.Equal(t, "grafana-session=new-token; Path=/; Max-Age=20; HttpOnly",
mockResponseWriter.HeaderStore.Get("set-cookie"), mockResponseWriter.HeaderStore)
}

View File

@ -476,6 +476,29 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext,
} }
func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, orgID int64) bool { func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, orgID int64) bool {
if h.features.IsEnabled(featuremgmt.FlagAuthnService) {
identity, ok, err := h.authnService.Authenticate(reqContext.Req.Context(),
authn.ClientSession, &authn.Request{HTTPRequest: reqContext.Req, Resp: reqContext.Resp})
if !ok {
return false
}
if err != nil {
if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) {
// Burn the cookie in case of invalid, expired or missing token
reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext))
}
writeErr(reqContext, err)
return true
}
reqContext.IsSignedIn = true
reqContext.SignedInUser = identity.SignedInUser()
reqContext.UserToken = identity.SessionToken
return true
}
if h.Cfg.LoginCookieName == "" { if h.Cfg.LoginCookieName == "" {
return false return false
} }
@ -586,6 +609,8 @@ func (h *ContextHandler) rotateEndOfRequestFunc(reqContext *models.ReqContext) w
reqContext.Logger.Debug("Failed to get client IP address", "addr", addr, "err", err) reqContext.Logger.Debug("Failed to get client IP address", "addr", addr, "err", err)
ip = nil ip = nil
} }
// FIXME (jguer): rotation should return a new token instead of modifying the existing one.
rotated, err := h.AuthTokenService.TryRotateToken(ctx, reqContext.UserToken, ip, reqContext.Req.UserAgent()) rotated, err := h.AuthTokenService.TryRotateToken(ctx, reqContext.UserToken, ip, reqContext.Req.UserAgent())
if err != nil { if err != nil {
reqContext.Logger.Error("Failed to rotate token", "error", err) reqContext.Logger.Error("Failed to rotate token", "error", err)

View File

@ -62,12 +62,16 @@ func (ctx *Context) run() {
// RemoteAddr returns more real IP address. // RemoteAddr returns more real IP address.
func (ctx *Context) RemoteAddr() string { func (ctx *Context) RemoteAddr() string {
addr := ctx.Req.Header.Get("X-Real-IP") return RemoteAddr(ctx.Req)
}
func RemoteAddr(req *http.Request) string {
addr := req.Header.Get("X-Real-IP")
if len(addr) == 0 { if len(addr) == 0 {
// X-Forwarded-For may contain multiple IP addresses, separated by // X-Forwarded-For may contain multiple IP addresses, separated by
// commas. // commas.
addr = strings.TrimSpace(strings.Split(ctx.Req.Header.Get("X-Forwarded-For"), ",")[0]) addr = strings.TrimSpace(strings.Split(req.Header.Get("X-Forwarded-For"), ",")[0])
} }
// parse user inputs from headers to prevent log forgery // parse user inputs from headers to prevent log forgery
@ -79,7 +83,7 @@ func (ctx *Context) RemoteAddr() string {
} }
if len(addr) == 0 { if len(addr) == 0 {
addr = ctx.Req.RemoteAddr addr = req.RemoteAddr
if i := strings.LastIndex(addr, ":"); i > -1 { if i := strings.LastIndex(addr, ":"); i > -1 {
addr = addr[:i] addr = addr[:i]
} }