-
Notifications
You must be signed in to change notification settings - Fork 9
/
sp_handlers.go
398 lines (349 loc) · 15.1 KB
/
sp_handlers.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
package saml
import (
"bytes"
"compress/flate"
"crypto/tls"
"encoding/base64"
"encoding/xml"
"fmt"
"net/url"
"strings"
"github.com/beevik/etree"
"github.com/pkg/errors"
"github.com/pressly/saml/xmlsec"
dsig "github.com/russellhaering/goxmldsig"
)
// SAMLRequest creates a new AuthnRequest object to be sent to the IdP
// Depending on the selected binding a HTTP-POST form, or a HTTP-Redirect URL are returned
func (sp *ServiceProvider) SAMLRequest(relayState string) (string, error) {
authnRequest, err := sp.NewAuthnRequest()
if err != nil {
return "", errors.Wrap(err, "failed to create auth request")
}
buf, err := xml.Marshal(authnRequest)
if err != nil {
return "", errors.Wrap(err, "failed to marshal auth request")
}
switch sp.IdPSSOServiceBinding {
case HTTPRedirectBinding:
return sp.SAMLRequestURL(buf, relayState)
case HTTPPostBinding:
return sp.SAMLRequestForm(buf, relayState)
default:
// default to HTTP-Redirect?
return "", errors.Errorf("invalid sso service binding")
}
}
// SAMLRequestURL builds a HTTP Redirect SAML Request URL
// aka SP-initiated login (SP->IdP).
// The data is passed in the ?SAMLRequest query parameter and
// the value is base64 encoded and deflate-compressed <AuthnRequest>
// XML element. The final redirect destination that will be invoked
// on successful login is passed using ?RelayState query parameter.
//
// TODO(diogo): HTTP-Redirect signed requests
func (sp *ServiceProvider) SAMLRequestURL(authnRequest []byte, relayState string) (string, error) {
// Compress authnRequest
flateBuf := bytes.NewBuffer(nil)
flateWriter, err := flate.NewWriter(flateBuf, flate.DefaultCompression)
if err != nil {
return "", errors.Wrap(err, "failed to create flate writer")
}
if _, err = flateWriter.Write(authnRequest); err != nil {
return "", errors.Wrap(err, "failed to write to flate writer")
}
flateWriter.Close()
authnReqCompressedBytes := flateBuf.Bytes()
// Base64 encode authnRequest
authnReqBase64Encoded := base64.StdEncoding.EncodeToString(authnReqCompressedBytes)
// Escape authnRequest
authnReqEscaped := url.QueryEscape(authnReqBase64Encoded)
// Escape relay state
relayStateEscaped := url.QueryEscape(relayState)
return fmt.Sprintf(`%s?RelayState=%s&SAMLRequest=%s`, sp.IdPSSOServiceURL, relayStateEscaped, authnReqEscaped), nil
}
// SAMLRequestForm creates a HTML form with an embedded SAML Request
func (sp *ServiceProvider) SAMLRequestForm(authnRequest []byte, relayState string) (string, error) {
if sp.IdPSignSAMLRequest {
pubkeyFile, err := sp.PubkeyFile()
if err != nil {
return "", errors.Wrap(err, "failed to read service provider public key")
}
privkeyFile, err := sp.PrivkeyFile()
if err != nil {
return "", errors.Wrap(err, "failed to read service provider private key")
}
cert, err := tls.LoadX509KeyPair(pubkeyFile, privkeyFile)
if err != nil {
return "", errors.Wrap(err, "failed to load service provider key pair")
}
signingContext := dsig.NewDefaultSigningContext(dsig.TLSCertKeyStore(cert))
// CA API Gateway IdP requires the exclusive canonicalization algorithm
//
// From the spec: http: //docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
// 5.4.3 Canonicalization Method
// SAML implementations SHOULD use Exclusive Canonicalization [Excl-C14N], with or without comments,
// both in the <ds:CanonicalizationMethod> element of <ds:SignedInfo>, and as a
// <ds:Transform> algorithm. Use of Exclusive Canonicalization ensures that signatures created over
// SAML messages embedded in an XML context can be verified independent of that context.
signingContext.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("")
signingContext.SetSignatureMethod(CryptoSHA256)
// Build an etree document from the AuthnRequest XML
doc := etree.NewDocument()
err = doc.ReadFromBytes(authnRequest)
if err != nil {
return "", errors.Wrap(err, "failed to deserialize authn request into xml document")
}
if len(doc.Child) < 1 {
return "", errors.Errorf("expecting at least one child element for authn request")
}
// Signature requires an element, not document
element := doc.Child[0].(*etree.Element)
sig, err := signingContext.ConstructSignature(element, true)
if err != nil {
return "", errors.Wrap(err, "failed to build authn request signature")
}
// Build a new element with the signature included
elementWithSig := element.Copy()
// Following the flow defined in the gosaml2 lib: https://github.com/russellhaering/gosaml2/blob/master/build_request.go#L17
var children []etree.Token
children = append(children, elementWithSig.Child[0]) // issuer is always first
children = append(children, sig) // next is the signature
children = append(children, elementWithSig.Child[1:]...) // then all other children
elementWithSig.Child = children
// Convert the signed element to a document before marhsalling
doc = etree.NewDocument()
doc.SetRoot(elementWithSig)
if authnRequest, err = doc.WriteToBytes(); err != nil {
return "", errors.Wrap(err, "failed to write xml document to string")
}
}
payload := fmt.Sprintf(`
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<body onload="document.forms[0].submit()">
<form action="%s" method="post">
<div>
<input type="hidden" name="RelayState" value="%s" />
<input type="hidden" name="SAMLRequest" value="%s" />
</div>
</form>
</body>
</html>`, sp.IdPSSOServiceURL, relayState, base64.StdEncoding.EncodeToString(authnRequest))
return payload, nil
}
// MetadataXML returns SAML 2.0 Service Provider metadata XML.
func (sp *ServiceProvider) MetadataXML() ([]byte, error) {
metadata, err := sp.Metadata()
if err != nil {
return nil, errors.Wrap(err, "could not build nor serve metadata XML")
}
out, err := xml.MarshalIndent(metadata, "", "\t")
if err != nil {
return nil, errors.Wrap(err, "could not format metadata")
}
return out, nil
}
func (sp *ServiceProvider) possibleResponseIDs() []string {
responseIDs := []string{}
if sp.AllowIdpInitiated {
responseIDs = append(responseIDs, "")
}
return responseIDs
}
func (sp *ServiceProvider) verifySignature(plaintextMessage []byte) error {
idpCertFile, err := sp.GetIdPCertFile()
if err != nil {
return errors.Wrap(err, "failed to get idp cert file")
}
if err := xmlsec.Verify(plaintextMessage, idpCertFile, &xmlsec.ValidationOptions{
DTDFile: sp.DTDFile,
}); err != nil {
if !IsSecurityException(err, &sp.SecurityOpts) {
// ...but it was not a security exception, so we ignore it and accept
// the verification.
return nil
}
return errors.Wrap(err, "failed to verify xmlsec signature")
}
return nil
}
// AssertResponse parses and validates a SAML response and its assertion
func (sp *ServiceProvider) AssertResponse(base64Res string) (*Assertion, error) {
// Parse SAML response from base64 encoded payload
//
samlResponseXML, err := base64.StdEncoding.DecodeString(base64Res)
if err != nil {
return nil, errors.Wrapf(err, "failed to base64-decode SAML response")
}
var res *Response
if err := xml.Unmarshal(samlResponseXML, &res); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal XML document: %s", string(samlResponseXML))
}
// Validate response
//
// Validate destination
// Note: OneLogin triggers this error when the Recipient field
// is left blank (or when not set to the correct ACS endpoint)
// in the OneLogin SAML configuration page. OneLogin returns
// Destination="{recipient}" in the SAML reponse in this case.
if res.Destination != sp.ACSURL {
return nil, errors.Errorf("Wrong ACS destination, expected %q, got %q", sp.ACSURL, res.Destination)
}
if res.Status.StatusCode.Value != "urn:oasis:names:tc:SAML:2.0:status:Success" {
return nil, errors.Errorf("Unexpected status code: %v", res.Status.StatusCode.Value)
}
// Validates if the assertion matches the ID set in the original SAML AuthnRequest
//
// This check should be performed first before validating the signature since it is a cheap way to discard invalid messages
// TODO: Track request IDs and add option to set them back in the service provider
// This code will always pass since the possible response IDs is hardcoded to have a single empty string element
// expectedResponse := false
// responseIDs := sp.possibleResponseIDs()
// for i := range responseIDs {
// if responseIDs[i] == assertion.Subject.SubjectConfirmation.SubjectConfirmationData.InResponseTo {
// expectedResponse = true
// }
// }
// if len(responseIDs) == 1 && responseIDs[0] == "" {
// expectedResponse = true
// }
// if !expectedResponse && len(responseIDs) > 0 {
// return nil, errors.New("Unexpected assertion InResponseTo value")
// }
// Save XML raw bytes so later we can reuse it to verify the signature
plainText := samlResponseXML
// All SAML Responses are required to have a signature
validSignature := false
// Validate response reference
// Before validating the signature with xmlsec, first check if the reference ID is correct
//
// http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 5.3
if res.Signature != nil {
if err := verifySignatureReference(res.Signature, res.ID); err != nil {
return nil, errors.Wrap(err, "failed to validate response signature reference")
}
if err := sp.verifySignature(plainText); err != nil {
return nil, errors.Wrapf(err, "failed to verify message signature: %v", string(plainText))
}
validSignature = true
}
// Check for encrypted assertions
// http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 2.3.4
assertion := res.Assertion
if res.EncryptedAssertion != nil {
keyFile, err := sp.PrivkeyFile()
if err != nil {
return nil, errors.Wrapf(err, "failed to get private key file")
}
plainTextAssertion, err := xmlsec.Decrypt(res.EncryptedAssertion.EncryptedData, keyFile)
if err != nil {
if IsSecurityException(err, &sp.SecurityOpts) {
return nil, errors.Wrap(err, "failed to decrypt assertion")
}
}
assertion = &Assertion{}
if err := xml.Unmarshal(plainTextAssertion, assertion); err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal encrypted assertion: %v", plainTextAssertion)
}
// Track plain text so later we can verify the signature with xmlsec
plainText = plainTextAssertion
}
if assertion == nil {
return nil, errors.New("missing assertion element")
}
// Validate assertion reference
// Before validating the signature with xmlsec, first check if the reference ID is correct
//
// http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 5.3
if assertion.Signature != nil {
if err := verifySignatureReference(assertion.Signature, assertion.ID); err != nil {
return nil, errors.Wrap(err, "failed to validate assertion signature reference")
}
if err := sp.verifySignature(plainText); err != nil {
return nil, errors.Wrapf(err, "failed to verify message signature: %v", string(plainText))
}
validSignature = true
}
if !validSignature {
return nil, errors.Errorf("missing assertion signature")
}
// Validate issuer
// Since assertion could be encrypted we need to wait before validating the issuer
// Only validate issuer if the entityID is set in the IdP metadata
// TODO: the spec lists the Issuer element of an Assertion as required, we shouldn't skip validation
switch {
case sp.IdPEntityID == "":
// Skip issuer validationgit s
case assertion.Issuer == nil:
return nil, errors.New(`missing Assertion > Issuer`)
case assertion.Issuer.Value != sp.IdPEntityID:
return nil, errors.Errorf("failed to validate assertion issuer: expected %q but got %q", sp.IdPEntityID, assertion.Issuer.Value)
}
// Validate recipient
switch {
case assertion.Subject == nil:
err = errors.New(`missing Assertion > Subject`)
case assertion.Subject.SubjectConfirmation == nil:
err = errors.New(`missing Assertion > Subject > SubjectConfirmation`)
case assertion.Subject.SubjectConfirmation.SubjectConfirmationData.Recipient != sp.ACSURL:
err = errors.Errorf("failed to validate assertion recipient: expected %q but got %q", sp.ACSURL, assertion.Subject.SubjectConfirmation.SubjectConfirmationData.Recipient)
}
if err != nil {
return nil, errors.Wrapf(err, "invalid assertion recipient")
}
// Make sure we have Conditions
if assertion.Conditions == nil {
return nil, errors.New(`missing Assertion > Conditions`)
}
// The NotBefore and NotOnOrAfter attributes specify time limits on the
// validity of the assertion within the context of its profile(s) of use.
// They do not guarantee that the statements in the assertion will be
// correct or accurate throughout the validity period. The NotBefore
// attribute specifies the time instant at which the validity interval
// begins. The NotOnOrAfter attribute specifies the time instant at which
// the validity interval has ended. If the value for either NotBefore or
// NotOnOrAfter is omitted, then it is considered unspecified.
now := Now()
validFrom := assertion.Conditions.NotBefore
if !validFrom.IsZero() && validFrom.After(now.Add(ClockDriftTolerance)) {
return nil, errors.Errorf("Assertion conditions are not valid yet, got %v, current time is %v", validFrom, now)
}
validUntil := assertion.Conditions.NotOnOrAfter
if !validUntil.IsZero() && validUntil.Before(now.Add(-ClockDriftTolerance)) {
return nil, errors.Errorf("Assertion conditions already expired, got %v current time is %v, extra time is %v", validUntil, now, now.Add(-ClockDriftTolerance))
}
// A time instant at which the subject can no longer be confirmed. The time
// value is encoded in UTC, as described in Section 1.3.3.
//
// Note that the time period specified by the optional NotBefore and
// NotOnOrAfter attributes, if present, SHOULD fall within the overall
// assertion validity period as specified by the element's NotBefore and
// NotOnOrAfter attributes. If both attributes are present, the value for
// NotBefore MUST be less than (earlier than) the value for NotOnOrAfter.
if validUntil := assertion.Subject.SubjectConfirmation.SubjectConfirmationData.NotOnOrAfter; validUntil.Before(now.Add(-ClockDriftTolerance)) {
err := errors.Errorf("Assertion conditions already expired, got %v current time is %v", validUntil, now)
return nil, errors.Wrap(err, "Assertion conditions already expired")
}
// TODO: reenable?
// if assertion.Conditions != nil && assertion.Conditions.AudienceRestriction != nil {
// if assertion.Conditions.AudienceRestriction.Audience.Value != sp.MetadataURL {
// returnt.Errorf("Audience restriction mismatch, got %q, expected %q", assertion.Conditions.AudienceRestriction.Audience.Value, sp.MetadataURL), errors.New("Audience restriction mismatch")
// }
// }
return assertion, nil
}
// Check if signature reference URI matches root element ID
// http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 5.4.2
func verifySignatureReference(signature *xmlsec.Signature, nodeID string) error {
signatureURI := signature.Reference.URI
if signatureURI == "" {
return nil
}
if strings.HasPrefix(signatureURI, "#") {
if nodeID == signatureURI[1:] {
return nil
}
return errors.Errorf("signature Reference.URI %q does not match ID %v", signatureURI, nodeID)
}
return errors.Errorf("cannot lookup external URIs (%q)", signatureURI)
}