pkg/provider/keycloak/keycloak.go (195 lines of code) (raw):

package keycloak import ( "bytes" "fmt" "io/ioutil" "log" "net/http" "net/url" "strings" "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" ) // Client wrapper around KeyCloak. type Client struct { client *provider.HTTPClient } // New create a new KeyCloakClient func New(idpAccount *cfg.IDPAccount) (*Client, error) { tr := provider.NewDefaultTransport(idpAccount.SkipVerify) client, err := provider.NewHTTPClient(tr, provider.BuildHttpClientOpts(idpAccount)) if err != nil { return nil, errors.Wrap(err, "error building http client") } return &Client{ client: client, }, nil } // Authenticate logs into KeyCloak and returns a SAML response func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { authSubmitURL, authForm, err := kc.getLoginForm(loginDetails) if err != nil { return "", errors.Wrap(err, "error retrieving login form from idp") } data, err := kc.postLoginForm(authSubmitURL, authForm) if err != nil { return "", fmt.Errorf("error submitting login form") } if authSubmitURL == "" { return "", fmt.Errorf("error submitting login form") } doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data)) if err != nil { return "", errors.Wrap(err, "error parsing document") } if containsTotpForm(doc) { totpSubmitURL, err := extractSubmitURL(doc) if err != nil { return "", errors.Wrap(err, "unable to locate IDP totp form submit URL") } doc, err = kc.postTotpForm(totpSubmitURL, loginDetails.MFAToken, doc) if err != nil { return "", errors.Wrap(err, "error posting totp form") } } return extractSamlResponse(doc), nil } func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Values, error) { res, err := kc.client.Get(loginDetails.URL) if err != nil { return "", nil, errors.Wrap(err, "error retrieving form") } doc, err := goquery.NewDocumentFromResponse(res) if err != nil { return "", nil, errors.Wrap(err, "failed to build document from response") } if res.StatusCode == http.StatusUnauthorized { authSubmitURL, err := extractSubmitURL(doc) if err != nil { return "", nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL") } loginDetails.URL = authSubmitURL return kc.getLoginForm(loginDetails) } authForm := url.Values{} doc.Find("input").Each(func(i int, s *goquery.Selection) { updateKeyCloakFormData(authForm, s, loginDetails) }) authSubmitURL, err := extractSubmitURL(doc) if err != nil { return "", nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL") } return authSubmitURL, authForm, nil } func (kc *Client) postLoginForm(authSubmitURL string, authForm url.Values) ([]byte, error) { req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode())) if err != nil { return nil, errors.Wrap(err, "error building authentication request") } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "error retrieving login form") } data, err := ioutil.ReadAll(res.Body) if err != nil { return nil, errors.Wrap(err, "error retrieving body") } return data, nil } func (kc *Client) postTotpForm(totpSubmitURL string, mfaToken string, doc *goquery.Document) (*goquery.Document, error) { otpForm := url.Values{} if mfaToken == "" { mfaToken = prompter.RequestSecurityCode("000000") } doc.Find("input").Each(func(i int, s *goquery.Selection) { updateOTPFormData(otpForm, s, mfaToken) }) req, err := http.NewRequest("POST", totpSubmitURL, strings.NewReader(otpForm.Encode())) if err != nil { return nil, errors.Wrap(err, "error building MFA request") } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") res, err := kc.client.Do(req) if err != nil { return nil, errors.Wrap(err, "error retrieving content") } doc, err = goquery.NewDocumentFromResponse(res) if err != nil { return nil, errors.Wrap(err, "error reading totp form response") } return doc, nil } func extractSubmitURL(doc *goquery.Document) (string, error) { var submitURL string doc.Find("form").Each(func(i int, s *goquery.Selection) { action, ok := s.Attr("action") if !ok { return } submitURL = action }) if submitURL == "" { return "", fmt.Errorf("unable to locate form submit URL") } return submitURL, nil } func extractSamlResponse(doc *goquery.Document) string { var samlAssertion string doc.Find("input").Each(func(i int, s *goquery.Selection) { name, ok := s.Attr("name") if ok && name == "SAMLResponse" { val, ok := s.Attr("value") if !ok { log.Fatalf("unable to locate saml assertion value") } samlAssertion = val } }) if samlAssertion == "" { log.Fatalf("unable to locate saml response field") } return samlAssertion } func containsTotpForm(doc *goquery.Document) bool { // search totp field at Keycloak < 8.0.1 totpIndex := doc.Find("input#totp").Index() if totpIndex != -1 { return true } // search otp field at Keycloak >= 8.0.1 totpIndex = doc.Find("input#otp").Index() if totpIndex != -1 { return true } return false } func updateKeyCloakFormData(authForm url.Values, s *goquery.Selection, user *creds.LoginDetails) { name, ok := s.Attr("name") // log.Printf("name = %s ok = %v", name, ok) if !ok { return } lname := strings.ToLower(name) if strings.Contains(lname, "username") { authForm.Add(name, user.Username) } else if strings.Contains(lname, "password") { authForm.Add(name, user.Password) } else { // pass through any hidden fields val, ok := s.Attr("value") if !ok { return } authForm.Add(name, val) } } func updateOTPFormData(otpForm url.Values, s *goquery.Selection, token string) { name, ok := s.Attr("name") // log.Printf("name = %s ok = %v", name, ok) if !ok { return } lname := strings.ToLower(name) // search otp field at Keycloak >= 8.0.1 if strings.Contains(lname, "totp") { otpForm.Add(name, token) } else if strings.Contains(lname, "otp") { otpForm.Add(name, token) } }