package iamauth import ( "context" "encoding/xml" "fmt" "io/ioutil" "net/http" "regexp" "strings" "time" "github.com/hashicorp/consul/internal/iamauth/responses" "github.com/hashicorp/consul/lib" "github.com/hashicorp/consul/lib/stringslice" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-retryablehttp" ) const ( // Retry configuration retryWaitMin = 500 * time.Millisecond retryWaitMax = 30 * time.Second ) type Authenticator struct { config *Config logger hclog.Logger } type IdentityDetails struct { EntityName string EntityId string AccountId string EntityPath string EntityTags map[string]string } func NewAuthenticator(config *Config, logger hclog.Logger) (*Authenticator, error) { if err := config.Validate(); err != nil { return nil, err } return &Authenticator{ config: config, logger: logger, }, nil } // ValidateLogin determines if the identity in the loginToken is permitted to login. // If so, it returns details about the identity. Otherwise, an error is returned. func (a *Authenticator) ValidateLogin(ctx context.Context, loginToken string) (*IdentityDetails, error) { token, err := NewBearerToken(loginToken, a.config) if err != nil { return nil, err } req, err := token.GetCallerIdentityRequest() if err != nil { return nil, err } if a.config.ServerIDHeaderValue != "" { err := validateHeaderValue(req.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) if err != nil { return nil, err } } callerIdentity, err := a.submitCallerIdentityRequest(ctx, req) if err != nil { return nil, err } a.logger.Debug("iamauth login attempt", "arn", callerIdentity.Arn) entity, err := responses.ParseArn(callerIdentity.Arn) if err != nil { return nil, err } identityDetails := &IdentityDetails{ EntityName: entity.FriendlyName, // This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID" // (in the case of an IAM user). EntityId: strings.Split(callerIdentity.UserId, ":")[0], AccountId: callerIdentity.Account, } clientArn := entity.CanonicalArn() // Fetch the IAM Role or IAM User, if configured. // This requires the token to contain a signed iam:GetRole or iam:GetUser request. if a.config.EnableIAMEntityDetails { iamReq, err := token.GetEntityRequest() if err != nil { return nil, err } if a.config.ServerIDHeaderValue != "" { err := validateHeaderValue(iamReq.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) if err != nil { return nil, err } } iamEntityDetails, err := a.submitGetIAMEntityRequest(ctx, iamReq, token.entityRequestType) if err != nil { return nil, err } // Only the CallerIdentity response is a guarantee of the client's identity. // The role/user details must have a unique id match to the CallerIdentity before use. if iamEntityDetails.EntityId() != identityDetails.EntityId { return nil, fmt.Errorf("unique id mismatch in login token") } // Use the full ARN with path from the Role/User details clientArn = iamEntityDetails.EntityArn() identityDetails.EntityPath = iamEntityDetails.EntityPath() identityDetails.EntityTags = iamEntityDetails.EntityTags() } if err := a.validateIdentity(clientArn); err != nil { return nil, err } return identityDetails, nil } // https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1321-L1361 func (a *Authenticator) validateIdentity(clientArn string) error { if stringslice.Contains(a.config.BoundIAMPrincipalARNs, clientArn) { // Matches one of BoundIAMPrincipalARNs, so it is trusted return nil } if a.config.EnableIAMEntityDetails { for _, principalArn := range a.config.BoundIAMPrincipalARNs { if strings.HasSuffix(principalArn, "*") && lib.GlobbedStringsMatch(principalArn, clientArn) { // Wildcard match, so it is trusted return nil } } } return fmt.Errorf("IAM principal %s is not trusted", clientArn) } func (a *Authenticator) submitCallerIdentityRequest(ctx context.Context, req *http.Request) (*responses.GetCallerIdentityResult, error) { responseBody, err := a.submitRequest(ctx, req) if err != nil { return nil, err } callerIdentityResponse, err := parseGetCallerIdentityResponse(responseBody) if err != nil { return nil, fmt.Errorf("error parsing STS response") } if n := len(callerIdentityResponse.GetCallerIdentityResult); n != 1 { return nil, fmt.Errorf("received %d identities in STS response but expected 1", n) } return &callerIdentityResponse.GetCallerIdentityResult[0], nil } func (a *Authenticator) submitGetIAMEntityRequest(ctx context.Context, req *http.Request, reqType string) (responses.IAMEntity, error) { responseBody, err := a.submitRequest(ctx, req) if err != nil { return nil, err } iamResponse, err := parseGetIAMEntityResponse(responseBody, reqType) if err != nil { return nil, fmt.Errorf("error parsing IAM response: %s", err) } return iamResponse, nil } // https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1636 func (a *Authenticator) submitRequest(ctx context.Context, req *http.Request) (string, error) { retryableReq, err := retryablehttp.FromRequest(req) if err != nil { return "", err } retryableReq = retryableReq.WithContext(ctx) client := cleanhttp.DefaultClient() client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } retryingClient := &retryablehttp.Client{ HTTPClient: client, RetryWaitMin: retryWaitMin, RetryWaitMax: retryWaitMax, RetryMax: a.config.MaxRetries, CheckRetry: retryablehttp.DefaultRetryPolicy, Backoff: retryablehttp.DefaultBackoff, } response, err := retryingClient.Do(retryableReq) if err != nil { return "", fmt.Errorf("error making request: %w", err) } if response != nil { defer response.Body.Close() } // Validate that the response type is XML if ct := response.Header.Get("Content-Type"); ct != "text/xml" { return "", fmt.Errorf("response body is invalid") } // we check for status code afterwards to also print out response body responseBody, err := ioutil.ReadAll(response.Body) if err != nil { return "", err } if response.StatusCode != 200 { return "", fmt.Errorf("received error code %d: %s", response.StatusCode, string(responseBody)) } return string(responseBody), nil } // https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1625-L1634 func parseGetCallerIdentityResponse(response string) (responses.GetCallerIdentityResponse, error) { result := responses.GetCallerIdentityResponse{} response = strings.TrimSpace(response) if !strings.HasPrefix(response, " 2 { return fmt.Errorf("found multiple SignedHeaders components") } signedHeaders := string(matches[1]) return ensureHeaderIsSigned(signedHeaders, headerName) } // NOTE: If we support GET requests, then we need to parse the X-Amz-SignedHeaders // argument out of the query string and search in there for the header value return fmt.Errorf("missing Authorization header") } func ensureHeaderIsSigned(signedHeaders, headerToSign string) error { // Not doing a constant time compare here, the values aren't secret for _, header := range strings.Split(signedHeaders, ";") { if header == strings.ToLower(headerToSign) { return nil } } return fmt.Errorf("header wasn't signed") }