Skip to content

Commit

Permalink
feat: regenerate active users token if it is expiring soon (#5629)
Browse files Browse the repository at this point in the history
* feat: regenerate active users token if it is expiring soon

Signed-off-by: Alexander Matyushentsev <[email protected]>

* Comment how 'renew-token' header is used

Signed-off-by: Alexander Matyushentsev <[email protected]>
  • Loading branch information
Alexander Matyushentsev authored Mar 3, 2021
1 parent cdabf31 commit 0ccd573
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 70 deletions.
4 changes: 2 additions & 2 deletions server/logout/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type Handler struct {
appClientset versioned.Interface
settingsMgr *settings.SettingsManager
rootPath string
verifyToken func(tokenString string) (jwt.Claims, error)
verifyToken func(tokenString string) (jwt.Claims, string, error)
revokeToken func(ctx context.Context, id string, expiringAt time.Duration) error
}

Expand Down Expand Up @@ -85,7 +85,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Set-Cookie", argocdCookie.String())
}

claims, err := h.verifyToken(tokenString)
claims, _, err := h.verifyToken(tokenString)
if err != nil {
http.Redirect(w, r, logoutRedirectURL, http.StatusSeeOther)
return
Expand Down
18 changes: 9 additions & 9 deletions server/logout/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,25 +180,25 @@ func TestHandlerConstructLogoutURL(t *testing.T) {
sessionManager := session.NewSessionManager(settingsManagerWithOIDCConfig, test.NewFakeProjLister(), "", session.NewUserStateStorage(nil))

oidcHandler := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfig, sessionManager, "", "default")
oidcHandler.verifyToken = func(tokenString string) (jwt.Claims, error) {
oidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
if !validJWTPattern.MatchString(tokenString) {
return nil, errors.New("invalid jwt")
return nil, "", errors.New("invalid jwt")
}
return &jwt.StandardClaims{Issuer: "okta"}, nil
return &jwt.StandardClaims{Issuer: "okta"}, "", nil
}
nonoidcHandler := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithoutOIDCConfig, sessionManager, "", "default")
nonoidcHandler.verifyToken = func(tokenString string) (jwt.Claims, error) {
nonoidcHandler.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
if !validJWTPattern.MatchString(tokenString) {
return nil, errors.New("invalid jwt")
return nil, "", errors.New("invalid jwt")
}
return &jwt.StandardClaims{Issuer: session.SessionManagerClaimsIssuer}, nil
return &jwt.StandardClaims{Issuer: session.SessionManagerClaimsIssuer}, "", nil
}
oidcHandlerWithoutLogoutURL := NewHandler(appclientset.NewSimpleClientset(), settingsManagerWithOIDCConfigButNoLogoutURL, sessionManager, "", "default")
oidcHandlerWithoutLogoutURL.verifyToken = func(tokenString string) (jwt.Claims, error) {
oidcHandlerWithoutLogoutURL.verifyToken = func(tokenString string) (jwt.Claims, string, error) {
if !validJWTPattern.MatchString(tokenString) {
return nil, errors.New("invalid jwt")
return nil, "", errors.New("invalid jwt")
}
return &jwt.StandardClaims{Issuer: "okta"}, nil
return &jwt.StandardClaims{Issuer: "okta"}, "", nil
}

oidcTokenHeader := make(map[string][]string)
Expand Down
6 changes: 3 additions & 3 deletions server/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ func TestProjectServer(t *testing.T) {
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)
claims, err := sessionMgr.Parse(tokenResponse.Token)
claims, _, err := sessionMgr.Parse(tokenResponse.Token)
assert.NoError(t, err)

mapClaims, err := jwtutil.MapClaims(claims)
Expand All @@ -367,7 +367,7 @@ func TestProjectServer(t *testing.T) {
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)
claims, err := sessionMgr.Parse(tokenResponse.Token)
claims, _, err := sessionMgr.Parse(tokenResponse.Token)
assert.NoError(t, err)

mapClaims, err := jwtutil.MapClaims(claims)
Expand All @@ -388,7 +388,7 @@ func TestProjectServer(t *testing.T) {
tokenResponse, err := projectServer.CreateToken(context.Background(), &project.ProjectTokenCreateRequest{Project: projectWithRole.Name, Role: tokenName, ExpiresIn: 1, Id: id})

assert.NoError(t, err)
claims, err := sessionMgr.Parse(tokenResponse.Token)
claims, _, err := sessionMgr.Parse(tokenResponse.Token)
assert.NoError(t, err)

mapClaims, err := jwtutil.MapClaims(claims)
Expand Down
69 changes: 46 additions & 23 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import (

const maxConcurrentLoginRequestsCountEnv = "ARGOCD_MAX_CONCURRENT_LOGIN_REQUESTS_COUNT"
const replicasCountEnv = "ARGOCD_API_SERVER_REPLICAS"
const renewTokenKey = "renew-token"

// ErrNoSession indicates no auth token was supplied as part of a request
var ErrNoSession = status.Errorf(codes.Unauthenticated, "no session information")
Expand Down Expand Up @@ -595,30 +596,44 @@ func (a *ArgoCDServer) newGRPCServer() *grpc.Server {
return grpcS
}

// TranslateGrpcCookieHeader conditionally sets a cookie on the response.
// translateGrpcCookieHeader conditionally sets a cookie on the response.
func (a *ArgoCDServer) translateGrpcCookieHeader(ctx context.Context, w http.ResponseWriter, resp golang_proto.Message) error {
if sessionResp, ok := resp.(*sessionpkg.SessionResponse); ok {
cookiePath := fmt.Sprintf("path=/%s", strings.TrimRight(strings.TrimLeft(a.ArgoCDServerOpts.RootPath, "/"), "/"))
flags := []string{cookiePath, "SameSite=lax", "httpOnly"}
if !a.Insecure {
flags = append(flags, "Secure")
}
token := sessionResp.Token
if token != "" {
var err error
token, err = zjwt.ZJWT(token)
if err != nil {
return err
}
}
cookies, err := httputil.MakeCookieMetadata(common.AuthCookieName, token, flags...)
err := a.setTokenCookie(token, w)
if err != nil {
return err
}
for _, cookie := range cookies {
w.Header().Add("Set-Cookie", cookie)
} else if md, ok := runtime.ServerMetadataFromContext(ctx); ok {
renewToken := md.HeaderMD[renewTokenKey]
if len(renewToken) > 0 {
return a.setTokenCookie(renewToken[0], w)
}
}

return nil
}

func (a *ArgoCDServer) setTokenCookie(token string, w http.ResponseWriter) error {
cookiePath := fmt.Sprintf("path=/%s", strings.TrimRight(strings.TrimLeft(a.ArgoCDServerOpts.RootPath, "/"), "/"))
flags := []string{cookiePath, "SameSite=lax", "httpOnly"}
if !a.Insecure {
flags = append(flags, "Secure")
}
if token != "" {
var err error
token, err = zjwt.ZJWT(token)
if err != nil {
return err
}
}
cookies, err := httputil.MakeCookieMetadata(common.AuthCookieName, token, flags...)
if err != nil {
return err
}
for _, cookie := range cookies {
w.Header().Add("Set-Cookie", cookie)
}
return nil
}

Expand Down Expand Up @@ -895,11 +910,19 @@ func (a *ArgoCDServer) Authenticate(ctx context.Context) (context.Context, error
if a.DisableAuth {
return ctx, nil
}
claims, claimsErr := a.getClaims(ctx)
claims, newToken, claimsErr := a.getClaims(ctx)
if claims != nil {
// Add claims to the context to inspect for RBAC
// nolint:staticcheck
ctx = context.WithValue(ctx, "claims", claims)
if newToken != "" {
// Session tokens that are expiring soon should be regenerated if user stays active.
// The renewed token is stored in outgoing ServerMetadata. Metadata is available to grpc-gateway
// response forwarder that will translate it into Set-Cookie header.
if err := grpc.SendHeader(ctx, metadata.New(map[string]string{renewTokenKey: newToken})); err != nil {
log.Warnf("Failed to set %s header", renewTokenKey)
}
}
}

if claimsErr != nil {
Expand All @@ -915,20 +938,20 @@ func (a *ArgoCDServer) Authenticate(ctx context.Context) (context.Context, error
return ctx, nil
}

func (a *ArgoCDServer) getClaims(ctx context.Context) (jwt.Claims, error) {
func (a *ArgoCDServer) getClaims(ctx context.Context) (jwt.Claims, string, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, ErrNoSession
return nil, "", ErrNoSession
}
tokenString := getToken(md)
if tokenString == "" {
return nil, ErrNoSession
return nil, "", ErrNoSession
}
claims, err := a.sessionMgr.VerifyToken(tokenString)
claims, newToken, err := a.sessionMgr.VerifyToken(tokenString)
if err != nil {
return claims, status.Errorf(codes.Unauthenticated, "invalid session: %v", err)
return claims, "", status.Errorf(codes.Unauthenticated, "invalid session: %v", err)
}
return claims, nil
return claims, newToken, nil
}

// getToken extracts the token from gRPC metadata or cookie headers
Expand Down
64 changes: 40 additions & 24 deletions util/session/sessionmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

oidc "github.com/coreos/go-oidc"
"github.com/dgrijalva/jwt-go/v4"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand Down Expand Up @@ -56,11 +57,12 @@ const (
SessionManagerClaimsIssuer = "argocd"

// invalidLoginError, for security purposes, doesn't say whether the username or password was invalid. This does not mitigate the potential for timing attacks to determine which is which.
invalidLoginError = "Invalid username or password"
blankPasswordError = "Blank passwords are not allowed"
accountDisabled = "Account %s is disabled"
usernameTooLongError = "Username is too long (%d bytes max)"
userDoesNotHaveCapability = "Account %s does not have %s capability"
invalidLoginError = "Invalid username or password"
blankPasswordError = "Blank passwords are not allowed"
accountDisabled = "Account %s is disabled"
usernameTooLongError = "Username is too long (%d bytes max)"
userDoesNotHaveCapability = "Account %s does not have %s capability"
autoRegenerateTokenDuration = time.Minute * 5
)

const (
Expand Down Expand Up @@ -230,15 +232,15 @@ func GetSubjectAccountAndCapability(subject string) (string, settings.AccountCap
}

// Parse tries to parse the provided string and returns the token claims for local login.
func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) {
func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, string, error) {
// Parse takes the token string and a function for looking up the key. The latter is especially
// useful if you use multiple keys for your application. The standard is to use 'kid' in the
// head of the token to identify which key to use, but the parsed token (head and claims) is provided
// to the callback, providing flexibility.
var claims jwt.MapClaims
argoCDSettings, err := mgr.settingsMgr.GetSettings()
if err != nil {
return nil, err
return nil, "", err
}
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
Expand All @@ -248,12 +250,12 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) {
return argoCDSettings.ServerSignature, nil
})
if err != nil {
return nil, err
return nil, "", err
}

issuedAt, err := jwtutil.IssuedAtTime(claims)
if err != nil {
return nil, err
return nil, "", err
}

subject := jwtutil.StringField(claims, "sub")
Expand All @@ -262,42 +264,56 @@ func (mgr *SessionManager) Parse(tokenString string) (jwt.Claims, error) {
if projName, role, ok := rbacpolicy.GetProjectRoleFromSubject(subject); ok {
proj, err := mgr.projectsLister.Get(projName)
if err != nil {
return nil, err
return nil, "", err
}
_, _, err = proj.GetJWTToken(role, issuedAt.Unix(), id)
if err != nil {
return nil, err
return nil, "", err
}

return token.Claims, nil
return token.Claims, "", nil
}

subject, capability := GetSubjectAccountAndCapability(subject)
claims["sub"] = subject

account, err := mgr.settingsMgr.GetAccount(subject)
if err != nil {
return nil, err
return nil, "", err
}

if !account.Enabled {
return nil, fmt.Errorf("account %s is disabled", subject)
return nil, "", fmt.Errorf("account %s is disabled", subject)
}

if !account.HasCapability(capability) {
return nil, fmt.Errorf("account %s does not have '%s' capability", subject, capability)
return nil, "", fmt.Errorf("account %s does not have '%s' capability", subject, capability)
}

if id == "" || mgr.storage.IsTokenRevoked(id) {
return nil, errors.New("token is revoked, please re-login")
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)
return nil, "", fmt.Errorf("account %s does not have token with id %s", subject, id)
}

if account.PasswordMtime != nil && issuedAt.Before(*account.PasswordMtime) {
return nil, fmt.Errorf("Account password has changed since token issued")
return nil, "", fmt.Errorf("Account password has changed since token issued")
}

newToken := ""
if exp, err := jwtutil.ExpirationTime(claims); err == nil {
tokenExpDuration := exp.Sub(issuedAt)
remainingDuration := time.Until(exp)

if remainingDuration < autoRegenerateTokenDuration && capability == settings.AccountCapabilityLogin {
if uniqueId, err := uuid.NewRandom(); err == nil {
if val, err := mgr.Create(fmt.Sprintf("%s:%s", subject, settings.AccountCapabilityLogin), int64(tokenExpDuration.Seconds()), uniqueId.String()); err == nil {
newToken = val
}
}
}
}
return token.Claims, nil
return token.Claims, newToken, nil
}

// GetLoginFailures retrieves the login failure information from the cache
Expand Down Expand Up @@ -481,14 +497,14 @@ func (mgr *SessionManager) VerifyUsernamePassword(username string, password stri

// VerifyToken verifies if a token is correct. Tokens can be issued either from us or by an IDP.
// We choose how to verify based on the issuer.
func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, error) {
func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, string, error) {
parser := &jwt.Parser{
ValidationHelper: jwt.NewValidationHelper(jwt.WithoutClaimsValidation(), jwt.WithoutAudienceValidation()),
}
var claims jwt.StandardClaims
_, _, err := parser.ParseUnverified(tokenString, &claims)
if err != nil {
return nil, err
return nil, "", err
}
switch claims.Issuer {
case SessionManagerClaimsIssuer:
Expand All @@ -498,7 +514,7 @@ func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, error) {
// IDP signed token
prov, err := mgr.provider()
if err != nil {
return claims, err
return claims, "", err
}

// Token must be verified for at least one audience
Expand All @@ -511,11 +527,11 @@ func (mgr *SessionManager) VerifyToken(tokenString string) (jwt.Claims, error) {
}
}
if err != nil {
return claims, err
return claims, "", err
}
var claims jwt.MapClaims
err = idToken.Claims(&claims)
return claims, err
return claims, "", err
}
}

Expand Down
Loading

0 comments on commit 0ccd573

Please sign in to comment.