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

     1  package shibboleth
     2  
     3  import (
     4  	"crypto/tls"
     5  	"fmt"
     6  	"html"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"net/url"
    10  	"regexp"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/PuerkitoBio/goquery"
    15  	"github.com/pkg/errors"
    16  	"github.com/sirupsen/logrus"
    17  	"github.com/tidwall/gjson"
    18  	"github.com/versent/saml2aws/pkg/cfg"
    19  	"github.com/versent/saml2aws/pkg/creds"
    20  	"github.com/versent/saml2aws/pkg/prompter"
    21  	"github.com/versent/saml2aws/pkg/provider"
    22  )
    23  
    24  var logger = logrus.WithField("provider", "shibboleth")
    25  
    26  // Client wrapper around Shibboleth enabling authentication and retrieval of assertions
    27  type Client struct {
    28  	client     *provider.HTTPClient
    29  	idpAccount *cfg.IDPAccount
    30  }
    31  
    32  // New create a new Shibboleth client
    33  func New(idpAccount *cfg.IDPAccount) (*Client, error) {
    34  
    35  	tr := &http.Transport{
    36  		Proxy:           http.ProxyFromEnvironment,
    37  		TLSClientConfig: &tls.Config{InsecureSkipVerify: idpAccount.SkipVerify, Renegotiation: tls.RenegotiateFreelyAsClient},
    38  	}
    39  
    40  	client, err := provider.NewHTTPClient(tr)
    41  	if err != nil {
    42  		return nil, errors.Wrap(err, "error building http client")
    43  	}
    44  
    45  	return &Client{
    46  		client:     client,
    47  		idpAccount: idpAccount,
    48  	}, nil
    49  }
    50  
    51  // Authenticate authenticate to Shibboleth and return the data from the body of the SAML assertion.
    52  func (sc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
    53  
    54  	var authSubmitURL string
    55  	var samlAssertion string
    56  
    57  	shibbolethURL := fmt.Sprintf("%s/idp/profile/SAML2/Unsolicited/SSO?providerId=%s", loginDetails.URL, sc.idpAccount.AmazonWebservicesURN)
    58  
    59  	res, err := sc.client.Get(shibbolethURL)
    60  	if err != nil {
    61  		return samlAssertion, errors.Wrap(err, "error retrieving form")
    62  	}
    63  
    64  	doc, err := goquery.NewDocumentFromResponse(res)
    65  	if err != nil {
    66  		return samlAssertion, errors.Wrap(err, "failed to build document from response")
    67  	}
    68  
    69  	authForm := url.Values{}
    70  
    71  	doc.Find("input").Each(func(i int, s *goquery.Selection) {
    72  		updateFormData(authForm, s, loginDetails)
    73  	})
    74  
    75  	doc.Find("form").Each(func(i int, s *goquery.Selection) {
    76  		action, ok := s.Attr("action")
    77  		if !ok {
    78  			return
    79  		}
    80  		authSubmitURL = action
    81  	})
    82  
    83  	if authSubmitURL == "" {
    84  		return samlAssertion, fmt.Errorf("unable to locate IDP authentication form submit URL")
    85  	}
    86  
    87  	req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode()))
    88  	if err != nil {
    89  		return samlAssertion, errors.Wrap(err, "error building authentication request")
    90  	}
    91  
    92  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    93  	req.URL.Host = res.Request.URL.Host
    94  	req.URL.Scheme = res.Request.URL.Scheme
    95  
    96  	res, err = sc.client.Do(req)
    97  	if err != nil {
    98  		return samlAssertion, errors.Wrap(err, "error retrieving login form results")
    99  	}
   100  
   101  	switch sc.idpAccount.MFA {
   102  	case "Auto":
   103  		b, _ := ioutil.ReadAll(res.Body)
   104  
   105  		mfaRes, err := verifyMfa(sc, loginDetails.URL, string(b))
   106  		if err != nil {
   107  			return mfaRes.Status, errors.Wrap(err, "error verifying MFA")
   108  		}
   109  
   110  		res = mfaRes
   111  
   112  	}
   113  
   114  	samlAssertion, err = extractSamlResponse(res)
   115  	if err != nil {
   116  		return samlAssertion, errors.Wrap(err, "error extracting SAMLResponse blob from final Shibboleth response")
   117  	}
   118  
   119  	return samlAssertion, nil
   120  }
   121  
   122  func updateFormData(authForm url.Values, s *goquery.Selection, user *creds.LoginDetails) {
   123  	name, ok := s.Attr("name")
   124  	authForm.Add("_eventId_proceed", "")
   125  
   126  	if !ok {
   127  		return
   128  	}
   129  	lname := strings.ToLower(name)
   130  	if strings.Contains(lname, "user") {
   131  		authForm.Add(name, user.Username)
   132  	} else if strings.Contains(lname, "email") {
   133  		authForm.Add(name, user.Username)
   134  	} else if strings.Contains(lname, "pass") {
   135  		authForm.Add(name, user.Password)
   136  	} else {
   137  		// pass through any hidden fields
   138  		val, ok := s.Attr("value")
   139  		if !ok {
   140  			return
   141  		}
   142  		authForm.Add(name, val)
   143  	}
   144  }
   145  
   146  func verifyMfa(oc *Client, shibbolethHost string, resp string) (*http.Response, error) {
   147  
   148  	duoHost, postAction, tx, app := parseTokens(resp)
   149  
   150  	parent := fmt.Sprintf(shibbolethHost + postAction)
   151  
   152  	duoTxCookie, err := verifyDuoMfa(oc, duoHost, parent, tx)
   153  	if err != nil {
   154  		return nil, errors.Wrap(err, "error when interacting with Duo iframe")
   155  	}
   156  
   157  	idpForm := url.Values{}
   158  	idpForm.Add("_eventId", "proceed")
   159  	idpForm.Add("sig_response", duoTxCookie+":"+app)
   160  
   161  	req, err := http.NewRequest("POST", parent, strings.NewReader(idpForm.Encode()))
   162  	if err != nil {
   163  		return nil, errors.Wrap(err, "error posting multi-factor verification to shibboleth server")
   164  	}
   165  
   166  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   167  
   168  	res, err := oc.client.Do(req)
   169  	if err != nil {
   170  		return nil, errors.Wrap(err, "error retrieving verify response")
   171  	}
   172  
   173  	return res, nil
   174  }
   175  
   176  func verifyDuoMfa(oc *Client, duoHost string, parent string, tx string) (string, error) {
   177  	// initiate duo mfa to get sid
   178  	duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
   179  
   180  	duoForm := url.Values{}
   181  	duoForm.Add("parent", parent)
   182  	duoForm.Add("java_version", "")
   183  	duoForm.Add("java_version", "")
   184  	duoForm.Add("flash_version", "")
   185  	duoForm.Add("screen_resolution_width", "3008")
   186  	duoForm.Add("screen_resolution_height", "1692")
   187  	duoForm.Add("color_depth", "24")
   188  
   189  	req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   190  	if err != nil {
   191  		return "", errors.Wrap(err, "error building authentication request")
   192  	}
   193  	q := req.URL.Query()
   194  	q.Add("tx", tx)
   195  	req.URL.RawQuery = q.Encode()
   196  
   197  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   198  
   199  	res, err := oc.client.Do(req)
   200  	if err != nil {
   201  		return "", errors.Wrap(err, "error retrieving verify response")
   202  	}
   203  
   204  	//try to extract sid
   205  	doc, err := goquery.NewDocumentFromResponse(res)
   206  	if err != nil {
   207  		return "", errors.Wrap(err, "error parsing document")
   208  	}
   209  
   210  	duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value")
   211  	if !ok {
   212  		return "", errors.Wrap(err, "unable to locate saml response")
   213  	}
   214  	duoSID = html.UnescapeString(duoSID)
   215  
   216  	//prompt for mfa type
   217  	//supporting push, call, and passcode for now
   218  
   219  	var token string
   220  
   221  	var duoMfaOptions = []string{
   222  		"Duo Push",
   223  		"Phone Call",
   224  		"Passcode",
   225  	}
   226  
   227  	duoMfaOption := prompter.Choose("Select a DUO MFA Option", duoMfaOptions)
   228  
   229  	if duoMfaOptions[duoMfaOption] == "Passcode" {
   230  		//get users DUO MFA Token
   231  		token = prompter.StringRequired("Enter passcode")
   232  	}
   233  
   234  	// send mfa auth request
   235  	duoSubmitURL = fmt.Sprintf("https://%s/frame/prompt", duoHost)
   236  
   237  	duoForm = url.Values{}
   238  	duoForm.Add("sid", duoSID)
   239  	duoForm.Add("device", "phone1")
   240  	duoForm.Add("factor", duoMfaOptions[duoMfaOption])
   241  	duoForm.Add("out_of_date", "false")
   242  	if duoMfaOptions[duoMfaOption] == "Passcode" {
   243  		duoForm.Add("passcode", token)
   244  	}
   245  
   246  	req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   247  	if err != nil {
   248  		return "", errors.Wrap(err, "error building authentication request")
   249  	}
   250  
   251  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   252  
   253  	res, err = oc.client.Do(req)
   254  	if err != nil {
   255  		return "", errors.Wrap(err, "error retrieving verify response")
   256  	}
   257  
   258  	body, err := ioutil.ReadAll(res.Body)
   259  	if err != nil {
   260  		return "", errors.Wrap(err, "error retrieving body from response")
   261  	}
   262  
   263  	resp := string(body)
   264  
   265  	duoTxStat := gjson.Get(resp, "stat").String()
   266  	duoTxID := gjson.Get(resp, "response.txid").String()
   267  	if duoTxStat != "OK" {
   268  		return "", errors.Wrap(err, "error authenticating mfa device")
   269  	}
   270  
   271  	// get duo cookie
   272  	duoSubmitURL = fmt.Sprintf("https://%s/frame/status", duoHost)
   273  
   274  	duoForm = url.Values{}
   275  	duoForm.Add("sid", duoSID)
   276  	duoForm.Add("txid", duoTxID)
   277  
   278  	req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   279  	if err != nil {
   280  		return "", errors.Wrap(err, "error building authentication request")
   281  	}
   282  
   283  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   284  
   285  	res, err = oc.client.Do(req)
   286  	if err != nil {
   287  		return "", errors.Wrap(err, "error retrieving verify response")
   288  	}
   289  
   290  	body, err = ioutil.ReadAll(res.Body)
   291  	if err != nil {
   292  		return "", errors.Wrap(err, "error retrieving body from response")
   293  	}
   294  
   295  	resp = string(body)
   296  
   297  	duoTxResult := gjson.Get(resp, "response.result").String()
   298  	duoResultURL := gjson.Get(resp, "response.result_url").String()
   299  
   300  	fmt.Println(gjson.Get(resp, "response.status").String())
   301  
   302  	if duoTxResult != "SUCCESS" {
   303  		//poll as this is likely a push request
   304  		for {
   305  			time.Sleep(3 * time.Second)
   306  
   307  			req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   308  			if err != nil {
   309  				return "", errors.Wrap(err, "error building authentication request")
   310  			}
   311  
   312  			req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   313  
   314  			res, err = oc.client.Do(req)
   315  			if err != nil {
   316  				return "", errors.Wrap(err, "error retrieving verify response")
   317  			}
   318  
   319  			body, err = ioutil.ReadAll(res.Body)
   320  			if err != nil {
   321  				return "", errors.Wrap(err, "error retrieving body from response")
   322  			}
   323  
   324  			resp := string(body)
   325  
   326  			duoTxResult = gjson.Get(resp, "response.result").String()
   327  			duoResultURL = gjson.Get(resp, "response.result_url").String()
   328  
   329  			fmt.Println(gjson.Get(resp, "response.status").String())
   330  
   331  			if duoTxResult == "FAILURE" {
   332  				return "", errors.Wrap(err, "failed to authenticate device")
   333  			}
   334  
   335  			if duoTxResult == "SUCCESS" {
   336  				break
   337  			}
   338  		}
   339  	}
   340  
   341  	duoRequestURL := fmt.Sprintf("https://%s%s", duoHost, duoResultURL)
   342  	req, err = http.NewRequest("POST", duoRequestURL, strings.NewReader(duoForm.Encode()))
   343  	if err != nil {
   344  		return "", errors.Wrap(err, "error constructing request object to result url")
   345  	}
   346  
   347  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   348  
   349  	res, err = oc.client.Do(req)
   350  	if err != nil {
   351  		return "", errors.Wrap(err, "error retrieving duo result response")
   352  	}
   353  
   354  	body, err = ioutil.ReadAll(res.Body)
   355  	if err != nil {
   356  		return "", errors.Wrap(err, "duoResultSubmit: error retrieving body from response")
   357  	}
   358  
   359  	resp = string(body)
   360  
   361  	duoTxCookie := gjson.Get(resp, "response.cookie").String()
   362  	if duoTxCookie == "" {
   363  		return "", errors.Wrap(err, "duoResultSubmit: Unable to get response.cookie")
   364  	}
   365  
   366  	return duoTxCookie, nil
   367  }
   368  
   369  func parseTokens(blob string) (string, string, string, string) {
   370  	hostRgx := regexp.MustCompile(`data-host=\"(.*?)\"`)
   371  	sigRgx := regexp.MustCompile(`data-sig-request=\"(.*?)\"`)
   372  	dpaRgx := regexp.MustCompile(`data-post-action=\"(.*?)\"`)
   373  
   374  	dataSigRequest := sigRgx.FindStringSubmatch(blob)
   375  	duoHost := hostRgx.FindStringSubmatch(blob)
   376  	postAction := dpaRgx.FindStringSubmatch(blob)
   377  
   378  	duoSignatures := strings.Split(dataSigRequest[1], ":")
   379  	return duoHost[1], postAction[1], duoSignatures[0], duoSignatures[1]
   380  }
   381  
   382  func extractSamlResponse(res *http.Response) (string, error) {
   383  	body, err := ioutil.ReadAll(res.Body)
   384  	if err != nil {
   385  		return "", errors.Wrap(err, "extractSamlResponse: error retrieving body from response")
   386  	}
   387  
   388  	samlRgx := regexp.MustCompile(`name=\"SAMLResponse\" value=\"(.*?)\"/>`)
   389  	samlResponseValue := samlRgx.FindStringSubmatch(string(body))
   390  	return samlResponseValue[1], nil
   391  }