github.com/vmware/go-vcloud-director/v2@v2.24.0/govcd/saml_auth.go (about)

     1  /*
     2   * Copyright 2020 VMware, Inc.  All rights reserved.  Licensed under the Apache v2 License.
     3   */
     4  
     5  package govcd
     6  
     7  import (
     8  	"bytes"
     9  	"compress/gzip"
    10  	"encoding/base64"
    11  	"errors"
    12  	"fmt"
    13  	"net/http"
    14  	"net/url"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/vmware/go-vcloud-director/v2/types/v56"
    19  	"github.com/vmware/go-vcloud-director/v2/util"
    20  )
    21  
    22  /*
    23  This file implements SAML authentication flow using Microsoft Active Directory Federation Services
    24  (ADFS). It adds support to authenticate to Cloud Director using SAML authentication (by applying
    25  WithSamlAdfs() configuration option to NewVCDClient function). The identity provider (IdP) must be
    26  Active Directory Federation Services (ADFS) and "/adfs/services/trust/13/usernamemixed" endpoint
    27  must be enabled to make it work. Furthermore username must be supplied in ADFS friendly format -
    28  test@contoso.com' or 'contoso.com\test'.
    29  
    30  It works by finding ADFS login endpoint for vCD by querying vCD SAML redirect endpoint
    31  for specific Org and then submits authentication request to "/adfs/services/trust/13/usernamemixed"
    32  endpoint of ADFS server. Using ADFS response it constructs a SIGN token which vCD accepts for the
    33  "/api/sessions". After first initial "login" it grabs the regular X-Vcloud-Authorization token and
    34  uses it for further requests.
    35  More information in vCD documentation:
    36  https://code.vmware.com/docs/10000/vcloud-api-programming-guide-for-service-providers/GUID-335CFC35-7AD8-40E5-91BE-53971937A2BB.html
    37  
    38  There is a working code example in /samples/saml_auth_adfs directory how to setup client using SAML
    39  auth.
    40  */
    41  
    42  // authorizeSamlAdfs is the main entry point for SAML authentication on ADFS endpoint
    43  // "/adfs/services/trust/13/usernamemixed"
    44  // Input parameters:
    45  // user - username for authentication to ADFS server (e.g. 'test@contoso.com' or
    46  // 'contoso.com\test')
    47  // pass - password for authentication to ADFS server
    48  // org  - Org to authenticate to
    49  // override_rpt_id - override relaying party trust ID. If it is empty - vCD Entity ID will be used
    50  // as relaying party trust ID
    51  //
    52  // The general concept is to get a SIGN token from ADFS IdP (Identity Provider) and exchange it with
    53  // regular vCD token for further operations. It is documented in
    54  // https://code.vmware.com/docs/10000/vcloud-api-programming-guide-for-service-providers/GUID-335CFC35-7AD8-40E5-91BE-53971937A2BB.html
    55  // This is achieved with the following steps:
    56  // 1 - Lookup vCD Entity ID to use for ADFS authentication or use custom value if overrideRptId
    57  // field is provided
    58  // 2 - Find ADFS server name by querying vCD SAML URL which responds with HTTP redirect (302)
    59  // 3 - Authenticate to ADFS server using vCD SAML Entity ID or custom value if overrideRptId is
    60  // specified Relying Party Trust Identifier
    61  // 4 - Process received ciphers from ADFS server (gzip and base64 encode) so that data can be used
    62  // as SIGN token in vCD
    63  // 5 - Authenticate to vCD using SIGN token in order to receive back regular
    64  // X-Vcloud-Authorization token
    65  // 6 - Set the received X-Vcloud-Authorization for further usage
    66  func (vcdClient *VCDClient) authorizeSamlAdfs(user, pass, org, overrideRptId string) error {
    67  	// Step 1 - find SAML entity ID configured in vCD metadata URL unless overrideRptId is provided
    68  	// Example URL: url.Scheme + "://" + url.Host + "/cloud/org/" + org + "/saml/metadata/alias/vcd"
    69  	samlEntityId := overrideRptId
    70  	var err error
    71  	if overrideRptId == "" {
    72  		samlEntityId, err = getSamlEntityId(vcdClient, org)
    73  		if err != nil {
    74  			return fmt.Errorf("SAML - error getting vCD SAML Entity ID: %s", err)
    75  		}
    76  	}
    77  
    78  	// Step 2 - find ADFS server used for SAML by calling vCD SAML endpoint and hoping for a
    79  	// redirect to ADFS server. Example URL:
    80  	// url.Scheme + "://" + url.Host + "/login/my-org/saml/login/alias/vcd?service=tenant:" + org
    81  	adfsAuthEndPoint, err := getSamlAdfsServer(vcdClient, org)
    82  	if err != nil {
    83  		return fmt.Errorf("SAML - error getting IdP (ADFS): %s", err)
    84  	}
    85  
    86  	// Step 3 - authenticate to ADFS to receive SIGN token which can be used for vCD authentication
    87  	signToken, err := getSamlAuthToken(vcdClient, user, pass, samlEntityId, adfsAuthEndPoint, org)
    88  	if err != nil {
    89  		return fmt.Errorf("SAML - could not get auth token from IdP (ADFS). Did you specify "+
    90  			"username in ADFS format ('user@contoso.com' or 'contoso.com\\user')? : %s", err)
    91  	}
    92  
    93  	// Step 4 - gzip and base64 encode SIGN token so that vCD can understand it
    94  	base64GzippedSignToken, err := gzipAndBase64Encode(signToken)
    95  	if err != nil {
    96  		return fmt.Errorf("SAML - error encoding SIGN token: %s", err)
    97  	}
    98  	util.Logger.Printf("[DEBUG] SAML got SIGN token from IdP '%s' for entity with ID '%s'",
    99  		adfsAuthEndPoint, samlEntityId)
   100  
   101  	// Step 5 - authenticate to vCD with SIGN token and receive vCD regular token in exchange
   102  	accessToken, err := authorizeSignToken(vcdClient, base64GzippedSignToken, org)
   103  	if err != nil {
   104  		return fmt.Errorf("SAML - error submitting SIGN token to vCD: %s", err)
   105  	}
   106  
   107  	// Step 6 - set regular vCD auth token X-Vcloud-Authorization
   108  	err = vcdClient.SetToken(org, AuthorizationHeader, accessToken)
   109  	if err != nil {
   110  		return fmt.Errorf("error during token-based authentication: %s", err)
   111  	}
   112  
   113  	return nil
   114  }
   115  
   116  // getSamlAdfsServer finds out Active Directory Federation Service (ADFS) server to use
   117  // for SAML authentication
   118  // It works by temporarily patching existing http.Client behavior to avoid automatically
   119  // following HTTP redirects and searches for Location header after the request to vCD SAML redirect
   120  // address. The URL to search redirect location is:
   121  // url.Scheme + "://" + url.Host + "/login/my-org/saml/login/alias/vcd?service=tenant:" + org
   122  //
   123  // Concurrency note. This function temporarily patches `vcdCli.Client.Http` therefore http.Client
   124  // would not follow redirects during this time. It is however safe as vCDClient is not expected to
   125  // use `http.Client` in any other place before authentication occurs.
   126  func getSamlAdfsServer(vcdCli *VCDClient, org string) (string, error) {
   127  	url := vcdCli.Client.VCDHREF
   128  
   129  	// Backup existing http.Client redirect behavior so that it does not follow HTTP redirects
   130  	// automatically and restore it right after this function by using defer. A new http.Client
   131  	// could be spawned here, but the existing one is re-used on purpose to inherit all other
   132  	// settings used for client (timeouts, etc).
   133  	backupRedirectChecker := vcdCli.Client.Http.CheckRedirect
   134  
   135  	defer func() {
   136  		vcdCli.Client.Http.CheckRedirect = backupRedirectChecker
   137  	}()
   138  
   139  	// Patch http client to avoid following redirects
   140  	vcdCli.Client.Http.CheckRedirect = func(req *http.Request, via []*http.Request) error {
   141  		return http.ErrUseLastResponse
   142  	}
   143  
   144  	// Construct SAML login URL which should return a redirect to ADFS server
   145  	loginURLString := url.Scheme + "://" + url.Host + "/login/" + org + "/saml/login/alias/vcd"
   146  	loginURL, err := url.Parse(loginURLString)
   147  	if err != nil {
   148  		return "", fmt.Errorf("unable to parse login URL '%s': %s", loginURLString, err)
   149  	}
   150  	util.Logger.Printf("[DEBUG] SAML looking up IdP (ADFS) host redirect in: %s", loginURL.String())
   151  
   152  	// Make a request to URL adding unencoded query parameters in the format:
   153  	// "?service=tenant:my-org"
   154  	req := vcdCli.Client.NewRequestWitNotEncodedParams(
   155  		nil, map[string]string{"service": "tenant:" + org}, http.MethodGet, *loginURL, nil)
   156  	httpResponse, err := checkResp(vcdCli.Client.Http.Do(req))
   157  	if err != nil {
   158  		return "", fmt.Errorf("SAML - ADFS server query failed: %s", err)
   159  	}
   160  
   161  	err = decodeBody(types.BodyTypeXML, httpResponse, nil)
   162  	if err != nil {
   163  		return "", fmt.Errorf("SAML - error decoding body: %s", err)
   164  	}
   165  
   166  	// httpResponse.Location() returns an error if no 'Location' header is present
   167  	adfsEndpoint, err := httpResponse.Location()
   168  	if err != nil {
   169  		return "", fmt.Errorf("SAML GET request for '%s' did not return HTTP redirect. "+
   170  			"Is SAML configured? Got error: %s", loginURL, err)
   171  	}
   172  
   173  	authEndPoint := adfsEndpoint.Scheme + "://" + adfsEndpoint.Host + "/adfs/services/trust/13/usernamemixed"
   174  	util.Logger.Printf("[DEBUG] SAML got IdP login endpoint: %s", authEndPoint)
   175  
   176  	return authEndPoint, nil
   177  }
   178  
   179  // getSamlEntityId attempts to load vCD hosted SAML metadata from URL:
   180  // url.Scheme + "://" + url.Host + "/cloud/org/" + org + "/saml/metadata/alias/vcd"
   181  // Returns an error if Entity ID is empty
   182  // Sample response body can be found in saml_auth_unit_test.go
   183  func getSamlEntityId(vcdCli *VCDClient, org string) (string, error) {
   184  	url := vcdCli.Client.VCDHREF
   185  	samlMetadataUrl := url.Scheme + "://" + url.Host + "/cloud/org/" + org + "/saml/metadata/alias/vcd"
   186  
   187  	metadata := types.VcdSamlMetadata{}
   188  	errString := fmt.Sprintf("SAML - unable to load metadata from URL %s: %%s", samlMetadataUrl)
   189  	_, err := vcdCli.Client.ExecuteRequest(samlMetadataUrl, http.MethodGet, "", errString, nil, &metadata)
   190  	if err != nil {
   191  		return "", err
   192  	}
   193  
   194  	samlEntityId := metadata.EntityID
   195  	util.Logger.Printf("[DEBUG] SAML got entity ID: %s", samlEntityId)
   196  
   197  	if samlEntityId == "" {
   198  		return "", errors.New("SAML - got empty entity ID")
   199  	}
   200  
   201  	return samlEntityId, nil
   202  }
   203  
   204  // getSamlAuthToken generates a token request payload using function
   205  // getSamlTokenRequestBody. This request is submitted to ADFS server endpoint
   206  // "/adfs/services/trust/13/usernamemixed" and `RequestedSecurityTokenTxt` is expected in response
   207  // Sample response body can be found in saml_auth_unit_test.go
   208  func getSamlAuthToken(vcdCli *VCDClient, user, pass, samlEntityId, authEndpoint, org string) (string, error) {
   209  	requestBody := getSamlTokenRequestBody(user, pass, samlEntityId, authEndpoint)
   210  	samlTokenRequestBody := strings.NewReader(requestBody)
   211  	tokenRequestResponse := types.AdfsAuthResponseEnvelope{}
   212  
   213  	// Post to ADFS endpoint "/adfs/services/trust/13/usernamemixed"
   214  	authEndpointUrl, err := url.Parse(authEndpoint)
   215  	if err != nil {
   216  		return "", fmt.Errorf("SAML - error parsing authentication endpoint %s: %s", authEndpoint, err)
   217  	}
   218  	req := vcdCli.Client.NewRequest(nil, http.MethodPost, *authEndpointUrl, samlTokenRequestBody)
   219  	req.Header.Add("Content-Type", types.SoapXML)
   220  	resp, err := vcdCli.Client.Http.Do(req)
   221  	resp, err = checkRespWithErrType(types.BodyTypeXML, resp, err, &types.AdfsAuthErrorEnvelope{})
   222  	if err != nil {
   223  		return "", fmt.Errorf("SAML - ADFS token request query failed for RPT ID ('%s'): %s",
   224  			samlEntityId, err)
   225  	}
   226  
   227  	err = decodeBody(types.BodyTypeXML, resp, &tokenRequestResponse)
   228  	if err != nil {
   229  		return "", fmt.Errorf("SAML - error decoding ADFS token request response: %s", err)
   230  	}
   231  
   232  	tokenString := tokenRequestResponse.Body.RequestSecurityTokenResponseCollection.RequestSecurityTokenResponse.RequestedSecurityTokenTxt.Text
   233  
   234  	return tokenString, nil
   235  }
   236  
   237  // authorizeSignToken submits a SIGN token received from ADFS server and gets regular vCD
   238  // "X-Vcloud-Authorization" token in exchange
   239  // Sample response body can be found in saml_auth_unit_test.go
   240  func authorizeSignToken(vcdCli *VCDClient, base64GzippedSignToken, org string) (string, error) {
   241  	url, err := url.Parse(vcdCli.Client.VCDHREF.Scheme + "://" + vcdCli.Client.VCDHREF.Host + "/api/sessions")
   242  	if err != nil {
   243  		return "", fmt.Errorf("SAML error - could not parse URL for posting SIGN token: %s", err)
   244  	}
   245  
   246  	signHeader := http.Header{}
   247  	signHeader.Add("Authorization", `SIGN token="`+base64GzippedSignToken+`",org="`+org+`"`)
   248  
   249  	req := vcdCli.Client.newRequest(nil, nil, http.MethodPost, *url, nil, vcdCli.Client.APIVersion, signHeader)
   250  	resp, err := checkResp(vcdCli.Client.Http.Do(req))
   251  	if err != nil {
   252  		return "", fmt.Errorf("SAML - error submitting SIGN token for authentication to %s: %s", req.URL.String(), err)
   253  	}
   254  	err = decodeBody(types.BodyTypeXML, resp, nil)
   255  	if err != nil {
   256  		return "", fmt.Errorf("SAML - error decoding body SIGN token auth response: %s", err)
   257  	}
   258  
   259  	accessToken := resp.Header.Get("X-Vcloud-Authorization")
   260  	util.Logger.Printf("[DEBUG] SAML - setting access token for further requests")
   261  	return accessToken, nil
   262  }
   263  
   264  // getSamlTokenRequestBody returns a SAML Token request body which is accepted by ADFS server
   265  // endpoint "/adfs/services/trust/13/usernamemixed".
   266  // The payload is not configured as a struct and unmarshalled because Go's unmarshalling changes
   267  // structure so that ADFS does not accept the payload
   268  func getSamlTokenRequestBody(user, password, samlEntityIdReference, adfsAuthEndpoint string) string {
   269  	return `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" 
   270  	xmlns:a="http://www.w3.org/2005/08/addressing" 
   271  	xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
   272  	<s:Header>
   273  		<a:Action s:mustUnderstand="1">http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue</a:Action>
   274  		<a:ReplyTo>
   275  			<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
   276  		</a:ReplyTo>
   277  		<a:To s:mustUnderstand="1">` + adfsAuthEndpoint + `</a:To>
   278  		<o:Security s:mustUnderstand="1" 
   279  			xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
   280  			<u:Timestamp u:Id="_0">
   281  				<u:Created>` + time.Now().Format(time.RFC3339) + `</u:Created>
   282  				<u:Expires>` + time.Now().Add(1*time.Minute).Format(time.RFC3339) + `</u:Expires>
   283  			</u:Timestamp>
   284  			<o:UsernameToken>
   285  				<o:Username>` + user + `</o:Username>
   286  				<o:Password o:Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">` + password + `</o:Password>
   287  			</o:UsernameToken>
   288  		</o:Security>
   289  	</s:Header>
   290  	<s:Body>
   291  		<trust:RequestSecurityToken xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
   292  			<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
   293  				<a:EndpointReference>
   294  					<a:Address>` + samlEntityIdReference + `</a:Address>
   295  				</a:EndpointReference>
   296  			</wsp:AppliesTo>
   297  			<trust:KeySize>0</trust:KeySize>
   298  			<trust:KeyType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer</trust:KeyType>
   299  			<i:RequestDisplayToken xml:lang="en" 
   300  				xmlns:i="http://schemas.xmlsoap.org/ws/2005/05/identity" />
   301  			<trust:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</trust:RequestType>
   302  			<trust:TokenType>http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0</trust:TokenType>
   303  		</trust:RequestSecurityToken>
   304  	</s:Body>
   305  </s:Envelope>`
   306  }
   307  
   308  // gzipAndBase64Encode accepts a string, gzips it and encodes in base64
   309  func gzipAndBase64Encode(text string) (string, error) {
   310  	var gzipBuffer bytes.Buffer
   311  	gz := gzip.NewWriter(&gzipBuffer)
   312  	if _, err := gz.Write([]byte(text)); err != nil {
   313  		return "", fmt.Errorf("error writing to gzip buffer: %s", err)
   314  	}
   315  	if err := gz.Close(); err != nil {
   316  		return "", fmt.Errorf("error closing gzip buffer: %s", err)
   317  	}
   318  	base64GzippedToken := base64.StdEncoding.EncodeToString(gzipBuffer.Bytes())
   319  
   320  	return base64GzippedToken, nil
   321  }