Files
QSfera/vendor/github.com/libregraph/lico/identity/authorities/saml2.go
T
2023-04-19 20:24:34 +02:00

651 lines
17 KiB
Go

/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package authorities
import (
"context"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/crewjam/httperr"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
dsig "github.com/russellhaering/goxmldsig"
"github.com/sirupsen/logrus"
"github.com/libregraph/lico/identity/authorities/samlext"
"github.com/libregraph/lico/utils"
)
var cleanWhitespaceRegexp = regexp.MustCompile(`\s+`)
type saml2AuthorityRegistration struct {
registry *Registry
data *authorityRegistrationData
discover bool
metadataEndpoint *url.URL
mutex sync.RWMutex
ready bool
serviceProvider *saml.ServiceProvider
serviceProviderSigningCerts []*x509.Certificate
}
func newSAML2AuthorityRegistration(registry *Registry, registrationData *authorityRegistrationData) (*saml2AuthorityRegistration, error) {
ar := &saml2AuthorityRegistration{
registry: registry,
data: registrationData,
}
if ar.data.RawMetadataEndpoint != "" {
if u, err := url.Parse(ar.data.RawMetadataEndpoint); err == nil {
ar.metadataEndpoint = u
} else {
return nil, fmt.Errorf("invalid metadata_endpoint value: %w", err)
}
}
if ar.data.EntityID == "" {
baseURIString := registry.baseURI.String()
metadataURI, _ := url.Parse(baseURIString + "/identifier/saml2/metadata") // Use our own meta data
ar.data.EntityID = metadataURI.String()
}
if ar.data.Discover != nil {
ar.discover = *ar.data.Discover
}
if !ar.discover {
return nil, errors.New("saml2 must use discover")
}
return ar, nil
}
func (ar *saml2AuthorityRegistration) ID() string {
return ar.data.ID
}
func (ar *saml2AuthorityRegistration) Name() string {
return ar.data.Name
}
func (ar *saml2AuthorityRegistration) AuthorityType() string {
return ar.data.AuthorityType
}
func (ar *saml2AuthorityRegistration) Authority() *Details {
details := &Details{
ID: ar.data.ID,
Name: ar.data.Name,
AuthorityType: ar.data.AuthorityType,
Trusted: ar.data.Trusted,
Insecure: ar.data.Insecure,
EndSessionEnabled: ar.data.EndSessionEnabled,
registration: ar,
}
ar.mutex.RLock()
details.ready = ar.ready
ar.mutex.RUnlock()
return details
}
func (ar *saml2AuthorityRegistration) Issuer() string {
issuer := ar.serviceProvider.IDPMetadata.EntityID
if issuer == "" {
issuer = ar.metadataEndpoint.String()
}
return issuer
}
func (ar *saml2AuthorityRegistration) Validate() error {
return nil
}
func (ar *saml2AuthorityRegistration) Initialize(ctx context.Context, registry *Registry) error {
ar.mutex.Lock()
defer ar.mutex.Unlock()
if ar.metadataEndpoint == nil {
return fmt.Errorf("no metadata_endpoint set")
}
if ar.data.EntityID == "" {
return fmt.Errorf("no entity_id set")
}
logger := registry.logger.WithFields(logrus.Fields{
"id": ar.data.ID,
"type": AuthorityTypeSAML2,
})
var client *http.Client
if ar.data.Insecure {
client = utils.InsecureHTTPClient
} else {
client = utils.DefaultHTTPClient
}
baseURIString := registry.baseURI.String()
acsURL, _ := url.Parse(baseURIString + "/identifier/saml2/acs") // Assertion Consumer Service
sloURL, _ := url.Parse(baseURIString + "/identifier/_/saml2/slo") // Single Logout Service
go func() {
var md *saml.EntityDescriptor
var err error
for {
logger.Debugf("fetching SAML2 provider meta data: %s", ar.metadataEndpoint.String())
md, err = func() (*saml.EntityDescriptor, error) {
req, fetchErr := http.NewRequest(http.MethodGet, ar.metadataEndpoint.String(), nil)
if fetchErr != nil {
return nil, fetchErr
}
req = req.WithContext(ctx)
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
resp, fetchErr := client.Do(req)
if fetchErr != nil {
return nil, fetchErr
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, httperr.Response(*resp)
}
data, fetchErr := ioutil.ReadAll(resp.Body)
if fetchErr != nil {
return nil, fetchErr
}
return samlsp.ParseMetadata(data)
}()
if err != nil {
logger.WithError(err).Errorln("error while saml2 provider meta data update")
}
select {
case <-ctx.Done():
return
default:
}
if md != nil {
for {
var serviceProviderSigningCerts []*x509.Certificate
serviceProviderSigningCerts, err = getCertsFromMetadata(md, "signing")
if err != nil {
break
}
if len(serviceProviderSigningCerts) == 0 {
err = errors.New("no signing certificate in meta data")
break
}
ar.mutex.Lock()
ar.serviceProviderSigningCerts = serviceProviderSigningCerts
ar.serviceProvider = &saml.ServiceProvider{
EntityID: ar.data.EntityID,
AcsURL: *acsURL,
SloURL: *sloURL,
IDPMetadata: md,
AllowIDPInitiated: false,
AuthnNameIDFormat: saml.TransientNameIDFormat,
}
ready := ar.ready
if ar.serviceProvider != nil {
ar.ready = true
} else {
ar.ready = false
}
if ready != ar.ready {
if ar.ready {
logger.Infoln("authority is now ready")
} else {
logger.Warnln("authority is no longer ready")
}
} else if !ar.ready {
logger.Warnln("authority not ready")
}
ready = ar.ready
ar.mutex.Unlock()
if ready {
logger.WithFields(logrus.Fields{
"signing_certs": len(serviceProviderSigningCerts),
"issuer": ar.Issuer(),
}).Debugln("SAML2 provider meta data loaded and initialized")
return
}
break
}
if err != nil {
logger.WithError(err).Errorln("error while initializing saml2 provider from meta data")
}
}
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
// breaks
}
}
}()
return nil
}
func (ar *saml2AuthorityRegistration) IdentityClaimValue(rawAssertion interface{}) (string, map[string]interface{}, error) {
assertion, _ := rawAssertion.(*saml.Assertion)
if assertion == nil {
return "", nil, errors.New("invalid assertion data")
}
icn := ar.data.IdentityClaimName
if icn == "" {
icn = "uid" // TODO(longsleep): Use constant.
}
var cvs string
var ok bool
for _, attributeStatement := range assertion.AttributeStatements {
for _, attr := range attributeStatement.Attributes {
values := []string{}
for _, value := range attr.Values {
values = append(values, value.Value)
}
ar.registry.logger.WithFields(logrus.Fields{
"FriendlyName": attr.FriendlyName,
"Name": attr.Name,
"NameFormat": attr.NameFormat,
"Values": values,
}).Debugln("saml2 attributeStatement")
if !ok {
claimName := attr.FriendlyName
if claimName == "" {
claimName = attr.Name
}
if claimName == icn && len(values) == 1 {
if attr.NameFormat != "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" {
ar.registry.logger.WithField("NameFormat", attr.NameFormat).Warnln("saml2 ignoring unsupported name format for identity claim name")
continue
}
cvs = values[0]
ok = true
}
}
}
}
if !ok {
return "", nil, errors.New("identity claim not found")
}
// Add extra external authority claims, for example SessionIndex.
claims := make(map[string]interface{})
for _, authnStatement := range assertion.AuthnStatements {
ar.registry.logger.WithFields(logrus.Fields{
"SessionNotOnOrAfter": authnStatement.SessionNotOnOrAfter,
"SessionIndex": authnStatement.SessionIndex,
}).Debugln("saml2 authnStatement")
if authnStatement.SessionIndex != "" {
claims["SessionIndex"] = authnStatement.SessionIndex
if authnStatement.SessionNotOnOrAfter != nil {
if saml.TimeNow().After(*authnStatement.SessionNotOnOrAfter) {
return "", nil, errors.New("session is expired")
}
claims["SessionNotOnOrAfter"] = authnStatement.SessionNotOnOrAfter
}
}
}
if assertion.Subject != nil {
switch assertion.Subject.NameID.Format {
case string(saml.TransientNameIDFormat):
claims["TransientNameID"] = assertion.Subject.NameID.Value
case string(saml.PersistentNameIDFormat):
claims["PersistentNameID"] = assertion.Subject.NameID.Value
case string(saml.UnspecifiedNameIDFormat):
claims["UnspecifiedNameID"] = assertion.Subject.NameID.Value
default:
return "", nil, errors.New("nameid format must be transient")
}
} else {
return "", nil, errors.New("subject not found")
}
// Convert claim value.
whitelisted := false
if ar.data.IdentityAliases != nil {
if alias, ok := ar.data.IdentityAliases[cvs]; ok && alias != "" {
cvs = alias
whitelisted = true
}
}
// Check whitelist.
if ar.data.IdentityAliasRequired && !whitelisted {
return "", nil, errors.New("identity claim has no alias")
}
return cvs, claims, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectAuthenticationRequestURL(state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
authReq, err := ar.serviceProvider.MakeAuthenticationRequest(ar.serviceProvider.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return nil, nil, err
}
uri, err := authReq.Redirect(state, ar.serviceProvider)
if err != nil {
return nil, nil, err
}
return uri, map[string]interface{}{
"rid": authReq.ID,
}, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionRequestURL(ref interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
logonRef := ref.(*string)
var nameID string
var nameIDFormat saml.NameIDFormat
if logonRef != nil {
logonRefParts := strings.SplitN(*logonRef, ":", 2)
switch logonRefParts[0] {
case "transient":
nameIDFormat = saml.TransientNameIDFormat
case "persistent":
nameIDFormat = saml.PersistentNameIDFormat
case "unspecified":
nameIDFormat = saml.UnspecifiedNameIDFormat
default:
return nil, nil, fmt.Errorf("unsupported name id format prefix: %v", logonRefParts[0])
}
nameID = logonRefParts[1]
}
req, err := ar.serviceProvider.MakeLogoutRequest(ar.serviceProvider.GetSLOBindingLocation(saml.HTTPRedirectBinding), nameID)
if err != nil {
return nil, nil, fmt.Errorf("failed to make redirect logout request: %w", err)
}
req.NameID.Format = string(nameIDFormat)
lor := &samlext.LogoutRequest{
LogoutRequest: req,
}
return lor.Redirect(state), nil, nil
}
func (ar *saml2AuthorityRegistration) MakeRedirectEndSessionResponseURL(rawReq interface{}, state string) (*url.URL, map[string]interface{}, error) {
ar.mutex.RLock()
defer ar.mutex.RUnlock()
if !ar.ready {
return nil, nil, errors.New("not ready")
}
req, _ := rawReq.(*saml.LogoutRequest)
if req == nil {
return nil, nil, errors.New("invalid request data")
}
// NOTE(longsleep): This resonse currently always reports success.
status := &saml.Status{
StatusCode: saml.StatusCode{
Value: saml.StatusSuccess,
},
}
res, err := samlext.MakeLogoutResponse(ar.serviceProvider, req, status, saml.HTTPRedirectBinding)
if err != nil {
return nil, nil, fmt.Errorf("failed to make logout response: %w", err)
}
uri := res.Redirect(state)
return uri, nil, nil
}
func (ar *saml2AuthorityRegistration) ParseStateResponse(req *http.Request, state string, extra map[string]interface{}) (interface{}, error) {
requestID := extra["rid"].(string)
return ar.serviceProvider.ParseResponse(req, []string{requestID})
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionRequest(req interface{}, state string) (bool, error) {
slo := req.(*samlext.IdpLogoutRequest)
if slo.Request == nil {
return false, fmt.Errorf("request not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if slo.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if slo.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *slo.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *slo.SigAlg).Warnln("saml2 insecure signature alg in idp logout request")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(slo.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := slo.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) ValidateIdpEndSessionResponse(res interface{}, state string) (bool, error) {
lor := res.(*samlext.IdpLogoutResponse)
if lor.Response == nil {
return false, fmt.Errorf("response not set")
}
// NOTE(longsleep): We currently only support redirect binding (which uses a detached signature).
if lor.Binding != saml.HTTPRedirectBinding {
return false, fmt.Errorf("binding not supported")
}
// Only validate signature if signed.
if lor.SigAlg == nil {
return false, nil
}
ar.mutex.RLock()
serviceProviderSigningCerts := ar.serviceProviderSigningCerts
ready := ar.ready
ar.mutex.RUnlock()
if !ready {
return false, errors.New("not ready")
}
if len(serviceProviderSigningCerts) == 0 {
// No signing certs, cannot do anything.
return false, nil
}
// Check if we are good.
switch *lor.SigAlg {
case dsig.RSASHA1SignatureMethod:
ar.registry.logger.WithField("sig_alg", *lor.SigAlg).Warnln("saml2 insecure signature alg in idp logout response")
if !ar.Authority().Insecure {
return false, nil
}
default:
// Let the rest pass, and decide later.
}
if len(lor.Signature) == 0 {
return true, fmt.Errorf("signature data is empty")
}
// Get first certificate, and verify.
if len(serviceProviderSigningCerts) > 1 {
ar.registry.logger.Warnln("saml2 authority has multiple signing keys, using first")
}
pubKey := serviceProviderSigningCerts[0].PublicKey
if verifyErr := lor.VerifySignature(pubKey); verifyErr != nil {
return true, fmt.Errorf("signature verification failed: %w", verifyErr)
}
return true, nil
}
func (ar *saml2AuthorityRegistration) Metadata() AuthorityMetadata {
ar.mutex.RLock()
sp := ar.serviceProvider
ar.mutex.RUnlock()
if sp == nil {
return nil
}
metadata := sp.Metadata()
// Set SLO to use redirect binding.
metadata.SPSSODescriptors[0].SSODescriptor.SingleLogoutServices = []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: sp.SloURL.String(),
},
}
return metadata
}
func getCertsFromMetadata(md *saml.EntityDescriptor, use string) ([]*x509.Certificate, error) {
var certStrs []string
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == use {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
}
}
}
// If there are no explicitly signing certs, just return the first non-empty cert we find.
if len(certStrs) == 0 {
for _, idpSSODescriptor := range md.IDPSSODescriptors {
for _, keyDescriptor := range idpSSODescriptor.KeyDescriptors {
if keyDescriptor.Use == "" {
for _, cert := range keyDescriptor.KeyInfo.X509Data.X509Certificates {
if cert.Data != "" {
certStrs = append(certStrs, cert.Data)
}
}
break
}
}
}
}
var certs []*x509.Certificate
for _, certStr := range certStrs {
certStr = cleanWhitespaceRegexp.ReplaceAllString(certStr, "")
certBytes, err := base64.StdEncoding.DecodeString(certStr)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
parsedCert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, err
}
certs = append(certs, parsedCert)
}
return certs, nil
}