providers/proxy: outpost wide logout implementation (#4605)

* initial outpost wide logout implementation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* handle deserialize error

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix file cleanup, add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-02-02 21:18:59 +01:00 committed by GitHub
parent 798245b8db
commit 7d4ce41e12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 239 additions and 21 deletions

View file

@ -46,5 +46,6 @@
"url": "https://github.com/goauthentik/authentik/issues/<num>",
"ignoreCase": false
}
]
],
"go.testFlags": ["-count=1"]
}

View file

@ -237,21 +237,22 @@ func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) {
redirect := a.endpoint.EndSessionEndpoint
s, err := a.sessions.Get(r, constants.SessionName)
if err != nil {
http.Redirect(rw, r, redirect, http.StatusFound)
a.redirectToStart(rw, r)
return
}
if c, exists := s.Values[constants.SessionClaims]; c == nil || !exists {
cc := c.(Claims)
uv := url.Values{
"id_token_hint": []string{cc.RawToken},
}
redirect += "?" + uv.Encode()
c, exists := s.Values[constants.SessionClaims]
if c == nil && !exists {
a.redirectToStart(rw, r)
return
}
s.Options.MaxAge = -1
err = s.Save(r, rw)
cc := c.(Claims)
uv := url.Values{
"id_token_hint": []string{cc.RawToken},
}
redirect += "?" + uv.Encode()
err = a.Logout(cc.Sub)
if err != nil {
http.Redirect(rw, r, redirect, http.StatusFound)
return
a.log.WithError(err).Warning("failed to logout of other sessions")
}
http.Redirect(rw, r, redirect, http.StatusFound)
}

View file

@ -7,6 +7,7 @@ import (
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/proxyv2/constants"
@ -69,6 +70,8 @@ func TestForwardHandleCaddy_Single_Claims(t *testing.T) {
a.forwardHandleCaddy(rr, req)
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{

View file

@ -7,6 +7,7 @@ import (
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/proxyv2/constants"
@ -51,6 +52,8 @@ func TestForwardHandleEnvoy_Single_Claims(t *testing.T) {
a.forwardHandleEnvoy(rr, req)
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{

View file

@ -5,6 +5,7 @@ import (
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/proxyv2/constants"
@ -68,6 +69,8 @@ func TestForwardHandleNginx_Single_Claims(t *testing.T) {
a.forwardHandleNginx(rr, req)
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{

View file

@ -7,6 +7,7 @@ import (
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/proxyv2/constants"
@ -69,6 +70,8 @@ func TestForwardHandleTraefik_Single_Claims(t *testing.T) {
a.forwardHandleTraefik(rr, req)
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{

View file

@ -6,6 +6,7 @@ import (
"net/url"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
@ -70,6 +71,8 @@ func TestProxy_ModifyRequest_Claims(t *testing.T) {
rr := httptest.NewRecorder()
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{
@ -98,6 +101,8 @@ func TestProxy_ModifyRequest_Claims_Invalid(t *testing.T) {
rr := httptest.NewRecorder()
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
Proxy: &ProxyClaims{

View file

@ -5,14 +5,21 @@ import (
"math"
"net/url"
"os"
"path"
"strconv"
"strings"
"github.com/garyburd/redigo/redis"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"goauthentik.io/api/v3"
"goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/proxyv2/constants"
"gopkg.in/boj/redistore.v1"
)
const RedisKeyPrefix = "authentik_proxy_session_"
func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) sessions.Store {
var store sessions.Store
if config.Get().Redis.Host != "" {
@ -21,7 +28,7 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
panic(err)
}
rs.SetMaxLength(math.MaxInt)
rs.SetKeyPrefix("authentik_proxy_session_")
rs.SetKeyPrefix(RedisKeyPrefix)
if p.TokenValidity.IsSet() {
t := p.TokenValidity.Get()
// Add one to the validity to ensure we don't have a session with indefinite length
@ -55,3 +62,86 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
}
return store
}
func (a *Application) Logout(sub string) error {
if fs, ok := a.sessions.(*sessions.FilesystemStore); ok {
files, err := os.ReadDir(os.TempDir())
if err != nil {
return err
}
for _, file := range files {
s := sessions.Session{}
if !strings.HasPrefix(file.Name(), "session_") {
continue
}
fullPath := path.Join(os.TempDir(), file.Name())
data, err := os.ReadFile(fullPath)
if err != nil {
a.log.WithError(err).Warning("failed to read file")
continue
}
err = securecookie.DecodeMulti(
constants.SessionName, string(data),
&s.Values, fs.Codecs...,
)
if err != nil {
a.log.WithError(err).Trace("failed to decode session")
continue
}
claims := s.Values[constants.SessionClaims].(Claims)
if claims.Sub == sub {
a.log.WithField("path", fullPath).Trace("deleting session")
err := os.Remove(fullPath)
if err != nil {
a.log.WithError(err).Warning("failed to delete session")
continue
}
}
}
}
if rs, ok := a.sessions.(*redistore.RediStore); ok {
pool := rs.Pool.Get()
defer pool.Close()
rep, err := pool.Do("KEYS", fmt.Sprintf("%s*", RedisKeyPrefix))
if err != nil {
return err
}
keys, err := redis.Strings(rep, err)
if err != nil {
return err
}
ser := redistore.GobSerializer{}
for _, key := range keys {
v, err := pool.Do("GET", key)
if err != nil {
a.log.WithError(err).Warning("failed to get value")
continue
}
b, err := redis.Bytes(v, err)
if err != nil {
a.log.WithError(err).Warning("failed to load value")
continue
}
s := sessions.Session{}
err = ser.Deserialize(b, &s)
if err != nil {
a.log.WithError(err).Warning("failed to deserialize")
continue
}
c := s.Values[constants.SessionClaims]
if c == nil {
continue
}
claims := c.(Claims)
if claims.Sub == sub {
a.log.WithField("key", key).Trace("deleting session")
_, err := pool.Do("DEL", key)
if err != nil {
a.log.WithError(err).Warning("failed to delete key")
continue
}
}
}
}
return nil
}

View file

@ -0,0 +1,77 @@
package application
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
func TestLogout(t *testing.T) {
a := newTestApplication()
_ = a.configureProxy()
req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil)
rr := httptest.NewRecorder()
// Login once
s, _ := a.sessions.Get(req, constants.SessionName)
s.ID = uuid.New().String()
s.Options.MaxAge = 86400
s.Values[constants.SessionClaims] = Claims{
Sub: "foo",
}
err := a.sessions.Save(req, rr, s)
if err != nil {
panic(err)
}
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadGateway, rr.Code)
// Login twice
s2, _ := a.sessions.Get(req, constants.SessionName)
s2.ID = uuid.New().String()
s2.Options.MaxAge = 86400
s2.Values[constants.SessionClaims] = Claims{
Sub: "foo",
}
err = a.sessions.Save(req, rr, s2)
if err != nil {
panic(err)
}
a.mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadGateway, rr.Code)
// Logout
req, _ = http.NewRequest("GET", "https://ext.t.goauthentik.io/outpost.goauthentik.io/sign_out", nil)
s3, _ := a.sessions.Get(req, constants.SessionName)
s3.ID = uuid.New().String()
s3.Options.MaxAge = 86400
s3.Values[constants.SessionClaims] = Claims{
Sub: "foo",
}
err = a.sessions.Save(req, rr, s3)
if err != nil {
panic(err)
}
rr = httptest.NewRecorder()
a.handleSignOut(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
s1Name := filepath.Join(os.TempDir(), "session_"+s.ID)
_, err = os.Stat(s1Name)
assert.True(t, errors.Is(err, os.ErrNotExist))
s2Name := filepath.Join(os.TempDir(), "session_"+s2.ID)
_, err = os.Stat(s2Name)
assert.True(t, errors.Is(err, os.ErrNotExist))
}

View file

@ -3,7 +3,6 @@ package application
import (
"net/http"
"github.com/quasoft/memstore"
"goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/ak"
)
@ -41,8 +40,5 @@ func newTestApplication() *Application {
ak.MockConfig(),
),
)
a.sessions = memstore.NewMemStore(
[]byte(ak.TestSecret()),
)
return a
}

View file

@ -74,15 +74,15 @@ func (a *Application) redirectToStart(rw http.ResponseWriter, r *http.Request) {
func (a *Application) redirect(rw http.ResponseWriter, r *http.Request) {
redirect := a.proxyConfig.ExternalHost
rd, ok := a.checkRedirectParam(r)
if ok {
redirect = rd
}
s, _ := a.sessions.Get(r, constants.SessionName)
redirectR, ok := s.Values[constants.SessionRedirect]
if ok {
redirect = redirectR.(string)
}
rd, ok := a.checkRedirectParam(r)
if ok {
redirect = rd
}
a.log.WithField("redirect", redirect).Trace("final redirect")
http.Redirect(rw, r, redirect, http.StatusFound)
}

View file

@ -73,6 +73,8 @@ When using domain-level mode, navigate to `auth.domain.tld/outpost.goauthentik.i
To log out, navigate to `/outpost.goauthentik.io/sign_out`.
Starting with authentik 2023.2, when logging out of a provider, all the users sessions within the respective outpost are invalidated.
## Allowing unauthenticated requests
To allow un-authenticated requests to certain paths/URLs, you can use the _Unauthenticated URLs_ / _Unauthenticated Paths_ field.

View file

@ -0,0 +1,34 @@
---
title: Release 2023.2
slug: "/releases/2023.2"
---
## New features
- Proxy provider logout improvements
In previous versions, logging out of a single proxied application would only invalidate that application's session. Starting with this release, when logging out of a proxied application (via the _/outpost.goauthentik.io/sign_out_ URL), all the users session within the outpost are terminated. Sessions in other outposts and with other protocols are unaffected.
## Upgrading
This release does not introduce any new requirements.
### docker-compose
Download the docker-compose file for 2023.2 from [here](https://goauthentik.io/version/2023.2/docker-compose.yml). Afterwards, simply run `docker-compose up -d`.
### Kubernetes
Update your values to use the new images:
```yaml
image:
repository: ghcr.io/goauthentik/server
tag: 2023.2.0
```
## Minor changes/fixes
## API Changes
_Insert output of `make gen-diff` here_