1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-23 02:38:35 +00:00

Support SAML authentication (#25165)

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>
This commit is contained in:
techknowlogick
2024-02-22 19:08:17 -05:00
committed by GitHub
parent c4b0cb4d0d
commit 5bb8d1924d
37 changed files with 1440 additions and 69 deletions

View File

@@ -0,0 +1,22 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml_test
import (
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/auth/source/saml"
)
// This test file exists to assert that our Source exposes the interfaces that we expect
// It tightly binds the interfaces and implementation without breaking go import cycles
type sourceInterface interface {
auth_model.Config
auth_model.SourceSettable
auth_model.RegisterableSource
auth.PasswordAuthenticator
}
var _ (sourceInterface) = &saml.Source{}

View File

@@ -0,0 +1,29 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
import (
"context"
"sync"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/log"
)
var samlRWMutex = sync.RWMutex{}
func Init(ctx context.Context) error {
loginSources, _ := auth.GetActiveAuthProviderSources(ctx, auth.SAML)
for _, source := range loginSources {
samlSource, ok := source.Cfg.(*Source)
if !ok {
continue
}
err := samlSource.RegisterSource()
if err != nil {
log.Error("Unable to register source: %s due to Error: %v.", source.Name, err)
}
}
return nil
}

View File

@@ -0,0 +1,38 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
type NameIDFormat int
const (
SAML11Email NameIDFormat = iota + 1
SAML11Persistent
SAML11Unspecified
SAML20Email
SAML20Persistent
SAML20Transient
SAML20Unspecified
)
const DefaultNameIDFormat NameIDFormat = SAML20Persistent
var NameIDFormatNames = map[NameIDFormat]string{
SAML11Email: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
SAML11Persistent: "urn:oasis:names:tc:SAML:1.1:nameid-format:persistent",
SAML11Unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
SAML20Email: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress",
SAML20Persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
SAML20Transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
SAML20Unspecified: "urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified",
}
// String returns the name of the NameIDFormat
func (n NameIDFormat) String() string {
return NameIDFormatNames[n]
}
// Int returns the int value of the NameIDFormat
func (n NameIDFormat) Int() int {
return int(n)
}

View File

@@ -0,0 +1,109 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
import (
"context"
"fmt"
"html"
"html/template"
"io"
"net/http"
"sort"
"time"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/util"
)
// Providers is list of known/available providers.
type Providers map[string]Source
var providers = Providers{}
// Provider is an interface for describing a single SAML provider
type Provider interface {
Name() string
IconHTML(size int) template.HTML
}
// AuthSourceProvider is a SAML provider
type AuthSourceProvider struct {
sourceName, iconURL string
}
func (p *AuthSourceProvider) Name() string {
return p.sourceName
}
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
if p.iconURL != "" {
return template.HTML(fmt.Sprintf(`<img class="gt-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`,
size,
size,
html.EscapeString(p.iconURL), html.EscapeString(p.Name()),
))
}
return svg.RenderHTML("gitea-lock-cog", size, "gt-mr-3")
}
func readIdentityProviderMetadata(ctx context.Context, source *Source) ([]byte, error) {
if source.IdentityProviderMetadata != "" {
return []byte(source.IdentityProviderMetadata), nil
}
req := httplib.NewRequest(source.IdentityProviderMetadataURL, "GET")
req.SetTimeout(20*time.Second, time.Minute)
resp, err := req.Response()
if err != nil {
return nil, fmt.Errorf("Unable to contact gitea: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, err
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return data, nil
}
func createProviderFromSource(source *auth.Source) (Provider, error) {
samlCfg, ok := source.Cfg.(*Source)
if !ok {
return nil, fmt.Errorf("invalid SAML source config: %v", samlCfg)
}
return &AuthSourceProvider{sourceName: source.Name, iconURL: samlCfg.IconURL}, nil
}
// GetSAMLProviders returns the list of configured SAML providers
func GetSAMLProviders(ctx context.Context, isActive util.OptionalBool) ([]Provider, error) {
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: isActive,
LoginType: auth.SAML,
})
if err != nil {
return nil, err
}
samlProviders := make([]Provider, 0, len(authSources))
for _, source := range authSources {
p, err := createProviderFromSource(source)
if err != nil {
return nil, err
}
samlProviders = append(samlProviders, p)
}
sort.Slice(samlProviders, func(i, j int) bool {
return samlProviders[i].Name() < samlProviders[j].Name()
})
return samlProviders, nil
}

