mirror of https://github.com/go-gitea/gitea.git
This reverts #25165 (5bb8d1924d), as there
was a chance some important reviews got missed.
so after reverting this patch it will be resubmitted for reviewing again
https://github.com/go-gitea/gitea/pull/25165#issuecomment-1960670242
temporary Open #5512 again
pull/29243/head
parent
875f5ea6d8
commit
4ba642d07d
File diff suppressed because one or more lines are too long
@ -1,172 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/auth"
|
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
|
||||||
"code.gitea.io/gitea/modules/context"
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
|
||||||
"code.gitea.io/gitea/modules/setting"
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
|
||||||
"code.gitea.io/gitea/services/auth/source/saml"
|
|
||||||
"code.gitea.io/gitea/services/externalaccount"
|
|
||||||
|
|
||||||
"github.com/markbates/goth"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SignInSAML(ctx *context.Context) {
|
|
||||||
provider := ctx.Params(":provider")
|
|
||||||
|
|
||||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML)
|
|
||||||
if err != nil || loginSource == nil {
|
|
||||||
ctx.NotFound("SAMLMetadata", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = loginSource.Cfg.(*saml.Source).Callout(ctx.Req, ctx.Resp); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "no provider for ") {
|
|
||||||
ctx.Error(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.ServerError("SignIn", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SignInSAMLCallback(ctx *context.Context) {
|
|
||||||
provider := ctx.Params(":provider")
|
|
||||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML)
|
|
||||||
if err != nil || loginSource == nil {
|
|
||||||
ctx.NotFound("SignInSAMLCallback", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if loginSource == nil {
|
|
||||||
ctx.ServerError("SignIn", fmt.Errorf("no valid provider found, check configured callback url in provider"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, gothUser, err := samlUserLoginCallback(*ctx, loginSource, ctx.Req, ctx.Resp)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("SignInSAMLCallback", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if u == nil {
|
|
||||||
if ctx.Doer != nil {
|
|
||||||
// attach user to already logged in user
|
|
||||||
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser, auth.SAML)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("LinkAccountToUser", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
|
||||||
return
|
|
||||||
} else if !setting.Service.AllowOnlyInternalRegistration && false {
|
|
||||||
// TODO: allow auto registration from saml users (OAuth2 uses the following setting.OAuth2Client.EnableAutoRegistration)
|
|
||||||
} else {
|
|
||||||
// no existing user is found, request attach or new account
|
|
||||||
showLinkingLogin(ctx, gothUser, auth.SAML)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSamlSignIn(ctx, loginSource, u, gothUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSamlSignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
|
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
|
||||||
"uid": u.ID,
|
|
||||||
"uname": u.Name,
|
|
||||||
}); err != nil {
|
|
||||||
ctx.ServerError("updateSession", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear whatever CSRF cookie has right now, force to generate a new one
|
|
||||||
ctx.Csrf.DeleteCookie(ctx)
|
|
||||||
|
|
||||||
// Register last login
|
|
||||||
u.SetLastLogin()
|
|
||||||
|
|
||||||
// update external user information
|
|
||||||
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser, auth.SAML); err != nil {
|
|
||||||
if !errors.Is(err, util.ErrNotExist) {
|
|
||||||
log.Error("UpdateExternalUser failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := resetLocale(ctx, u); err != nil {
|
|
||||||
ctx.ServerError("resetLocale", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
|
|
||||||
middleware.DeleteRedirectToCookie(ctx.Resp)
|
|
||||||
ctx.RedirectToFirst(redirectTo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func samlUserLoginCallback(ctx context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
|
|
||||||
samlSource := authSource.Cfg.(*saml.Source)
|
|
||||||
|
|
||||||
gothUser, err := samlSource.Callback(request, response)
|
|
||||||
if err != nil {
|
|
||||||
return nil, gothUser, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &user_model.User{
|
|
||||||
LoginName: gothUser.UserID,
|
|
||||||
LoginType: auth.SAML,
|
|
||||||
LoginSource: authSource.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
hasUser, err := user_model.GetUser(ctx, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, goth.User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasUser {
|
|
||||||
return user, gothUser, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// search in external linked users
|
|
||||||
externalLoginUser := &user_model.ExternalLoginUser{
|
|
||||||
ExternalID: gothUser.UserID,
|
|
||||||
LoginSourceID: authSource.ID,
|
|
||||||
}
|
|
||||||
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
|
|
||||||
if err != nil {
|
|
||||||
return nil, goth.User{}, err
|
|
||||||
}
|
|
||||||
if hasUser {
|
|
||||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
|
||||||
return user, gothUser, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// no user found to login
|
|
||||||
return nil, gothUser, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SAMLMetadata(ctx *context.Context) {
|
|
||||||
provider := ctx.Params(":provider")
|
|
||||||
loginSource, err := auth.GetActiveAuthSourceByName(ctx, provider, auth.SAML)
|
|
||||||
if err != nil || loginSource == nil {
|
|
||||||
ctx.NotFound("SAMLMetadata", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = loginSource.Cfg.(*saml.Source).Metadata(ctx.Req, ctx.Resp); err != nil {
|
|
||||||
ctx.ServerError("SAMLMetadata", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
// 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{}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
// 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{})
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
<div class="saml field {{if not (eq .type 8)}}gt-hidden{{end}}">
|
|
||||||
|
|
||||||
<div class="inline required field">
|
|
||||||
<label>{{ctx.Locale.Tr "admin.auths.saml_nameidformat"}}</label>
|
|
||||||
<div class="ui selection type dropdown">
|
|
||||||
<input type="hidden" id="name_id_format" name="name_id_format" value="{{.name_id_format}}">
|
|
||||||
<div class="text">{{.CurrentNameIDFormat}}</div>
|
|
||||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
|
||||||
<div class="menu">
|
|
||||||
{{range .NameIDFormats}}
|
|
||||||
<div class="item" data-value="{{.Type.Int}}">{{.Name}}</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="optional field">
|
|
||||||
<label for="saml_icon_url">{{ctx.Locale.Tr "admin.auths.saml_icon_url"}}</label>
|
|
||||||
<input id="saml_icon_url" name="saml_icon_url" value="{{.SAMLIconURL}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="identity_provider_metadata_url">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata_url"}}</label>
|
|
||||||
<input id="identity_provider_metadata_url" name="identity_provider_metadata_url" value="{{.IdentityProviderMetadataURL}}">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="identity_provider_metadata">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_metadata"}}</label>
|
|
||||||
<textarea rows=2 id="identity_provider_metadata" name="identity_provider_metadata" value="{{.IdentityProviderMetadata}}"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline field">
|
|
||||||
<div class="ui checkbox">
|
|
||||||
<label><strong>{{ctx.Locale.Tr "admin.auths.saml_insecure_skip_assertion_signature_validation"}}</strong></label>
|
|
||||||
<input name="insecure_skip_assertion_signature_validation" type="checkbox" {{if .InsecureSkipAssertionSignatureValidation}}checked{{end}}>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="service_provider_certificate">{{ctx.Locale.Tr "admin.auths.saml_service_provider_certificate"}}</label>
|
|
||||||
<textarea rows=2 id="service_provider_certificate" name="service_provider_certificate" value="{{.ServiceProviderCertificate}}"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="service_provider_private_key">{{ctx.Locale.Tr "admin.auths.saml_service_provider_private_key"}}</label>
|
|
||||||
<textarea rows=2 id="service_provider_private_key" name="service_provider_private_key" value="{{.ServiceProviderPrivateKey}}"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="email_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_email_assertion_key"}}</label>
|
|
||||||
<input id="email_assertion_key" name="email_assertion_key" value="{{if not .EmailAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress{{else}}{{.EmailAssertionKey}}{{end}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="name_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_name_assertion_key"}}</label>
|
|
||||||
<input id="name_assertion_key" name="name_assertion_key" value="{{if not .NameAssertionKey}}http://schemas.xmlsoap.org/claims/CommonName{{else}}{{.NameAssertionKey}}{{end}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="username_assertion_key">{{ctx.Locale.Tr "admin.auths.saml_identity_provider_username_assertion_key"}}</label>
|
|
||||||
<input id="username_assertion_key" name="username_assertion_key" value="{{if not .UsernameAssertionKey}}http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name{{else}}{{.UsernameAssertionKey}}{{end}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/auth"
|
|
||||||
"code.gitea.io/gitea/models/db"
|
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
|
||||||
"code.gitea.io/gitea/modules/setting"
|
|
||||||
"code.gitea.io/gitea/modules/test"
|
|
||||||
"code.gitea.io/gitea/services/auth/source/saml"
|
|
||||||
"code.gitea.io/gitea/tests"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSAMLRegistration(t *testing.T) {
|
|
||||||
defer tests.PrepareTestEnv(t)()
|
|
||||||
|
|
||||||
samlURL := "localhost:8080"
|
|
||||||
|
|
||||||
if os.Getenv("CI") == "" || !setting.Database.Type.IsPostgreSQL() {
|
|
||||||
// Make it possible to run tests against a local simplesaml instance
|
|
||||||
samlURL = os.Getenv("TEST_SIMPLESAML_URL")
|
|
||||||
if samlURL == "" {
|
|
||||||
t.Skip("TEST_SIMPLESAML_URL not set and not running in CI")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey, cert, err := saml.GenerateSAMLSPKeypair()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// verify that the keypair can be parsed
|
|
||||||
keyPair, err := tls.X509KeyPair([]byte(cert), []byte(privateKey))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NoError(t, auth.CreateSource(db.DefaultContext, &auth.Source{
|
|
||||||
Type: auth.SAML,
|
|
||||||
Name: "test-sp",
|
|
||||||
IsActive: true,
|
|
||||||
IsSyncEnabled: false,
|
|
||||||
Cfg: &saml.Source{
|
|
||||||
IdentityProviderMetadata: "",
|
|
||||||
IdentityProviderMetadataURL: fmt.Sprintf("http://%s/simplesaml/saml2/idp/metadata.php", samlURL),
|
|
||||||
InsecureSkipAssertionSignatureValidation: false,
|
|
||||||
NameIDFormat: 4,
|
|
||||||
ServiceProviderCertificate: "", // SimpleSAMLPhp requires that the SP certificate be specified in the server configuration rather than SP metadata
|
|
||||||
ServiceProviderPrivateKey: "",
|
|
||||||
EmailAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
|
||||||
NameAssertionKey: "http://schemas.xmlsoap.org/claims/CommonName",
|
|
||||||
UsernameAssertionKey: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
|
||||||
IconURL: "",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// check the saml metadata url
|
|
||||||
req := NewRequest(t, "GET", "/user/saml/test-sp/metadata")
|
|
||||||
MakeRequest(t, req, http.StatusOK)
|
|
||||||
|
|
||||||
req = NewRequest(t, "GET", "/user/saml/test-sp")
|
|
||||||
resp := MakeRequest(t, req, http.StatusTemporaryRedirect)
|
|
||||||
|
|
||||||
jar, err := cookiejar.New(nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
client := http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
Jar: jar,
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequest("GET", test.RedirectURL(resp), nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var formRedirectURL *url.URL
|
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
||||||
// capture the redirected destination to use in POST request
|
|
||||||
formRedirectURL = req.URL
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := client.Do(httpReq)
|
|
||||||
client.CheckRedirect = nil
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
||||||
assert.NotNil(t, formRedirectURL)
|
|
||||||
|
|
||||||
form := url.Values{
|
|
||||||
"username": {"user1"},
|
|
||||||
"password": {"user1pass"},
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err = http.NewRequest("POST", formRedirectURL.String(), strings.NewReader(form.Encode()))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
httpReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
|
|
||||||
res, err = client.Do(httpReq)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
samlResMatcher := regexp.MustCompile(`<input.*?name="SAMLResponse".*?value="([^"]+)".*?>`)
|
|
||||||
matches := samlResMatcher.FindStringSubmatch(string(body))
|
|
||||||
assert.Len(t, matches, 2)
|
|
||||||
assert.NoError(t, res.Body.Close())
|
|
||||||
|
|
||||||
session := emptyTestSession(t)
|
|
||||||
|
|
||||||
req = NewRequestWithValues(t, "POST", "/user/saml/test-sp/acs", map[string]string{
|
|
||||||
"SAMLResponse": matches[1],
|
|
||||||
})
|
|
||||||
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
|
||||||
assert.Equal(t, test.RedirectURL(resp), "/user/link_account")
|
|
||||||
|
|
||||||
csrf := GetCSRF(t, session, test.RedirectURL(resp))
|
|
||||||
|
|
||||||
// link the account
|
|
||||||
req = NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{
|
|
||||||
"_csrf": csrf,
|
|
||||||
"user_name": "samluser",
|
|
||||||
"email": "saml@example.com",
|
|
||||||
})
|
|
||||||
|
|
||||||
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
|
||||||
assert.Equal(t, test.RedirectURL(resp), "/")
|
|
||||||
|
|
||||||
// verify that the user was created
|
|
||||||
u, err := user_model.GetUserByEmail(db.DefaultContext, "saml@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, u)
|
|
||||||
assert.Equal(t, "samluser", u.Name)
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue