github.com/versent/saml2aws@v2.17.0+incompatible/pkg/provider/psu/psu.go (about)

     1  package psu
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"github.com/headzoo/surf"
     7  	"github.com/headzoo/surf/browser"
     8  	"github.com/pkg/errors"
     9  	"github.com/robertkrimen/otto"
    10  	"github.com/sirupsen/logrus"
    11  	"github.com/versent/saml2aws/pkg/cfg"
    12  	"github.com/versent/saml2aws/pkg/creds"
    13  	"github.com/versent/saml2aws/pkg/prompter"
    14  	"github.com/versent/saml2aws/pkg/provider"
    15  	"regexp"
    16  	"strconv"
    17  	"time"
    18  )
    19  
    20  var logger = logrus.WithField("provider", "psu")
    21  
    22  // Client contains our browser and IDP Account configuration
    23  type Client struct {
    24  	b  *browser.Browser
    25  	ia *cfg.IDPAccount
    26  }
    27  
    28  // duoResults contains the extracted duoResults JS object from the 2FA page
    29  type duoResults struct {
    30  	AccountType string `json:"account_type"`
    31  	Devices     struct {
    32  		Devices []duoDevice `json:"devices"`
    33  	} `json:"devices"`
    34  	Error            string `json:"error"`
    35  	Referrer         string `json:"referrer"`
    36  	Remoteuser       string `json:"remoteuser"`
    37  	RequiredFactors  string `json:"requiredFactors"`
    38  	SatisfiedFactors string `json:"satisfiedFactors"`
    39  	Service          string `json:"service"`
    40  }
    41  
    42  // duoDevice describes a Duo 2FA device, its capabilities, etc
    43  // This is very similar to https://godoc.org/github.com/duosecurity/duo_api_golang/authapi#PreauthResult
    44  // but augmented for our purposes
    45  type duoDevice struct {
    46  	Capabilities []string `json:"capabilities"`
    47  	Device       string   `json:"device"`
    48  	DisplayName  string   `json:"display_name"`
    49  	SmsNextcode  string   `json:"sms_nextcode,omitempty"`
    50  	Type         string   `json:"type"`
    51  	OptionType   string   `json:"omitempty"`
    52  	Prompt       string   `json:"omitempty"`
    53  }
    54  
    55  // New returns a new psu.Client with the browser and idp account instantiated
    56  func New(idpAccount *cfg.IDPAccount) (*Client, error) {
    57  
    58  	tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
    59  
    60  	// create our browser
    61  	b := surf.NewBrowser()
    62  	b.SetTimeout(time.Duration(idpAccount.Timeout) * time.Second)
    63  	b.SetTransport(tr)
    64  
    65  	return &Client{
    66  		b:  b,
    67  		ia: idpAccount,
    68  	}, nil
    69  }
    70  
    71  // Authenticate authenticates to PSU
    72  func (pc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
    73  
    74  	assertion, err := pc.login(loginDetails)
    75  
    76  	return assertion, err
    77  }
    78  
    79  func (pc *Client) login(loginDetails *creds.LoginDetails) (string, error) {
    80  	// Send our request to the IdP, which will redirect us to WebAccess
    81  	requestURL := fmt.Sprintf("%s/idp/profile/SAML2/Unsolicited/SSO?providerId=%s", loginDetails.URL, pc.ia.AmazonWebservicesURN)
    82  	logger.Debugf("Sending request to IdP: %s\n", requestURL)
    83  	err := pc.b.Open(requestURL)
    84  	if err != nil {
    85  		return "", errors.Wrapf(err, "Requesting initial IDP URL (%s)", requestURL)
    86  	}
    87  
    88  	logger.Debugf("Current URL: %s\n", pc.b.Url())
    89  
    90  	// find our login form
    91  	fm, err := pc.b.Form("form")
    92  	if err != nil {
    93  		return "", errors.Wrapf(err, "Unable to find login form on %s", pc.b.Url())
    94  	}
    95  
    96  	// submit username/password
    97  	logger.Debugf("Submitting creds to: %s\n", fm.Action())
    98  
    99  	err = fm.Input("login", loginDetails.Username)
   100  	if err != nil {
   101  		return "", errors.Wrap(err, "Could not find login input field")
   102  	}
   103  
   104  	err = fm.Input("password", loginDetails.Password)
   105  	if err != nil {
   106  		return "", errors.Wrap(err, "Could not find password input field")
   107  	}
   108  
   109  	err = fm.Submit()
   110  	if err != nil {
   111  		return "", errors.Wrapf(err, "Error when submitting creds to %s", fm.Action())
   112  	}
   113  
   114  	// find the 2fa form to make sure we are logged in before going any further
   115  	fm, err = pc.b.Form("form")
   116  	if err != nil {
   117  		return "", errors.Wrapf(err, "Could not locate 2FA form on %s, perhaps the login failed?", pc.b.Url())
   118  	}
   119  
   120  	// extract duoResults object from body text
   121  	dr, err := extractDuoResults(pc.b.Body())
   122  	if err != nil {
   123  		return "", errors.Wrapf(err, "Calling extractDuoResults() on 2FA login page body")
   124  	}
   125  
   126  	// parse duoResults into devices
   127  	duoDevices := parseDuoResults(dr)
   128  
   129  	// present list of duo options and prompt for input
   130  	fmt.Print("Enter a passcode or select one of the following options:\n\n")
   131  	for i, d := range duoDevices {
   132  		fmt.Printf(" %d. %s\n", i, d.Prompt)
   133  	}
   134  
   135  	fmt.Println() // get an extra space between options and input prompt
   136  
   137  	option := prompter.StringRequired("Passcode or option")
   138  	optint, err := strconv.Atoi(option) // try to convert input to int to partially validate it
   139  	if err != nil {
   140  		return "", errors.Wrapf(err, "Failed to convert %v to int", option)
   141  	}
   142  
   143  	// fill out 2FA form
   144  	if optint > len(duoDevices) {
   145  		// selection is larger than the number of options, assume it is a passcode
   146  		err = fm.Set("duo_passcode", option)
   147  		if err != nil {
   148  			return "", errors.Wrap(err, "Setting duo_passcode form field")
   149  		}
   150  
   151  		err = fm.Set("duo_factor", "passcode")
   152  		if err != nil {
   153  			return "", errors.Wrap(err, "Setting duo_factor form field")
   154  		}
   155  	} else {
   156  		// otherwise, set device and factor
   157  		err = fm.Input("duo_device", duoDevices[optint].Device)
   158  		if err != nil {
   159  			return "", errors.Wrap(err, "Setting duo_device form field")
   160  		}
   161  
   162  		err = fm.Set("duo_factor", duoDevices[optint].OptionType)
   163  		if err != nil {
   164  			return "", errors.Wrap(err, "Setting duo_factor form field")
   165  		}
   166  	}
   167  
   168  	// submit form
   169  	err = fm.Submit()
   170  	if err != nil {
   171  		return "", errors.Wrap(err, "Error when submitting form")
   172  	}
   173  
   174  	// pull the assertion out of the response
   175  	doc := pc.b.Dom()
   176  	s := doc.Find("input[name=SAMLResponse]").First()
   177  	assertion, ok := s.Attr("value")
   178  	if !ok {
   179  		return "", fmt.Errorf("Response from %s did not provide a SAML assertion (SAMLResponse html element)", pc.b.Url())
   180  	}
   181  	return assertion, nil
   182  }
   183  
   184  func stringInSlice(a string, list []string) bool {
   185  	for _, b := range list {
   186  		if b == a {
   187  			return true
   188  		}
   189  	}
   190  	return false
   191  }
   192  
   193  // build list of devices and options for the user prompt
   194  // it's simpler to do this in one block
   195  func parseDuoResults(dr duoResults) (devices []duoDevice) {
   196  	i := 0
   197  	for _, t := range []string{"push", "phone", "sms"} {
   198  		for _, d := range dr.Devices.Devices {
   199  			if stringInSlice(t, d.Capabilities) {
   200  				devices = append(devices, d)
   201  				devices[i].OptionType = t
   202  				switch t {
   203  				case "push":
   204  					devices[i].Prompt = fmt.Sprintf("Duo Push to %s", d.DisplayName)
   205  				case "phone":
   206  					devices[i].Prompt = fmt.Sprintf("Phone call to %s", d.DisplayName)
   207  				case "sms":
   208  					nextcode := ""
   209  					if d.SmsNextcode != "" {
   210  						nextcode = fmt.Sprintf(" (next code starts with %s)", d.SmsNextcode)
   211  					}
   212  					devices[i].Prompt = fmt.Sprintf("SMS passcodes to %s%s", d.DisplayName, nextcode)
   213  				}
   214  				i++
   215  			}
   216  		}
   217  	}
   218  	return
   219  }
   220  
   221  // extract duoResults from a body of text
   222  func extractDuoResults(body string) (dr duoResults, err error) {
   223  	// extract duoResults text from body
   224  	re := regexp.MustCompile(`var\s+duoResults\s+=\s+({[\S\s]*});`)
   225  	matches := re.FindStringSubmatch(body)
   226  	if len(matches) != 2 {
   227  		return dr, errors.New("Something went wrong, duoResults variable not present on page after submitting login")
   228  	}
   229  
   230  	// Create new JavaScript VM with a single variable called input, with our JS object assigned
   231  	vm := otto.New()
   232  	err = vm.Set("input", matches[1])
   233  	if err != nil {
   234  		return dr, errors.Wrap(err, "Setting JS VM input value")
   235  	}
   236  
   237  	// Call JavaScript's JSON.stringify() on the input variable we Set() above
   238  	stringifyOutput, err := vm.Run(`JSON.stringify( eval('('+input+')') )`)
   239  	if err != nil {
   240  		return dr, errors.Wrapf(err, "JSON.stringify returned `%s` when trying to extract Duo result JSON object", err)
   241  	}
   242  
   243  	// call otto's .ToString() on duoResultsJSON to turn it from a ott.Value to a string
   244  	duoResultsJSON, err := stringifyOutput.ToString()
   245  	if err != nil {
   246  		return dr, errors.Wrap(err, "ToString()")
   247  	}
   248  
   249  	// unmarshal JSON byte object into a duoResults struct
   250  	err = json.Unmarshal([]byte(duoResultsJSON), &dr)
   251  	if err != nil {
   252  		return dr, errors.Wrap(err, "Calling json.Unmarshal on duoResultsJSON")
   253  	}
   254  
   255  	// check that we didn't get a 0-length result
   256  	if len(dr.Devices.Devices) == 0 {
   257  		return dr, errors.New("No 2FA devices returned")
   258  	}
   259  
   260  	return
   261  }