diff --git a/oauthproxy.go b/oauthproxy.go index 00ac1d49..d45e6384 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -564,6 +564,10 @@ func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Reques return p.sessionStore.Clear(rw, req) } +func (p *OAuthProxy) ClearAllSessions(req *http.Request, session *sessionsapi.SessionState) error { + return p.sessionStore.ClearAllUserSessions(req, session) +} + // LoadCookiedSession reads the user's authentication details from the request func (p *OAuthProxy) LoadCookiedSession(req *http.Request) (*sessionsapi.SessionState, error) { return p.sessionStore.Load(req) @@ -775,6 +779,17 @@ func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request, signOutA return } err = p.ClearSessionCookie(rw, req) + if signOutAllSessions { + session, errAuthSession := p.getAuthenticatedSession(rw, req) + if errAuthSession != nil { + logger.Errorf("Error clearing all sessions cookie: %v", errAuthSession) + } else { + clearAllError := p.ClearAllSessions(req, session) + if clearAllError != nil { + logger.Errorf("Error clearing session cookie: %v", clearAllError) + } + } + } if err != nil { logger.Errorf("Error clearing session cookie: %v", err) p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) diff --git a/pkg/apis/sessions/interfaces.go b/pkg/apis/sessions/interfaces.go index 97c364cf..395f3087 100644 --- a/pkg/apis/sessions/interfaces.go +++ b/pkg/apis/sessions/interfaces.go @@ -12,6 +12,7 @@ type SessionStore interface { Save(rw http.ResponseWriter, req *http.Request, s *SessionState) error Load(req *http.Request) (*SessionState, error) Clear(rw http.ResponseWriter, req *http.Request) error + ClearAllUserSessions(req *http.Request, session *SessionState) error VerifyConnection(ctx context.Context) error } diff --git a/pkg/encryption/utils.go b/pkg/encryption/utils.go index 426a3131..39eb2ddf 100644 --- a/pkg/encryption/utils.go +++ b/pkg/encryption/utils.go @@ -96,6 +96,14 @@ func GenerateRandomASCIIString(length int) (string, error) { return string(b), nil } +// Encrypts a string with a secret using HMAC-SHA256 and returns a base64-encoded string. +func EncryptStringWithSecret(input, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(input)) + sum := mac.Sum(nil) + return base64.RawURLEncoding.EncodeToString(sum) +} + func GenerateCodeChallenge(method, codeVerifier string) (string, error) { switch method { case CodeChallengeMethodPlain: diff --git a/pkg/encryption/utils_test.go b/pkg/encryption/utils_test.go index 9e69df84..097fad55 100644 --- a/pkg/encryption/utils_test.go +++ b/pkg/encryption/utils_test.go @@ -87,6 +87,14 @@ func TestSecretBytesNonBase64(t *testing.T) { assert.Equal(t, 32, len(sb32)) } +func TestEncryptStringWithSecret(t *testing.T) { + secret := "my-secret" + input := "my-input" + + result := EncryptStringWithSecret(input, secret) + assert.Equal(t, result, "7DlH3g1Io9AmyD8tVPEPHdpH9N4jsO07mNCkfNcfW2A") +} + func TestSignAndValidate(t *testing.T) { seed := "0123456789abcdef" key := "cookie-name" diff --git a/pkg/middleware/stored_session_test.go b/pkg/middleware/stored_session_test.go index 2d3a6f66..3502b83d 100644 --- a/pkg/middleware/stored_session_test.go +++ b/pkg/middleware/stored_session_test.go @@ -763,9 +763,10 @@ var _ = Describe("Stored Session Suite", func() { }) type fakeSessionStore struct { - SaveFunc func(http.ResponseWriter, *http.Request, *sessionsapi.SessionState) error - LoadFunc func(req *http.Request) (*sessionsapi.SessionState, error) - ClearFunc func(rw http.ResponseWriter, req *http.Request) error + SaveFunc func(http.ResponseWriter, *http.Request, *sessionsapi.SessionState) error + LoadFunc func(req *http.Request) (*sessionsapi.SessionState, error) + ClearFunc func(rw http.ResponseWriter, req *http.Request) error + ClearAllUserSessionsFunc func(req *http.Request, session *sessionsapi.SessionState) error } func (f *fakeSessionStore) Save(rw http.ResponseWriter, req *http.Request, s *sessionsapi.SessionState) error { @@ -788,6 +789,13 @@ func (f *fakeSessionStore) Clear(rw http.ResponseWriter, req *http.Request) erro return nil } +func (f *fakeSessionStore) ClearAllUserSessions(req *http.Request, session *sessionsapi.SessionState) error { + if f.ClearAllUserSessionsFunc != nil { + return f.ClearAllUserSessionsFunc(req, session) + } + return nil +} + func (f *fakeSessionStore) VerifyConnection(_ context.Context) error { return nil } diff --git a/pkg/sessions/cookie/session_store.go b/pkg/sessions/cookie/session_store.go index f2f4045f..5da84f3b 100644 --- a/pkg/sessions/cookie/session_store.go +++ b/pkg/sessions/cookie/session_store.go @@ -33,6 +33,11 @@ type SessionStore struct { Minimal bool } +// ClearAll implements sessions.SessionStore. +func (s *SessionStore) ClearAllUserSessions(_ *http.Request, _ *sessions.SessionState) error { + return fmt.Errorf("ClearAllUserSessions is only supported by redis store") +} + // Save takes a sessions.SessionState and stores the information from it // within Cookies set on the HTTP response writer func (s *SessionStore) Save(rw http.ResponseWriter, req *http.Request, ss *sessions.SessionState) error { diff --git a/pkg/sessions/persistence/interfaces.go b/pkg/sessions/persistence/interfaces.go index 5bab9912..04fe3d64 100644 --- a/pkg/sessions/persistence/interfaces.go +++ b/pkg/sessions/persistence/interfaces.go @@ -13,7 +13,9 @@ import ( type Store interface { Save(context.Context, string, []byte, time.Duration) error Load(context.Context, string) ([]byte, error) + LoadList(ctx context.Context, key string) ([]string, error) Clear(context.Context, string) error Lock(key string) sessions.Lock + RPush(context.Context, string, string, time.Duration) error VerifyConnection(context.Context) error } diff --git a/pkg/sessions/persistence/manager.go b/pkg/sessions/persistence/manager.go index 9652f015..702e8baa 100644 --- a/pkg/sessions/persistence/manager.go +++ b/pkg/sessions/persistence/manager.go @@ -8,6 +8,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" ) // Manager wraps a Store and handles the implementation details of the @@ -42,9 +43,14 @@ func (m *Manager) Save(rw http.ResponseWriter, req *http.Request, s *sessions.Se } } - err = tckt.saveSession(s, func(key string, val []byte, exp time.Duration) error { - return m.Store.Save(req.Context(), key, val, exp) - }) + err = tckt.saveSession( + s, + func(key string, val []byte, exp time.Duration) error { + return m.Store.Save(req.Context(), key, val, exp) + }, + func(key string, val string, exp time.Duration) error { + return m.Store.RPush(req.Context(), key, val, exp) + }) if err != nil { return err } @@ -68,6 +74,30 @@ func (m *Manager) Load(req *http.Request) (*sessions.SessionState, error) { ) } +// ClearAll implements sessions.SessionStore. +func (m *Manager) ClearAllUserSessions(req *http.Request, session *sessions.SessionState) error { + ticket, _ := decodeTicketFromRequest(req, m.Options) + sessionKey := encryption.EncryptStringWithSecret(session.User+session.Email, ticket.options.Secret) + keys, err := m.Store.LoadList(req.Context(), sessionKey) + if err != nil { + return fmt.Errorf("error decoding ticket to clear session: %v", err) + } + + for _, key := range keys { + err = m.Store.Clear(req.Context(), key) + if err != nil { + return fmt.Errorf("error clearing session for key: %v", err) + } + } + + err = m.Store.Clear(req.Context(), sessionKey) + if err != nil { + return fmt.Errorf("error clearing sessions keys: %v", err) + } + + return err +} + // Clear clears any saved session information for a given ticket cookie. // Then it clears all session data for that ticket in the Store. func (m *Manager) Clear(rw http.ResponseWriter, req *http.Request) error { diff --git a/pkg/sessions/persistence/ticket.go b/pkg/sessions/persistence/ticket.go index 5020ada9..bf533764 100644 --- a/pkg/sessions/persistence/ticket.go +++ b/pkg/sessions/persistence/ticket.go @@ -22,6 +22,10 @@ import ( // a key string, value []byte & (optional) expiration time.Duration type saveFunc func(string, []byte, time.Duration) error +// saveUserStateFunc performs a persistent store's save functionality using +// a key string, value []byte & (optional) expiration time.Duration +type saverUserMapSessionFunc func(string, string, time.Duration) error + // loadFunc performs a load from a persistent store using a // string key and returning the stored value as []byte type loadFunc func(string) ([]byte, error) @@ -157,7 +161,7 @@ func decodeTicketFromRequest(req *http.Request, cookieOpts *options.Cookie) (*ti // saveSession encodes the SessionState with the ticket's secret and persists // it to disk via the passed saveFunc. -func (t *ticket) saveSession(s *sessions.SessionState, saver saveFunc) error { +func (t *ticket) saveSession(s *sessions.SessionState, saver saveFunc, saverUserMapSession saverUserMapSessionFunc) error { c, err := t.makeCipher() if err != nil { return err @@ -166,6 +170,9 @@ func (t *ticket) saveSession(s *sessions.SessionState, saver saveFunc) error { if err != nil { return fmt.Errorf("failed to encode the session state with the ticket: %v", err) } + + encodedUserState := encryption.EncryptStringWithSecret(s.User+s.Email, t.options.Secret) + saverUserMapSession(encodedUserState, t.id, 2*time.Hour) return saver(t.id, ciphertext, t.options.Expire) } diff --git a/pkg/sessions/persistence/ticket_test.go b/pkg/sessions/persistence/ticket_test.go index 4cc62e2c..082d9716 100644 --- a/pkg/sessions/persistence/ticket_test.go +++ b/pkg/sessions/persistence/ticket_test.go @@ -70,15 +70,26 @@ var _ = Describe("Session Ticket Tests", func() { ss := &sessions.SessionState{User: "foobar"} store := map[string][]byte{} - err = t.saveSession(ss, func(k string, v []byte, e time.Duration) error { - store[k] = v - return nil - }) + storeUserSessionList := map[string][]string{} + storedUserSessionListExpected := map[string][]string{ + "16-axDAZ63SxeHvCLMjoF5EEX0ipSzNNqxpUITxPxgk": {t.id}, + } + err = t.saveSession( + ss, + func(k string, v []byte, e time.Duration) error { + store[k] = v + return nil + }, + func(key string, value string, d time.Duration) error { + storeUserSessionList[key] = append(storeUserSessionList[key], value) + return nil + }) Expect(err).ToNot(HaveOccurred()) stored, err := sessions.DecodeSessionState(store[t.id], c, false) Expect(err).ToNot(HaveOccurred()) Expect(stored).To(Equal(ss)) + Expect(storeUserSessionList).To(Equal(storedUserSessionListExpected)) }) It("errors when the saveFunc errors", func() { @@ -89,9 +100,27 @@ var _ = Describe("Session Ticket Tests", func() { &sessions.SessionState{User: "foobar"}, func(k string, v []byte, e time.Duration) error { return errors.New("save error") + }, + func(key string, value string, d time.Duration) error { + return nil }) Expect(err).To(MatchError(errors.New("save error"))) }) + + It("should not return error when the saverUserMapSession errors", func() { + t, err := newTicket(&options.Cookie{Name: "dummy"}) + Expect(err).ToNot(HaveOccurred()) + + err = t.saveSession( + &sessions.SessionState{User: "foobar"}, + func(k string, v []byte, e time.Duration) error { + return nil + }, + func(key string, value string, d time.Duration) error { + return errors.New("save user session error") + }) + Expect(err).To(BeNil()) + }) }) Context("loadSession", func() { diff --git a/pkg/sessions/redis/client.go b/pkg/sessions/redis/client.go index 00cff17c..1376d5f6 100644 --- a/pkg/sessions/redis/client.go +++ b/pkg/sessions/redis/client.go @@ -12,6 +12,9 @@ import ( type Client interface { Get(ctx context.Context, key string) ([]byte, error) Lock(key string) sessions.Lock + Expire(ctx context.Context, key string, expiration time.Duration) error + RPush(ctx context.Context, key string, value string) error + LRange(ctx context.Context, key string) ([]string, error) Set(ctx context.Context, key string, value []byte, expiration time.Duration) error Del(ctx context.Context, key string) error Ping(ctx context.Context) error @@ -29,6 +32,18 @@ func newClient(c *redis.Client) Client { } } +func (c *client) Expire(ctx context.Context, key string, expiration time.Duration) error { + return c.Client.Expire(ctx, key, expiration).Err() +} + +func (c *client) LRange(ctx context.Context, key string) ([]string, error) { + return c.Client.LRange(ctx, key, 0, -1).Result() +} + +func (c *client) RPush(ctx context.Context, key string, value string) error { + return c.Client.RPush(ctx, key, value).Err() +} + func (c *client) Get(ctx context.Context, key string) ([]byte, error) { return c.Client.Get(ctx, key).Bytes() } @@ -61,6 +76,12 @@ func newClusterClient(c *redis.ClusterClient) Client { } } +// Expire implements Client. +// Subtle: this method shadows the method (*ClusterClient).Expire of clusterClient.ClusterClient. +func (c *clusterClient) Expire(ctx context.Context, key string, expiration time.Duration) error { + return c.ClusterClient.Expire(ctx, key, expiration).Err() +} + func (c *clusterClient) Get(ctx context.Context, key string) ([]byte, error) { return c.ClusterClient.Get(ctx, key).Bytes() } @@ -69,6 +90,14 @@ func (c *clusterClient) Set(ctx context.Context, key string, value []byte, expir return c.ClusterClient.Set(ctx, key, value, expiration).Err() } +func (c *clusterClient) RPush(ctx context.Context, key string, value string) error { + return c.ClusterClient.RPush(ctx, key, value).Err() +} + +func (c *clusterClient) LRange(ctx context.Context, key string) ([]string, error) { + return c.ClusterClient.LRange(ctx, key, 0, -1).Result() +} + func (c *clusterClient) Del(ctx context.Context, key string) error { return c.ClusterClient.Del(ctx, key).Err() } diff --git a/pkg/sessions/redis/redis_store.go b/pkg/sessions/redis/redis_store.go index e41a1e1f..e6ec4c3e 100644 --- a/pkg/sessions/redis/redis_store.go +++ b/pkg/sessions/redis/redis_store.go @@ -45,6 +45,31 @@ func (store *SessionStore) Save(ctx context.Context, key string, value []byte, e return nil } +// Save takes a sessions.SessionState and stores the information from it +// to redis, and adds a new persistence cookie on the HTTP response writer +func (store *SessionStore) RPush(ctx context.Context, key string, value string, exp time.Duration) error { + err := store.Client.RPush(ctx, key, value) + if err != nil { + return fmt.Errorf("error appending redis session: %v", err) + } + + if exp > 0 { + if err := store.Client.Expire(ctx, key, exp); err != nil { + return fmt.Errorf("error settings expiration time on appending redis session: %v", err) + } + } + return nil +} + +// LoadList reads a list of strings from Redis at the given key and returns. +func (store *SessionStore) LoadList(ctx context.Context, key string) ([]string, error) { + values, err := store.Client.LRange(ctx, key) + if err != nil { + return nil, fmt.Errorf("error loading redis list: %v", err) + } + return values, nil +} + // Load reads sessions.SessionState information from a persistence // cookie within the HTTP request object func (store *SessionStore) Load(ctx context.Context, key string) ([]byte, error) { diff --git a/pkg/sessions/tests/mock_store.go b/pkg/sessions/tests/mock_store.go index c82d8c08..707f9d08 100644 --- a/pkg/sessions/tests/mock_store.go +++ b/pkg/sessions/tests/mock_store.go @@ -14,14 +14,50 @@ type entry struct { expiration time.Duration } +type entryList struct { + data []string + expiration time.Duration +} + // MockStore is a generic in-memory implementation of persistence.Store // for mocking in tests type MockStore struct { cache map[string]entry + cacheList map[string]entryList lockCache map[string]*MockLock elapsed time.Duration } +// LoadList implements persistence.Store. +func (s *MockStore) LoadList(_ context.Context, key string) ([]string, error) { + entry, ok := s.cacheList[key] + if !ok || entry.expiration <= s.elapsed { + delete(s.cache, key) + return nil, fmt.Errorf("key not found: %s", key) + } + return entry.data, nil +} + +// RPush implements persistence.Store. +func (s *MockStore) RPush(_ context.Context, key string, value string, time time.Duration) error { + entry, ok := s.cacheList[key] + if ok { + // If the key exists, check if the expiration is still valid + entry.data = append(entry.data, value) + s.cacheList[key] = entry + } else { + // If the key does not exist, create a new entryList + if s.cacheList == nil { + s.cacheList = make(map[string]entryList) + } + s.cacheList[key] = entryList{ + data: []string{value}, + expiration: time, + } + } + return nil +} + // NewMockStore creates a MockStore func NewMockStore() *MockStore { return &MockStore{ diff --git a/pkg/sessions/tests/session_store_tests.go b/pkg/sessions/tests/session_store_tests.go index 8e2d02f9..7af32d00 100644 --- a/pkg/sessions/tests/session_store_tests.go +++ b/pkg/sessions/tests/session_store_tests.go @@ -27,6 +27,7 @@ type testInput struct { request *http.Request response *httptest.ResponseRecorder persistentFastForward PersistentStoreFastForwardFunc + opts *options.SessionOptions } // sessionStoreFunc is used in testInput to wrap the SessionStore interface. @@ -93,6 +94,7 @@ func RunSessionStoreTests(newSS NewSessionStoreFunc, persistentFastForward Persi request: request, response: response, persistentFastForward: persistentFastForward, + opts: opts, } }) @@ -451,6 +453,32 @@ func SessionStoreInterfaceTests(in *testInput) { CheckCookieOptions(in) }) + Context("Clear all user sessions", func() { + BeforeEach(func() { + req := httptest.NewRequest("GET", "http://example.com/", nil) + resp := httptest.NewRecorder() + err := in.ss().Save(resp, req, in.session) + Expect(err).ToNot(HaveOccurred()) + resultCookies := resp.Result().Cookies() + for _, c := range resultCookies { + in.request.AddCookie(c) + } + }) + It("should clear all user sessions", func() { + if in.opts.Type == options.CookieSessionStoreType { + Expect(in.ss().ClearAllUserSessions(in.request, in.session)).To(MatchError("ClearAllUserSessions is only supported by redis store")) + return + } + err := in.ss().ClearAllUserSessions(in.request, in.session) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the session is cleared + loadedSession, loadErr := in.ss().Load(in.request) + Expect(loadedSession).To(BeNil()) + Expect(loadErr).To(HaveOccurred()) + }) + }) + Context("when Load is called", func() { Context("with a valid session cookie in the request", func() { BeforeEach(func() {