diff --git a/docs/operator-manual/argocd-cm.yaml b/docs/operator-manual/argocd-cm.yaml index 2fdf1c16cf228..c5507a46b66de 100644 --- a/docs/operator-manual/argocd-cm.yaml +++ b/docs/operator-manual/argocd-cm.yaml @@ -15,6 +15,8 @@ data: # Enables anonymous user access. The anonymous users get default role permissions specified argocd-rbac-cm.yaml. users.anonymous.enabled: "true" + # Specifies token expiration duration + users.session.duration: "24h" # Enables google analytics tracking is specified ga.trackingid: "UA-12345-1" diff --git a/go.mod b/go.mod index fc8f657ba2f5d..3fc0950dcd4f6 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.14 require ( github.com/Masterminds/semver v1.5.0 github.com/TomOnTime/utfutil v0.0.0-20180511104225-09c41003ee1d - github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6 // indirect github.com/alicebob/miniredis v2.5.0+incompatible + github.com/alicebob/miniredis/v2 v2.14.2 github.com/argoproj/gitops-engine v0.2.1-0.20210129183711-c5b7114c501f github.com/argoproj/pkg v0.2.0 github.com/bombsimon/logrusr v1.0.0 @@ -60,7 +60,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/undefinedlabs/go-mpatch v1.0.6 github.com/vmihailenco/msgpack/v5 v5.1.0 // indirect - github.com/yuin/gopher-lua v0.0.0-20190115140932-732aa6820ec4 + github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 golang.org/x/exp v0.0.0-20200821190819-94841d0725da // indirect golang.org/x/net v0.0.0-20201110031124-69a78807bb2b diff --git a/go.sum b/go.sum index a973f44b32e0a..0a3f7e864a48d 100644 --- a/go.sum +++ b/go.sum @@ -78,10 +78,12 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6 h1:45bxf7AZMwWcqkLzDAQugVEwedisr5nRJ1r+7LYnv0U= -github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI= github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/alicebob/miniredis/v2 v2.14.2 h1:VeoqKUAsJfT2af61nDE7qhBzqn3J6xjnt9MFAbdrEtg= +github.com/alicebob/miniredis/v2 v2.14.2/go.mod h1:gquAfGbzn92jvtrSC69+6zZnwSODVXVpYDRaGhWaL6I= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= @@ -718,8 +720,8 @@ github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0B github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/gopher-lua v0.0.0-20190115140932-732aa6820ec4 h1:1yOVVSFiradDwXpgdkDjlGOcGJqcohH/W49Zn8Ywgco= -github.com/yuin/gopher-lua v0.0.0-20190115140932-732aa6820ec4/go.mod h1:fFiAh+CowNFr0NK5VASokuwKwkbacRmHsVA7Yb1Tqac= +github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da h1:NimzV1aGyq29m5ukMK0AMWEhFaL/lrEOaephfuoiARg= +github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= @@ -858,6 +860,7 @@ golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190124100055-b90733256f2e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/server/account/account.go b/server/account/account.go index 8d6ac53160731..007ec6d1f4b11 100644 --- a/server/account/account.go +++ b/server/account/account.go @@ -204,7 +204,7 @@ func (s *Server) CreateToken(ctx context.Context, r *account.CreateTokenRequest) now := time.Now() var err error - tokenString, err = s.sessionMgr.Create(r.Name, r.ExpiresIn, id) + tokenString, err = s.sessionMgr.Create(fmt.Sprintf("%s:%s", r.Name, settings.AccountCapabilityApiKey), r.ExpiresIn, id) if err != nil { return err } diff --git a/server/account/account_test.go b/server/account/account_test.go index a0be6e802efae..7cc84100e6310 100644 --- a/server/account/account_test.go +++ b/server/account/account_test.go @@ -64,11 +64,11 @@ func newTestAccountServerExt(ctx context.Context, enforceFn rbac.ClaimsEnforcerF } kubeclientset := fake.NewSimpleClientset(cm, secret) settingsMgr := settings.NewSettingsManager(ctx, kubeclientset, testNamespace) - sessionMgr := sessionutil.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", sessionutil.NewInMemoryUserStateStorage()) + sessionMgr := sessionutil.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", sessionutil.NewUserStateStorage(nil)) enforcer := rbac.NewEnforcer(kubeclientset, testNamespace, common.ArgoCDRBACConfigMapName, nil) enforcer.SetClaimsEnforcerFunc(enforceFn) - return NewServer(sessionMgr, settingsMgr, enforcer), session.NewServer(sessionMgr, nil, nil, nil) + return NewServer(sessionMgr, settingsMgr, enforcer), session.NewServer(sessionMgr, settingsMgr, nil, nil, nil) } func getAdminAccount(mgr *settings.SettingsManager) (*settings.Account, error) { diff --git a/server/cache/cache.go b/server/cache/cache.go index f5f9de442ede8..86c5944dbef86 100644 --- a/server/cache/cache.go +++ b/server/cache/cache.go @@ -12,7 +12,6 @@ import ( cacheutil "github.com/argoproj/argo-cd/util/cache" appstatecache "github.com/argoproj/argo-cd/util/cache/appstate" "github.com/argoproj/argo-cd/util/oidc" - "github.com/argoproj/argo-cd/util/session" ) var ErrCacheMiss = appstatecache.ErrCacheMiss @@ -66,14 +65,6 @@ func (c *Cache) GetAppManagedResources(appName string, res *[]*appv1.ResourceDif return c.cache.GetAppManagedResources(appName, res) } -func (c *Cache) GetLoginAttempts(attempts *map[string]session.LoginAttempts) error { - return c.cache.GetItem("session|login.attempts", attempts) -} - -func (c *Cache) SetLoginAttempts(attempts map[string]session.LoginAttempts) error { - return c.cache.SetItem("session|login.attempts", attempts, c.loginAttemptsExpiration, attempts == nil) -} - func (c *Cache) SetRepoConnectionState(repo string, state *appv1.ConnectionState) error { return c.cache.SetItem(repoConnectionStateKey(repo), &state, c.connectionStatusCacheExpiration, state == nil) } diff --git a/server/logout/logout.go b/server/logout/logout.go index aaafa9f440395..1ce0fbad9e380 100644 --- a/server/logout/logout.go +++ b/server/logout/logout.go @@ -1,19 +1,21 @@ package logout import ( + "context" "fmt" "net/http" "regexp" "strings" + "time" "github.com/dgrijalva/jwt-go/v4" + log "github.com/sirupsen/logrus" "github.com/argoproj/argo-cd/common" "github.com/argoproj/argo-cd/pkg/client/clientset/versioned" + jwtutil "github.com/argoproj/argo-cd/util/jwt" "github.com/argoproj/argo-cd/util/session" "github.com/argoproj/argo-cd/util/settings" - - jwtutil "github.com/argoproj/argo-cd/util/jwt" ) //NewHandler creates handler serving to do api/logout endpoint @@ -24,6 +26,7 @@ func NewHandler(appClientset versioned.Interface, settingsMrg *settings.Settings settingsMgr: settingsMrg, rootPath: rootPath, verifyToken: sessionMgr.VerifyToken, + revokeToken: sessionMgr.RevokeToken, } } @@ -33,6 +36,7 @@ type Handler struct { settingsMgr *settings.SettingsManager rootPath string verifyToken func(tokenString string) (jwt.Claims, error) + revokeToken func(ctx context.Context, id string, expiringAt time.Duration) error } var ( @@ -86,6 +90,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } issuer := jwtutil.StringField(mapClaims, "iss") + id := jwtutil.StringField(mapClaims, "jti") + if exp, err := jwtutil.ExpirationTime(mapClaims); err == nil && id != "" { + if err := h.revokeToken(context.Background(), id, time.Until(exp)); err != nil { + log.Warnf("failed to invalidate token '%s': %v", id, err) + } + } if argoCDSettings.OIDCConfig() == nil || argoCDSettings.OIDCConfig().LogoutURL == "" || issuer == session.SessionManagerClaimsIssuer { http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther) diff --git a/server/logout/logout_test.go b/server/logout/logout_test.go index 91c25a9713d03..c9478964a35ae 100644 --- a/server/logout/logout_test.go +++ b/server/logout/logout_test.go @@ -80,6 +80,7 @@ func TestConstructLogoutURL(t *testing.T) { }) } } + func TestHandlerConstructLogoutURL(t *testing.T) { kubeClientWithOIDCConfig := fake.NewSimpleClientset( &corev1.ConfigMap{ @@ -176,7 +177,7 @@ func TestHandlerConstructLogoutURL(t *testing.T) { settingsManagerWithoutOIDCConfig := settings.NewSettingsManager(context.Background(), kubeClientWithoutOIDCConfig, "default") settingsManagerWithOIDCConfigButNoLogoutURL := settings.NewSettingsManager(context.Background(), kubeClientWithOIDCConfigButNoLogoutURL, "default") - sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) oidcHandler := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfig, sessionManager, "", "default") oidcHandler.verifyToken = func(tokenString string) (jwt.Claims, error) { diff --git a/server/project/project_test.go b/server/project/project_test.go index 33fea6e3161d2..577547d295b63 100644 --- a/server/project/project_test.go +++ b/server/project/project_test.go @@ -82,7 +82,7 @@ func TestProjectServer(t *testing.T) { } t.Run("TestNormalizeProj", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithRole := existingProj.DeepCopy() roleName := "roleName" role1 := v1alpha1.ProjectRole{Name: roleName, JWTTokens: []v1alpha1.JWTToken{{IssuedAt: 1}}} @@ -319,7 +319,7 @@ func TestProjectServer(t *testing.T) { id := "testId" t.Run("TestCreateTokenDenied", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithRole := existingProj.DeepCopy() projectWithRole.Spec.Roles = []v1alpha1.ProjectRole{{Name: tokenName}} projectServer := NewServer("default", fake.NewSimpleClientset(), apps.NewSimpleClientset(projectWithRole), enforcer, sync.NewKeyLock(), sessionMgr, policyEnf, projInformer, settingsMgr) @@ -328,7 +328,7 @@ func TestProjectServer(t *testing.T) { }) t.Run("TestCreateTokenSuccessfullyUsingGroup", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithRole := existingProj.DeepCopy() projectWithRole.Spec.Roles = []v1alpha1.ProjectRole{{Name: tokenName, Groups: []string{"my-group"}}} projectServer := NewServer("default", fake.NewSimpleClientset(), apps.NewSimpleClientset(projectWithRole), enforcer, sync.NewKeyLock(), sessionMgr, policyEnf, projInformer, settingsMgr) @@ -343,7 +343,7 @@ func TestProjectServer(t *testing.T) { projectWithRole.Spec.Roles = []v1alpha1.ProjectRole{{Name: tokenName}} clientset := apps.NewSimpleClientset(projectWithRole) - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewUserStateStorage(nil)) projectServer := NewServer("default", fake.NewSimpleClientset(), clientset, enforcer, sync.NewKeyLock(), sessionMgr, policyEnf, projInformer, settingsMgr) tokenResponse, err := projectServer.CreateToken(context.Background(), &project.ProjectTokenCreateRequest{Project: projectWithRole.Name, Role: tokenName, ExpiresIn: 100}) assert.NoError(t, err) @@ -363,7 +363,7 @@ func TestProjectServer(t *testing.T) { projectWithRole.Spec.Roles = []v1alpha1.ProjectRole{{Name: tokenName}} clientset := apps.NewSimpleClientset(projectWithRole) - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewUserStateStorage(nil)) projectServer := NewServer("default", fake.NewSimpleClientset(), clientset, enforcer, sync.NewKeyLock(), sessionMgr, policyEnf, projInformer, settingsMgr) tokenResponse, err := projectServer.CreateToken(context.Background(), &project.ProjectTokenCreateRequest{Project: projectWithRole.Name, Role: tokenName, ExpiresIn: 1, Id: id}) assert.NoError(t, err) @@ -383,7 +383,7 @@ func TestProjectServer(t *testing.T) { projectWithRole.Spec.Roles = []v1alpha1.ProjectRole{{Name: tokenName}} clientset := apps.NewSimpleClientset(projectWithRole) - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjListerFromInterface(clientset.ArgoprojV1alpha1().AppProjects("default")), "", session.NewUserStateStorage(nil)) projectServer := NewServer("default", fake.NewSimpleClientset(), clientset, enforcer, sync.NewKeyLock(), sessionMgr, policyEnf, projInformer, settingsMgr) tokenResponse, err := projectServer.CreateToken(context.Background(), &project.ProjectTokenCreateRequest{Project: projectWithRole.Name, Role: tokenName, ExpiresIn: 1, Id: id}) @@ -406,7 +406,7 @@ func TestProjectServer(t *testing.T) { _ = enforcer.SetBuiltinPolicy(`p, *, *, *, *, deny`) t.Run("TestDeleteTokenDenied", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projWithToken := existingProj.DeepCopy() issuedAt := int64(1) secondIssuedAt := issuedAt + 1 @@ -419,7 +419,7 @@ func TestProjectServer(t *testing.T) { }) t.Run("TestDeleteTokenSuccessfullyWithGroup", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projWithToken := existingProj.DeepCopy() issuedAt := int64(1) secondIssuedAt := issuedAt + 1 @@ -435,7 +435,7 @@ func TestProjectServer(t *testing.T) { p, role:admin, projects, update, *, allow`) t.Run("TestDeleteTokenSuccessfully", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projWithToken := existingProj.DeepCopy() issuedAt := int64(1) secondIssuedAt := issuedAt + 1 @@ -456,7 +456,7 @@ p, role:admin, projects, update, *, allow`) p, role:admin, projects, update, *, allow`) t.Run("TestDeleteTokenByIdSuccessfully", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projWithToken := existingProj.DeepCopy() issuedAt := int64(1) secondIssuedAt := issuedAt + 1 @@ -479,7 +479,7 @@ p, role:admin, projects, update, *, allow`) enforcer = newEnforcer(kubeclientset) t.Run("TestCreateTwoTokensInRoleSuccess", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projWithToken := existingProj.DeepCopy() tokenName := "testToken" token := v1alpha1.ProjectRole{Name: tokenName, JWTTokens: []v1alpha1.JWTToken{{IssuedAt: 1}}} @@ -644,7 +644,7 @@ p, role:admin, projects, update, *, allow`) }) t.Run("TestSyncWindowsActive", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithSyncWindows := existingProj.DeepCopy() projectWithSyncWindows.Spec.SyncWindows = v1alpha1.SyncWindows{} win := &v1alpha1.SyncWindow{Kind: "allow", Schedule: "* * * * *", Duration: "1h"} @@ -657,7 +657,7 @@ p, role:admin, projects, update, *, allow`) }) t.Run("TestGetSyncWindowsStateCannotGetProjectDetails", func(t *testing.T) { - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithSyncWindows := existingProj.DeepCopy() projectWithSyncWindows.Spec.SyncWindows = v1alpha1.SyncWindows{} win := &v1alpha1.SyncWindow{Kind: "allow", Schedule: "* * * * *", Duration: "1h"} @@ -676,7 +676,7 @@ p, role:admin, projects, update, *, allow`) // nolint:staticcheck ctx := context.WithValue(context.Background(), "claims", &jwt.MapClaims{"groups": []string{"my-group"}}) - sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewInMemoryUserStateStorage()) + sessionMgr := session.NewSessionManager(settingsMgr, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil)) projectWithSyncWindows := existingProj.DeepCopy() win := &v1alpha1.SyncWindow{Kind: "allow", Schedule: "* * * * *", Duration: "1h"} projectWithSyncWindows.Spec.SyncWindows = append(projectWithSyncWindows.Spec.SyncWindows, win) diff --git a/server/server.go b/server/server.go index 01b0e586cc18b..3189decfdc915 100644 --- a/server/server.go +++ b/server/server.go @@ -157,7 +157,8 @@ type ArgoCDServer struct { appLister applisters.ApplicationNamespaceLister // stopCh is the channel which when closed, will shutdown the Argo CD server - stopCh chan struct{} + stopCh chan struct{} + userStateStorage util_session.UserStateStorage } type ArgoCDServerOpts struct { @@ -216,7 +217,8 @@ func NewServer(ctx context.Context, opts ArgoCDServerOpts) *ArgoCDServer { appInformer := factory.Argoproj().V1alpha1().Applications().Informer() appLister := factory.Argoproj().V1alpha1().Applications().Lister().Applications(opts.Namespace) - sessionMgr := util_session.NewSessionManager(settingsMgr, projLister, opts.DexServerAddr, opts.Cache) + userStateStorage := util_session.NewUserStateStorage(opts.RedisClient) + sessionMgr := util_session.NewSessionManager(settingsMgr, projLister, opts.DexServerAddr, userStateStorage) enf := rbac.NewEnforcer(opts.KubeClientset, opts.Namespace, common.ArgoCDRBACConfigMapName, nil) enf.EnableEnforce(!opts.DisableAuth) err = enf.SetBuiltinPolicy(assets.BuiltinPolicyCSV) @@ -237,6 +239,7 @@ func NewServer(ctx context.Context, opts ArgoCDServerOpts) *ArgoCDServer { appInformer: appInformer, appLister: appLister, policyEnforcer: policyEnf, + userStateStorage: userStateStorage, } } @@ -261,6 +264,8 @@ func (a *ArgoCDServer) healthCheck(r *http.Request) error { // k8s.io/ go-to-protobuf uses protoc-gen-gogo, which comes from gogo/protobuf (a fork of // golang/protobuf). func (a *ArgoCDServer) Run(ctx context.Context, port int, metricsPort int) { + a.userStateStorage.Init(ctx) + grpcS := a.newGRPCServer() grpcWebS := grpcweb.WrapServer(grpcS) var httpS *http.Server @@ -541,7 +546,7 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server { if maxConcurrentLoginRequestsCount > 0 { loginRateLimiter = session.NewLoginRateLimiter(maxConcurrentLoginRequestsCount) } - sessionService := session.NewServer(a.sessionMgr, a, a.policyEnforcer, loginRateLimiter) + sessionService := session.NewServer(a.sessionMgr, a.settingsMgr, a, a.policyEnforcer, loginRateLimiter) projectLock := sync.NewKeyLock() applicationService := application.NewServer( a.Namespace, diff --git a/server/server_norace_test.go b/server/server_norace_test.go index ed8a7a66b273f..5f11abde4c95b 100644 --- a/server/server_norace_test.go +++ b/server/server_norace_test.go @@ -15,9 +15,8 @@ import ( "github.com/argoproj/argo-cd/common" "github.com/argoproj/argo-cd/pkg/apiclient" - "github.com/argoproj/argo-cd/test" - applicationpkg "github.com/argoproj/argo-cd/pkg/apiclient/application" + "github.com/argoproj/argo-cd/test" ) func TestUserAgent(t *testing.T) { @@ -27,7 +26,8 @@ func TestUserAgent(t *testing.T) { // the data race, it APPEARS to be intentional, but in any case it's nothing we are doing in Argo CD // that is causing this issue. - s := fakeServer() + s, closer := fakeServer() + defer closer() cancelInformer := test.StartInformer(s.projInformer) defer cancelInformer() port, err := test.GetFreePort() @@ -102,7 +102,8 @@ func Test_StaticHeaders(t *testing.T) { // Test default policy "sameorigin" { - s := fakeServer() + s, closer := fakeServer() + defer closer() cancelInformer := test.StartInformer(s.projInformer) defer cancelInformer() port, err := test.GetFreePort() @@ -131,7 +132,8 @@ func Test_StaticHeaders(t *testing.T) { // Test custom policy { - s := fakeServer() + s, closer := fakeServer() + defer closer() s.XFrameOptions = "deny" cancelInformer := test.StartInformer(s.projInformer) defer cancelInformer() @@ -161,7 +163,8 @@ func Test_StaticHeaders(t *testing.T) { // Test disabled { - s := fakeServer() + s, closer := fakeServer() + defer closer() s.XFrameOptions = "" cancelInformer := test.StartInformer(s.projInformer) defer cancelInformer() diff --git a/server/server_test.go b/server/server_test.go index 6e7d05722f44a..b225dda08b202 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -28,11 +28,12 @@ import ( "github.com/argoproj/argo-cd/util/rbac" ) -func fakeServer() *ArgoCDServer { +func fakeServer() (*ArgoCDServer, func()) { cm := test.NewFakeConfigMap() secret := test.NewFakeSecret() kubeclientset := fake.NewSimpleClientset(cm, secret) appClientSet := apps.NewSimpleClientset() + redis, closer := test.NewInMemoryRedis() argoCDOpts := ArgoCDServerOpts{ Namespace: test.FakeArgoCDNamespace, @@ -51,8 +52,9 @@ func fakeServer() *ArgoCDServer { 1*time.Minute, 1*time.Minute, ), + RedisClient: redis, } - return NewServer(context.Background(), argoCDOpts) + return NewServer(context.Background(), argoCDOpts), closer } func TestEnforceProjectToken(t *testing.T) { @@ -365,7 +367,8 @@ func TestRevokedToken(t *testing.T) { } func TestCertsAreNotGeneratedInInsecureMode(t *testing.T) { - s := fakeServer() + s, closer := fakeServer() + defer closer() assert.True(t, s.Insecure) assert.Nil(t, s.settings.Certificate) } @@ -385,7 +388,7 @@ func TestAuthenticate(t *testing.T) { }, { test: "TestSessionPresent", - user: "admin", + user: "admin:login", anonymousEnabled: false, }, { @@ -411,7 +414,7 @@ func TestAuthenticate(t *testing.T) { argocd := NewServer(context.Background(), argoCDOpts) ctx := context.Background() if testData.user != "" { - token, err := argocd.sessionMgr.Create("admin", 0, "") + token, err := argocd.sessionMgr.Create(testData.user, 0, "abc") assert.NoError(t, err) ctx = metadata.NewIncomingContext(context.Background(), metadata.Pairs(apiclient.MetaDataTokenKey, token)) } diff --git a/server/session/session.go b/server/session/session.go index 480dec68cd9b2..b3fe399fa54fd 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -2,7 +2,11 @@ package session import ( "context" + "fmt" + "github.com/argoproj/argo-cd/util/settings" + + "github.com/google/uuid" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -15,6 +19,7 @@ import ( // Server provides a Session service type Server struct { mgr *sessionmgr.SessionManager + settingsMgr *settings.SettingsManager authenticator Authenticator policyEnf *rbacpolicy.RBACPolicyEnforcer limitLoginAttempts func() (util.Closer, error) @@ -25,8 +30,8 @@ type Authenticator interface { } // NewServer returns a new instance of the Session service -func NewServer(mgr *sessionmgr.SessionManager, authenticator Authenticator, policyEnf *rbacpolicy.RBACPolicyEnforcer, rateLimiter func() (util.Closer, error)) *Server { - return &Server{mgr, authenticator, policyEnf, rateLimiter} +func NewServer(mgr *sessionmgr.SessionManager, settingsMgr *settings.SettingsManager, authenticator Authenticator, policyEnf *rbacpolicy.RBACPolicyEnforcer, rateLimiter func() (util.Closer, error)) *Server { + return &Server{mgr, settingsMgr, authenticator, policyEnf, rateLimiter} } // Create generates a JWT token signed by Argo CD intended for web/CLI logins of the admin user @@ -50,7 +55,19 @@ func (s *Server) Create(_ context.Context, q *session.SessionCreateRequest) (*se if err != nil { return nil, err } - jwtToken, err := s.mgr.Create(q.Username, 0, "") + uniqueId, err := uuid.NewRandom() + if err != nil { + return nil, err + } + argoCDSettings, err := s.settingsMgr.GetSettings() + if err != nil { + return nil, err + } + jwtToken, err := s.mgr.Create( + fmt.Sprintf("%s:%s", q.Username, settings.AccountCapabilityLogin), + int64(argoCDSettings.UserSessionDuration.Seconds()), + uniqueId.String()) + if err != nil { return nil, err } diff --git a/test/testdata.go b/test/testdata.go index a574681af3c24..268b88f7424c8 100644 --- a/test/testdata.go +++ b/test/testdata.go @@ -3,6 +3,9 @@ package test import ( "context" + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v8" + "github.com/argoproj/gitops-engine/pkg/utils/testing" apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -151,3 +154,11 @@ func NewFakeProjLister(objects ...runtime.Object) applister.AppProjectNamespaceL defer cancel() return factory.Argoproj().V1alpha1().AppProjects().Lister().AppProjects(FakeArgoCDNamespace) } + +func NewInMemoryRedis() (*redis.Client, func()) { + mr, err := miniredis.Run() + if err != nil { + panic(err) + } + return redis.NewClient(&redis.Options{Addr: mr.Addr()}), mr.Close +} diff --git a/util/jwt/jwt.go b/util/jwt/jwt.go index d96473820fd05..e1000e31f58a9 100644 --- a/util/jwt/jwt.go +++ b/util/jwt/jwt.go @@ -79,30 +79,40 @@ func GetID(m jwtgo.MapClaims) (string, error) { return "", fmt.Errorf("jti '%v' is not a string", m["jti"]) } -// IssuedAt returns the issued at as an int64 -func IssuedAt(m jwtgo.MapClaims) (int64, error) { - iatField, ok := m["iat"] +func numField(m jwtgo.MapClaims, key string) (int64, error) { + field, ok := m[key] if !ok { return 0, errors.New("token does not have iat claim") } - switch iat := iatField.(type) { + switch val := field.(type) { case float64: - return int64(iat), nil + return int64(val), nil case json.Number: - return iat.Int64() + return val.Int64() case int64: - return iat, nil + return val, nil default: - return 0, fmt.Errorf("iat '%v' is not a number", iat) + return 0, fmt.Errorf("%s '%v' is not a number", key, val) } } +// IssuedAt returns the issued at as an int64 +func IssuedAt(m jwtgo.MapClaims) (int64, error) { + return numField(m, "iat") +} + // IssuedAtTime returns the issued at as a time.Time func IssuedAtTime(m jwtgo.MapClaims) (time.Time, error) { iat, err := IssuedAt(m) return time.Unix(iat, 0), err } +// ExpirationTime returns the expiration as a time.Time +func ExpirationTime(m jwtgo.MapClaims) (time.Time, error) { + exp, err := numField(m, "exp") + return time.Unix(exp, 0), err +} + func Claims(in interface{}) jwtgo.Claims { claims, ok := in.(jwtgo.Claims) if ok { diff --git a/util/session/sessionmanager.go b/util/session/sessionmanager.go index a7960052e82c7..7d354f73395c8 100644 --- a/util/session/sessionmanager.go +++ b/util/session/sessionmanager.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "os" + "strings" "time" oidc "github.com/coreos/go-oidc" @@ -42,29 +43,6 @@ type SessionManager struct { verificationDelayNoiseEnabled bool } -type inMemoryUserStateStorage struct { - attempts map[string]LoginAttempts -} - -func NewInMemoryUserStateStorage() *inMemoryUserStateStorage { - return &inMemoryUserStateStorage{attempts: map[string]LoginAttempts{}} -} - -func (storage *inMemoryUserStateStorage) GetLoginAttempts(attempts *map[string]LoginAttempts) error { - *attempts = storage.attempts - return nil -} - -func (storage *inMemoryUserStateStorage) SetLoginAttempts(attempts map[string]LoginAttempts) error { - storage.attempts = attempts - return nil -} - -type UserStateStorage interface { - GetLoginAttempts(attempts *map[string]LoginAttempts) error - SetLoginAttempts(attempts map[string]LoginAttempts) error -} - // LoginAttempts is a timestamped counter for failed login attempts type LoginAttempts struct { // Time of the last failed login @@ -235,6 +213,22 @@ func (mgr *SessionManager) signClaims(claims jwt.Claims) (string, error) { })) } +// GetSubjectAccountAndCapability analyzes Argo CD account token subject and extract account name +// and the capability it was generated for (default capability is API Key). +func GetSubjectAccountAndCapability(subject string) (string, settings.AccountCapability) { + capability := settings.AccountCapabilityApiKey + if parts := strings.Split(subject, ":"); len(parts) > 1 { + subject = parts[0] + switch parts[1] { + case string(settings.AccountCapabilityLogin): + capability = settings.AccountCapabilityLogin + case string(settings.AccountCapabilityApiKey): + capability = settings.AccountCapabilityApiKey + } + } + return subject, capability +} + // Parse tries to parse the provided string and returns the token claims for local login. func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) { // Parse takes the token string and a function for looking up the key. The latter is especially @@ -278,6 +272,9 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) { return token.Claims, nil } + subject, capability := GetSubjectAccountAndCapability(subject) + claims["sub"] = subject + account, err := mgr.settingsMgr.GetAccount(subject) if err != nil { return nil, err @@ -287,17 +284,13 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) { return nil, fmt.Errorf("account %s is disabled", subject) } - var capability settings.AccountCapability - if id != "" { - capability = settings.AccountCapabilityApiKey - } else { - capability = settings.AccountCapabilityLogin - } if !account.HasCapability(capability) { return nil, fmt.Errorf("account %s does not have '%s' capability", subject, capability) } - if id != "" && account.TokenIndex(id) == -1 { + if id == "" || mgr.storage.IsTokenRevoked(id) { + return nil, errors.New("token is revoked, please re-login") + } else if capability == settings.AccountCapabilityApiKey && account.TokenIndex(id) == -1 { return nil, fmt.Errorf("account %s does not have token with id %s", subject, id) } @@ -541,6 +534,10 @@ func (mgr *SessionManager) provider() (oidcutil.Provider, error) { return mgr.prov, nil } +func (mgr *SessionManager) RevokeToken(ctx context.Context, id string, expiringAt time.Duration) error { + return mgr.storage.RevokeToken(ctx, id, expiringAt) +} + func LoggedIn(ctx context.Context) bool { return Sub(ctx) != "" } diff --git a/util/session/sessionmanager_norace_test.go b/util/session/sessionmanager_norace_test.go index 4cc50541b96cf..97bbefe58cae4 100644 --- a/util/session/sessionmanager_norace_test.go +++ b/util/session/sessionmanager_norace_test.go @@ -22,7 +22,7 @@ func TestRandomPasswordVerificationDelay(t *testing.T) { var sleptFor time.Duration settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("password", true), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) mgr.verificationDelayNoiseEnabled = true mgr.sleep = func(d time.Duration) { sleptFor = d diff --git a/util/session/sessionmanager_test.go b/util/session/sessionmanager_test.go index fab1ff2ab5409..4f2fe2ab583ea 100644 --- a/util/session/sessionmanager_test.go +++ b/util/session/sessionmanager_test.go @@ -78,13 +78,13 @@ func newSessionManager(settingsMgr *settings.SettingsManager, projectLister v1al } func TestSessionManager_AdminToken(t *testing.T) { - const ( - defaultSubject = "admin" - ) + redisClient, closer := test.NewInMemoryRedis() + defer closer() + settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("pass", true), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(redisClient)) - token, err := mgr.Create(defaultSubject, 0, "") + token, err := mgr.Create("admin:login", 0, "123") if err != nil { t.Errorf("Could not create token: %v", err) } @@ -97,15 +97,35 @@ func TestSessionManager_AdminToken(t *testing.T) { mapClaims := *(claims.(*jwt.MapClaims)) subject := mapClaims["sub"].(string) if subject != "admin" { - t.Errorf("Token claim subject \"%s\" does not match expected subject \"%s\".", subject, defaultSubject) + t.Errorf("Token claim subject \"%s\" does not match expected subject \"%s\".", subject, "admin") } } +func TestSessionManager_AdminToken_Revoked(t *testing.T) { + redisClient, closer := test.NewInMemoryRedis() + defer closer() + + settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("pass", true), "argocd") + storage := NewUserStateStorage(redisClient) + + mgr := newSessionManager(settingsMgr, getProjLister(), storage) + + token, err := mgr.Create("admin:login", 0, "123") + require.NoError(t, err) + + err = storage.RevokeToken(context.Background(), "123", time.Hour) + require.NoError(t, err) + + _, err = mgr.Parse(token) + require.Error(t, err) + assert.Equal(t, "token is revoked, please re-login", err.Error()) +} + func TestSessionManager_AdminToken_Deactivated(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("pass", false), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) - token, err := mgr.Create("admin", 0, "") + token, err := mgr.Create("admin:login", 0, "abc") if err != nil { t.Errorf("Could not create token: %v", err) } @@ -117,7 +137,7 @@ func TestSessionManager_AdminToken_Deactivated(t *testing.T) { func TestSessionManager_AdminToken_LoginCapabilityDisabled(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("pass", true, settings.AccountCapabilityLogin), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) token, err := mgr.Create("admin", 0, "abc") if err != nil { @@ -145,7 +165,7 @@ func TestSessionManager_ProjectToken(t *testing.T) { }, }}, } - mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewUserStateStorage(nil)) jwtToken, err := mgr.Create("proj:default:test", 100, "abc") require.NoError(t, err) @@ -163,7 +183,7 @@ func TestSessionManager_ProjectToken(t *testing.T) { Spec: appv1.AppProjectSpec{Roles: []appv1.ProjectRole{{Name: "test"}}}, } - mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(&proj), NewUserStateStorage(nil)) jwtToken, err := mgr.Create("proj:default:test", 10, "") require.NoError(t, err) @@ -246,7 +266,7 @@ func TestVerifyUsernamePassword(t *testing.T) { t.Run(tc.name, func(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient(password, !tc.disabled), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) err := mgr.VerifyUsernamePassword(tc.userName, tc.password) @@ -328,7 +348,7 @@ func TestCacheValueGetters(t *testing.T) { func TestLoginRateLimiter(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("password", true), "argocd") - storage := NewInMemoryUserStateStorage() + storage := NewUserStateStorage(nil) mgr := newSessionManager(settingsMgr, getProjLister(), storage) @@ -369,7 +389,7 @@ func TestMaxUsernameLength(t *testing.T) { username += "a" } settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("password", true), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) err := mgr.VerifyUsernamePassword(username, "password") assert.Error(t, err) assert.Contains(t, err.Error(), fmt.Sprintf(usernameTooLongError, maxUsernameLength)) @@ -377,7 +397,7 @@ func TestMaxUsernameLength(t *testing.T) { func TestMaxCacheSize(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("password", true), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) invalidUsers := []string{"invalid1", "invalid2", "invalid3", "invalid4", "invalid5", "invalid6", "invalid7"} // Temporarily decrease max cache size @@ -393,7 +413,7 @@ func TestMaxCacheSize(t *testing.T) { func TestFailedAttemptsExpiry(t *testing.T) { settingsMgr := settings.NewSettingsManager(context.Background(), getKubeClient("password", true), "argocd") - mgr := newSessionManager(settingsMgr, getProjLister(), NewInMemoryUserStateStorage()) + mgr := newSessionManager(settingsMgr, getProjLister(), NewUserStateStorage(nil)) invalidUsers := []string{"invalid1", "invalid2", "invalid3", "invalid4", "invalid5", "invalid6", "invalid7"} diff --git a/util/session/state.go b/util/session/state.go new file mode 100644 index 0000000000000..d84fc5b9a2a95 --- /dev/null +++ b/util/session/state.go @@ -0,0 +1,134 @@ +package session + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/go-redis/redis/v8" + log "github.com/sirupsen/logrus" + + util "github.com/argoproj/argo-cd/util/io" +) + +const ( + revokedTokenPrefix = "revoked-token|" + newRevokedTokenKey = "new-revoked-token" +) + +type userStateStorage struct { + attempts map[string]LoginAttempts + redis *redis.Client + revokedTokens map[string]bool + lock sync.RWMutex + resyncDuration time.Duration +} + +func NewUserStateStorage(redis *redis.Client) *userStateStorage { + return &userStateStorage{ + attempts: map[string]LoginAttempts{}, + revokedTokens: map[string]bool{}, + resyncDuration: time.Hour, + redis: redis, + } +} + +func (storage *userStateStorage) Init(ctx context.Context) { + go storage.watchRevokedTokens(ctx) + ticker := time.NewTicker(storage.resyncDuration) + go func() { + storage.loadRevokedTokensSafe() + for range ticker.C { + storage.loadRevokedTokensSafe() + } + }() + go func() { + <-ctx.Done() + ticker.Stop() + }() +} + +func (storage *userStateStorage) watchRevokedTokens(ctx context.Context) { + pubsub := storage.redis.Subscribe(ctx, newRevokedTokenKey) + defer util.Close(pubsub) + + ch := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case val := <-ch: + storage.lock.Lock() + storage.revokedTokens[val.Payload] = true + storage.lock.Unlock() + } + } +} + +func (storage *userStateStorage) loadRevokedTokensSafe() { + for err := storage.loadRevokedTokens(); err != nil; { + log.Warnf("Failed to resync revoked tokens. retrying again in 1 minute: %v", err) + time.Sleep(time.Minute) + } +} + +func (storage *userStateStorage) loadRevokedTokens() error { + storage.lock.Lock() + defer storage.lock.Unlock() + storage.revokedTokens = map[string]bool{} + iterator := storage.redis.Scan(context.Background(), 0, revokedTokenPrefix+"*", -1).Iterator() + for iterator.Next(context.Background()) { + parts := strings.Split(iterator.Val(), "|") + if len(parts) != 2 { + log.Warnf("Unexpected redis key prefixed with '%s'. Must have token id after the prefix but got: '%s'.", + revokedTokenPrefix, + iterator.Val()) + continue + } + storage.revokedTokens[parts[1]] = true + } + if iterator.Err() != nil { + return iterator.Err() + } + + return nil +} + +func (storage *userStateStorage) GetLoginAttempts(attempts *map[string]LoginAttempts) error { + *attempts = storage.attempts + return nil +} + +func (storage *userStateStorage) SetLoginAttempts(attempts map[string]LoginAttempts) error { + storage.attempts = attempts + return nil +} + +func (storage *userStateStorage) RevokeToken(ctx context.Context, id string, expiringAt time.Duration) error { + storage.lock.Lock() + storage.revokedTokens[id] = true + storage.lock.Unlock() + if err := storage.redis.Set(ctx, revokedTokenPrefix+id, "", expiringAt).Err(); err != nil { + return err + } + return storage.redis.Publish(ctx, newRevokedTokenKey, id).Err() +} + +func (storage *userStateStorage) IsTokenRevoked(id string) bool { + storage.lock.RLock() + defer storage.lock.RUnlock() + return storage.revokedTokens[id] +} + +type UserStateStorage interface { + Init(ctx context.Context) + // GetLoginAttempts return number of concurrent login attempts + GetLoginAttempts(attempts *map[string]LoginAttempts) error + // SetLoginAttempts sets number of concurrent login attempts + SetLoginAttempts(attempts map[string]LoginAttempts) error + // RevokeToken revokes token with given id (information about revocation expires after specified timeout) + RevokeToken(ctx context.Context, id string, expiringAt time.Duration) error + // IsTokenRevoked checks if given token is revoked + IsTokenRevoked(id string) bool +} diff --git a/util/session/state_test.go b/util/session/state_test.go new file mode 100644 index 0000000000000..f76b470d6428d --- /dev/null +++ b/util/session/state_test.go @@ -0,0 +1,29 @@ +package session + +import ( + "context" + "testing" + "time" + + "github.com/argoproj/argo-cd/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserStateStorage_LoadRevokedTokens(t *testing.T) { + redis, closer := test.NewInMemoryRedis() + defer closer() + + err := redis.Set(context.Background(), revokedTokenPrefix+"abc", "", time.Hour).Err() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + storage := NewUserStateStorage(redis) + storage.Init(ctx) + time.Sleep(time.Millisecond * 100) + + assert.True(t, storage.IsTokenRevoked("abc")) +} diff --git a/util/settings/settings.go b/util/settings/settings.go index 24e11d604cea1..d49fd7d6a00b1 100644 --- a/util/settings/settings.go +++ b/util/settings/settings.go @@ -14,6 +14,7 @@ import ( "sync" "time" + timeutil "github.com/argoproj/pkg/time" "github.com/ghodss/yaml" log "github.com/sirupsen/logrus" apiv1 "k8s.io/api/core/v1" @@ -68,6 +69,8 @@ type ArgoCDSettings struct { KustomizeBuildOptions string `json:"kustomizeBuildOptions,omitempty"` // Indicates if anonymous user is enabled or not AnonymousUserEnabled bool `json:"anonymousUserEnabled,omitempty"` + // Specifies token expiration duration + UserSessionDuration time.Duration `json:"userSessionDuration,omitempty"` // UiCssURL local or remote path to user-defined CSS to customize ArgoCD UI UiCssURL string `json:"uiCssURL,omitempty"` // Content of UI Banner @@ -247,6 +250,8 @@ const ( kustomizeVersionKeyPrefix = "kustomize.version" // anonymousUserEnabledKey is the key which enables or disables anonymous user anonymousUserEnabledKey = "users.anonymous.enabled" + // anonymousUserEnabledKey is the key which specifies token expiration duration + userSessionDurationKey = "users.session.duration" // diffOptions is the key where diff options are configured resourceCompareOptionsKey = "resource.compareoptions" // settingUiCssURLKey designates the key for user-defined CSS URL for UI customization @@ -847,6 +852,15 @@ func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *apiv1.Confi log.Warnf("Failed to validate UI banner URL in configmap: %v", err) } settings.UiBannerURL = argoCDCM.Data[settingUiBannerURLKey] + if userSessionDurationStr, ok := argoCDCM.Data[userSessionDurationKey]; ok { + if val, err := timeutil.ParseDuration(userSessionDurationStr); err != nil { + log.Warnf("Failed to parse '%s' key: %v", userSessionDurationKey, err) + } else { + settings.UserSessionDuration = *val + } + } else { + settings.UserSessionDuration = time.Hour * 24 + } } // validateExternalURL ensures the external URL that is set on the configmap is valid