View File

@@ -0,0 +1,202 @@
// 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{})
}

View File

@@ -0,0 +1,16 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
import (
"context"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/auth/source/db"
)
// Authenticate falls back to the db authenticator
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
return db.Authenticate(ctx, user, login, password)
}

View File

@@ -0,0 +1,89 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
import (
"fmt"
"net/http"
"strings"
"github.com/markbates/goth"
)
// Callout redirects request/response pair to authenticate against the provider
func (source *Source) Callout(request *http.Request, response http.ResponseWriter) error {
samlRWMutex.RLock()
defer samlRWMutex.RUnlock()
if _, ok := providers[source.authSource.Name]; !ok {
return fmt.Errorf("no provider for this saml")
}
authURL, err := providers[source.authSource.Name].samlSP.BuildAuthURL("")
if err == nil {
http.Redirect(response, request, authURL, http.StatusTemporaryRedirect)
}
return err
}
// Callback handles SAML callback, resolve to a goth user and send back to original url
// this will trigger a new authentication request, but because we save it in the session we can use that
func (source *Source) Callback(request *http.Request, response http.ResponseWriter) (goth.User, error) {
samlRWMutex.RLock()
defer samlRWMutex.RUnlock()
user := goth.User{
Provider: source.authSource.Name,
}
samlResponse := request.FormValue("SAMLResponse")
assertions, err := source.samlSP.RetrieveAssertionInfo(samlResponse)
if err != nil {
return user, err
}
if assertions.WarningInfo.OneTimeUse {
return user, fmt.Errorf("SAML response contains one time use warning")
}
if assertions.WarningInfo.ProxyRestriction != nil {
return user, fmt.Errorf("SAML response contains proxy restriction warning: %v", assertions.WarningInfo.ProxyRestriction)
}
if assertions.WarningInfo.NotInAudience {
return user, fmt.Errorf("SAML response contains audience warning")
}
if assertions.WarningInfo.InvalidTime {
return user, fmt.Errorf("SAML response contains invalid time warning")
}
samlMap := make(map[string]string)
for key, value := range assertions.Values {
keyParsed := strings.ToLower(key[strings.LastIndex(key, "/")+1:]) // Uses the trailing slug as the key name.
valueParsed := value.Values[0].Value
samlMap[keyParsed] = valueParsed
}
user.UserID = assertions.NameID
if user.UserID == "" {
return user, fmt.Errorf("no nameID found in SAML response")
}
// email
if _, ok := samlMap[source.EmailAssertionKey]; !ok {
user.Email = samlMap[source.EmailAssertionKey]
}
// name
if _, ok := samlMap[source.NameAssertionKey]; !ok {
user.NickName = samlMap[source.NameAssertionKey]
}
// username
if _, ok := samlMap[source.UsernameAssertionKey]; !ok {
user.Name = samlMap[source.UsernameAssertionKey]
}
// TODO: utilize groups once mapping is supported
return user, nil
}

View File

@@ -0,0 +1,32 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
import (
"encoding/xml"
"fmt"
"net/http"
)
// Metadata redirects request/response pair to authenticate against the provider
func (source *Source) Metadata(request *http.Request, response http.ResponseWriter) error {
samlRWMutex.RLock()
defer samlRWMutex.RUnlock()
if _, ok := providers[source.authSource.Name]; !ok {
return fmt.Errorf("provider does not exist")
}
metadata, err := providers[source.authSource.Name].samlSP.Metadata()
if err != nil {
return err
}
buf, err := xml.Marshal(metadata)
if err != nil {
return err
}
response.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8")
_, _ = response.Write(buf)
return nil
}

View File

@@ -0,0 +1,23 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package saml
// RegisterSource causes an OAuth2 configuration to be registered
func (source *Source) RegisterSource() error {
samlRWMutex.Lock()
defer samlRWMutex.Unlock()
if err := source.initSAMLSp(); err != nil {
return err
}
providers[source.authSource.Name] = *source
return nil
}
// UnregisterSource causes an SAML configuration to be unregistered
func (source *Source) UnregisterSource() error {
samlRWMutex.Lock()
defer samlRWMutex.Unlock()
delete(providers, source.authSource.Name)
return nil
}