diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 18e38324c26..cad6ac08626 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -9,13 +9,16 @@ import ( "time" "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/user" + "github.com/grafana/grafana/pkg/web" "golang.org/x/oauth2" ) const ( ClientAPIKey = "auth.client.api-key" // #nosec G101 + ClientSession = "auth.client.session" ClientAnonymous = "auth.client.anonymous" ClientBasic = "auth.client.basic" ClientRender = "auth.client.render" @@ -27,7 +30,8 @@ type ClientParams struct { 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 { // 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 int64 HTTPRequest *http.Request + + // for use in post auth hooks + Resp web.ResponseWriter } const ( @@ -69,12 +76,14 @@ type Identity struct { IsGrafanaAdmin *bool AuthModule string // AuthModule is the name of the external system AuthID string // AuthId is the unique identifier for the user in the external system - OAuthToken *oauth2.Token LookUpParams models.UserLookupParams IsDisabled bool HelpFlags1 user.HelpFlags1 LastSeenAt time.Time Teams []int64 + + OAuthToken *oauth2.Token + SessionToken *auth.UserToken } func (i *Identity) Role() org.RoleType { diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 7269d120120..e156992c9a7 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/authn" sync "github.com/grafana/grafana/pkg/services/authn/authnimpl/usersync" "github.com/grafana/grafana/pkg/services/authn/clients" @@ -26,8 +27,11 @@ import ( var _ authn.Service = new(Service) func ProvideService( - cfg *setting.Cfg, tracer tracing.Tracer, orgService org.Service, accessControlService accesscontrol.Service, - apikeyService apikey.Service, userService user.Service, loginAttempts loginattempt.Service, quotaService quota.Service, + cfg *setting.Cfg, tracer tracing.Tracer, + 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, ) *Service { s := &Service{ @@ -41,6 +45,10 @@ func ProvideService( s.clients[authn.ClientRender] = clients.ProvideRender(userService, renderService) 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 { 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() 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 } } diff --git a/pkg/services/authn/authnimpl/usersync/orgsync.go b/pkg/services/authn/authnimpl/usersync/orgsync.go index 8aba2b781c5..ac6cfe27f8a 100644 --- a/pkg/services/authn/authnimpl/usersync/orgsync.go +++ b/pkg/services/authn/authnimpl/usersync/orgsync.go @@ -27,7 +27,8 @@ type OrgSync struct { 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 { s.log.Debug("Not syncing org user", "auth_module", id.AuthModule, "auth_id", id.AuthID) return nil diff --git a/pkg/services/authn/authnimpl/usersync/orgsync_test.go b/pkg/services/authn/authnimpl/usersync/orgsync_test.go index c4965b7227d..996fe6d19f9 100644 --- a/pkg/services/authn/authnimpl/usersync/orgsync_test.go +++ b/pkg/services/authn/authnimpl/usersync/orgsync_test.go @@ -113,7 +113,7 @@ func TestOrgSync_SyncOrgUser(t *testing.T) { accessControl: tt.fields.accessControl, 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) } diff --git a/pkg/services/authn/authnimpl/usersync/usersync.go b/pkg/services/authn/authnimpl/usersync/usersync.go index 33db88f8645..2c9374ad82e 100644 --- a/pkg/services/authn/authnimpl/usersync/usersync.go +++ b/pkg/services/authn/authnimpl/usersync/usersync.go @@ -25,7 +25,8 @@ type UserSync struct { } // 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 { s.log.Debug("Not syncing user", "auth_module", id.AuthModule, "auth_id", id.AuthID) return nil diff --git a/pkg/services/authn/authnimpl/usersync/usersync_test.go b/pkg/services/authn/authnimpl/usersync/usersync_test.go index 89243fdae04..810ba4c800c 100644 --- a/pkg/services/authn/authnimpl/usersync/usersync_test.go +++ b/pkg/services/authn/authnimpl/usersync/usersync_test.go @@ -430,7 +430,7 @@ func TestUserSync_SyncUser(t *testing.T) { quotaService: tt.fields.quotaService, 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 { require.Error(t, err) return diff --git a/pkg/services/authn/clients/session.go b/pkg/services/authn/clients/session.go new file mode 100644 index 00000000000..886cb0e0d2e --- /dev/null +++ b/pkg/services/authn/clients/session.go @@ -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 +} diff --git a/pkg/services/authn/clients/session_test.go b/pkg/services/authn/clients/session_test.go new file mode 100644 index 00000000000..257703220ac --- /dev/null +++ b/pkg/services/authn/clients/session_test.go @@ -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) +} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index 397b7bec368..2cd8091ecba 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -476,6 +476,29 @@ func (h *ContextHandler) initContextWithBasicAuth(reqContext *models.ReqContext, } 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 == "" { 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) 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()) if err != nil { reqContext.Logger.Error("Failed to rotate token", "error", err) diff --git a/pkg/web/context.go b/pkg/web/context.go index ca878c7ccd4..413ac3709d6 100644 --- a/pkg/web/context.go +++ b/pkg/web/context.go @@ -62,12 +62,16 @@ func (ctx *Context) run() { // RemoteAddr returns more real IP address. 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 { // X-Forwarded-For may contain multiple IP addresses, separated by // 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 @@ -79,7 +83,7 @@ func (ctx *Context) RemoteAddr() string { } if len(addr) == 0 { - addr = ctx.Req.RemoteAddr + addr = req.RemoteAddr if i := strings.LastIndex(addr, ":"); i > -1 { addr = addr[:i] }