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:
parent
798245b8db
commit
7d4ce41e12
|
@ -46,5 +46,6 @@
|
|||
"url": "https://github.com/goauthentik/authentik/issues/<num>",
|
||||
"ignoreCase": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"go.testFlags": ["-count=1"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
c, exists := s.Values[constants.SessionClaims]
|
||||
if c == nil && !exists {
|
||||
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()
|
||||
}
|
||||
s.Options.MaxAge = -1
|
||||
err = s.Save(r, rw)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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_
|
Reference in New Issue