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 }