pkg/provider/shibbolethecp/shibbolethecp.go (157 lines of code) (raw):

package shibbolethecp import ( "bufio" "bytes" "crypto/tls" "encoding/base64" "fmt" "io" "io/ioutil" "net/http" "text/template" "time" "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/beevik/etree" "github.com/google/uuid" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) const SAML_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success" const SHIB_DUO_FACTOR = "X-Shibboleth-Duo-Factor" const SHIB_DUO_PASSCODE = "X-Shibboleth-Duo-Passcode" // Client wrapper around shibbolethecp enabling authentication and retrieval of assertions type Client struct { client *provider.HTTPClient idpAccount *cfg.IDPAccount } var logger = logrus.WithField("provider", "shibbolethecp") const authnRequestTpl = ` <S:Envelope xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:S="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"> <S:Body> <saml2p:AuthnRequest ID="{{.ID}}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:PAOS" AssertionConsumerServiceURL="{{.AssertionConsumerServiceURL}}" IssueInstant="{{.IssueInstant}}" Version="2.0"> <saml2:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"> {{.EntityID}} </saml2:Issuer> </saml2p:AuthnRequest> </S:Body> </S:Envelope>` type authnRequestData struct { ID string AssertionConsumerServiceURL string IssueInstant string EntityID string } // New creates a new shibboleth-ecp 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 authenticates to a Shibboleth ECP profile and return the data from the body of the SAML assertion. func (c *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { // Step 1: Request resource from IdP, indicate we are ECP capable ar, err := authnRequest(c.idpAccount.AlibabaCloudURN) if err != nil { return "", err } req, err := http.NewRequest("POST", loginDetails.URL, ar) if err != nil { return "", errors.Wrapf(err, "Error creating new http request for %s", loginDetails.URL) } req.Header.Set("Content-Type", "text/xml") req.Header.Set("charset", "utf-8") req.Header.Set(SHIB_DUO_FACTOR, c.idpAccount.MFA) req.SetBasicAuth(loginDetails.Username, loginDetails.Password) // if user chose passcode, then optionally prompt for the token and set the SHIB_DUO_PASSCODE header if c.idpAccount.MFA == "passcode" { if loginDetails.MFAToken == "" { req.Header.Set(SHIB_DUO_PASSCODE, prompter.RequestSecurityCode("000000")) } else { req.Header.Set(SHIB_DUO_PASSCODE, loginDetails.MFAToken) } } res, err := c.client.Do(req) defer res.Body.Close() if err != nil { return "", errors.Wrap(err, "Sending initial SOAP authnRequest") } if res.StatusCode != 200 { return "", errors.Wrapf(err, "Response code from IDP at %s: %s", res.Status, res.Request.URL) } bodyBytes, _ := ioutil.ReadAll(res.Body) logger.Debugf("IDP Response: %s", bodyBytes) res.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) // reset // Step 2: Process the returned <AuthnRequest> // check for SAML_SUCCESS in S:Body/saml2p:Response/saml2p:Status/saml2p:StatusCode/@Value assertion, err := extractAssertion(res.Body) logger.Debugf("err = %s", err) if err != nil { return "", err } logger.Debugf("SAML Assertion: %s", assertion) // saml2alibabacloud expects the assertion to be base64 encoded return base64.StdEncoding.EncodeToString([]byte(assertion)), nil } // authnRequest creates a SOAP-XML AuthnRequest from EntityID func authnRequest(entityID string) (io.Reader, error) { // create authnRequest from template, due to fragility in xml/encoding when handling namespaces t, err := template.New("authnRequest").Parse(authnRequestTpl) if err != nil { return nil, errors.Wrap(err, "Error parsing authnRequest template") } ard := authnRequestData{ ID: uuid.New().String(), IssueInstant: time.Now().Format(time.RFC3339), AssertionConsumerServiceURL: "https://signin.aliyun.com/saml-role/sso", EntityID: entityID, } var buf bytes.Buffer bufw := bufio.NewWriter(&buf) if err := t.Execute(bufw, ard); err != nil { return nil, errors.Wrap(err, "Creating authnRequest from template") } bufw.Flush() // create our http request and set headers bufr := bufio.NewReader(&buf) return bufr, nil } // extractAssertion extracts a SAML assertion from a SOAP response body func extractAssertion(body io.Reader) (string, error) { // parse the response doc := etree.NewDocument() n, err := doc.ReadFrom(body) if err != nil { return "", errors.Wrap(err, "Unable to parse IDP response as XML using etree") } if n <= 0 { return "", fmt.Errorf("etree ReadFrom() read %d bytes from IDP response", n) } // set the root root := doc.Root() // find status code statusCodeElement := root.FindElement("//saml2p:StatusCode") if statusCodeElement == nil { return "", errors.New("Unable to find StatusCode element by XML path") } // check statuscode value statusCode := statusCodeElement.SelectAttrValue("Value", "unknown") logger.Debugf("SAML StatusCode Value = %s", statusCode) if statusCode != SAML_SUCCESS { return "", errors.Errorf("IDP response did not return success. StatusCode = %s", statusCode) } // Step 3: Extract the SOAP-wrapped <Assertion> from IdP // find the SAML Response element responseElement := root.FindElement("//saml2p:Response") if responseElement == nil { return "", errors.New("Unable to find Response element in IdP response by XML path") } // then pull everything from the Response element down into a string to return doc.SetRoot(responseElement) assertion, err := doc.WriteToString() if err != nil { return "", errors.Wrap(err, "Could not serialize Response to string") } return assertion, nil }