From 8b39faef1b6ba466ed31655eed173df8a2c19816 Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Thu, 17 Dec 2020 10:30:40 -0500 Subject: [PATCH] Rearrange some auth logic and add some opts useful for filtering/testing (#835) --- internal/auth/auth.go | 559 +++++++++++++++++++++------------------- internal/auth/option.go | 33 ++- 2 files changed, 310 insertions(+), 282 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 95175be09..84dfdba60 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -195,277 +195,6 @@ func Verify(ctx context.Context, opt ...Option) (ret VerifyResults) { return } -// AdditionalVerification is used to perform checks of additional resources for -// actions that need to touch more than one. -func (r *VerifyResults) AdditionalVerification(ctx context.Context, opt ...Option) (ret VerifyResults) { - v := r.v - - ret.Error = handlers.ForbiddenError() - - // Set other parameters the same to start with - ret.Scope = r.Scope - ret.UserId = r.UserId - ret.AuthTokenId = r.AuthTokenId - ret.v = r.v - - opts := getOpts(opt...) - - act := opts.withAction - res := perms.Resource{ - ScopeId: opts.withScopeId, - Id: opts.withId, - Pin: opts.withPin, - Type: opts.withType, - } - // Global scope has no parent ID; account for this - if opts.withId == scope.Global.String() && opts.withType == resource.Scope { - res.ScopeId = scope.Global.String() - } - - // Only perform lookup if it's actually different, otherwise use cached info - if res.ScopeId != r.Scope.Id { - iamRepo, err := v.iamRepoFn() - if err != nil { - v.logger.Error("additional verification: failed to get iam repo", "error", err) - return - } - - // Look up scope details to return. We can skip a lookup when using the - // global scope - switch res.ScopeId { - case "global": - ret.Scope = &scopes.ScopeInfo{ - Id: scope.Global.String(), - Type: scope.Global.String(), - Name: scope.Global.String(), - Description: "Global Scope", - ParentScopeId: "", - } - - default: - scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId) - if err != nil { - v.logger.Error("additional verification: failed to get look up scope", "error", err) - return - } - if scp == nil { - v.logger.Error("additional verification: non-existent scope", "error", err) - return - } - ret.Scope = &scopes.ScopeInfo{ - Id: scp.GetPublicId(), - Type: scp.GetType(), - Name: scp.GetName(), - Description: scp.GetDescription(), - ParentScopeId: scp.GetParentId(), - } - } - } - - // Always allowed - if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms { - ret.Error = nil - return - } - - aclResults := v.acl.Allowed(res, act) - - if !aclResults.Allowed { - if v.requestInfo.DisableAuthzFailures { - ret.Error = nil - // TODO: Decide whether to remove this - v.logger.Info("failed authz info for request", "resource", pretty.Sprint(v.res), "user_id", ret.UserId, "action", v.act.String()) - } else { - // If the anon user was used (either no token, or invalid (perhaps - // expired) token), return a 401. That way if it's an authn'd user - // that is not authz'd we'll return 403 to be explicit. - if ret.UserId == "u_anon" { - ret.Error = handlers.UnauthenticatedError() - } - return - } - } - - ret.Error = nil - return -} - -func (v verifier) performAuthCheck() (aclResults perms.ACLResults, userId string, scopeInfo *scopes.ScopeInfo, retAcl perms.ACL, retErr error) { - // Ensure we return an error by default if we forget to set this somewhere - retErr = errors.New("unknown") - // Make the linter happy - _ = retErr - scopeInfo = new(scopes.ScopeInfo) - userId = "u_anon" - var accountId string - - // Validate the token and fetch the corresponding user ID - switch v.requestInfo.TokenFormat { - case AuthTokenTypeUnknown: - // Nothing; remain as the anonymous user - - case AuthTokenTypeRecoveryKms: - // We validated the encrypted token in decryptToken and handled the - // nonces there, so just set the user - userId = "u_recovery" - - case AuthTokenTypeBearer, AuthTokenTypeSplitCookie: - if v.requestInfo.Token == "" { - // This will end up staying as the anonymous user - break - } - tokenRepo, err := v.authTokenRepoFn() - if err != nil { - retErr = fmt.Errorf("perform auth check: failed to get authtoken repo: %w", err) - return - } - at, err := tokenRepo.ValidateToken(v.ctx, v.requestInfo.PublicId, v.requestInfo.Token) - if err != nil { - // Continue as the anonymous user as maybe this token is expired but - // we can still perform the action - v.logger.Error("perform auth check: error validating token; continuing as anonymous user", "error", err) - break - } - if at != nil { - accountId = at.GetAuthAccountId() - userId = at.GetIamUserId() - if userId == "" { - v.logger.Warn("perform auth check: valid token did not map to a user, likely because no account is associated with the user any longer; continuing as u_anon", "token_id", at.GetPublicId()) - userId = "u_anon" - accountId = "" - } - } - } - - iamRepo, err := v.iamRepoFn() - if err != nil { - retErr = fmt.Errorf("perform auth check: failed to get iam repo: %w", err) - return - } - - // Look up scope details to return. We can skip a lookup when using the - // global scope - switch v.res.ScopeId { - case "global": - scopeInfo = &scopes.ScopeInfo{ - Id: scope.Global.String(), - Type: scope.Global.String(), - Name: scope.Global.String(), - Description: "Global Scope", - ParentScopeId: "", - } - - default: - scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId) - if err != nil { - retErr = fmt.Errorf("perform auth check: failed to lookup scope: %w", err) - return - } - if scp == nil { - retErr = fmt.Errorf("perform auth check: non-existent scope %q", v.res.ScopeId) - return - } - scopeInfo = &scopes.ScopeInfo{ - Id: scp.GetPublicId(), - Type: scp.GetType(), - Name: scp.GetName(), - Description: scp.GetDescription(), - ParentScopeId: scp.GetParentId(), - } - } - - // At this point we don't need to look up grants since it's automatically allowed - if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms { - aclResults.Allowed = true - retErr = nil - return - } - - var parsedGrants []perms.Grant - var grantPairs []perms.GrantPair - - // Fetch and parse grants for this user ID (which may include grants for - // u_anon and u_auth) - grantPairs, err = iamRepo.GrantsForUser(v.ctx, userId) - if err != nil { - retErr = fmt.Errorf("perform auth check: failed to query for user grants: %w", err) - return - } - parsedGrants = make([]perms.Grant, 0, len(grantPairs)) - for _, pair := range grantPairs { - parsed, err := perms.Parse( - pair.ScopeId, - pair.Grant, - perms.WithUserId(userId), - perms.WithAccountId(accountId), - perms.WithSkipFinalValidation(true)) - if err != nil { - retErr = fmt.Errorf("perform auth check: failed to parse grant %#v: %w", pair.Grant, err) - return - } - parsedGrants = append(parsedGrants, parsed) - } - - retAcl = perms.NewACL(parsedGrants...) - aclResults = retAcl.Allowed(*v.res, v.act) - retErr = nil - return -} - -// GetTokenFromRequest pulls the token from either the Authorization header or -// split cookies and parses it. If it cannot be parsed successfully, the issue -// is logged and we return blank, so logic will continue as the anonymous user. -// The public ID and _encrypted_ token are returned along with the token format. -func GetTokenFromRequest(logger hclog.Logger, kmsCache *kms.Kms, req *http.Request) (string, string, TokenFormat) { - // First, get the token, either from the authorization header or from split - // cookies - var receivedTokenType TokenFormat - var fullToken string - if authHeader := req.Header.Get("Authorization"); authHeader != "" { - headerSplit := strings.SplitN(strings.TrimSpace(authHeader), " ", 2) - if len(headerSplit) == 2 && strings.EqualFold(strings.TrimSpace(headerSplit[0]), "bearer") { - receivedTokenType = AuthTokenTypeBearer - fullToken = strings.TrimSpace(headerSplit[1]) - } - } - if receivedTokenType != AuthTokenTypeBearer { - var httpCookiePayload string - var jsCookiePayload string - if hc, err := req.Cookie(handlers.HttpOnlyCookieName); err == nil { - httpCookiePayload = hc.Value - } - if jc, err := req.Cookie(handlers.JsVisibleCookieName); err == nil { - jsCookiePayload = jc.Value - } - if httpCookiePayload != "" && jsCookiePayload != "" { - receivedTokenType = AuthTokenTypeSplitCookie - fullToken = jsCookiePayload + httpCookiePayload - } - } - - if receivedTokenType == AuthTokenTypeUnknown || fullToken == "" { - // We didn't find auth info or a client screwed up and put in a blank - // header instead of nothing at all, so return blank which will indicate - // the anonymouse user - return "", "", AuthTokenTypeUnknown - } - - if strings.HasPrefix(fullToken, "r_") { - return "", fullToken, AuthTokenTypeRecoveryKms - } - - splitFullToken := strings.Split(fullToken, "_") - if len(splitFullToken) != 3 { - logger.Trace("get token from request: unexpected number of segments in token; continuing as anonymous user", "expected", 3, "found", len(splitFullToken)) - return "", "", AuthTokenTypeUnknown - } - - publicId := strings.Join(splitFullToken[0:2], "_") - encryptedToken := splitFullToken[2] - - return publicId, encryptedToken, receivedTokenType -} - func (v *verifier) decryptToken() { switch v.requestInfo.TokenFormat { case AuthTokenTypeUnknown: @@ -590,3 +319,291 @@ func (v *verifier) decryptToken() { v.logger.Warn("recovery KMS was used to authorize a call", "url", v.requestInfo.Path, "method", v.requestInfo.Method) } } + +func (v verifier) performAuthCheck() (aclResults perms.ACLResults, userId string, scopeInfo *scopes.ScopeInfo, retAcl perms.ACL, retErr error) { + // Ensure we return an error by default if we forget to set this somewhere + retErr = errors.New("unknown") + // Make the linter happy + _ = retErr + scopeInfo = new(scopes.ScopeInfo) + userId = "u_anon" + var accountId string + + // Validate the token and fetch the corresponding user ID + switch v.requestInfo.TokenFormat { + case AuthTokenTypeUnknown: + // Nothing; remain as the anonymous user + + case AuthTokenTypeRecoveryKms: + // We validated the encrypted token in decryptToken and handled the + // nonces there, so just set the user + userId = "u_recovery" + + case AuthTokenTypeBearer, AuthTokenTypeSplitCookie: + if v.requestInfo.Token == "" { + // This will end up staying as the anonymous user + break + } + tokenRepo, err := v.authTokenRepoFn() + if err != nil { + retErr = fmt.Errorf("perform auth check: failed to get authtoken repo: %w", err) + return + } + at, err := tokenRepo.ValidateToken(v.ctx, v.requestInfo.PublicId, v.requestInfo.Token) + if err != nil { + // Continue as the anonymous user as maybe this token is expired but + // we can still perform the action + v.logger.Error("perform auth check: error validating token; continuing as anonymous user", "error", err) + break + } + if at != nil { + accountId = at.GetAuthAccountId() + userId = at.GetIamUserId() + if userId == "" { + v.logger.Warn("perform auth check: valid token did not map to a user, likely because no account is associated with the user any longer; continuing as u_anon", "token_id", at.GetPublicId()) + userId = "u_anon" + accountId = "" + } + } + } + + iamRepo, err := v.iamRepoFn() + if err != nil { + retErr = fmt.Errorf("perform auth check: failed to get iam repo: %w", err) + return + } + + // Look up scope details to return. We can skip a lookup when using the + // global scope + switch v.res.ScopeId { + case "global": + scopeInfo = &scopes.ScopeInfo{ + Id: scope.Global.String(), + Type: scope.Global.String(), + Name: scope.Global.String(), + Description: "Global Scope", + ParentScopeId: "", + } + + default: + scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId) + if err != nil { + retErr = fmt.Errorf("perform auth check: failed to lookup scope: %w", err) + return + } + if scp == nil { + retErr = fmt.Errorf("perform auth check: non-existent scope %q", v.res.ScopeId) + return + } + scopeInfo = &scopes.ScopeInfo{ + Id: scp.GetPublicId(), + Type: scp.GetType(), + Name: scp.GetName(), + Description: scp.GetDescription(), + ParentScopeId: scp.GetParentId(), + } + } + + // At this point we don't need to look up grants since it's automatically allowed + if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms { + aclResults.Allowed = true + retErr = nil + return + } + + var parsedGrants []perms.Grant + var grantPairs []perms.GrantPair + + // Fetch and parse grants for this user ID (which may include grants for + // u_anon and u_auth) + grantPairs, err = iamRepo.GrantsForUser(v.ctx, userId) + if err != nil { + retErr = fmt.Errorf("perform auth check: failed to query for user grants: %w", err) + return + } + parsedGrants = make([]perms.Grant, 0, len(grantPairs)) + for _, pair := range grantPairs { + parsed, err := perms.Parse( + pair.ScopeId, + pair.Grant, + perms.WithUserId(userId), + perms.WithAccountId(accountId), + perms.WithSkipFinalValidation(true)) + if err != nil { + retErr = fmt.Errorf("perform auth check: failed to parse grant %#v: %w", pair.Grant, err) + return + } + parsedGrants = append(parsedGrants, parsed) + } + + retAcl = perms.NewACL(parsedGrants...) + aclResults = retAcl.Allowed(*v.res, v.act) + retErr = nil + return +} + +// AdditionalVerification is used to perform checks of additional resources for +// actions that need to touch more than one. +func (r *VerifyResults) AdditionalVerification(ctx context.Context, opt ...Option) (ret VerifyResults) { + v := r.v + + ret.Error = handlers.ForbiddenError() + + // Set other parameters the same to start with + ret.Scope = r.Scope + ret.UserId = r.UserId + ret.AuthTokenId = r.AuthTokenId + ret.v = r.v + + opts := getOpts(opt...) + + act := opts.withAction + res := perms.Resource{ + ScopeId: opts.withScopeId, + Id: opts.withId, + Pin: opts.withPin, + Type: opts.withType, + } + // Global scope has no parent ID; account for this + if opts.withId == scope.Global.String() && opts.withType == resource.Scope { + res.ScopeId = scope.Global.String() + } + + // Only perform lookup if it's actually different, otherwise use cached info + if res.ScopeId != r.Scope.Id { + iamRepo, err := v.iamRepoFn() + if err != nil { + v.logger.Error("additional verification: failed to get iam repo", "error", err) + return + } + + // Look up scope details to return. We can skip a lookup when using the + // global scope + switch res.ScopeId { + case "global": + ret.Scope = &scopes.ScopeInfo{ + Id: scope.Global.String(), + Type: scope.Global.String(), + Name: scope.Global.String(), + Description: "Global Scope", + ParentScopeId: "", + } + + default: + scp, err := iamRepo.LookupScope(v.ctx, v.res.ScopeId) + if err != nil { + v.logger.Error("additional verification: failed to get look up scope", "error", err) + return + } + if scp == nil { + v.logger.Error("additional verification: non-existent scope", "error", err) + return + } + ret.Scope = &scopes.ScopeInfo{ + Id: scp.GetPublicId(), + Type: scp.GetType(), + Name: scp.GetName(), + Description: scp.GetDescription(), + ParentScopeId: scp.GetParentId(), + } + } + } + + // NOTE: the exclusions for recovery/anonymous user below are best effort. + // It's up to the caller to ensure that an invalid user ID for the operation + // is not actually allowed. The options here are really for + // listing/filtering purposes. + + // Always allowed + if v.requestInfo.TokenFormat == AuthTokenTypeRecoveryKms { + if opts.withRecoveryTokenNotAllowed { + // Don't allow it. This is used in some cases like target listing + // where the command will succeed for most things but it's not + // allowed for an authorize-session action. + return + } + ret.Error = nil + return + } + + if opts.withAnonymousUserNotAllowed { + // Similar to above, in some cases we never allow the anonymous user, + // such as with targets and authorize-session + return + } + + aclResults := v.acl.Allowed(res, act) + + if !aclResults.Allowed { + if v.requestInfo.DisableAuthzFailures { + ret.Error = nil + // TODO: Decide whether to remove this + v.logger.Info("failed authz info for request", "resource", pretty.Sprint(v.res), "user_id", ret.UserId, "action", v.act.String()) + } else { + // If the anon user was used (either no token, or invalid (perhaps + // expired) token), return a 401. That way if it's an authn'd user + // that is not authz'd we'll return 403 to be explicit. + if ret.UserId == "u_anon" { + ret.Error = handlers.UnauthenticatedError() + } + return + } + } + + ret.Error = nil + return +} + +// GetTokenFromRequest pulls the token from either the Authorization header or +// split cookies and parses it. If it cannot be parsed successfully, the issue +// is logged and we return blank, so logic will continue as the anonymous user. +// The public ID and _encrypted_ token are returned along with the token format. +func GetTokenFromRequest(logger hclog.Logger, kmsCache *kms.Kms, req *http.Request) (string, string, TokenFormat) { + // First, get the token, either from the authorization header or from split + // cookies + var receivedTokenType TokenFormat + var fullToken string + if authHeader := req.Header.Get("Authorization"); authHeader != "" { + headerSplit := strings.SplitN(strings.TrimSpace(authHeader), " ", 2) + if len(headerSplit) == 2 && strings.EqualFold(strings.TrimSpace(headerSplit[0]), "bearer") { + receivedTokenType = AuthTokenTypeBearer + fullToken = strings.TrimSpace(headerSplit[1]) + } + } + if receivedTokenType != AuthTokenTypeBearer { + var httpCookiePayload string + var jsCookiePayload string + if hc, err := req.Cookie(handlers.HttpOnlyCookieName); err == nil { + httpCookiePayload = hc.Value + } + if jc, err := req.Cookie(handlers.JsVisibleCookieName); err == nil { + jsCookiePayload = jc.Value + } + if httpCookiePayload != "" && jsCookiePayload != "" { + receivedTokenType = AuthTokenTypeSplitCookie + fullToken = jsCookiePayload + httpCookiePayload + } + } + + if receivedTokenType == AuthTokenTypeUnknown || fullToken == "" { + // We didn't find auth info or a client screwed up and put in a blank + // header instead of nothing at all, so return blank which will indicate + // the anonymouse user + return "", "", AuthTokenTypeUnknown + } + + if strings.HasPrefix(fullToken, "r_") { + return "", fullToken, AuthTokenTypeRecoveryKms + } + + splitFullToken := strings.Split(fullToken, "_") + if len(splitFullToken) != 3 { + logger.Trace("get token from request: unexpected number of segments in token; continuing as anonymous user", "expected", 3, "found", len(splitFullToken)) + return "", "", AuthTokenTypeUnknown + } + + publicId := strings.Join(splitFullToken[0:2], "_") + encryptedToken := splitFullToken[2] + + return publicId, encryptedToken, receivedTokenType +} diff --git a/internal/auth/option.go b/internal/auth/option.go index a5ec1f4a9..5cb1868b0 100644 --- a/internal/auth/option.go +++ b/internal/auth/option.go @@ -19,20 +19,19 @@ type Option func(*options) // options = how options are represented type options struct { - withScopeId string - withPin string - withId string - withAction action.Type - withType resource.Type - withUserId string - withKms *kms.Kms + withScopeId string + withPin string + withId string + withAction action.Type + withType resource.Type + withUserId string + withKms *kms.Kms + withRecoveryTokenNotAllowed bool + withAnonymousUserNotAllowed bool } func getDefaultOptions() options { - return options{ - withScopeId: "", - withUserId: "", - } + return options{} } func WithScopeId(id string) Option { @@ -76,3 +75,15 @@ func WithKms(kms *kms.Kms) Option { o.withKms = kms } } + +func WithRecoveryTokenNotAllowed(notAllowed bool) Option { + return func(o *options) { + o.withRecoveryTokenNotAllowed = notAllowed + } +} + +func WithAnonymousUserNotAllowed(notAllowed bool) Option { + return func(o *options) { + o.withAnonymousUserNotAllowed = notAllowed + } +}