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 }