forked from Shiloh/githaven
5bb8d1924d
Closes https://github.com/go-gitea/gitea/issues/5512 This PR adds basic SAML support - Adds SAML 2.0 as an auth source - Adds SAML configuration documentation - Adds integration test: - Use bare-bones SAML IdP to test protocol flow and test account is linked successfully (only runs on Postgres by default) - Adds documentation for configuring and running SAML integration test locally Future PRs: - Support group mapping - Support auto-registration (account linking) Co-Authored-By: @jackHay22 --------- Co-authored-by: jackHay22 <jack@allspice.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: morphelinho <morphelinho@users.noreply.github.com> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Yarden Shoham <git@yardenshoham.com> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: silverwind <me@silverwind.io>
203 lines
7.0 KiB
Go
203 lines
7.0 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package saml
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/url"
|
|
"time"
|
|
|
|
"code.gitea.io/gitea/models/auth"
|
|
"code.gitea.io/gitea/modules/json"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
|
|
saml2 "github.com/russellhaering/gosaml2"
|
|
"github.com/russellhaering/gosaml2/types"
|
|
dsig "github.com/russellhaering/goxmldsig"
|
|
)
|
|
|
|
// Source holds configuration for the SAML login source.
|
|
type Source struct {
|
|
// IdentityProviderMetadata description: The SAML Identity Provider metadata XML contents (for static configuration of the SAML Service Provider). The value of this field should be an XML document whose root element is `<EntityDescriptor>` or `<EntityDescriptors>`. To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
|
IdentityProviderMetadata string
|
|
// IdentityProviderMetadataURL description: The SAML Identity Provider metadata URL (for dynamic configuration of the SAML Service Provider).
|
|
IdentityProviderMetadataURL string
|
|
// InsecureSkipAssertionSignatureValidation description: Whether the Service Provider should (insecurely) accept assertions from the Identity Provider without a valid signature.
|
|
InsecureSkipAssertionSignatureValidation bool
|
|
// NameIDFormat description: The SAML NameID format to use when performing user authentication.
|
|
NameIDFormat NameIDFormat
|
|
// ServiceProviderCertificate description: The SAML Service Provider certificate in X.509 encoding (begins with "-----BEGIN CERTIFICATE-----"). This certificate is used by the Identity Provider to validate the Service Provider's AuthnRequests and LogoutRequests. It corresponds to the Service Provider's private key (`serviceProviderPrivateKey`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
|
ServiceProviderCertificate string
|
|
// ServiceProviderIssuer description: The SAML Service Provider name, used to identify this Service Provider. This is required if the "externalURL" field is not set (as the SAML metadata endpoint is computed as "<externalURL>.auth/saml/metadata"), or when using multiple SAML authentication providers.
|
|
ServiceProviderIssuer string
|
|
// ServiceProviderPrivateKey description: The SAML Service Provider private key in PKCS#8 encoding (begins with "-----BEGIN PRIVATE KEY-----"). This private key is used to sign AuthnRequests and LogoutRequests. It corresponds to the Service Provider's certificate (`serviceProviderCertificate`). To escape the value into a JSON string, you may want to use a tool like https://json-escape-text.now.sh.
|
|
ServiceProviderPrivateKey string
|
|
|
|
CallbackURL string
|
|
IconURL string
|
|
|
|
// EmailAssertionKey description: Assertion key for user.Email
|
|
EmailAssertionKey string
|
|
// NameAssertionKey description: Assertion key for user.NickName
|
|
NameAssertionKey string
|
|
// UsernameAssertionKey description: Assertion key for user.Name
|
|
UsernameAssertionKey string
|
|
|
|
// reference to the authSource
|
|
authSource *auth.Source
|
|
|
|
samlSP *saml2.SAMLServiceProvider
|
|
}
|
|
|
|
func GenerateSAMLSPKeypair() (string, string, error) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
|
keyPem := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: keyBytes,
|
|
},
|
|
)
|
|
|
|
now := time.Now()
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(0),
|
|
NotBefore: now.Add(-5 * time.Minute),
|
|
NotAfter: now.Add(365 * 24 * time.Hour),
|
|
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{},
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
certificate, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
certPem := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certificate,
|
|
},
|
|
)
|
|
|
|
return string(keyPem), string(certPem), nil
|
|
}
|
|
|
|
func (source *Source) initSAMLSp() error {
|
|
source.CallbackURL = setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/acs"
|
|
|
|
idpMetadata, err := readIdentityProviderMetadata(context.Background(), source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
{
|
|
if source.IdentityProviderMetadataURL != "" {
|
|
log.Trace(fmt.Sprintf("Identity Provider metadata: %s", source.IdentityProviderMetadataURL), string(idpMetadata))
|
|
}
|
|
}
|
|
|
|
metadata := &types.EntityDescriptor{}
|
|
err = xml.Unmarshal(idpMetadata, metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
certStore := dsig.MemoryX509CertificateStore{
|
|
Roots: []*x509.Certificate{},
|
|
}
|
|
|
|
if metadata.IDPSSODescriptor == nil {
|
|
return errors.New("saml idp metadata missing IDPSSODescriptor")
|
|
}
|
|
|
|
for _, kd := range metadata.IDPSSODescriptor.KeyDescriptors {
|
|
for idx, xcert := range kd.KeyInfo.X509Data.X509Certificates {
|
|
if xcert.Data == "" {
|
|
return fmt.Errorf("metadata certificate(%d) must not be empty", idx)
|
|
}
|
|
certData, err := base64.StdEncoding.DecodeString(xcert.Data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idpCert, err := x509.ParseCertificate(certData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
certStore.Roots = append(certStore.Roots, idpCert)
|
|
}
|
|
}
|
|
|
|
var keyStore dsig.X509KeyStore
|
|
|
|
if source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "" {
|
|
keyPair, err := tls.X509KeyPair([]byte(source.ServiceProviderCertificate), []byte(source.ServiceProviderPrivateKey))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keyStore = dsig.TLSCertKeyStore(keyPair)
|
|
}
|
|
|
|
source.samlSP = &saml2.SAMLServiceProvider{
|
|
IdentityProviderSSOURL: metadata.IDPSSODescriptor.SingleSignOnServices[0].Location,
|
|
IdentityProviderIssuer: metadata.EntityID,
|
|
AudienceURI: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata",
|
|
AssertionConsumerServiceURL: source.CallbackURL,
|
|
SkipSignatureValidation: source.InsecureSkipAssertionSignatureValidation,
|
|
NameIdFormat: source.NameIDFormat.String(),
|
|
IDPCertificateStore: &certStore,
|
|
SignAuthnRequests: source.ServiceProviderCertificate != "" && source.ServiceProviderPrivateKey != "",
|
|
SPKeyStore: keyStore,
|
|
ServiceProviderIssuer: setting.AppURL + "user/saml/" + url.PathEscape(source.authSource.Name) + "/metadata",
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FromDB fills up a SAML from serialized format.
|
|
func (source *Source) FromDB(bs []byte) error {
|
|
if err := json.UnmarshalHandleDoubleEncode(bs, &source); err != nil {
|
|
return err
|
|
}
|
|
|
|
return source.initSAMLSp()
|
|
}
|
|
|
|
// ToDB exports a SAML to a serialized format.
|
|
func (source *Source) ToDB() ([]byte, error) {
|
|
return json.Marshal(source)
|
|
}
|
|
|
|
// SetAuthSource sets the related AuthSource
|
|
func (source *Source) SetAuthSource(authSource *auth.Source) {
|
|
source.authSource = authSource
|
|
}
|
|
|
|
func init() {
|
|
auth.RegisterTypeConfig(auth.SAML, &Source{})
|
|
}
|