pkg/provider/shibboleth/shibboleth.go (297 lines of code) (raw):
package shibboleth
import (
"crypto/tls"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/aliyun/saml2alibabacloud/pkg/cfg"
"github.com/aliyun/saml2alibabacloud/pkg/creds"
"github.com/aliyun/saml2alibabacloud/pkg/prompter"
"github.com/aliyun/saml2alibabacloud/pkg/provider"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
)
// Client wrapper around Shibboleth enabling authentication and retrieval of assertions
type Client struct {
client *provider.HTTPClient
idpAccount *cfg.IDPAccount
}
// New create a new Shibboleth client
func New(idpAccount *cfg.IDPAccount) (*Client, error) {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: idpAccount.SkipVerify, Renegotiation: tls.RenegotiateFreelyAsClient},
}
client, err := provider.NewHTTPClient(tr, provider.BuildHttpClientOpts(idpAccount))
if err != nil {
return nil, errors.Wrap(err, "error building http client")
}
return &Client{
client: client,
idpAccount: idpAccount,
}, nil
}
// Authenticate authenticate to Shibboleth and return the data from the body of the SAML assertion.
func (sc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
var authSubmitURL string
var samlAssertion string
shibbolethURL := fmt.Sprintf("%s/idp/profile/SAML2/Unsolicited/SSO?providerId=%s", loginDetails.URL, sc.idpAccount.AlibabaCloudURN)
res, err := sc.client.Get(shibbolethURL)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving form")
}
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return samlAssertion, errors.Wrap(err, "failed to build document from response")
}
authForm := url.Values{}
doc.Find("input").Each(func(i int, s *goquery.Selection) {
updateFormData(authForm, s, loginDetails)
})
doc.Find("form").Each(func(i int, s *goquery.Selection) {
action, ok := s.Attr("action")
if !ok {
return
}
authSubmitURL = action
})
if authSubmitURL == "" {
return samlAssertion, fmt.Errorf("unable to locate IDP authentication form submit URL")
}
req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode()))
if err != nil {
return samlAssertion, errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.URL.Host = res.Request.URL.Host
req.URL.Scheme = res.Request.URL.Scheme
res, err = sc.client.Do(req)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving login form results")
}
switch sc.idpAccount.MFA {
case "Auto":
b, _ := ioutil.ReadAll(res.Body)
mfaRes, err := verifyMfa(sc, loginDetails.URL, string(b))
if err != nil {
return mfaRes.Status, errors.Wrap(err, "error verifying MFA")
}
res = mfaRes
}
samlAssertion, err = extractSamlResponse(res)
if err != nil {
return samlAssertion, errors.Wrap(err, "error extracting SAMLResponse blob from final Shibboleth response")
}
return samlAssertion, nil
}
func updateFormData(authForm url.Values, s *goquery.Selection, user *creds.LoginDetails) {
name, ok := s.Attr("name")
authForm.Add("_eventId_proceed", "")
if !ok {
return
}
lname := strings.ToLower(name)
if strings.Contains(lname, "user") {
authForm.Add(name, user.Username)
} else if strings.Contains(lname, "email") {
authForm.Add(name, user.Username)
} else if strings.Contains(lname, "pass") {
authForm.Add(name, user.Password)
} else {
// pass through any hidden fields
val, ok := s.Attr("value")
if !ok {
return
}
authForm.Add(name, val)
}
}
func verifyMfa(oc *Client, shibbolethHost string, resp string) (*http.Response, error) {
duoHost, postAction, tx, app := parseTokens(resp)
parent := fmt.Sprintf(shibbolethHost + postAction)
duoTxCookie, err := verifyDuoMfa(oc, duoHost, parent, tx)
if err != nil {
return nil, errors.Wrap(err, "error when interacting with Duo iframe")
}
idpForm := url.Values{}
idpForm.Add("_eventId", "proceed")
idpForm.Add("sig_response", duoTxCookie+":"+app)
req, err := http.NewRequest("POST", parent, strings.NewReader(idpForm.Encode()))
if err != nil {
return nil, errors.Wrap(err, "error posting multi-factor verification to shibboleth server")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err := oc.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "error retrieving verify response")
}
return res, nil
}
func verifyDuoMfa(oc *Client, duoHost string, parent string, tx string) (string, error) {
// initiate duo mfa to get sid
duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
duoForm := url.Values{}
duoForm.Add("parent", parent)
duoForm.Add("java_version", "")
duoForm.Add("java_version", "")
duoForm.Add("flash_version", "")
duoForm.Add("screen_resolution_width", "3008")
duoForm.Add("screen_resolution_height", "1692")
duoForm.Add("color_depth", "24")
req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
q := req.URL.Query()
q.Add("tx", tx)
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
// retrieve response from post
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return "", errors.Wrap(err, "error parsing document")
}
// Duo cookie is returned here if mfa bypassed - immediatly return it if found
duoTxCookie, ok := doc.Find("input[name=\"js_cookie\"]").Attr("value")
if ok {
if duoTxCookie == "" {
return "", errors.Wrap(err, "duoMfaBypass: invalid response cookie")
}
return duoTxCookie, nil
}
// Duo cookie not found - continue with full MFA transaction
duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value")
if !ok {
return "", errors.Wrap(err, "unable to locate saml response")
}
duoSID = html.UnescapeString(duoSID)
//prompt for mfa type
//supporting push, call, and passcode for now
var token string
var duoMfaOptions = []string{
"Duo Push",
"Phone Call",
"Passcode",
}
duoMfaOption := prompter.Choose("Select a DUO MFA Option", duoMfaOptions)
if duoMfaOptions[duoMfaOption] == "Passcode" {
//get users DUO MFA Token
token = prompter.StringRequired("Enter passcode")
}
// send mfa auth request
duoSubmitURL = fmt.Sprintf("https://%s/frame/prompt", duoHost)
duoForm = url.Values{}
duoForm.Add("sid", duoSID)
duoForm.Add("device", "phone1")
duoForm.Add("factor", duoMfaOptions[duoMfaOption])
duoForm.Add("out_of_date", "false")
if duoMfaOptions[duoMfaOption] == "Passcode" {
duoForm.Add("passcode", token)
}
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp := string(body)
duoTxStat := gjson.Get(resp, "stat").String()
duoTxID := gjson.Get(resp, "response.txid").String()
if duoTxStat != "OK" {
return "", errors.Wrap(err, "error authenticating mfa device")
}
// get duo cookie
duoSubmitURL = fmt.Sprintf("https://%s/frame/status", duoHost)
duoForm = url.Values{}
duoForm.Add("sid", duoSID)
duoForm.Add("txid", duoTxID)
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)
duoTxResult := gjson.Get(resp, "response.result").String()
duoResultURL := gjson.Get(resp, "response.result_url").String()
log.Println(gjson.Get(resp, "response.status").String())
if duoTxResult != "SUCCESS" {
//poll as this is likely a push request
for {
time.Sleep(3 * time.Second)
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp := string(body)
duoTxResult = gjson.Get(resp, "response.result").String()
duoResultURL = gjson.Get(resp, "response.result_url").String()
log.Println(gjson.Get(resp, "response.status").String())
if duoTxResult == "FAILURE" {
return "", errors.Wrap(err, "failed to authenticate device")
}
if duoTxResult == "SUCCESS" {
break
}
}
}
duoRequestURL := fmt.Sprintf("https://%s%s", duoHost, duoResultURL)
req, err = http.NewRequest("POST", duoRequestURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error constructing request object to result url")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving duo result response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "duoResultSubmit: error retrieving body from response")
}
resp = string(body)
duoTxCookie = gjson.Get(resp, "response.cookie").String()
if duoTxCookie == "" {
return "", errors.Wrap(err, "duoResultSubmit: Unable to get response.cookie")
}
return duoTxCookie, nil
}
func parseTokens(blob string) (string, string, string, string) {
hostRgx := regexp.MustCompile(`data-host=\"(.*?)\"`)
sigRgx := regexp.MustCompile(`data-sig-request=\"(.*?)\"`)
dpaRgx := regexp.MustCompile(`data-post-action=\"(.*?)\"`)
dataSigRequest := sigRgx.FindStringSubmatch(blob)
duoHost := hostRgx.FindStringSubmatch(blob)
postAction := dpaRgx.FindStringSubmatch(blob)
duoSignatures := strings.Split(dataSigRequest[1], ":")
return duoHost[1], postAction[1], duoSignatures[0], duoSignatures[1]
}
func extractSamlResponse(res *http.Response) (string, error) {
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "extractSamlResponse: error retrieving body from response")
}
samlRgx := regexp.MustCompile(`name=\"SAMLResponse\" value=\"(.*?)\"/>`)
samlResponseValue := samlRgx.FindStringSubmatch(string(body))
return samlResponseValue[1], nil
}