pkg/provider/akamai/akamai.go (513 lines of code) (raw):
package akamai
import (
"bytes"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/aliyun/saml2alibabacloud/pkg/prompter"
"github.com/sirupsen/logrus"
"github.com/PuerkitoBio/goquery"
"github.com/aliyun/saml2alibabacloud/pkg/cfg"
"github.com/aliyun/saml2alibabacloud/pkg/creds"
"github.com/aliyun/saml2alibabacloud/pkg/provider"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"encoding/json"
)
const (
IdentifierDuoMfa = "duo"
IdentifierSmsMfa = "sms"
IdentifierEmailMfa = "email"
IdentifierTotpMfa = "totp"
)
var logger = logrus.WithField("provider", "akamai")
var (
supportedMfaOptions = map[string]MfaUserOption{
IdentifierDuoMfa: {"DUO MFA authentication", "duo"},
IdentifierSmsMfa: {"SMS MFA authentication", "sms"},
IdentifierEmailMfa: {"EMAIL MFA authentication", "email"},
IdentifierTotpMfa: {"TOTP MFA authentication", "totp"},
}
)
type MfaUserOption struct {
UserDisplayString string
UserMfaOption string
}
// Client is a wrapper representing a Akamai SAML client
type Client struct {
client *provider.HTTPClient
mfa string
}
// AuthRequest represents an mfa Akamai request
type AuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Navigate request for saml
type NavRequest struct {
Hostname string `json:"hostname"`
}
type MfaPushRequest struct {
Force bool `json:"force"`
Uuid string `json:"uuid"`
}
type MfaTokenVerify struct {
Category string `json:"category"`
Token string `json:"token"`
Uuid string `json:"uuid"`
DuoSigRequest string `json:"sig_request"`
DuoSigResponse string `json:"sig_response"`
}
// New creates a new Akamai client
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")
}
// assign a response validator to ensure all responses are either success or a redirect
// this is to avoid have explicit checks for every single response
client.CheckResponseStatus = provider.SuccessOrRedirectResponseValidator
return &Client{
client: client,
mfa: idpAccount.MFA,
}, nil
}
// Authenticate logs into Akamai and returns a SAML response
func (oc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
var samlAssertion string
akamaiURL, err := url.Parse(loginDetails.URL)
if err != nil {
return samlAssertion, errors.Wrap(err, "error building akamaiURL")
}
akamaiOrgHost := akamaiURL.Host
akamaiQuery := akamaiURL.Query()
akamaiSamlApp := string(akamaiQuery.Get("app"))
// Get xsrf data and cookie by doing get request
akamaiLoginURL := fmt.Sprintf("https://%s/", akamaiOrgHost)
req, err := http.NewRequest("GET", akamaiLoginURL, nil)
if err != nil {
return samlAssertion, errors.Wrap(err, "error building authentication request")
}
res, err := oc.client.Do(req)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving login request")
}
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return samlAssertion, errors.Wrap(err, "error parsing document")
}
xsrfToken, ok := doc.Find("input[id=\"xsrf\"]").Attr("value")
if !ok {
return samlAssertion, errors.Wrap(err, "unable to locate xsrf token in html")
}
// Send login request to Akamai
authReq := AuthRequest{Username: loginDetails.Username, Password: loginDetails.Password}
authBody := new(bytes.Buffer)
err = json.NewEncoder(authBody).Encode(authReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error encoding authreq")
}
authSubmitURL := fmt.Sprintf("https://%s/api/v1/login", akamaiOrgHost)
loginReq, err := http.NewRequest("POST", authSubmitURL, authBody)
if err != nil {
return samlAssertion, errors.Wrap(err, "error building authentication request")
}
loginReq.Header.Add("Content-Type", "application/json")
loginReq.Header.Add("Accept", "application/json")
loginReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(loginReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error login to EAA IDP")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving body from response")
}
resp := string(body)
authStatus := gjson.Get(resp, "status").String()
if authStatus != "200" {
authFailReason := gjson.Get(resp, "msg").String()
fmt.Printf("Login Failed %s\n", authFailReason)
logger.Debug("Login Failed:", authFailReason)
return samlAssertion, errors.Wrap(err, "Login Failure")
}
// Send saml navigate request to Akamai
navReq := NavRequest{Hostname: akamaiSamlApp}
navBody := new(bytes.Buffer)
err = json.NewEncoder(navBody).Encode(navReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error encoding navreq")
}
navSubmitURL := fmt.Sprintf("https://%s/api/v2/apps/navigate", akamaiOrgHost)
navloginReq, err := http.NewRequest("POST", navSubmitURL, navBody)
if err != nil {
return samlAssertion, errors.Wrap(err, "error building navigation request")
}
navloginReq.Header.Add("Content-Type", "application/json")
navloginReq.Header.Add("Accept", "application/json")
navloginReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(navloginReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error while navigation request to EAA ")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving response from navigate request")
}
mfaStatus := gjson.GetBytes(body, "mfa.status").String()
if mfaStatus == "verify" {
err = verifyMfa(oc, akamaiOrgHost, loginDetails, xsrfToken)
if err != nil {
return samlAssertion, errors.Wrap(err, "error verifying MFA")
}
} else if mfaStatus == "register" {
fmt.Printf("MFA is enabled but not registered for user. Register MFA by accessing EAA IDP from Browser\n")
logger.Debug("MFA is enabled but not registered for user")
return samlAssertion, errors.Wrap(err, "register mfa by logging to IDP")
}
/* MFA is done call navigate again */
navReq = NavRequest{Hostname: akamaiSamlApp}
navBody = new(bytes.Buffer)
err = json.NewEncoder(navBody).Encode(navReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error encoding authreq")
}
navSubmitURL = fmt.Sprintf("https://%s/api/v2/apps/navigate", akamaiOrgHost)
navloginReq, err = http.NewRequest("POST", navSubmitURL, navBody)
if err != nil {
return samlAssertion, errors.Wrap(err, "error sending final navigate request")
}
navloginReq.Header.Add("Content-Type", "application/json")
navloginReq.Header.Add("Accept", "application/json")
navloginReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(navloginReq)
if err != nil {
return samlAssertion, errors.Wrap(err, "error navigate request to EAA ")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return samlAssertion, errors.Wrap(err, "error retrieving body from response")
}
/* saml assertion response from json of navigate */
samlResponseHtml := gjson.GetBytes(body, "navigate.body").String()
doc, err = goquery.NewDocumentFromReader(strings.NewReader(samlResponseHtml))
if err != nil {
return samlAssertion, errors.Wrap(err, "error parsing saml response in document")
}
samlAssertion, ok = doc.Find("input[name=\"SAMLResponse\"]").Attr("value")
if !ok {
return samlAssertion, errors.Wrap(err, "unable to locate SAMLResponse in html")
}
logger.Debug("auth complete")
return samlAssertion, nil
}
func verifyMfa(oc *Client, akamaiOrgHost string, loginDetails *creds.LoginDetails, xsrfToken string) error {
/* Get supported MFA for this login */
mfaConfigURL := fmt.Sprintf("https://%s/api/v1/config/mfa", akamaiOrgHost)
mfaConfigReq, err := http.NewRequest("GET", mfaConfigURL, nil)
if err != nil {
return errors.Wrap(err, "error building mfa config request")
}
mfaConfigReq.Header.Add("Accept", "application/json")
mfaConfigReq.Header.Add("xsrf", string(xsrfToken))
res, err := oc.client.Do(mfaConfigReq)
if err != nil {
return errors.Wrap(err, "error mfa config request to EAA ")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving mfa config request")
}
mfaConfigData := gjson.GetBytes(body, "mfa.config.options")
if mfaConfigData.Index == 0 {
log.Println("Mfa Config option not found")
return errors.Wrap(err, "Mfa not configured ")
}
/* Mfa config data present or not check otherwise return directly */
mfaOption := 0
var mfaOptions []MfaUserOption
for _, name := range mfaConfigData.Array() {
identifier := name.String()
if val, ok := supportedMfaOptions[identifier]; ok {
mfaOptions = append(mfaOptions, val)
}
}
/* Get MFA token settings from IDP */
mfaSettingURL := fmt.Sprintf("https://%s/api/v1/mfa/token/settings", akamaiOrgHost)
mfaSettingReq, err := http.NewRequest("GET", mfaSettingURL, nil)
if err != nil {
return errors.Wrap(err, "error building mfa setting request")
}
mfaSettingReq.Header.Add("Accept", "application/json")
mfaSettingReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(mfaSettingReq)
if err != nil {
return errors.Wrap(err, "error mfa setting request to EAA ")
}
mfaSettingData, err := ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving body from response")
}
var mfaDisplayOptions []string
var mfaUserOption string
var mfaConfiguredSupported int
if oc.mfa != "Auto" {
mfaUserOption = strings.ToLower(oc.mfa)
for _, val := range mfaOptions {
if mfaUserOption == val.UserMfaOption {
mfaDisplayOptions = nil
mfaConfiguredSupported = 1
break
}
mfaDisplayOptions = append(mfaDisplayOptions, val.UserDisplayString)
}
mfaDisplayNum := len(mfaDisplayOptions)
if mfaDisplayNum > 1 {
mfaOption = prompter.Choose("Select which MFA option to use", mfaDisplayOptions)
mfaUserOption = mfaOptions[mfaOption].UserMfaOption
} else if mfaDisplayNum == 1 {
mfaUserOption = mfaOptions[1].UserMfaOption
} else if mfaDisplayNum == 0 && mfaConfiguredSupported != 1 {
return errors.New("unsupported mfa provider")
}
} else {
mfaUserOption = gjson.GetBytes(mfaSettingData, "mfa.settings.preferred.option").String()
}
if _, ok := supportedMfaOptions[mfaUserOption]; !ok {
return errors.New("unsupported mfa provider")
}
/* specific mfa */
switch mfa := mfaUserOption; mfa {
case IdentifierSmsMfa, IdentifierEmailMfa, IdentifierTotpMfa:
/* 1. Get MFA UUID */
mfaUuid := fmt.Sprintf("mfa.settings.%s.0.uuid", mfa)
uuidMfa := gjson.GetBytes(mfaSettingData, mfaUuid).String()
/* 2.Push MFA */
var mfaApi = mfa
if mfa == IdentifierSmsMfa {
mfaApi = "phone"
}
var mfaResStatus string
if mfa == IdentifierSmsMfa || mfa == IdentifierEmailMfa {
mfaPushURL := fmt.Sprintf("https://%s/api/v1/mfa/user/%s/token/push", akamaiOrgHost, mfaApi)
mfaPushData := MfaPushRequest{Force: false, Uuid: uuidMfa}
mfaPushBody := new(bytes.Buffer)
err = json.NewEncoder(mfaPushBody).Encode(mfaPushData)
if err != nil {
return errors.Wrap(err, "error encoding mfa push data ")
}
mfaPushReq, err := http.NewRequest("POST", mfaPushURL, mfaPushBody)
if err != nil {
return errors.Wrap(err, "error building mfa push request")
}
mfaPushReq.Header.Add("Content-Type", "application/json")
mfaPushReq.Header.Add("Accept", "application/json")
mfaPushReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(mfaPushReq)
if err != nil {
return errors.Wrap(err, "error while sending MFA push code ")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving MFA push response ")
}
mfaResStatus := gjson.GetBytes(body, "status").String()
if mfaResStatus != "200" {
return errors.Wrap(err, "Unable to send mfa token")
}
}
/* 3. Verify MFA */
verifyCode := prompter.StringRequired("Enter MFA verification code")
mfaVerifyURL := fmt.Sprintf("https://%s/api/v1/mfa/user/%s/token/verify", akamaiOrgHost, mfaApi)
mfaVerifyData := MfaTokenVerify{Category: mfa, Token: verifyCode, Uuid: uuidMfa}
mfaVerifyBody := new(bytes.Buffer)
err = json.NewEncoder(mfaVerifyBody).Encode(mfaVerifyData)
if err != nil {
return errors.Wrap(err, "error encoding mfa verify req")
}
mfaVerifyReq, err := http.NewRequest("POST", mfaVerifyURL, mfaVerifyBody)
if err != nil {
return errors.Wrap(err, "error creating mfa verification request")
}
mfaVerifyReq.Header.Add("Content-Type", "application/json")
mfaVerifyReq.Header.Add("Accept", "application/json")
mfaVerifyReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(mfaVerifyReq)
if err != nil {
return errors.Wrap(err, "error verifying mfa to EAA ")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving mfa verify response")
}
mfaResStatus = gjson.GetBytes(body, "status").String()
if mfaResStatus != "200" {
return errors.Wrap(err, "Unable to verify mfa token")
}
return nil
case IdentifierDuoMfa:
duoSettings := fmt.Sprintf("mfa.settings.%s.0", mfa)
duoHost := gjson.GetBytes(mfaSettingData, duoSettings).Get("duo_host").String()
duoSignature := gjson.GetBytes(mfaSettingData, duoSettings).Get("token").String()
duoSignatures := strings.Split(duoSignature, ":")
//duoSignatures[0] = TX
//duoSignatures[1] = APP
// initiate duo mfa to get sid
duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
duoForm := url.Values{}
duoForm.Add("parent", fmt.Sprintf("https://%s/#/token", akamaiOrgHost))
duoForm.Add("java_version", "")
duoForm.Add("java_version", "")
duoForm.Add("flash_version", "")
duoForm.Add("screen_resolution_width", "1440")
duoForm.Add("screen_resolution_height", "900")
duoForm.Add("color_depth", "24")
req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return errors.Wrap(err, "error building duo request")
}
q := req.URL.Query()
q.Add("tx", duoSignatures[0])
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 sending duo request")
}
//try to extract sid
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return errors.Wrap(err, "error parsing document from duo")
}
duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value")
if !ok {
return errors.Wrap(err, "unable to locate sid in duo response")
}
duoSID = html.UnescapeString(duoSID)
//prompt for mfa type
//only supporting push or passcode for now
var token string
var duoMfaOptions = []string{
"Duo Push",
"Passcode",
}
duoMfaOption := 0
if loginDetails.DuoMFAOption == "Duo Push" {
duoMfaOption = 0
} else if loginDetails.DuoMFAOption == "Passcode" {
duoMfaOption = 1
} else {
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 duo prompt 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 duo prompt request")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving duo prompt 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 duo 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 duo status 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 sending duo status request")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving duo status 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")
}
// callback to Akamai to verify
mfaVerifyURL := fmt.Sprintf("https://%s/api/v1/mfa/user/%s/token/verify", akamaiOrgHost, mfa)
mfaDuoSigResponse := fmt.Sprintf("%s:%s", duoTxCookie, duoSignatures[1])
mfaVerifyData := MfaTokenVerify{Category: mfa, Uuid: mfa,
DuoSigRequest: duoSignature, DuoSigResponse: mfaDuoSigResponse}
mfaVerifyBody := new(bytes.Buffer)
err = json.NewEncoder(mfaVerifyBody).Encode(mfaVerifyData)
if err != nil {
return errors.Wrap(err, "error encoding duo mfa verify req")
}
mfaVerifyReq, err := http.NewRequest("POST", mfaVerifyURL, mfaVerifyBody)
if err != nil {
return errors.Wrap(err, "error creating duo mfa verification request")
}
mfaVerifyReq.Header.Add("Content-Type", "application/json")
mfaVerifyReq.Header.Add("Accept", "application/json")
mfaVerifyReq.Header.Add("xsrf", string(xsrfToken))
res, err = oc.client.Do(mfaVerifyReq)
if err != nil {
return errors.Wrap(err, "error sending duo mfa request to EAA ")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "error retrieving duo mfa response ")
}
mfaResStatus := gjson.GetBytes(body, "status").String()
if mfaResStatus != "200" {
return errors.Wrap(err, "Unable to verify mfa token")
}
return nil
}
return errors.New("no mfa options provided")
}